1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "net/netip"
10 "net/url"
11 "strings"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "bnema.dev/ermite/internal/adapters/httpserver"
17 "bnema.dev/ermite/internal/adapters/web"
18 "bnema.dev/ermite/internal/clientlimit"
19 "bnema.dev/ermite/internal/config"
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"
22 "bnema.dev/ermite/internal/usecase"
23)
24
25// newWeb builds the web pages on the use cases of this process, through the
26// ports (ADR 0020), like the API and Git over HTTP.
27//
28// The owner's session cookie turns a request into the owner's (web.Server
29// checks it with sessions); every other request is anonymous. The web passes
30// the principal of the request to each use case call itself.
31func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32 // A nil *usecase.Passkeys must not become a non-nil interface (the web
33 // tests for nil to leave the passkey routes out).
34 var passkeys ports.WebPasskeyService
35 if f.Passkeys != nil {
36 passkeys = webPasskeys{f.Passkeys}
37 }
38 var signing ports.SigningKeyService
39 if f.SigningKey != nil {
40 signing = f.SigningKey
41 }
42 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
43 // for them and no download route.
44 var artifacts ports.ArtifactService
45 if cfg.CIArtifacts {
46 artifacts = f.CI
47 }
48 return web.New(web.Config{
49 Repos: f.Repos,
50 Browse: f.Browse,
51 MergeRequests: f.MergeRequests,
52 Issues: f.Issues,
53 IssueNumbers: f.Issues,
54 Inbox: f.Inbox,
55 IssueForm: f.IssueSubmissions,
56 Moderation: f.IssueModeration,
57 SigningKey: signing,
58 Runs: f.CI,
59 Artifacts: artifacts,
60 Releases: f.Releases,
61 Events: f.EventLog,
62 Mirrors: f.Mirrors,
63 Owner: cfg.Owner,
64 PublicURL: cfg.PublicURL,
65 VideoHosts: cfg.VideoHosts,
66 SourceURL: cfg.SourceURL,
67 Version: serverVersion(),
68
69 Sessions: webSessions{f.Sessions},
70 Passkeys: passkeys,
71 Limiter: limiter,
72 TrustedProxies: cfg.TrustedProxies,
73 })
74}
75
76// sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
77// URL: the session cookie is Secure (and __Host-), which a browser keeps only
78// from https, except on a loopback host (localhost, 127.0.0.0/8, ::1), where
79// browsers treat http as secure.
80func sessionsNeedHTTPS(publicURL string) bool {
81 u, err := url.Parse(publicURL)
82 if err != nil || !strings.EqualFold(u.Scheme, "http") {
83 return false
84 }
85 host := strings.ToLower(u.Hostname())
86 if host == "localhost" {
87 return false
88 }
89 ip, err := netip.ParseAddr(host)
90 return err != nil || !ip.Unmap().IsLoopback()
91}
92
93// passkeysAvailable reports whether passkeys can work on this public URL, and
94// if not, why. WebAuthn needs a secure context (https, or localhost) and a
95// relying party ID that is a DNS name, not an IP address, so a server on
96// http://127.0.0.1 starts without passkeys. A sign-in needs the session
97// cookie, hence the same https rule as sessionsNeedHTTPS.
98func passkeysAvailable(publicURL string) (ok bool, reason string) {
99 if sessionsNeedHTTPS(publicURL) {
100 return false, "needs https"
101 }
102 u, err := url.Parse(publicURL)
103 if err != nil || u.Hostname() == "" {
104 return false, "public URL has no host"
105 }
106 if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil {
107 return false, "public URL host is an IP address"
108 }
109 return true, ""
110}
111
112// warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
113// not work on an http public URL. It does not stop the server: the pages,
114// the API and Git do not need the cookie.
115func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) {
116 if !sessionsNeedHTTPS(publicURL) {
117 return
118 }
119 log := zerowrap.FromCtx(ctx)
120 log.Warn().Str("public_url", publicURL).Msg(
121 "browser sign-in cannot work: " + config.EnvPublicURL + " is http:// on a host that is not loopback, " +
122 "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https")
123}
124
125// warnSharedClientKey tells the operator, at startup, that without trusted
126// proxies every request is attributed to the address of its TCP peer. Behind a
127// reverse proxy that is the proxy: all the clients then share one key, and so
128// one cap on the Git backends running at once (maxPerClient) and one budget of
129// failed authentications. The keying is unchanged; the fix is to list the
130// proxy networks.
131func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClient int) {
132 if len(trusted) > 0 || maxPerClient <= 0 {
133 return
134 }
135 log := zerowrap.FromCtx(ctx)
136 log.Warn().Int("max_git_backends_per_client", maxPerClient).Msg(
137 config.EnvTrustedProxies + " is not set: clients are told apart by their TCP peer address only. " +
138 "Behind a reverse proxy, all clients share the proxy's address, so one cap of concurrent Git " +
139 "requests and one budget of failed authentications; set it to the proxy networks")
140}
141
142// webSessions adapts the session use cases to ports.WebSessionService: a valid
143// token gives the owner, the principal the web passes to the use cases for
144// that request.
145type webSessions struct{ *usecase.Sessions }
146
147var _ ports.WebSessionService = webSessions{}
148
149func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) {
150 token, expiresAt, err := s.Sessions.Login(ctx, nonce)
151 if errors.Is(err, domain.ErrUnauthenticated) {
152 return "", 0, ports.ErrLinkRejected
153 }
154 if err != nil {
155 return "", 0, err
156 }
157 lifetime, ok := cookieLifetime(expiresAt)
158 if !ok {
159 return "", 0, ports.ErrLinkRejected
160 }
161 return token, lifetime, nil
162}
163
164// cookieLifetime is how long the session cookie lasts for a session that
165// expires at expiresAt: as long as the session, which the expiry of the token
166// that made a login link may cut short. The session token carries its own
167// expiry, so a cookie that outlives it by under a second is harmless. The
168// expiry is truncated to the second, hence the slack on the full TTL. ok is
169// false when under a second is left: there is no session to give.
170func cookieLifetime(expiresAt time.Time) (lifetime time.Duration, ok bool) {
171 lifetime = usecase.SessionTTL
172 if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
173 lifetime = d.Round(time.Second)
174 }
175 return lifetime, lifetime >= time.Second
176}
177
178func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) {
179 p, err := s.Sessions.Verify(ctx, token)
180 if errors.Is(err, domain.ErrUnauthenticated) {
181 return domain.Anonymous(), false, nil
182 }
183 if err != nil {
184 return domain.Anonymous(), false, err
185 }
186 return p, true, nil
187}
188
189// webPasskeys adapts the passkey use cases to ports.WebPasskeyService. Only
190// FinishLogin differs from *usecase.Passkeys: the session a passkey sign-in
191// opens is given to the web as a token and a lifetime, like webSessions.Login
192// does for a link, and a refusal is ports.ErrLinkRejected.
193type webPasskeys struct{ *usecase.Passkeys }
194
195var _ ports.WebPasskeyService = webPasskeys{}
196
197func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
198 token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response)
199 if errors.Is(err, domain.ErrUnauthenticated) {
200 return "", 0, ports.ErrLinkRejected
201 }
202 if err != nil {
203 return "", 0, err
204 }
205 lifetime, ok := cookieLifetime(expiresAt)
206 if !ok {
207 return "", 0, ports.ErrLinkRejected
208 }
209 return token, lifetime, nil
210}
211
212// webRoutes mounts the web on the patterns it serves.
213func webRoutes(h http.Handler) []httpserver.Route {
214 routes := make([]httpserver.Route, 0, len(web.Patterns))
215 for _, p := range web.Patterns {
216 routes = append(routes, httpserver.Route{Pattern: p, Handler: h})
217 }
218 return routes
219}