| 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. |
| 13 | package slides |
| 14 | |
| 15 | import ( |
| 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. |
| 28 | const 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. |
| 33 | const 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. |
| 39 | const 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. |
| 43 | type 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. |
| 124 | func (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. |
| 138 | func 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. |
| 183 | func 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. |
| 207 | func 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 | |