// Command doltsrht is the dolt.sr.ht service daemon: one process running three // listeners. // // - the web UI (chi) on the -b address (default localhost:5307), assembled on // the core-go server's AnonRouter with our own middleware group (config + // database + optional unified-login cookie) so anonymous browsing and public // clones keep working — we deliberately do NOT use core-go's default // middleware, whose auth.Middleware 401s any un-cookied request. The MCP // surface rides the same listener at /mcp, above the cookie plane and // outside web's same-origin group, because it is bearer-only // (docs/DESIGN.mcp.md §3). // - the remotesapi (gRPC ChunkStoreService + HTTP chunk data plane, one h2c // port) on [dolt.sr.ht]remotesapi-listen (default 127.0.0.1:5306). // - the CredentialsService.WhoAmI gRPC server for the `dolt login` keypair // flow on [dolt.sr.ht]credsapi-listen (default 127.0.0.1:5308). // // server.New runs crypto.InitCrypto, which requires [sr.ht]network-key and // [webhooks]private-key; a missing key panics there. The remotesapi server is // built before the web Config because the web store manager's Evict drives the // remotesapi chunk-store cache. package main import ( "context" "database/sql" "fmt" "log/slog" "net/http" "os" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" _ "github.com/lib/pq" // registers the "postgres" database/sql driver "github.com/vaughan0/go-ini" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/logrusbridge" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/database" "sourcecraft.dev/bigbes/sr-ht-core/server" "sourcecraft.dev/bigbes/sr-ht-ecore/chimw" "sourcecraft.dev/bigbes/sr-ht-ecore/instconf" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/remoteapi" "sourcecraft.dev/bigbes/sr-ht-dolt/storage" "sourcecraft.dev/bigbes/sr-ht-dolt/web" ) // serviceName is the SourceHut service identifier and config section name. const serviceName = "dolt.sr.ht" const ( defaultWebAddr = "localhost:5307" defaultReposRoot = "/var/lib/dolt" defaultStaticDir = "./static" defaultRemotesapiAddr = "127.0.0.1:5306" defaultCredsapiAddr = "127.0.0.1:5308" ) // storeManager satisfies web.StoreManager over the storage package and the // remotesapi server's chunk-store cache. web never imports storage/ or // remoteapi/; main is where the on-disk store lifecycle and the served cache are // tied together, so a database deleted through the web UI both removes its // on-disk store and evicts any handle the remotesapi server memoized. type storeManager struct { cache *storage.Cache } var _ web.StoreManager = (*storeManager)(nil) func (m *storeManager) InitStore(ctx context.Context, absPath, ownerName, ownerEmail string) error { return storage.InitStore(ctx, absPath, ownerName, ownerEmail) } func (m *storeManager) DeleteStore(ctx context.Context, root, absPath string) error { return storage.DeleteStore(ctx, root, absPath) } func (m *storeManager) Evict(diskPath string) error { return m.cache.Evict(diskPath) } // settings are the resolved [dolt.sr.ht] config values the daemon needs, split // out from main so the required-key and defaulting logic is unit-testable // without booting the process. type settings struct { connString string reposRoot string staticDir string remotesapiAddr string credsapiAddr string // origin is the canonical external origin, [dolt.sr.ht]origin as // instconf.CanonicalOrigin spells it. It is what /mcp guards its Host header // with (mcpsrv.New), and it is kept whole rather than reduced to httpHost // below because that check wants the name and this one wants the URL. origin string // httpHost is the bare authority (host[:port]) of the external origin. It is // stamped into sealed chunk-download URLs and seeds the keypair-JWT audience. httpHost string } // resolveSettings reads the [dolt.sr.ht] section, applying defaults and failing // on the keys that have no sensible default (connection-string and origin). // // Both gaps are reported together rather than one per boot. An operator filling // in a fresh config.ini wants the whole list in front of them, not one key per // restart, which is what instconf.Require is for. func resolveSettings(conf ini.File) (settings, error) { if err := instconf.Require(conf, instconf.Need(serviceName, "connection-string"), instconf.Need(serviceName, "origin"), ); err != nil { // A hint rather than a longer sentence: scribe prints it on its own // line, and what an operator meeting this needs is the keys to add, not // a restatement of the failure. return settings{}, culpa.WithHint(culpa.Wrap(err, "reading the config"), "origin is what places this service in every other service's nav") } // The authority and not the bare host: the port is part of what identifies // this endpoint, and https://x:8443 and https://x:9443 are two different // sealed-URL hosts and two different JWT audiences. "" here means the origin // is set but names no host — a scheme-less "dolt.example.org" is a path, not // a URL — which is a configuration error and not a reason to guess. origin := instconf.ExternalOrigin(conf, serviceName) host := instconf.OriginAuthority(origin) if host == "" { return settings{}, culpa.WithHint( culpa.New(fmt.Sprintf("[%s]origin names no host", serviceName)), "origin must be protocol://host, e.g. https://dolt.example.org") } return settings{ connString: config.GetString(conf, serviceName, "connection-string", ""), reposRoot: config.GetString(conf, serviceName, "repos", defaultReposRoot), staticDir: config.GetString(conf, serviceName, "static-dir", defaultStaticDir), remotesapiAddr: config.GetString(conf, serviceName, "remotesapi-listen", defaultRemotesapiAddr), credsapiAddr: config.GetString(conf, serviceName, "credsapi-listen", defaultCredsapiAddr), origin: origin, httpHost: host, }, nil } func main() { conf := config.LoadConfig() // Before anything that can fail: everything below, and every library this // process links, reports through slog's default logger. setupLogging(conf) // server.New parses -b/-d/-m/-p and runs crypto.InitCrypto (needs // [sr.ht]network-key + [webhooks]private-key; missing keys panic here). // Pass the full os.Args: core-go's getopt skips argv[0] as the program name // itself (like every upstream sourcehut daemon). Passing os.Args[1:] makes // getopt swallow the first real flag (e.g. -b) as the program name, so the // web bind silently falls back to defaultWebAddr (localhost) — unreachable // from Traefik/other containers. srv := server.New(serviceName, defaultWebAddr, conf, os.Args) cfg, err := resolveSettings(conf) if err != nil { fatal("reading the configuration", err) } db, err := sql.Open("postgres", cfg.connString) if err != nil { fatal("opening the postgres pool", err) } // Build the remotesapi server first: its chunk-store cache backs the web // store manager's Evict. rapiConf := remoteapi.Config{ Conf: conf, DB: db, ReposRoot: cfg.reposRoot, ListenAddr: cfg.remotesapiAddr, CredsListenAddr: cfg.credsapiAddr, HttpHost: cfg.httpHost, // dolt's remotesrv takes a *logrus.Entry and nothing else. Bridged, so // that the half of this process serving clones and pushes reports // through the same handler, at the same level and behind the same masks // as the half we wrote. DoltLogger: logrusbridge.Entry(), } rsrv, err := remoteapi.New(rapiConf) if err != nil { fatal("building the remotesapi server", err) } csrv, err := remoteapi.NewCredServer(rapiConf) if err != nil { fatal("building the credentials server", err) } stores := &storeManager{cache: rsrv.Cache()} // The git-description mirror is wired only on an instance that has a // git.sr.ht to ask. web.Config documents a nil Git as "no mirroring", but // nothing used to produce one: core-go's client.Do walks the API-origin // ladder through config.GetAPI, which panics when it reaches the end, so an // instance without git.sr.ht met that as a stack trace on the first push // rather than as a description it simply did not copy. var git web.GitDescriber if _, ok := instconf.InternalAPIOrigin(conf, "git.sr.ht"); ok { git = web.GitDescriptionResolver{} } else { slog.Warn("no git.sr.ht API origin is configured; companion databases will not mirror their git twin's description", "component", "web", "keys", instconf.APIOriginKeys()) } // The MCP surface, built before the router: its Host allowlist and its // credential plane come out of the config, so a wiring mistake in either // stops the boot rather than answering every agent 500 later. agents, err := newMCPServer(conf, cfg) if err != nil { fatal("building the mcp surface", err) } srv.AnonRouter().Group(func(r chi.Router) { if err := mountRoutes(r, surfaces{ conf: conf, db: db, cfg: cfg, stores: stores, git: git, mcp: agents, }); err != nil { fatal("mounting the web routes", err) } }) // Start the two gRPC listeners; each blocks in Serve, so run them in // goroutines and let the web server's Run own the SIGINT lifecycle. go func() { if err := rsrv.Serve(); err != nil { fatal("serving the remotesapi", err) } }() go func() { if err := csrv.Serve(); err != nil { fatal("serving the credentials api", err) } }() slog.Info("listening", "remotesapi", cfg.remotesapiAddr, "credentials", cfg.credsapiAddr, "web", defaultWebAddr, "mcp", mcpRoute, "instance_tokens", tokensDescription(conf)) // Blocks until SIGINT, then returns after draining the web listeners. srv.Run() // GracefulStop on the remotesapi server also closes every memoized chunk // store (its cache Close), so no separate storage cache Close is needed. slog.Info("stopping the grpc servers") rsrv.GracefulStop() csrv.GracefulStop() } // surfaces is everything mountRoutes needs to install the two things this // listener serves. It is a struct rather than a parameter list so that the boot // test assembles the daemon's own router — the one whose middleware order and // mount points are the thing worth testing — without a Postgres, a store on disk // or core-go's server.New. type surfaces struct { conf ini.File db *sql.DB cfg settings stores web.StoreManager git web.GitDescriber mcp http.Handler } // mountRoutes installs the web listener's surfaces on r: /mcp for agents, and // everything a browser reaches under it. // // r must be a chi Group and not a bare router. server.New has already frozen the // AnonRouter for direct middleware registration, and a Group is a fresh inline // mux over the same routing tree — which is where middleware and routes can // still be attached together. func mountRoutes(r chi.Router, s surfaces) error { // RequestID and RealIP first: the request line below carries the id and the // viewer's address, and neither exists until these have run. r.Use(chimiddleware.RequestID, chimiddleware.RealIP) // The request line as a slog record rather than chi's colourised line on // stdout — the one line this daemon emitted that was neither structured nor // on stderr, so an operator grepping the journal for a request id found every // panic and none of the requests. It goes outermost, above the panic guards, // so that the status it reports is the one that actually went out. r.Use(chimw.RequestLogger(chimw.SlogFormatter{})) r.Use(chimiddleware.Recoverer) r.Use(config.Middleware(s.conf, serviceName), database.Middleware(s.db)) // The MCP surface, and three things decide where this line is // (docs/DESIGN.mcp.md §3, §4.1): // // Before web.Register, because web claims "/" and wraps everything it mounts // in the same-origin CSRF group. /mcp is a bearer surface: no cookie, no // Origin header, no browser — the guard would refuse every call it ever // receives. // // Before the cookie middleware below, because the unified-login cookie is the // web UI's plane and this one accepts exactly one credential. A chi group // takes its middleware chain when its route is registered, so what is // installed after this line does not reach /mcp; that is the point of the // line's position and not an accident of it. // // In a Group of its own rather than a bare r.Handle here, because // web.Register installs middleware of its own on the router it is handed // (web/router.go's mount), and chi refuses a r.Use once any route exists on // that mux — "all middlewares must be defined before routes on a mux" is a // panic, so registering /mcp directly on r would fail this daemon's boot. // // Handle and not Mount: the streamable transport serves that exact path. // Mount would rewrite the routing path to the empty remainder and would also // claim /mcp/*, a subtree this surface does not serve. r.Group(func(r chi.Router) { r.Handle(mcpRoute, s.mcp) }) r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous return web.Register(r, web.Config{ Conf: s.conf, ReposRoot: s.cfg.reposRoot, StaticDir: s.cfg.staticDir, Stores: s.stores, Repos: web.DBAdapter{}, Browse: web.BrowseAdapter{}, Users: web.MetaUserResolver{}, Git: s.git, RepoDiskPath: func(owner, name string) string { return storage.RepoDiskPath(s.cfg.reposRoot, owner, name) }, }) } // fatal reports a startup failure and ends the process. slog has no Fatal, on // the argument that a logging call should not decide a program's lifetime; this // is the one place in this binary that wants both, so it is written once here // rather than as an Error/Exit pair at every call site. func fatal(doing string, err error) { slog.Error(doing+" failed", scribe.Err(err)) os.Exit(1) }