| 1 |
|
package remoteapi |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"database/sql" |
| 6 |
|
"fmt" |
| 7 |
|
"log/slog" |
| 8 |
|
"net" |
| 9 |
|
|
| 10 |
|
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1" |
| 11 |
|
"github.com/dolthub/dolt/go/libraries/doltcore/remotesrv" |
| 12 |
|
"github.com/dolthub/dolt/go/libraries/utils/filesys" |
| 13 |
|
"github.com/sirupsen/logrus" |
| 14 |
|
"github.com/vaughan0/go-ini" |
| 15 |
|
|
| 16 |
|
"go.bigb.es/auxilia/scribe" |
| 17 |
|
|
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/db" |
| 19 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/storage" |
| 20 |
|
) |
| 21 |
|
|
| 22 |
|
// serviceName is the SourceHut service identifier for dolt.sr.ht. It selects |
| 23 |
|
// the config section and OAuth grant namespace used across the auth stack. |
| 24 |
|
const serviceName = "dolt.sr.ht" |
| 25 |
|
|
| 26 |
|
// Config configures the remotesapi server assembly. It is the single struct the |
| 27 |
|
// main wiring populates for both New (the chunk-store server) and NewCredServer |
| 28 |
|
// (the credentials server); each uses the subset it needs. |
| 29 |
|
type Config struct { |
| 30 |
|
// Conf is the loaded instance config (the shared config.ini as ini.File), |
| 31 |
|
// threaded into request contexts so the auth resolvers can read |
| 32 |
|
// [webhooks]private-key, network-key, meta origin, etc. |
| 33 |
|
Conf ini.File |
| 34 |
|
// DB is the shared Postgres pool. |
| 35 |
|
DB *sql.DB |
| 36 |
|
// ReposRoot is the absolute directory under which bare NBS stores live |
| 37 |
|
// ("<ReposRoot>/~<owner>/<name>"). The remotesrv filesys is rooted here. |
| 38 |
|
ReposRoot string |
| 39 |
|
// ListenAddr is the host:port for the remotesapi (gRPC + HTTP chunk data |
| 40 |
|
// plane multiplexed on one h2c port). |
| 41 |
|
ListenAddr string |
| 42 |
|
// CredsListenAddr is the host:port for the separate CredentialsService |
| 43 |
|
// (WhoAmI) gRPC server. |
| 44 |
|
CredsListenAddr string |
| 45 |
|
// HttpHost is the authority the server stamps into sealed chunk-download |
| 46 |
|
// URLs (e.g. "dolt.srht.bigb.es"; may carry a port for local testing, e.g. |
| 47 |
|
// "127.0.0.1:5306"). It also seeds the expected JWT audience: dolt's client |
| 48 |
|
// derives the audience with net.SplitHostPort(endpoint), i.e. the bare host |
| 49 |
|
// with any port stripped, so the audience the server must expect is |
| 50 |
|
// normalizeAud(HttpHost). Leave empty to echo the request :authority into |
| 51 |
|
// chunk URLs (auth then cannot be host-checked; used only by tests without a |
| 52 |
|
// stable host). |
| 53 |
|
HttpHost string |
| 54 |
|
// DoltLogger is the logger dolt's own remotesrv writes through. It is a |
| 55 |
|
// *logrus.Entry because that API takes nothing else; everything this |
| 56 |
|
// package logs itself goes through slog's default. |
| 57 |
|
// |
| 58 |
|
// The daemon passes logrusbridge.Entry(), which routes remotesrv's records |
| 59 |
|
// into the same slog handler as ours. That is not cosmetic: remotesrv is |
| 60 |
|
// the code serving remote clone and push traffic, so it is the likeliest |
| 61 |
|
// place in this process for a credential to reach a log field, and a |
| 62 |
|
// logger of its own would put those records past the masks. A nil entry |
| 63 |
|
// lets remotesrv install logrus' standard logger and log around |
| 64 |
|
// everything — acceptable in a test that only wants it quiet, not in the |
| 65 |
|
// daemon. |
| 66 |
|
DoltLogger *logrus.Entry |
| 67 |
|
} |
| 68 |
|
|
| 69 |
|
// Server is the assembled remotesapi server: the remotesrv chunk-store server |
| 70 |
|
// plus the storage cache it serves from. The cache is exposed so the web delete |
| 71 |
|
// flow can evict a store when its repository is removed. |
| 72 |
|
type Server struct { |
| 73 |
|
srv *remotesrv.Server |
| 74 |
|
cache *storage.Cache |
| 75 |
|
logger *slog.Logger |
| 76 |
|
addr string |
| 77 |
|
} |
| 78 |
|
|
| 79 |
|
// New assembles the remotesapi chunk-store server: a db-backed repo lookup, the |
| 80 |
|
// storage cache, our auth/authz interceptors, and the importable remotesrv |
| 81 |
|
// server bound to a single h2c port. It does not start listening; call Serve. |
| 82 |
0 |
func New(cfg Config) (*Server, error) { |
| 83 |
0 |
if cfg.DB == nil { |
| 84 |
0 |
return nil, fmt.Errorf("remoteapi: New requires a non-nil DB") |
| 85 |
0 |
} |
| 86 |
0 |
if cfg.ReposRoot == "" { |
| 87 |
0 |
return nil, fmt.Errorf("remoteapi: New requires a ReposRoot") |
| 88 |
0 |
} |
| 89 |
0 |
logger := slog.Default().With("component", "remotesapi") |
| 90 |
0 |
|
| 91 |
0 |
// Repo lookup: resolve owner/name to the absolute on-disk store dir via the |
| 92 |
0 |
// repository row. By the time Cache.Get runs, the row already exists: the |
| 93 |
0 |
// interceptor runs first on every RPC and, for an authenticated owner |
| 94 |
0 |
// touching a new name in their own namespace, has auto-created the row and |
| 95 |
0 |
// its empty store (push-to-create). A still-missing row here is therefore a |
| 96 |
0 |
// genuine not-found, propagated to the cache's caller. |
| 97 |
0 |
lookup := func(ctx context.Context, owner, name string) (string, error) { |
| 98 |
0 |
repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name) |
| 99 |
0 |
if err != nil { |
| 100 |
0 |
return "", err |
| 101 |
0 |
} |
| 102 |
0 |
return repo.Path, nil |
| 103 |
|
} |
| 104 |
0 |
cache := storage.NewCache(lookup) |
| 105 |
0 |
|
| 106 |
0 |
keys := newKeyStore(cfg.DB) |
| 107 |
0 |
icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, cfg.ReposRoot, storage.InitEmptyStore) |
| 108 |
0 |
|
| 109 |
0 |
// Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via |
| 110 |
0 |
// LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean |
| 111 |
0 |
// relative prefixes; a bare LocalFS breaks every clone/push at chunk |
| 112 |
0 |
// transfer. |
| 113 |
0 |
fs, err := filesys.LocalFilesysWithWorkingDir(cfg.ReposRoot) |
| 114 |
0 |
if err != nil { |
| 115 |
0 |
return nil, fmt.Errorf("remoteapi: root filesys at %q: %w", cfg.ReposRoot, err) |
| 116 |
0 |
} |
| 117 |
|
|
| 118 |
0 |
srv, err := remotesrv.NewServer(remotesrv.ServerArgs{ |
| 119 |
0 |
Logger: cfg.DoltLogger, |
| 120 |
0 |
HttpHost: cfg.HttpHost, |
| 121 |
0 |
HttpListenAddr: cfg.ListenAddr, |
| 122 |
0 |
GrpcListenAddr: cfg.ListenAddr, // == HttpListenAddr ⇒ single h2c port |
| 123 |
0 |
FS: fs, |
| 124 |
0 |
DBCache: cache, |
| 125 |
0 |
ReadOnly: false, |
| 126 |
0 |
Options: icept.Options(), |
| 127 |
0 |
ConcurrencyControl: remotesapi.PushConcurrencyControl_PUSH_CONCURRENCY_CONTROL_IGNORE_WORKING_SET, |
| 128 |
0 |
}) |
| 129 |
0 |
if err != nil { |
| 130 |
0 |
if cerr := cache.Close(); cerr != nil { |
| 131 |
0 |
logger.Warn("closing the chunk-store cache after a NewServer failure", scribe.Err(cerr)) |
| 132 |
0 |
} |
| 133 |
0 |
return nil, fmt.Errorf("remoteapi: remotesrv.NewServer: %w", err) |
| 134 |
|
} |
| 135 |
|
|
| 136 |
0 |
return &Server{srv: srv, cache: cache, logger: logger, addr: cfg.ListenAddr}, nil |
| 137 |
|
} |
| 138 |
|
|
| 139 |
|
// Cache returns the storage cache backing this server so the web delete flow |
| 140 |
|
// can evict a store on repository removal. |
| 141 |
0 |
func (s *Server) Cache() *storage.Cache { return s.cache } |
| 142 |
|
|
| 143 |
|
// Serve binds the listeners and serves until GracefulStop. It blocks. It |
| 144 |
|
// returns an error only if binding the listeners fails; the underlying |
| 145 |
|
// remotesrv.Serve blocks until shutdown and does not return an error. |
| 146 |
0 |
func (s *Server) Serve() error { |
| 147 |
0 |
listeners, err := s.srv.Listeners() |
| 148 |
0 |
if err != nil { |
| 149 |
0 |
return fmt.Errorf("remoteapi: bind %q: %w", s.addr, err) |
| 150 |
0 |
} |
| 151 |
0 |
s.srv.Serve(listeners) |
| 152 |
0 |
return nil |
| 153 |
|
} |
| 154 |
|
|
| 155 |
|
// GracefulStop stops the server and closes every memoized chunk store. |
| 156 |
0 |
func (s *Server) GracefulStop() { |
| 157 |
0 |
s.srv.GracefulStop() |
| 158 |
0 |
if err := s.cache.Close(); err != nil { |
| 159 |
0 |
s.logger.Warn("closing the chunk-store cache on shutdown", scribe.Err(err)) |
| 160 |
0 |
} |
| 161 |
|
} |
| 162 |
|
|
| 163 |
|
// normalizeAud reduces a configured HttpHost to the bare host the dolt client |
| 164 |
|
// puts in a JWT audience. Verified against dolt's grpc_dial_provider: |
| 165 |
|
// getHostFromEndpoint(endpoint) calls net.SplitHostPort and returns the host |
| 166 |
|
// with any port stripped, so the audience the server receives is always the |
| 167 |
|
// bare host. Normalizing here lets operators write either "dolt.srht.bigb.es" |
| 168 |
|
// or "dolt.srht.bigb.es:443" (or a "127.0.0.1:PORT" test host) and get the same |
| 169 |
|
// expected audience. An empty host yields an empty audience (no keypair auth). |
| 170 |
4 |
func normalizeAud(host string) string { |
| 171 |
4 |
if host == "" { |
| 172 |
1 |
return "" |
| 173 |
1 |
} |
| 174 |
3 |
if h, _, err := net.SplitHostPort(host); err == nil { |
| 175 |
2 |
return h |
| 176 |
2 |
} |
| 177 |
1 |
return host |
| 178 |
|
} |