package remoteapi import ( "context" "database/sql" "fmt" "log/slog" "net" remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1" "github.com/dolthub/dolt/go/libraries/doltcore/remotesrv" "github.com/dolthub/dolt/go/libraries/utils/filesys" "github.com/sirupsen/logrus" "github.com/vaughan0/go-ini" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-dolt/db" "sourcecraft.dev/bigbes/sr-ht-dolt/storage" ) // serviceName is the SourceHut service identifier for dolt.sr.ht. It selects // the config section and OAuth grant namespace used across the auth stack. const serviceName = "dolt.sr.ht" // Config configures the remotesapi server assembly. It is the single struct the // main wiring populates for both New (the chunk-store server) and NewCredServer // (the credentials server); each uses the subset it needs. type Config struct { // Conf is the loaded instance config (the shared config.ini as ini.File), // threaded into request contexts so the auth resolvers can read // [webhooks]private-key, network-key, meta origin, etc. Conf ini.File // DB is the shared Postgres pool. DB *sql.DB // ReposRoot is the absolute directory under which bare NBS stores live // ("/~/"). The remotesrv filesys is rooted here. ReposRoot string // ListenAddr is the host:port for the remotesapi (gRPC + HTTP chunk data // plane multiplexed on one h2c port). ListenAddr string // CredsListenAddr is the host:port for the separate CredentialsService // (WhoAmI) gRPC server. CredsListenAddr string // HttpHost is the authority the server stamps into sealed chunk-download // URLs (e.g. "dolt.srht.bigb.es"; may carry a port for local testing, e.g. // "127.0.0.1:5306"). It also seeds the expected JWT audience: dolt's client // derives the audience with net.SplitHostPort(endpoint), i.e. the bare host // with any port stripped, so the audience the server must expect is // normalizeAud(HttpHost). Leave empty to echo the request :authority into // chunk URLs (auth then cannot be host-checked; used only by tests without a // stable host). HttpHost string // DoltLogger is the logger dolt's own remotesrv writes through. It is a // *logrus.Entry because that API takes nothing else; everything this // package logs itself goes through slog's default. // // The daemon passes logrusbridge.Entry(), which routes remotesrv's records // into the same slog handler as ours. That is not cosmetic: remotesrv is // the code serving remote clone and push traffic, so it is the likeliest // place in this process for a credential to reach a log field, and a // logger of its own would put those records past the masks. A nil entry // lets remotesrv install logrus' standard logger and log around // everything — acceptable in a test that only wants it quiet, not in the // daemon. DoltLogger *logrus.Entry } // Server is the assembled remotesapi server: the remotesrv chunk-store server // plus the storage cache it serves from. The cache is exposed so the web delete // flow can evict a store when its repository is removed. type Server struct { srv *remotesrv.Server cache *storage.Cache logger *slog.Logger addr string } // New assembles the remotesapi chunk-store server: a db-backed repo lookup, the // storage cache, our auth/authz interceptors, and the importable remotesrv // server bound to a single h2c port. It does not start listening; call Serve. func New(cfg Config) (*Server, error) { if cfg.DB == nil { return nil, fmt.Errorf("remoteapi: New requires a non-nil DB") } if cfg.ReposRoot == "" { return nil, fmt.Errorf("remoteapi: New requires a ReposRoot") } logger := slog.Default().With("component", "remotesapi") // Repo lookup: resolve owner/name to the absolute on-disk store dir via the // repository row. By the time Cache.Get runs, the row already exists: the // interceptor runs first on every RPC and, for an authenticated owner // touching a new name in their own namespace, has auto-created the row and // its empty store (push-to-create). A still-missing row here is therefore a // genuine not-found, propagated to the cache's caller. lookup := func(ctx context.Context, owner, name string) (string, error) { repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name) if err != nil { return "", err } return repo.Path, nil } cache := storage.NewCache(lookup) keys := newKeyStore(cfg.DB) icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, cfg.ReposRoot, storage.InitEmptyStore) // Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via // LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean // relative prefixes; a bare LocalFS breaks every clone/push at chunk // transfer. fs, err := filesys.LocalFilesysWithWorkingDir(cfg.ReposRoot) if err != nil { return nil, fmt.Errorf("remoteapi: root filesys at %q: %w", cfg.ReposRoot, err) } srv, err := remotesrv.NewServer(remotesrv.ServerArgs{ Logger: cfg.DoltLogger, HttpHost: cfg.HttpHost, HttpListenAddr: cfg.ListenAddr, GrpcListenAddr: cfg.ListenAddr, // == HttpListenAddr ⇒ single h2c port FS: fs, DBCache: cache, ReadOnly: false, Options: icept.Options(), ConcurrencyControl: remotesapi.PushConcurrencyControl_PUSH_CONCURRENCY_CONTROL_IGNORE_WORKING_SET, }) if err != nil { if cerr := cache.Close(); cerr != nil { logger.Warn("closing the chunk-store cache after a NewServer failure", scribe.Err(cerr)) } return nil, fmt.Errorf("remoteapi: remotesrv.NewServer: %w", err) } return &Server{srv: srv, cache: cache, logger: logger, addr: cfg.ListenAddr}, nil } // Cache returns the storage cache backing this server so the web delete flow // can evict a store on repository removal. func (s *Server) Cache() *storage.Cache { return s.cache } // Serve binds the listeners and serves until GracefulStop. It blocks. It // returns an error only if binding the listeners fails; the underlying // remotesrv.Serve blocks until shutdown and does not return an error. func (s *Server) Serve() error { listeners, err := s.srv.Listeners() if err != nil { return fmt.Errorf("remoteapi: bind %q: %w", s.addr, err) } s.srv.Serve(listeners) return nil } // GracefulStop stops the server and closes every memoized chunk store. func (s *Server) GracefulStop() { s.srv.GracefulStop() if err := s.cache.Close(); err != nil { s.logger.Warn("closing the chunk-store cache on shutdown", scribe.Err(err)) } } // normalizeAud reduces a configured HttpHost to the bare host the dolt client // puts in a JWT audience. Verified against dolt's grpc_dial_provider: // getHostFromEndpoint(endpoint) calls net.SplitHostPort and returns the host // with any port stripped, so the audience the server receives is always the // bare host. Normalizing here lets operators write either "dolt.srht.bigb.es" // or "dolt.srht.bigb.es:443" (or a "127.0.0.1:PORT" test host) and get the same // expected audience. An empty host yields an empty audience (no keypair auth). func normalizeAud(host string) string { if host == "" { return "" } if h, _, err := net.SplitHostPort(host); err == nil { return h } return host }