// 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)
}