// 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://<host> 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{},
Git: web.GitDescriptionResolver{},
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()
}