v0.5.0 bfab294

interceptor.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"errors"
8	"math"
9	"net/http"
10	"net/netip"
11	"strconv"
12	"strings"
13	"time"
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)
22
23var errInternal = errors.New("internal error")
24
25// interceptor authenticates the caller of every call and logs it.
26//
27// A request that carries "Authorization: Bearer <secret>" is authenticated
28// with it; one without the header is anonymous. A secret that is not valid is
29// unauthenticated and counts as a failure of the client address in the
30// limiter; a client that used up its failures gets resource_exhausted (429 on
31// the Connect protocol) whatever it presents, like on the Git routes.
32//
33// A call made through InProcessClient is trusted with the principal its
34// context carries: that is how the web calls the handlers in process. What
35// marks it is a value only the in-process transport can put in the context
36// (see inProcessKey); a principal in the context of a network request, say one
37// put there by a server's base context, is ignored and the request is
38// authenticated from its headers.
39type interceptor struct {
40	auth    Authenticator
41	limiter *clientlimit.Limiter
42	trusted []netip.Prefix
43}
44
45func newInterceptor(cfg Config) *interceptor {
46	return &interceptor{auth: cfg.Auth, limiter: cfg.Limiter, trusted: cfg.TrustedProxies}
47}
48
49// authenticate returns the context to run the call in.
50func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) {
51	if inProcess(ctx) {
52		if _, ok := domain.PrincipalFromContext(ctx); ok {
53			return ctx, nil
54		}
55	}
56	// Whatever the context carried, the caller is anonymous until proven
57	// otherwise.
58	ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous())
59	if procedure == ermiteconnect.ServerServiceGetVersionProcedure || procedure == ermiteconnect.ServerServiceGetSigningKeyProcedure {
60		// GetVersion needs no authentication, so that a client can check
61		// compatibility first, even with a token that has expired. The signing
62		// key is public: whoever verifies a commit needs it.
63		return ctx, nil
64	}
65	log := zerowrap.FromCtx(ctx)
66	addr := clientlimit.ClientAddr(&http.Request{RemoteAddr: peerAddr, Header: h}, i.trusted)
67	ctx = context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
68	if blocked, wait := i.limiter.Blocked(ctx, addr); blocked {
69		log.Warn().Str("client", addr).Msg("too many failed authentications, api request blocked")
70		err := connect.NewError(connect.CodeResourceExhausted, errors.New("too many failed authentications"))
71		err.Meta().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
72		return ctx, err
73	}
74
75	secret, present := bearer(h)
76	if !present {
77		return ctx, nil
78	}
79	p, err := i.auth.Authenticate(ctx, secret)
80	switch {
81	case errors.Is(err, domain.ErrUnauthenticated):
82		i.limiter.Fail(ctx, addr)
83		log.Debug().Str("client", addr).Msg("invalid api credentials")
84		return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
85	case err != nil:
86		return ctx, connectError(ctx, err)
87	}
88	return domain.ContextWithPrincipal(ctx, p), nil
89}
90
91type clientKey struct{}
92
93// ContextWithClient attributes the in-process calls made with ctx to the
94// client at addr (clientlimit.ClientAddr of the browser's request), so that
95// the per-client limits of the API, such as the event streams one, apply to
96// each visitor of the web rather than to the web as a whole. A network call
97// is always attributed to its own peer: the interceptor overwrites it.
98func ContextWithClient(ctx context.Context, addr string) context.Context {
99	return context.WithValue(ctx, clientKey{}, clientlimit.Key(addr))
100}
101
102// clientFromContext returns the normalized address of the client of a call:
103// its peer for a network call, the one given to ContextWithClient for an
104// in-process call, empty when there is none.
105func clientFromContext(ctx context.Context) string {
106	c, _ := ctx.Value(clientKey{}).(string)
107	return c
108}
109
110// bearer extracts the secret of an "Authorization: Bearer" header. present is
111// true when the header exists, even when it carries nothing usable, which
112// then fails authentication.
113func bearer(h http.Header) (secret string, present bool) {
114	auth := h.Get("Authorization")
115	if auth == "" {
116		return "", false
117	}
118	scheme, rest, ok := strings.Cut(auth, " ")
119	if !ok || !strings.EqualFold(scheme, "Bearer") {
120		return "", true
121	}
122	return strings.TrimSpace(rest), true
123}
124
125// logCall logs a finished call at debug level: procedure, code, caller and
126// duration. Never headers or messages: they can carry credentials.
127func logCall(ctx context.Context, procedure string, start time.Time, err error) {
128	code := "ok"
129	if err != nil {
130		code = connect.CodeOf(err).String()
131	}
132	p, _ := domain.PrincipalFromContext(ctx)
133	log := zerowrap.FromCtx(ctx)
134	log.Debug().
135		Str("procedure", procedure).
136		Str("code", code).
137		Str("principal", p.Label()).Str("token_id", p.TokenID()).
138		Dur("duration", time.Since(start)).
139		Msg("api call")
140}
141
142// WrapUnary implements connect.Interceptor.
143func (i *interceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
144	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
145		start := time.Now()
146		ctx, err := i.authenticate(ctx, req.Spec().Procedure, req.Peer().Addr, req.Header())
147		var resp connect.AnyResponse
148		if err == nil {
149			resp, err = next(ctx, req)
150		}
151		logCall(ctx, req.Spec().Procedure, start, err)
152		return resp, err
153	}
154}
155
156// WrapStreamingClient implements connect.Interceptor; the server has no
157// client side.
158func (i *interceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
159	return next
160}
161
162// WrapStreamingHandler implements connect.Interceptor.
163func (i *interceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
164	return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
165		start := time.Now()
166		ctx, err := i.authenticate(ctx, conn.Spec().Procedure, conn.Peer().Addr, conn.RequestHeader())
167		if err == nil {
168			err = next(ctx, conn)
169		}
170		logCall(ctx, conn.Spec().Procedure, start, err)
171		return err
172	}
173}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close