~bigbes/sourcehut-dolt

ref: 2b781a27e6fd7eaa68ee4e692fe0da7348569daf sourcehut-dolt/remoteapi/server.go -rw-r--r-- 6.2 KiB
2b781a27 — Eugene Blikh remoteapi: authz interceptors, remotesrv assembly, credentials service 30 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package remoteapi

import (
	"context"
	"database/sql"
	"fmt"
	"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/sourcehut-dolt/db"
	"go.bigb.es/sourcehut-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
	// ("<ReposRoot>/~<owner>/<name>"). 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
	// Logger is the base logger; may be nil (a default is used).
	Logger *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 *logrus.Entry
	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 := cfg.Logger
	if logger == nil {
		logger = logrus.NewEntry(logrus.StandardLogger())
	}

	// Repo lookup: resolve owner/name to the absolute on-disk store dir via the
	// repository row. v1 has no push-to-create, so a missing row is an error the
	// cache propagates (mapped to gRPC NotFound at the interceptor layer, which
	// runs first anyway).
	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, logger)

	// 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:             logger,
		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.Warnf("remoteapi: closing cache after NewServer failure: %v", 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.Warnf("remoteapi: closing cache on shutdown: %v", 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
}