// 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 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" "net/url" "os" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" _ "github.com/lib/pq" // registers the "postgres" database/sql driver "github.com/sirupsen/logrus" "github.com/vaughan0/go-ini" "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-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 // 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). func resolveSettings(conf ini.File) (settings, error) { connString, ok := conf.Get(serviceName, "connection-string") if !ok || connString == "" { return settings{}, fmt.Errorf("missing required [%s]connection-string in config.ini", serviceName) } host, err := hostFromOrigin(config.GetOrigin(conf, serviceName, true)) if err != nil { return settings{}, fmt.Errorf("[%s]origin: %w", serviceName, err) } return settings{ connString: connString, 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), httpHost: host, }, nil } // hostFromOrigin extracts the host[:port] authority from a service origin URL // (e.g. "https://dolt.srht.bigb.es" -> "dolt.srht.bigb.es"). A missing or // host-less origin is an error: the origin is required (it is what places the // service in every other service's nav) and drives sealed-URL and JWT-audience // correctness. func hostFromOrigin(origin string) (string, error) { if origin == "" { return "", fmt.Errorf("empty origin; set origin=https:// in config.ini") } u, err := url.Parse(origin) if err != nil { return "", fmt.Errorf("parse origin %q: %w", origin, err) } if u.Host == "" { return "", fmt.Errorf("origin %q has no host", origin) } return u.Host, nil } func main() { conf := config.LoadConfig() // 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 { log.Fatalf("doltsrht: %v", err) } db, err := sql.Open("postgres", cfg.connString) if err != nil { log.Fatalf("doltsrht: open postgres: %v", err) } logger := logrus.NewEntry(logrus.StandardLogger()) // 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, Logger: logger, } rsrv, err := remoteapi.New(rapiConf) if err != nil { log.Fatalf("doltsrht: build remotesapi server: %v", err) } csrv, err := remoteapi.NewCredServer(rapiConf) if err != nil { log.Fatalf("doltsrht: build credentials server: %v", err) } stores := &storeManager{cache: rsrv.Cache()} srv.AnonRouter().Group(func(r chi.Router) { r.Use(chimw.RealIP, chimw.Recoverer) r.Use(config.Middleware(conf, serviceName), database.Middleware(db)) r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous if err := web.Register(r, web.Config{ Conf: conf, ReposRoot: cfg.reposRoot, StaticDir: cfg.staticDir, Stores: stores, Repos: web.DBAdapter{}, Browse: web.BrowseAdapter{}, Users: web.MetaUserResolver{}, RepoDiskPath: func(owner, name string) string { return storage.RepoDiskPath(cfg.reposRoot, owner, name) }, }); err != nil { log.Fatalf("doltsrht: web.Register: %v", 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 { log.Fatalf("doltsrht: remotesapi serve: %v", err) } }() go func() { if err := csrv.Serve(); err != nil { log.Fatalf("doltsrht: credentials serve: %v", err) } }() logger.Infof("doltsrht: remotesapi on %s, credentials on %s, web on %s", cfg.remotesapiAddr, cfg.credsapiAddr, defaultWebAddr) // 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. logger.Info("doltsrht: stopping gRPC servers") rsrv.GracefulStop() csrv.GracefulStop() }