1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
4// ConnectRPC: handlers that map the messages of the contract to the use cases
5// of usecase and back, the interceptor that authenticates callers, and the error
6// mapping.
7package rpc
8
9import (
10 "context"
11 "net/http"
12 "net/netip"
13 "slices"
14
15 "connectrpc.com/connect"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19 "bnema.dev/ermite/internal/clientlimit"
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"
22)
23
24// MaxRequestBytes bounds the size of a request message. Responses are not
25// bounded: a diff has its own limits and blobs are streamed.
26const MaxRequestBytes = 1 << 20
27
28// RunnerMaxRequestBytes bounds the size of a request message of RunnerService
29// and JobArtifactService: a job summary of exactly 1 MiB (UploadSummary) and a
30// block message with 1 MiB of data must be accepted, so the limit leaves room
31// for the envelope (job id, step number, block id, framing).
32const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
33
34// Features are the optional capabilities of the contract that this server
35// always implements, as GetVersion lists them. FeaturePasskeys comes on top
36// when Config.Passkeys is set, and FeatureCISecrets when the settings services
37// are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39
40// FeaturePasskeys is listed by GetVersion when passkeys are available.
41// FeatureCIArtifacts (artifact.go) when the artifact services are served.
42const FeaturePasskeys = "passkeys"
43
44// FeatureCIOIDC is listed by GetVersion when the server issues OIDC ID tokens
45// to CI jobs (Job.id_token_url, ServerService.RotateOIDCKey).
46const FeatureCIOIDC = "ci.oidc"
47
48// Config configures the API.
49type Config struct {
50 Auth ports.AuthService
51 Repos ports.RepoService
52 Tokens ports.TokenService
53 Browse ports.BrowseService
54 Events ports.EventService
55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
56 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService
58 Version string
59 // TrustedProxies are the networks allowed to set X-Forwarded-For.
60 TrustedProxies []netip.Prefix
61 // Limiter counts failed authentications; nil builds a default one. Share
62 // it with the other adapters that authenticate, so that a client has one
63 // budget of failures. Leave it unset rather than assigning a typed nil
64 // pointer (a nil *T in the interface is not nil, and would be called).
65 Limiter ports.FailureLimiter
66 // Runner serves RunnerService and Runs serves RunService; without them the
67 // CI services are not served. Leave them unset rather than assigning a
68 // typed nil pointer.
69 Runner ports.RunnerService
70 Runs ports.RunService
71 // Sessions are the owner's web sessions; nil serves no SessionService.
72 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
73 // the interface is not nil, and the service would be served and called).
74 Sessions ports.SessionService
75 // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
76 // not available (the RPCs answer failed_precondition and GetVersion does
77 // not list "passkeys"). Same rule as Sessions about typed nil pointers.
78 Passkeys ports.PasskeyService
79 // Secrets, Variables and Environments serve SecretService, VariableService
80 // and EnvironmentService, and GetVersion lists "ci.secrets", when all three
81 // are set (they are one use case). Same rule as Sessions about typed nil
82 // pointers.
83 Secrets ports.SecretService
84 Variables ports.VariableService
85 Environments ports.EnvironmentService
86 // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server
87 // issues none (GetVersion then does not list "ci.oidc"). Same rule as
88 // Sessions about typed nil pointers.
89 OIDCKeys ports.OIDCKeyService
90 // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,
91 // and GetVersion lists "ci.artifacts", when both are set and Runner is.
92 // Same rule as Sessions about typed nil pointers.
93 JobArtifacts ports.JobArtifactService
94 Artifacts ports.ArtifactService
95 // TrustedKeys serves TrustedKeyService, and GetVersion lists
96 // "commit_signatures", when set (ADR 0030). Same rule as Sessions about
97 // typed nil pointers.
98 TrustedKeys ports.TrustedKeyService
99}
100
101// API is the set of Connect handlers of the contract.
102type API struct {
103 handler http.Handler
104}
105
106// New builds the API.
107func New(cfg Config) *API {
108 if cfg.Limiter == nil {
109 cfg.Limiter = clientlimit.NewLimiter(nil)
110 }
111 opts := []connect.HandlerOption{
112 connect.WithInterceptors(newInterceptor(cfg)),
113 connect.WithReadMaxBytes(MaxRequestBytes),
114 connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
115 log := zerowrap.FromCtx(ctx)
116 log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
117 return connect.NewError(connect.CodeInternal, errInternal)
118 }),
119 }
120 mux := http.NewServeMux()
121 mux.Handle(ermiteconnect.NewRepoServiceHandler(&repoService{cfg.Repos}, opts...))
122 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
123 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
124 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
125 features := slices.Clone(Features)
126 if cfg.Passkeys != nil {
127 features = append(features, FeaturePasskeys)
128 }
129 settings := cfg.Secrets != nil && cfg.Variables != nil && cfg.Environments != nil
130 if settings {
131 features = append(features, FeatureCISecrets)
132 }
133 artifacts := cfg.JobArtifacts != nil && cfg.Artifacts != nil && cfg.Runner != nil
134 if artifacts {
135 features = append(features, FeatureCIArtifacts)
136 }
137 if cfg.OIDCKeys != nil {
138 features = append(features, FeatureCIOIDC)
139 }
140 if cfg.TrustedKeys != nil {
141 features = append(features, FeatureCommitSignatures)
142 }
143 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
145 if cfg.Sessions != nil {
146 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147 }
148 if cfg.TrustedKeys != nil {
149 mux.Handle(ermiteconnect.NewTrustedKeyServiceHandler(&trustedKeyService{cfg.TrustedKeys}, opts...))
150 }
151 if settings {
152 mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...))
153 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
154 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
155 }
156 if cfg.Runner != nil {
157 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
158 runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}
159 mux.Handle(ermiteconnect.NewRunnerServiceHandler(runners, runnerOpts...))
160 if artifacts {
161 // A block message carries up to 1 MiB of data, like a summary.
162 mux.Handle(ermiteconnect.NewJobArtifactServiceHandler(&jobArtifactService{artifacts: cfg.JobArtifacts, runner: runners}, runnerOpts...))
163 mux.Handle(ermiteconnect.NewArtifactServiceHandler(&artifactService{artifacts: cfg.Artifacts}, opts...))
164 }
165 }
166 if cfg.Runs != nil {
167 mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{runs: cfg.Runs}, opts...))
168 }
169 return &API{handler: mux}
170}
171
172// Handler returns the handler of all services. Its paths are the procedure
173// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
174// it under /-/api/ and strips the prefix.
175func (a *API) Handler() http.Handler { return a.handler }
176
177// caller returns the principal the interceptor put in ctx; anonymous when
178// there is none.
179func caller(ctx context.Context) domain.Principal {
180 p, _ := domain.PrincipalFromContext(ctx)
181 return p
182}