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 app and back, the interceptor that authenticates callers, and the error
6// mapping. The same handlers serve remote clients over HTTP and the web in
7// process (see InProcessClient).
8package rpc
9
10import (
11 "context"
12 "net/http"
13 "net/netip"
14
15 "connectrpc.com/connect"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19 "bnema.dev/ermite/internal/app"
20 "bnema.dev/ermite/internal/clientlimit"
21 "bnema.dev/ermite/internal/domain"
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// implements, as GetVersion lists them.
30var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}
31
32// Authenticator turns a token secret into a principal, or
33// domain.ErrUnauthenticated. app.Auth fits.
34type Authenticator interface {
35 Authenticate(ctx context.Context, secret string) (domain.Principal, error)
36}
37
38// Config configures the API.
39type Config struct {
40 Auth Authenticator
41 Repos *app.Repos
42 Tokens *app.Tokens
43 Browse *app.Browse
44 Events *app.Events
45 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
46 MergeRequests *app.MergeRequests
47 SigningKey *app.SigningKey
48 Version string
49 // TrustedProxies are the networks allowed to set X-Forwarded-For.
50 TrustedProxies []netip.Prefix
51 // Limiter counts failed authentications; nil builds a default one. Share
52 // it with the other adapters that authenticate, so that a client has one
53 // budget of failures.
54 Limiter *clientlimit.Limiter
55 // Sessions are the owner's web sessions; nil serves no SessionService.
56 Sessions *app.Sessions
57}
58
59// API is the set of Connect handlers of the contract.
60type API struct {
61 handler http.Handler
62}
63
64// New builds the API.
65func New(cfg Config) *API {
66 if cfg.Limiter == nil {
67 cfg.Limiter = clientlimit.NewLimiter(nil)
68 }
69 opts := []connect.HandlerOption{
70 connect.WithInterceptors(newInterceptor(cfg)),
71 connect.WithReadMaxBytes(MaxRequestBytes),
72 connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
73 log := zerowrap.FromCtx(ctx)
74 log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
75 return connect.NewError(connect.CodeInternal, errInternal)
76 }),
77 }
78 mux := http.NewServeMux()
79 mux.Handle(ermiteconnect.NewRepoServiceHandler(&repoService{cfg.Repos}, opts...))
80 mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
81 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
82 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
83 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...))
84 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
85 if cfg.Sessions != nil {
86 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
87 }
88 return &API{handler: mux}
89}
90
91// Handler returns the handler of all services. Its paths are the procedure
92// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
93// it under /-/api/ and strips the prefix.
94func (a *API) Handler() http.Handler { return a.handler }
95
96// caller returns the principal the interceptor (or the in-process caller)
97// put in ctx; anonymous when there is none.
98func caller(ctx context.Context) domain.Principal {
99 p, _ := domain.PrincipalFromContext(ctx)
100 return p
101}