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