// 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/slog"
"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/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/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
// 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.
host := instconf.OriginAuthority(instconf.ExternalOrigin(conf, serviceName))
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),
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())
}
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{},
Git: git,
RepoDiskPath: func(owner, name string) string {
return storage.RepoDiskPath(cfg.reposRoot, owner, name)
},
}); 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)
// 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()
}
// 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)
}