| 1 |
|
// Command doltsrht is the dolt.sr.ht service daemon: one process running three |
| 2 |
|
// listeners. |
| 3 |
|
// |
| 4 |
|
// - the web UI (chi) on the -b address (default localhost:5307), assembled on |
| 5 |
|
// the core-go server's AnonRouter with our own middleware group (config + |
| 6 |
|
// database + optional unified-login cookie) so anonymous browsing and public |
| 7 |
|
// clones keep working — we deliberately do NOT use core-go's default |
| 8 |
|
// middleware, whose auth.Middleware 401s any un-cookied request. The MCP |
| 9 |
|
// surface rides the same listener at /mcp, above the cookie plane and |
| 10 |
|
// outside web's same-origin group, because it is bearer-only |
| 11 |
|
// (docs/DESIGN.mcp.md §3). |
| 12 |
|
// - the remotesapi (gRPC ChunkStoreService + HTTP chunk data plane, one h2c |
| 13 |
|
// port) on [dolt.sr.ht]remotesapi-listen (default 127.0.0.1:5306). |
| 14 |
|
// - the CredentialsService.WhoAmI gRPC server for the `dolt login` keypair |
| 15 |
|
// flow on [dolt.sr.ht]credsapi-listen (default 127.0.0.1:5308). |
| 16 |
|
// |
| 17 |
|
// server.New runs crypto.InitCrypto, which requires [sr.ht]network-key and |
| 18 |
|
// [webhooks]private-key; a missing key panics there. The remotesapi server is |
| 19 |
|
// built before the web Config because the web store manager's Evict drives the |
| 20 |
|
// remotesapi chunk-store cache. |
| 21 |
|
package main |
| 22 |
|
|
| 23 |
|
import ( |
| 24 |
|
"context" |
| 25 |
|
"database/sql" |
| 26 |
|
"fmt" |
| 27 |
|
"log/slog" |
| 28 |
|
"net/http" |
| 29 |
|
"os" |
| 30 |
|
|
| 31 |
|
"github.com/go-chi/chi/v5" |
| 32 |
|
chimiddleware "github.com/go-chi/chi/v5/middleware" |
| 33 |
|
_ "github.com/lib/pq" // registers the "postgres" database/sql driver |
| 34 |
|
"github.com/vaughan0/go-ini" |
| 35 |
|
|
| 36 |
|
"go.bigb.es/auxilia/culpa" |
| 37 |
|
"go.bigb.es/auxilia/logrusbridge" |
| 38 |
|
"go.bigb.es/auxilia/scribe" |
| 39 |
|
|
| 40 |
|
"sourcecraft.dev/bigbes/sr-ht-core/config" |
| 41 |
|
"sourcecraft.dev/bigbes/sr-ht-core/database" |
| 42 |
|
"sourcecraft.dev/bigbes/sr-ht-core/server" |
| 43 |
|
|
| 44 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta" |
| 45 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/chimw" |
| 46 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf" |
| 47 |
|
|
| 48 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/authn" |
| 49 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/remoteapi" |
| 50 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/storage" |
| 51 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/web" |
| 52 |
|
) |
| 53 |
|
|
| 54 |
|
// serviceName is the SourceHut service identifier and config section name. |
| 55 |
|
const serviceName = "dolt.sr.ht" |
| 56 |
|
|
| 57 |
|
const ( |
| 58 |
|
defaultWebAddr = "localhost:5307" |
| 59 |
|
defaultReposRoot = "/var/lib/dolt" |
| 60 |
|
defaultStaticDir = "./static" |
| 61 |
|
defaultRemotesapiAddr = "127.0.0.1:5306" |
| 62 |
|
defaultCredsapiAddr = "127.0.0.1:5308" |
| 63 |
|
) |
| 64 |
|
|
| 65 |
|
// storeManager satisfies web.StoreManager over the storage package and the |
| 66 |
|
// remotesapi server's chunk-store cache. web never imports storage/ or |
| 67 |
|
// remoteapi/; main is where the on-disk store lifecycle and the served cache are |
| 68 |
|
// tied together, so a database deleted through the web UI both removes its |
| 69 |
|
// on-disk store and evicts any handle the remotesapi server memoized. |
| 70 |
|
type storeManager struct { |
| 71 |
|
cache *storage.Cache |
| 72 |
|
} |
| 73 |
|
|
| 74 |
|
var _ web.StoreManager = (*storeManager)(nil) |
| 75 |
|
|
| 76 |
0 |
func (m *storeManager) InitStore(ctx context.Context, absPath, ownerName, ownerEmail string) error { |
| 77 |
0 |
return storage.InitStore(ctx, absPath, ownerName, ownerEmail) |
| 78 |
0 |
} |
| 79 |
|
|
| 80 |
0 |
func (m *storeManager) InitEmptyStore(ctx context.Context, absPath string) error { |
| 81 |
0 |
return storage.InitEmptyStore(ctx, absPath) |
| 82 |
0 |
} |
| 83 |
|
|
| 84 |
0 |
func (m *storeManager) DeleteStore(ctx context.Context, root, absPath string) error { |
| 85 |
0 |
return storage.DeleteStore(ctx, root, absPath) |
| 86 |
0 |
} |
| 87 |
|
|
| 88 |
0 |
func (m *storeManager) MoveStore(ctx context.Context, root, srcPath, dstPath string) error { |
| 89 |
0 |
return storage.MoveStore(ctx, root, srcPath, dstPath) |
| 90 |
0 |
} |
| 91 |
|
|
| 92 |
0 |
func (m *storeManager) Evict(diskPath string) error { |
| 93 |
0 |
return m.cache.Evict(diskPath) |
| 94 |
0 |
} |
| 95 |
|
|
| 96 |
|
// settings are the resolved [dolt.sr.ht] config values the daemon needs, split |
| 97 |
|
// out from main so the required-key and defaulting logic is unit-testable |
| 98 |
|
// without booting the process. |
| 99 |
|
type settings struct { |
| 100 |
|
connString string |
| 101 |
|
reposRoot string |
| 102 |
|
staticDir string |
| 103 |
|
remotesapiAddr string |
| 104 |
|
credsapiAddr string |
| 105 |
|
// origin is the canonical external origin, [dolt.sr.ht]origin as |
| 106 |
|
// instconf.CanonicalOrigin spells it. It is what /mcp guards its Host header |
| 107 |
|
// with (mcpsrv.New), and it is kept whole rather than reduced to httpHost |
| 108 |
|
// below because that check wants the name and this one wants the URL. |
| 109 |
|
origin string |
| 110 |
|
// httpHost is the bare authority (host[:port]) of the external origin. It is |
| 111 |
|
// stamped into sealed chunk-download URLs and seeds the keypair-JWT audience. |
| 112 |
|
httpHost string |
| 113 |
|
} |
| 114 |
|
|
| 115 |
|
// resolveSettings reads the [dolt.sr.ht] section, applying defaults and failing |
| 116 |
|
// on the keys that have no sensible default (connection-string and origin). |
| 117 |
|
// |
| 118 |
|
// Both gaps are reported together rather than one per boot. An operator filling |
| 119 |
|
// in a fresh config.ini wants the whole list in front of them, not one key per |
| 120 |
|
// restart, which is what instconf.Require is for. |
| 121 |
15 |
func resolveSettings(conf ini.File) (settings, error) { |
| 122 |
15 |
if err := instconf.Require(conf, |
| 123 |
15 |
instconf.Need(serviceName, "connection-string"), |
| 124 |
15 |
instconf.Need(serviceName, "origin"), |
| 125 |
15 |
); err != nil { |
| 126 |
3 |
// A hint rather than a longer sentence: scribe prints it on its own |
| 127 |
3 |
// line, and what an operator meeting this needs is the keys to add, not |
| 128 |
3 |
// a restatement of the failure. |
| 129 |
3 |
return settings{}, culpa.WithHint(culpa.Wrap(err, "reading the config"), |
| 130 |
3 |
"origin is what places this service in every other service's nav") |
| 131 |
3 |
} |
| 132 |
|
|
| 133 |
|
// The authority and not the bare host: the port is part of what identifies |
| 134 |
|
// this endpoint, and https://x:8443 and https://x:9443 are two different |
| 135 |
|
// sealed-URL hosts and two different JWT audiences. "" here means the origin |
| 136 |
|
// is set but names no host — a scheme-less "dolt.example.org" is a path, not |
| 137 |
|
// a URL — which is a configuration error and not a reason to guess. |
| 138 |
12 |
origin := instconf.ExternalOrigin(conf, serviceName) |
| 139 |
12 |
host := instconf.OriginAuthority(origin) |
| 140 |
12 |
if host == "" { |
| 141 |
1 |
return settings{}, culpa.WithHint( |
| 142 |
1 |
culpa.New(fmt.Sprintf("[%s]origin names no host", serviceName)), |
| 143 |
1 |
"origin must be protocol://host, e.g. https://dolt.example.org") |
| 144 |
1 |
} |
| 145 |
|
|
| 146 |
11 |
return settings{ |
| 147 |
11 |
connString: config.GetString(conf, serviceName, "connection-string", ""), |
| 148 |
11 |
reposRoot: config.GetString(conf, serviceName, "repos", defaultReposRoot), |
| 149 |
11 |
staticDir: config.GetString(conf, serviceName, "static-dir", defaultStaticDir), |
| 150 |
11 |
remotesapiAddr: config.GetString(conf, serviceName, "remotesapi-listen", defaultRemotesapiAddr), |
| 151 |
11 |
credsapiAddr: config.GetString(conf, serviceName, "credsapi-listen", defaultCredsapiAddr), |
| 152 |
11 |
origin: origin, |
| 153 |
11 |
httpHost: host, |
| 154 |
11 |
}, nil |
| 155 |
|
} |
| 156 |
|
|
| 157 |
0 |
func main() { |
| 158 |
0 |
conf := config.LoadConfig() |
| 159 |
0 |
|
| 160 |
0 |
// Before anything that can fail: everything below, and every library this |
| 161 |
0 |
// process links, reports through slog's default logger. |
| 162 |
0 |
setupLogging(conf) |
| 163 |
0 |
|
| 164 |
0 |
// server.New parses -b/-d/-m/-p and runs crypto.InitCrypto (needs |
| 165 |
0 |
// [sr.ht]network-key + [webhooks]private-key; missing keys panic here). |
| 166 |
0 |
// Pass the full os.Args: core-go's getopt skips argv[0] as the program name |
| 167 |
0 |
// itself (like every upstream sourcehut daemon). Passing os.Args[1:] makes |
| 168 |
0 |
// getopt swallow the first real flag (e.g. -b) as the program name, so the |
| 169 |
0 |
// web bind silently falls back to defaultWebAddr (localhost) — unreachable |
| 170 |
0 |
// from Traefik/other containers. |
| 171 |
0 |
srv := server.New(serviceName, defaultWebAddr, conf, os.Args) |
| 172 |
0 |
|
| 173 |
0 |
cfg, err := resolveSettings(conf) |
| 174 |
0 |
if err != nil { |
| 175 |
0 |
fatal("reading the configuration", err) |
| 176 |
0 |
} |
| 177 |
|
|
| 178 |
0 |
db, err := sql.Open("postgres", cfg.connString) |
| 179 |
0 |
if err != nil { |
| 180 |
0 |
fatal("opening the postgres pool", err) |
| 181 |
0 |
} |
| 182 |
|
|
| 183 |
|
// Build the remotesapi server first: its chunk-store cache backs the web |
| 184 |
|
// store manager's Evict. |
| 185 |
0 |
rapiConf := remoteapi.Config{ |
| 186 |
0 |
Conf: conf, |
| 187 |
0 |
DB: db, |
| 188 |
0 |
ReposRoot: cfg.reposRoot, |
| 189 |
0 |
ListenAddr: cfg.remotesapiAddr, |
| 190 |
0 |
CredsListenAddr: cfg.credsapiAddr, |
| 191 |
0 |
HttpHost: cfg.httpHost, |
| 192 |
0 |
// dolt's remotesrv takes a *logrus.Entry and nothing else. Bridged, so |
| 193 |
0 |
// that the half of this process serving clones and pushes reports |
| 194 |
0 |
// through the same handler, at the same level and behind the same masks |
| 195 |
0 |
// as the half we wrote. |
| 196 |
0 |
DoltLogger: logrusbridge.Entry(), |
| 197 |
0 |
} |
| 198 |
0 |
rsrv, err := remoteapi.New(rapiConf) |
| 199 |
0 |
if err != nil { |
| 200 |
0 |
fatal("building the remotesapi server", err) |
| 201 |
0 |
} |
| 202 |
0 |
csrv, err := remoteapi.NewCredServer(rapiConf) |
| 203 |
0 |
if err != nil { |
| 204 |
0 |
fatal("building the credentials server", err) |
| 205 |
0 |
} |
| 206 |
|
|
| 207 |
0 |
stores := &storeManager{cache: rsrv.Cache()} |
| 208 |
0 |
|
| 209 |
0 |
// The git-description mirror is wired only on an instance that has a |
| 210 |
0 |
// git.sr.ht to ask. web.Config documents a nil Git as "no mirroring", but |
| 211 |
0 |
// nothing used to produce one: core-go's client.Do walks the API-origin |
| 212 |
0 |
// ladder through config.GetAPI, which panics when it reaches the end, so an |
| 213 |
0 |
// instance without git.sr.ht met that as a stack trace on the first push |
| 214 |
0 |
// rather than as a description it simply did not copy. |
| 215 |
0 |
var git web.GitDescriber |
| 216 |
0 |
if _, ok := instconf.InternalAPIOrigin(conf, "git.sr.ht"); ok { |
| 217 |
0 |
git = web.GitDescriptionResolver{} |
| 218 |
0 |
} else { |
| 219 |
0 |
slog.Warn("no git.sr.ht API origin is configured; companion databases will not mirror their git twin's description", |
| 220 |
0 |
"component", "web", "keys", instconf.APIOriginKeys()) |
| 221 |
0 |
} |
| 222 |
|
|
| 223 |
|
// The MCP surface, built before the router: its Host allowlist and its |
| 224 |
|
// credential plane come out of the config, so a wiring mistake in either |
| 225 |
|
// stops the boot rather than answering every agent 500 later. |
| 226 |
0 |
agents, err := newMCPServer(conf, cfg) |
| 227 |
0 |
if err != nil { |
| 228 |
0 |
fatal("building the mcp surface", err) |
| 229 |
0 |
} |
| 230 |
|
|
| 231 |
|
// The GraphQL surface, built here for the same reason: its seams and its |
| 232 |
|
// credential plane come out of the config, so a wiring mistake stops the |
| 233 |
|
// boot rather than answering every query 500 later. |
| 234 |
0 |
gql, err := newGraphServer(conf) |
| 235 |
0 |
if err != nil { |
| 236 |
0 |
fatal("building the graphql surface", err) |
| 237 |
0 |
} |
| 238 |
|
|
| 239 |
0 |
srv.AnonRouter().Group(func(r chi.Router) { |
| 240 |
0 |
if err := mountRoutes(r, surfaces{ |
| 241 |
0 |
conf: conf, |
| 242 |
0 |
db: db, |
| 243 |
0 |
cfg: cfg, |
| 244 |
0 |
stores: stores, |
| 245 |
0 |
git: git, |
| 246 |
0 |
mcp: agents, |
| 247 |
0 |
gql: gql, |
| 248 |
0 |
}); err != nil { |
| 249 |
0 |
fatal("mounting the web routes", err) |
| 250 |
0 |
} |
| 251 |
|
}) |
| 252 |
|
|
| 253 |
|
// Start the two gRPC listeners; each blocks in Serve, so run them in |
| 254 |
|
// goroutines and let the web server's Run own the SIGINT lifecycle. |
| 255 |
0 |
go func() { |
| 256 |
0 |
if err := rsrv.Serve(); err != nil { |
| 257 |
0 |
fatal("serving the remotesapi", err) |
| 258 |
0 |
} |
| 259 |
|
}() |
| 260 |
0 |
go func() { |
| 261 |
0 |
if err := csrv.Serve(); err != nil { |
| 262 |
0 |
fatal("serving the credentials api", err) |
| 263 |
0 |
} |
| 264 |
|
}() |
| 265 |
|
|
| 266 |
0 |
slog.Info("listening", |
| 267 |
0 |
"remotesapi", cfg.remotesapiAddr, |
| 268 |
0 |
"credentials", cfg.credsapiAddr, |
| 269 |
0 |
"web", defaultWebAddr, |
| 270 |
0 |
"mcp", mcpRoute, |
| 271 |
0 |
"instance_tokens", tokensDescription(conf)) |
| 272 |
0 |
|
| 273 |
0 |
// Blocks until SIGINT, then returns after draining the web listeners. |
| 274 |
0 |
srv.Run() |
| 275 |
0 |
|
| 276 |
0 |
// GracefulStop on the remotesapi server also closes every memoized chunk |
| 277 |
0 |
// store (its cache Close), so no separate storage cache Close is needed. |
| 278 |
0 |
slog.Info("stopping the grpc servers") |
| 279 |
0 |
rsrv.GracefulStop() |
| 280 |
0 |
csrv.GracefulStop() |
| 281 |
|
} |
| 282 |
|
|
| 283 |
|
// surfaces is everything mountRoutes needs to install the two things this |
| 284 |
|
// listener serves. It is a struct rather than a parameter list so that the boot |
| 285 |
|
// test assembles the daemon's own router — the one whose middleware order and |
| 286 |
|
// mount points are the thing worth testing — without a Postgres, a store on disk |
| 287 |
|
// or core-go's server.New. |
| 288 |
|
type surfaces struct { |
| 289 |
|
conf ini.File |
| 290 |
|
db *sql.DB |
| 291 |
|
cfg settings |
| 292 |
|
stores web.StoreManager |
| 293 |
|
git web.GitDescriber |
| 294 |
|
mcp http.Handler |
| 295 |
|
gql http.Handler |
| 296 |
|
} |
| 297 |
|
|
| 298 |
|
// mountRoutes installs the web listener's surfaces on r: /mcp for agents, and |
| 299 |
|
// everything a browser reaches under it. |
| 300 |
|
// |
| 301 |
|
// r must be a chi Group and not a bare router. server.New has already frozen the |
| 302 |
|
// AnonRouter for direct middleware registration, and a Group is a fresh inline |
| 303 |
|
// mux over the same routing tree — which is where middleware and routes can |
| 304 |
|
// still be attached together. |
| 305 |
7 |
func mountRoutes(r chi.Router, s surfaces) error { |
| 306 |
7 |
// RequestID and RealIP first: the request line below carries the id and the |
| 307 |
7 |
// viewer's address, and neither exists until these have run. |
| 308 |
7 |
r.Use(chimiddleware.RequestID, chimiddleware.RealIP) |
| 309 |
7 |
// The request line as a slog record rather than chi's colourised line on |
| 310 |
7 |
// stdout — the one line this daemon emitted that was neither structured nor |
| 311 |
7 |
// on stderr, so an operator grepping the journal for a request id found every |
| 312 |
7 |
// panic and none of the requests. It goes outermost, above the panic guards, |
| 313 |
7 |
// so that the status it reports is the one that actually went out. |
| 314 |
7 |
r.Use(chimw.RequestLogger(chimw.SlogFormatter{})) |
| 315 |
7 |
r.Use(chimiddleware.Recoverer) |
| 316 |
7 |
r.Use(config.Middleware(s.conf, serviceName), database.Middleware(s.db)) |
| 317 |
7 |
|
| 318 |
7 |
// The MCP surface, and three things decide where this line is |
| 319 |
7 |
// (docs/DESIGN.mcp.md §3, §4.1): |
| 320 |
7 |
// |
| 321 |
7 |
// Before web.Register, because web claims "/" and wraps everything it mounts |
| 322 |
7 |
// in the same-origin CSRF group. /mcp is a bearer surface: no cookie, no |
| 323 |
7 |
// Origin header, no browser — the guard would refuse every call it ever |
| 324 |
7 |
// receives. |
| 325 |
7 |
// |
| 326 |
7 |
// Before the cookie middleware below, because the unified-login cookie is the |
| 327 |
7 |
// web UI's plane and this one accepts exactly one credential. A chi group |
| 328 |
7 |
// takes its middleware chain when its route is registered, so what is |
| 329 |
7 |
// installed after this line does not reach /mcp; that is the point of the |
| 330 |
7 |
// line's position and not an accident of it. |
| 331 |
7 |
// |
| 332 |
7 |
// In a Group of its own rather than a bare r.Handle here, because |
| 333 |
7 |
// web.Register installs middleware of its own on the router it is handed |
| 334 |
7 |
// (web/router.go's mount), and chi refuses a r.Use once any route exists on |
| 335 |
7 |
// that mux — "all middlewares must be defined before routes on a mux" is a |
| 336 |
7 |
// panic, so registering /mcp directly on r would fail this daemon's boot. |
| 337 |
7 |
// |
| 338 |
7 |
// Handle and not Mount: the streamable transport serves that exact path. |
| 339 |
7 |
// Mount would rewrite the routing path to the empty remainder and would also |
| 340 |
7 |
// claim /mcp/*, a subtree this surface does not serve. |
| 341 |
7 |
r.Group(func(r chi.Router) { |
| 342 |
7 |
r.Handle(mcpRoute, s.mcp) |
| 343 |
7 |
|
| 344 |
7 |
// /query is here for every reason /mcp is — before web's same-origin |
| 345 |
7 |
// CSRF group, before the cookie middleware, in a Group of its own — and |
| 346 |
7 |
// for one more: this schema answers anonymous callers, so it cannot be |
| 347 |
7 |
// mounted the way core-go's WithSchema mounts a schema (on the |
| 348 |
7 |
// authenticated router, whose auth.Middleware 401s an un-cookied |
| 349 |
7 |
// request). A public database is public on this endpoint too. |
| 350 |
7 |
// |
| 351 |
7 |
// It does need the config and database middleware installed above, |
| 352 |
7 |
// which is why the group is here rather than higher: every resolver |
| 353 |
7 |
// reads the metadata store through the request-scoped adapter. |
| 354 |
7 |
r.Handle(queryRoute, s.gql) |
| 355 |
7 |
// The file meta.sr.ht reads to learn what this service can be granted. |
| 356 |
7 |
// A service that mounts its own /query owes the instance this too — |
| 357 |
7 |
// core-go serves it only for the schemas it hosts itself. |
| 358 |
7 |
r.Get(apimeta.Path, apimeta.Handler(repoScopeName)) |
| 359 |
7 |
}) |
| 360 |
|
|
| 361 |
7 |
r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous |
| 362 |
7 |
|
| 363 |
7 |
return web.Register(r, web.Config{ |
| 364 |
7 |
Conf: s.conf, |
| 365 |
7 |
ReposRoot: s.cfg.reposRoot, |
| 366 |
7 |
StaticDir: s.cfg.staticDir, |
| 367 |
7 |
Stores: s.stores, |
| 368 |
7 |
Repos: web.DBAdapter{}, |
| 369 |
7 |
Browse: web.BrowseAdapter{}, |
| 370 |
7 |
Users: web.MetaUserResolver{}, |
| 371 |
7 |
Git: s.git, |
| 372 |
7 |
RepoDiskPath: func(owner, name string) string { |
| 373 |
0 |
return storage.RepoDiskPath(s.cfg.reposRoot, owner, name) |
| 374 |
0 |
}, |
| 375 |
|
}) |
| 376 |
|
} |
| 377 |
|
|
| 378 |
|
// fatal reports a startup failure and ends the process. slog has no Fatal, on |
| 379 |
|
// the argument that a logging call should not decide a program's lifetime; this |
| 380 |
|
// is the one place in this binary that wants both, so it is written once here |
| 381 |
|
// rather than as an Error/Exit pair at every call site. |
| 382 |
0 |
func fatal(doing string, err error) { |
| 383 |
0 |
slog.Error(doing+" failed", scribe.Err(err)) |
| 384 |
0 |
os.Exit(1) |
| 385 |
0 |
} |