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// a job summary of exactly 1 MiB (UploadSummary) must be accepted, so the limit
30// leaves room for the envelope (job id, step number, framing).
31const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
32
33// Features are the optional capabilities of the contract that this server
34// always implements, as GetVersion lists them. FeaturePasskeys comes on top
35// when Config.Passkeys is set, and FeatureCISecrets when the settings services
36// are.
37var 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"}
38
39// FeaturePasskeys is listed by GetVersion when passkeys are available.
40const FeaturePasskeys = "passkeys"
41
42// Config configures the API.
43type Config struct {
44 Auth ports.AuthService
45 Repos ports.RepoService
46 Tokens ports.TokenService
47 Browse ports.BrowseService
48 Events ports.EventService
49 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
50 MergeRequests ports.MergeRequestService
51 SigningKey ports.SigningKeyService
52 Version string
53 // TrustedProxies are the networks allowed to set X-Forwarded-For.
54 TrustedProxies []netip.Prefix
55 // Limiter counts failed authentications; nil builds a default one. Share
56 // it with the other adapters that authenticate, so that a client has one
57 // budget of failures. Leave it unset rather than assigning a typed nil
58 // pointer (a nil *T in the interface is not nil, and would be called).
59 Limiter ports.FailureLimiter
60 // Runner serves RunnerService and Runs serves RunService; without them the
61 // CI services are not served. Leave them unset rather than assigning a
62 // typed nil pointer.
63 Runner ports.RunnerService
64 Runs ports.RunService
65 // Sessions are the owner's web sessions; nil serves no SessionService.
66 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
67 // the interface is not nil, and the service would be served and called).
68 Sessions ports.SessionService
69 // Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
70 // not available (the RPCs answer failed_precondition and GetVersion does
71 // not list "passkeys"). Same rule as Sessions about typed nil pointers.
72 Passkeys ports.PasskeyService
73 // Secrets, Variables and Environments serve SecretService, VariableService
74 // and EnvironmentService, and GetVersion lists "ci.secrets", when all three
75 // are set (they are one use case). Same rule as Sessions about typed nil
76 // pointers.
77 Secrets ports.SecretService
78 Variables ports.VariableService
79 Environments ports.EnvironmentService
80}
81
82// API is the set of Connect handlers of the contract.
83type API struct {
84 handler http.Handler
85}
86
87// New builds the API.
88func New(cfg Config) *API {
89 if cfg.Limiter == nil {
90 cfg.Limiter = clientlimit.NewLimiter(nil)
91 }
92 opts := []connect.HandlerOption{
93 connect.WithInterceptors(newInterceptor(cfg)),
94 connect.WithReadMaxBytes(MaxRequestBytes),
95 connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
96 log := zerowrap.FromCtx(ctx)
97 log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
98 return connect.NewError(connect.CodeInternal, errInternal)
99 }),
100 }
101 mux := http.NewServeMux()
102 mux.Handle(ermiteconnect.NewRepoServiceHandler(&repoService{cfg.Repos}, opts...))
103 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
104 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
105 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
106 features := slices.Clone(Features)
107 if cfg.Passkeys != nil {
108 features = append(features, FeaturePasskeys)
109 }
110 settings := cfg.Secrets != nil && cfg.Variables != nil && cfg.Environments != nil
111 if settings {
112 features = append(features, FeatureCISecrets)
113 }
114 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
115 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
116 if cfg.Sessions != nil {
117 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
118 }
119 if settings {
120 mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...))
121 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
122 mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
123 }
124 if cfg.Runner != nil {
125 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
126 mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))
127 }
128 if cfg.Runs != nil {
129 mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{runs: cfg.Runs}, opts...))
130 }
131 return &API{handler: mux}
132}
133
134// Handler returns the handler of all services. Its paths are the procedure
135// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
136// it under /-/api/ and strips the prefix.
137func (a *API) Handler() http.Handler { return a.handler }
138
139// caller returns the principal the interceptor put in ctx; anonymous when
140// there is none.
141func caller(ctx context.Context) domain.Principal {
142 p, _ := domain.PrincipalFromContext(ctx)
143 return p
144}