oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// config.go is everything cmd/slides/main.go reads out of the
2// environment and hands to New, plus the rules that decide whether a
3// given combination is safe to start.
4//
5// One rule runs through all of it: exactly one Env value —
6// "development" — may ever take a default, and cmd/slides/main.go's
7// chooseEnv makes sure only an explicit -env flag can select it.
8// Anywhere else, a setting that decides whether sign-in is safe is a
9// startup refusal when it is missing, never a silent fallback. A
10// misconfigured instance is down, which gets fixed the same day; an
11// instance that quietly did the wrong thing looks healthy for as long
12// as nobody checks.
13package slides
14
15import (
16 "fmt"
17 "log/slog"
18 "net/url"
19 "strings"
20
21 "amadan.net/oficina/home/connect"
22 "amadan.net/rastrillo/rastrillo/blobs"
23 "amadan.net/rastrillo/rastrillo/mail"
24)
25
26// EnvDevelopment is the one Env value that unlocks a default. Spelled
27// once, so the several places that compare against it cannot drift.
28const EnvDevelopment = "development"
29
30// EnvProduction is Env's default when nothing sets it — deliberately
31// the closed one, so forgetting to set $SLIDES_ENV never opens
32// anything.
33const EnvProduction = "production"
34
35// DefaultOrigin is Config.Origin's fallback, reachable only in
36// development. Exported so a caller that needs to know which origin a
37// development app is protecting itself against does not duplicate the
38// literal.
39const DefaultOrigin = "http://localhost:8091"
40
41// Config is what New needs to build the app. cmd/slides/main.go fills
42// it from the environment; the test harness fills it directly.
43type Config struct {
44 Home *connect.Credentials
45 // Origin is this instance's external origin, scheme included —
46 // "https://slides.oficina.works". It is three things at once:
47 // csrf.Protect's yardstick for the legacy Origin/Referer fallback
48 // (a modern browser is decided by Sec-Fetch-Site before this
49 // string is read), the base of every emailed magic link, and what
50 // decides Secure/__Host- cookie names.
51 //
52 // Empty is allowed only in development, where it falls back to
53 // DefaultOrigin. Anywhere else it is a startup refusal: on the
54 // platform's socket/LISTEN_FDS activation path there is no -addr
55 // to derive an origin from, so a silent placeholder would mean
56 // mailing sign-in links to a hostname nobody can reach.
57 Origin string
58
59 // Env is EnvDevelopment or anything else (empty means
60 // EnvProduction). It gates the defaults below and nothing else.
61 // cmd/slides/main.go sources it from an explicit -env flag;
62 // $SLIDES_ENV is consulted too, but never for "development"
63 // itself — see chooseEnv there for why an env var must not be able
64 // to select it.
65 Env string
66
67 // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC
68 // key). An empty one hashes to a single fixed, publicly computable
69 // value, identical across every deployment that made the same
70 // mistake — see auth.Config.InstanceKey's own doc comment. Empty
71 // is allowed only in development, where it falls back to
72 // devInstanceKey (auth.go); anywhere else it is a startup refusal.
73 InstanceKey string
74
75 // Mailer delivers the magic link. Nil is allowed only in
76 // development, where mail.Logged puts the link in the log, clearly
77 // marked. Outside development nil is a startup refusal: a sign-in
78 // that cannot send its link is not sign-in, and quietly logging a
79 // live credential in a real deployment's log is the mistake that
80 // fallback exists to flag, not to absorb.
81 //
82 // cmd/slides/main.go builds it from $SLIDES_SMTP_{HOST,PORT,USER,
83 // PASS,FROM}, falling back per variable to the $CARLOS_SMTP_*
84 // equivalent that `carlos email enable` delivers — see smtpSetting
85 // there for why the fallback is per variable rather than per set.
86 Mailer mail.Sender
87
88 // AllowedAddresses is the only set of addresses this instance ever
89 // mails a link to or admits a session for. Compared
90 // case-insensitively, each entry trimmed (auth.go's
91 // normalizeAddress).
92 //
93 // It is wired to two places on purpose: rauth.Config.Authorize
94 // gates admission, after Keymail or a link has verified the
95 // address, and mailQueue (mailqueue.go) drops the mail for an
96 // address not on it. The second is what keeps this instance's
97 // sending identity from being an open relay for any address a
98 // stranger types into the form, since Authorize fires only after
99 // a link has been mailed and clicked. Neither changes what POST
100 // /signin answers (gateBegin).
101 //
102 // Empty is allowed only in development, where it means "admit
103 // everyone" so the dev script and the suite need no setup.
104 // Anywhere else it is a startup refusal rather than "admit
105 // nobody": an instance admitting nobody boots looking perfectly
106 // healthy — /signin renders, POST /signin answers as it would for
107 // anyone, because an answer that does not depend on the list is
108 // the whole point of gateBegin — and the only symptom is that no
109 // link ever arrives.
110 AllowedAddresses []string
111
112 // Blobs is where uploaded bytes live — the object store, never the
113 // instance's own filesystem, which is ephemeral and hibernates.
114 // cmd/slides/main.go binds the platform's bucket with
115 // blobs.S3FromEnv and falls back to blobs.Dir in development.
116 // Nothing in this task reads it yet; deck assets do.
117 Blobs blobs.Store
118
119 // Logger defaults to slog.Default().
120 Logger *slog.Logger
121}
122
123// development reports whether cfg is in the one Env that may default.
124func (cfg Config) development() bool { return cfg.Env == EnvDevelopment }
125
126// resolve turns the config a caller supplied into the config the app
127// actually runs on: Env, Logger, Origin, InstanceKey and Mailer each
128// either filled in or refused, plus the parsed allowlist. Every
129// fail-closed rule in this file is applied here, once, before anything
130// opens.
131//
132// It returns a Config rather than five values because what New stores
133// on the app must be the RESOLVED one. Storing the caller's raw Config
134// was a real bug: in development a.cfg.Origin was "" while the app was
135// protecting http://localhost:8091, and a.cfg.Mailer was nil while mail
136// was going to the log — so any later code reading its own config would
137// have read a description of the app that was not true.
138func resolve(cfg Config) (Config, allowlist, error) {
139 if cfg.Env == "" {
140 cfg.Env = EnvProduction
141 }
142 if cfg.Logger == nil {
143 cfg.Logger = slog.Default()
144 }
145
146 origin, err := resolveOrigin(cfg)
147 if err != nil {
148 return Config{}, allowlist{}, err
149 }
150 cfg.Origin = origin
151
152 instanceKey, err := resolveInstanceKey(cfg)
153 if err != nil {
154 return Config{}, allowlist{}, err
155 }
156 cfg.InstanceKey = instanceKey
157
158 mailer, err := resolveMailer(cfg, cfg.Logger)
159 if err != nil {
160 return Config{}, allowlist{}, err
161 }
162 cfg.Mailer = mailer
163
164 // AllowedAddresses is deliberately NOT rewritten onto cfg: the
165 // parsed, normalized form is the allowlist type, and leaving the
166 // raw slice in place keeps one obvious answer to "what was
167 // configured" beside the derived answer to "what is admitted".
168 list, err := resolveAllowlist(cfg)
169 if err != nil {
170 return Config{}, allowlist{}, err
171 }
172 return cfg, list, nil
173}
174
175// resolveOrigin applies Config.Origin's own rule: a non-empty value is
176// trimmed and validated, an empty one falls back to DefaultOrigin in
177// development and is refused anywhere else.
178//
179// Trimmed before the emptiness test on purpose: $SLIDES_ORIGIN=" " is
180// not empty by a bare == "" check, so it would sail past the guard and
181// only surface later, at request time, on whichever legacy client
182// still exercises csrf.Protect's Origin/Referer fallback.
183func resolveOrigin(cfg Config) (string, error) {
184 origin := strings.TrimSpace(cfg.Origin)
185 if origin == "" {
186 if cfg.development() {
187 return DefaultOrigin, nil
188 }
189 return "", fmt.Errorf("slides: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $SLIDES_ORIGIN to this instance's real origin", cfg.Env, EnvDevelopment)
190 }
191 if err := validateOrigin(origin); err != nil {
192 return "", fmt.Errorf("slides: Config.Origin is invalid: %w", err)
193 }
194 return origin, nil
195}
196
197// validateOrigin reports whether origin is something csrf.Protect and
198// an emailed link can both actually use: an absolute http(s) URL naming
199// a scheme and a host and nothing else.
200//
201// "Nothing else" is checked deliberately. url.Parse happily accepts a
202// bare path, a value carrying a query string, or one with embedded
203// userinfo, none of which could ever equal a browser's Origin header,
204// and none of which belongs at the front of a magic link. Hostname()
205// rather than Host: "http://:8091" — what a bare -addr :8091 would
206// derive — has a non-empty Host and no host at all.
207func validateOrigin(origin string) error {
208 u, err := url.Parse(origin)
209 if err != nil {
210 return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err)
211 }
212 if u.Scheme != "http" && u.Scheme != "https" {
213 return fmt.Errorf("origin %q must have an http or https scheme", origin)
214 }
215 if u.Hostname() == "" {
216 return fmt.Errorf("origin %q has no host", origin)
217 }
218 if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
219 return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin)
220 }
221 return nil
222}
223