1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "crypto/ed25519"
8 "crypto/rand"
9 "crypto/rsa"
10 "errors"
11 "fmt"
12 "net"
13 "os"
14 "path/filepath"
15 "strings"
16 "time"
17
18 "github.com/bnema/zerowrap"
19
20 "bnema.dev/ermite/internal/adapters/ciartifacts"
21 "bnema.dev/ermite/internal/adapters/cilogs"
22 "bnema.dev/ermite/internal/adapters/cisummaries"
23 "bnema.dev/ermite/internal/adapters/ermitegit"
24 "bnema.dev/ermite/internal/adapters/eventbus"
25 "bnema.dev/ermite/internal/adapters/gitcli"
26 "bnema.dev/ermite/internal/adapters/githttp"
27 "bnema.dev/ermite/internal/adapters/githubapi"
28 "bnema.dev/ermite/internal/adapters/githubraw"
29 "bnema.dev/ermite/internal/adapters/gitremote"
30 "bnema.dev/ermite/internal/adapters/httpserver"
31 "bnema.dev/ermite/internal/adapters/kvstore"
32 "bnema.dev/ermite/internal/adapters/oidchttp"
33 "bnema.dev/ermite/internal/adapters/rpc"
34 "bnema.dev/ermite/internal/adapters/secretbox"
35 "bnema.dev/ermite/internal/adapters/sigverify"
36 "bnema.dev/ermite/internal/adapters/sshsig"
37 "bnema.dev/ermite/internal/adapters/web"
38 "bnema.dev/ermite/internal/adapters/webauthn"
39 "bnema.dev/ermite/internal/clientlimit"
40 "bnema.dev/ermite/internal/config"
41 "bnema.dev/ermite/internal/domain"
42 "bnema.dev/ermite/internal/ports"
43 "bnema.dev/ermite/internal/usecase"
44)
45
46// maxReceiveBytes bounds the body of a push, both at the HTTP handler and in
47// Git (receive.maxInputSize).
48const maxReceiveBytes = githttp.DefaultMaxBodyBytes
49
50// options are the parts of the wiring that tests replace.
51type options struct {
52 // hookExe is the executable the hook scripts exec; empty means this
53 // process's own executable.
54 hookExe string
55 // maintenanceDelay overrides usecase.MaintenanceDelay when positive.
56 maintenanceDelay time.Duration
57 // maxBodyBytes overrides the limit of a push body when positive.
58 maxBodyBytes int64
59 // eventBuffer overrides usecase.DefaultEventBuffer when positive.
60 eventBuffer int
61 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
62 revalidateEvery time.Duration
63 // archivesPerClient overrides the archives one client downloads at once
64 // when positive.
65 archivesPerClient int
66 // remote replaces the lister of github.com refs (tests never reach the
67 // network).
68 remoteLister ports.GitRemote
69 // workflowFetcher replaces the downloader of github.com reusable workflows
70 // (tests never reach the network).
71 workflowFetcher ports.WorkflowFetcher
72 // ci overrides the settings of the CI engine (tests shorten its
73 // durations).
74 ci func(*usecase.CIConfig)
75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
76 // keys (tests give prepared ones: a 3072-bit key takes a while).
77 oidcKey func() (*rsa.PrivateKey, error)
78}
79
80// remote returns the lister of github.com refs: the test's, else def.
81func (o options) remote(def ports.GitRemote) ports.GitRemote {
82 if o.remoteLister != nil {
83 return o.remoteLister
84 }
85 return def
86}
87
88// workflows returns the downloader of github.com reusable workflows: the
89// test's, else def.
90func (o options) workflows(def ports.WorkflowFetcher) ports.WorkflowFetcher {
91 if o.workflowFetcher != nil {
92 return o.workflowFetcher
93 }
94 return def
95}
96
97// forge is the wired server: every adapter and use case, ready to serve.
98type forge struct {
99 cfg config.Config
100 store *kvstore.Store
101 server *httpserver.Server
102 hooks *githttp.HookServer
103 maint *usecase.Maintainer
104 git *gitcli.Git
105 // reader is the ermite-git reader when ERMITE_GIT_READER selects it.
106 reader *ermitegit.Adapter
107
108 // The use cases and the API are exposed for tests and for the phases
109 // that add the web.
110 Repos *usecase.Repos
111 Tokens *usecase.Tokens
112 Auth *usecase.Auth
113 Pushes *usecase.Pushes
114 Browse *usecase.Browse
115 // MergeRequests and SigningKey serve MergeRequestService and
116 // ServerService.GetSigningKey.
117 MergeRequests *usecase.MergeRequests
118 SigningKey *usecase.SigningKey
119 // Signatures are the trusted keys and the commit signature checks
120 // (ADR 0030).
121 Signatures *usecase.Signatures
122 Sessions *usecase.Sessions
123 // Passkeys is nil when passkeys are not available (passkeysAvailable).
124 Passkeys *usecase.Passkeys
125 EventLog *usecase.Events
126 // CI is the CI engine: workflow trigger, planner, runner protocol and the
127 // owner side. It starts with the server and stops with it.
128 CI *usecase.CI
129 // Settings are the secrets, variables and environments of CI.
130 Settings *usecase.CISettings
131 // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
132 // key.
133 OIDC *usecase.OIDC
134 API *rpc.API
135 Bus ports.EventBus
136}
137
138// newSigningSeed makes the seed of a new ed25519 signing key.
139func newSigningSeed() ([]byte, error) {
140 seed := make([]byte, ed25519.SeedSize)
141 if _, err := rand.Read(seed); err != nil {
142 return nil, err
143 }
144 return seed, nil
145}
146
147// serverVersion is the version GetVersion reports: the module version of the
148// build, or "devel" for an untagged one.
149func serverVersion() string {
150 if v := buildVersion(); v != "(devel)" {
151 return v
152 }
153 return "devel"
154}
155
156// newForge opens the metadata store, resets run/, checks git, reconciles the repositories,
157// writes the hook scripts, opens the hook socket and builds the HTTP server.
158// The data directory must be prepared (prepareDataDir). Close it after the
159// server stopped.
160func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, err error) {
161 log := zerowrap.FromCtx(ctx)
162 store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv"))
163 if err != nil {
164 return nil, err
165 }
166 f := &forge{cfg: cfg, store: store}
167 defer func() {
168 if err != nil {
169 f.Close(context.WithoutCancel(ctx))
170 }
171 }()
172 // The kv store holds the directory lock: only now is run/ ours to reset.
173 // The hook scripts and the socket are written into it afterwards.
174 if err := resetRunDir(cfg.DataDir); err != nil {
175 return nil, err
176 }
177
178 path := os.Getenv("PATH")
179 if path == "" {
180 path = "/usr/local/bin:/usr/bin:/bin"
181 }
182 maxBody := maxReceiveBytes
183 if opts.maxBodyBytes > 0 {
184 maxBody = opts.maxBodyBytes
185 }
186 git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path, MaxReceiveBytes: maxBody})
187 f.git = git
188 gitVersion, err := git.CheckVersion(ctx)
189 if err != nil {
190 return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
191 }
192 log.Info().Str("git", gitVersion.String()).Msg("git checked")
193 if _, err := git.HTTPBackend(ctx, false); err != nil {
194 return nil, fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err)
195 }
196
197 clock := systemClock{}
198 f.Bus = eventbus.New()
199 f.Repos = usecase.NewRepos(store, git, f.Bus, clock)
200 report, err := f.Repos.Reconcile(ctx)
201 if err != nil {
202 return nil, fmt.Errorf("reconcile repositories: %w", err)
203 }
204 log.Info().
205 Int("registered", len(report.Registered)).
206 Int("missing_dirs", len(report.MissingDirs)).
207 Int("invalid_dirs", len(report.InvalidDirs)).
208 Msg("repositories reconciled")
209
210 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
211 f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
212 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
213 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
214
215 exe := opts.hookExe
216 if exe == "" {
217 if exe, err = os.Executable(); err != nil {
218 return nil, fmt.Errorf("find the ermite executable for the hooks: %w", err)
219 }
220 }
221 if err := githttp.WriteHookScripts(gitcli.HooksDir(cfg.DataDir), exe); err != nil {
222 return nil, err
223 }
224 f.hooks = githttp.NewHookServer(filepath.Join(cfg.DataDir, "run", "hook.sock"), f.Pushes)
225 if err := f.hooks.Listen(ctx); err != nil {
226 return nil, err
227 }
228
229 var reader ports.RepoReader = git
230 if cfg.GitReader == config.GitReaderErmiteGit {
231 f.reader = ermitegit.New(ermitegit.Config{
232 Fallback: git,
233 RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
234 })
235 git.OnTrash(f.reader.Forget)
236 f.maint.WithFreshener(f.reader)
237 f.Pushes.WithFreshener(f.reader)
238 reader = f.reader
239 }
240 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
241 f.Browse = usecase.NewBrowse(store, reader)
242
243 // Secrets, variables and environments (ADR 0019, CI-3). With a key the
244 // secrets are sealed under it, and the server refuses to start when the
245 // key is not the one they were sealed with; without one they cannot be set
246 // and jobs receive none.
247 var sealer ports.SecretSealer // a nil *secretbox.Box must not become a non-nil interface
248 if cfg.SecretsKey != nil {
249 box, err := secretbox.New(cfg.SecretsKey)
250 if err != nil {
251 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
252 }
253 sealer = box
254 }
255 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
256 if err != nil {
257 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
258 }
259 // The OIDC provider of CI jobs (CI-4): its signing key is sealed with the
260 // secrets key, so there is none without one.
261 f.OIDC, err = usecase.NewOIDC(ctx, usecase.OIDCConfig{
262 Store: store, Sealer: sealer, Tokens: store.CI(), Clock: clock, PublicURL: cfg.PublicURL, NewKey: opts.oidcKey,
263 })
264 if err != nil {
265 return nil, fmt.Errorf("oidc: %w", err)
266 }
267
268 // CI (ADR 0025): the engine reads workflows through the reader, and plans
269 // them in the background; a push only queues its refs.
270 committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail}
271 ciCfg := usecase.CIConfig{
272 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Summaries: cisummaries.New(cisummaries.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
273 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
274 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
275 PollTimeout: cfg.CIPollTimeout, Archive: git,
276 Remote: opts.remote(gitremote.New(gitremote.Config{
277 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
278 Proxy: gitremote.ProxyEnv(os.Getenv),
279 })),
280 Tokens: store.Tokens(),
281 RefTips: store.CI(),
282 Settings: f.Settings,
283 Workflows: opts.workflows(githubraw.New(githubraw.Config{})),
284 OIDC: f.OIDC,
285 }
286 artifacts(&ciCfg, cfg, store)
287 f.pullRequests(&ciCfg, store, git, committer)
288 if opts.ci != nil {
289 opts.ci(&ciCfg)
290 }
291 f.CI = usecase.NewCI(ctx, ciCfg)
292 f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
293 f.Repos.WithCIRunCleaner(f.CI)
294 f.CI.Start()
295 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
296 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
297 })
298
299 // The instance signing key (ADR 0022): generated on the first start.
300 seed, err := store.SigningSeed(ctx, newSigningSeed, clock.Now().UTC())
301 if err != nil {
302 return nil, err
303 }
304 signer, err := sshsig.New(seed)
305 if err != nil {
306 return nil, err
307 }
308 f.SigningKey = usecase.NewSigningKey(signer, committer)
309 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
310 return nil, err
311 }
312 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
313 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
314 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
315 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
316 })
317 f.MergeRequests.WithVerifier(f.Signatures)
318 f.CI.SetPullGate(f.MergeRequests)
319 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
320
321 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
322 if err != nil {
323 return nil, err
324 }
325
326 // Owner passkeys (ADR 0026): built only when the public URL allows them.
327 // When it does, a verifier that cannot be built is fatal.
328 if ok, reason := passkeysAvailable(cfg.PublicURL); ok {
329 verifier, err := webauthn.New(cfg.PublicURL, cfg.Owner)
330 if err != nil {
331 return nil, fmt.Errorf("passkeys: %w", err)
332 }
333 f.Passkeys = usecase.NewPasskeys(store.Passkeys(), verifier, f.Sessions, clock)
334 log.Info().Str("public_url", cfg.PublicURL).Msg("passkeys enabled")
335 } else {
336 log.Info().Str("reason", reason).Msg("passkeys disabled")
337 }
338
339 // One budget of failed authentications per client, for Git, the API and
340 // the web login.
341 limiter := clientlimit.NewLimiter(nil)
342 // A nil *usecase.Passkeys must not become a non-nil interface: the API
343 // tests for nil to answer failed_precondition and to leave the feature out.
344 var apiPasskeys ports.PasskeyService
345 if f.Passkeys != nil {
346 apiPasskeys = f.Passkeys
347 }
348 var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
349 if f.OIDC != nil {
350 apiOIDC = f.OIDC
351 }
352 var jobArtifacts ports.JobArtifactService
353 var artifacts ports.ArtifactService
354 if cfg.CIArtifacts {
355 jobArtifacts, artifacts = f.CI, f.CI
356 }
357 f.API = rpc.New(rpc.Config{
358 Auth: f.Auth,
359 Repos: f.Repos,
360 Tokens: f.Tokens,
361 Browse: f.Browse,
362 Events: f.EventLog,
363 MergeRequests: f.MergeRequests,
364 SigningKey: f.SigningKey,
365 TrustedKeys: f.Signatures,
366 Sessions: f.Sessions,
367 Passkeys: apiPasskeys,
368 Runner: f.CI,
369 Runs: f.CI,
370 Secrets: f.Settings,
371 Variables: f.Settings,
372 Environments: f.Settings,
373 OIDCKeys: apiOIDC,
374 JobArtifacts: jobArtifacts,
375 Artifacts: artifacts,
376 Version: serverVersion(),
377 TrustedProxies: cfg.TrustedProxies,
378 Limiter: limiter,
379 })
380
381 gh := githttp.New(githttp.Config{
382 Auth: f.Auth,
383 Repos: f.Repos,
384 Pushes: f.Pushes,
385 Backend: git,
386 Owner: cfg.Owner,
387 Hooks: f.hooks,
388 TrustedProxies: cfg.TrustedProxies,
389 Limiter: limiter,
390 MaxBodyBytes: maxBody,
391 })
392 routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(oidchttp.Patterns)+len(web.Patterns))
393 for _, p := range githttp.Patterns {
394 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
395 }
396 // The owner form of the clone URL (github.repository), which GitHub's
397 // actions use. config.Owner follows the rules of repository names, so it is
398 // a literal segment that never overlaps /-/.
399 for _, p := range githttp.OwnerPatterns(cfg.Owner) {
400 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
401 }
402 // The REST subset the actions call on github.api_url.
403 api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
404 for _, p := range githubapi.Patterns {
405 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
406 }
407 // The OIDC provider of CI jobs (issuer <public URL>/-/oidc).
408 idp := oidchttp.New(oidchttp.Config{OIDC: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
409 for _, p := range oidchttp.Patterns {
410 routes = append(routes, httpserver.Route{Pattern: p, Handler: idp})
411 }
412 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
413 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
414 site, err := newWeb(cfg, f, limiter)
415 if err != nil {
416 return nil, fmt.Errorf("web: %w", err)
417 }
418 routes = append(routes, webRoutes(site)...)
419 f.server = httpserver.New(httpserver.Config{
420 Addr: cfg.Addr,
421 PublicURL: cfg.PublicURL,
422 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
423 Routes: routes,
424 API: f.API.Handler(),
425 // Event streams never end by themselves: close them when the
426 // shutdown begins, or it would wait for them until its timeout.
427 OnShutdown: []func(){func() { f.EventLog.Close(context.WithoutCancel(ctx)) }},
428 })
429 return f, nil
430}
431
432// Run listens on the configured address and serves until ctx ends.
433func (f *forge) Run(ctx context.Context) error {
434 ln, err := net.Listen("tcp", f.cfg.Addr)
435 if err != nil {
436 return fmt.Errorf("listen on %s: %w", f.cfg.Addr, err)
437 }
438 return f.Serve(ctx, ln)
439}
440
441// Serve serves HTTP on ln and the hook socket until ctx ends. The hook socket
442// outlives the HTTP server: requests still in flight while it drains are
443// pushes whose hooks need it. A failure of either stops both.
444func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
445 ctx, cancel := context.WithCancel(ctx)
446 defer cancel()
447 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
448 defer stopHooks()
449
450 hookErr := make(chan error, 1)
451 go func() {
452 err := f.hooks.Serve(hookCtx)
453 cancel() // a dead hook socket would reject every push: stop serving
454 hookErr <- err
455 }()
456
457 httpErr := f.server.Serve(ctx, ln)
458 stopHooks()
459 return errors.Join(httpErr, <-hookErr)
460}
461
462// Close releases what newForge opened. It is safe to call twice. Call it after
463// the HTTP server stopped: it stops the event log and the long-lived Git
464// processes of the read side, which requests in flight may still use.
465func (f *forge) Close(ctx context.Context) {
466 log := zerowrap.FromCtx(ctx)
467 if f.hooks != nil {
468 f.hooks.Close(ctx)
469 }
470 if f.CI != nil {
471 f.CI.Close(ctx)
472 }
473 if f.EventLog != nil {
474 f.EventLog.Close(ctx)
475 }
476 if f.reader != nil {
477 f.reader.Close()
478 }
479 if f.git != nil {
480 if err := f.git.Close(ctx); err != nil {
481 log.Error().Err(err).Msg("close git reader")
482 }
483 }
484 if f.maint != nil {
485 f.maint.Close(ctx)
486 }
487 if f.store != nil {
488 if err := f.store.Close(ctx); err != nil {
489 log.Error().Err(err).Msg("close metadata store")
490 }
491 f.store = nil
492 }
493}
494
495// artifacts gives the CI the stores and limits of the artifacts (CI-4 D3):
496// records in the kv store, contents under ci/artifacts. ERMITE_CI_ARTIFACTS=0
497// gives none, and the API then serves no artifact service.
498func artifacts(ci *usecase.CIConfig, cfg config.Config, store *kvstore.Store) {
499 if !cfg.CIArtifacts {
500 return
501 }
502 ci.Artifacts, ci.ArtifactFiles = store.Artifacts(), ciartifacts.New(ciartifacts.Dir(cfg.DataDir))
503 ci.ArtifactLimits = ports.ArtifactLimits{Retention: cfg.CIArtifactRetention, Quota: cfg.CIArtifactQuota, MaxSize: cfg.CIArtifactMax}
504}
505
506// pullRequests gives the CI the merge requests, the refs/pull refs and the
507// identity of the test merge commits (CI-3, D5).
508func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *gitcli.Git, committer domain.Identity) {
509 cfg.MRs, cfg.Pull, cfg.Committer = store.MergeRequests(), git, committer
510 if f.reader != nil {
511 cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface
512 }
513}
514
515// signatures wires the commit signature checks (ADR 0030): the stored trusted
516// keys, the instance key as a builtin one for the committer email, the SSH and
517// OpenPGP verifiers, and the raw commit objects of the selected reader.
518func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key domain.SigningPublicKey, committer domain.Identity, clock ports.Clock) error {
519 objects, ok := reader.(ports.CommitObjectReader)
520 if !ok {
521 return errors.New("the git reader cannot read raw commit objects")
522 }
523 builtin := domain.TrustedKey{
524 ID: key.Fingerprint, Format: domain.FormatSSH, Title: "ermite instance key",
525 PublicKey: key.AuthorizedKey, Emails: []string{strings.ToLower(committer.Email)}, Builtin: true,
526 }
527 verifiers := map[domain.SignatureFormat]ports.SignatureVerifier{
528 domain.FormatSSH: sigverify.SSH{}, domain.FormatGPG: sigverify.GPG{},
529 }
530 sigs, err := usecase.NewSignatures(store.TrustedKeys(), sigverify.Inspector{}, verifiers, objects, builtin, clock)
531 if err != nil {
532 return err
533 }
534 f.Signatures = sigs
535 f.Browse.WithVerifier(sigs)
536 return nil
537}