coverage~bigbes/sr-ht-doltede3b0bbcmd/doltsrht/main.go

Coverage
25.8% 17/66 statements
Δ
Blob
1c4cfb6
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 }