oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "amadan.net/oficina/calls/internal/calls/apns"
5 "encoding/base64"
6 "fmt"
7 "log/slog"
8 "strings"
9 "time"
10
11 "amadan.net/oficina/home/connect"
12 "amadan.net/rastrillo/rastrillo/mail"
13
14 "amadan.net/oficina/calls/internal/calls/relay"
15)
16
17// EnvDevelopment is the one Env value that relaxes startup refusals.
18// Only the -env flag can select it (cmd/calls/main.go); an environment
19// variable cannot, so a misconfigured production box fails loudly
20// rather than quietly running with a zero key.
21const EnvDevelopment = "development"
22
23// Config is everything the app needs to start. Every field that is a
24// secret or a boundary is a startup refusal when it is missing outside
25// development, per spec §7.1.
26type Config struct {
27 Home *connect.Credentials
28 // Env is "development" or anything else (treated as production).
29 Env string
30 // Origin is the app's external origin, scheme included
31 // ("https://calls.oficina.works"). CALLS_ORIGIN.
32 Origin string
33 // InstanceKey seals rastrillo/auth's pending blob and signs the
34 // pending-join cookie. CALLS_INSTANCE_KEY.
35 InstanceKey string
36 // DEK is the base64 data encryption key, exactly 32 bytes decoded.
37 // CALLS_DEK, set with `carlos secrets set`.
38 DEK string
39 // AllowedAddresses is the team: the addresses that are members.
40 // CALLS_ALLOWED_ADDRESSES, comma-separated.
41 AllowedAddresses []string
42
43 // The Apple push key, for telling a member who is not in the room
44 // that somebody is waiting. All four are required together or none
45 // of them: a half-configured push is a boot-time mistake, not a
46 // runtime surprise on the first knock.
47 //
48 // APNsKey is the .p8 Apple issued, PEM as downloaded, set with
49 // `carlos secrets set` and never committed. CALLS_APNS_KEY.
50 APNsKey string
51 // CALLS_APNS_KEY_ID, the ten characters Apple names the key by.
52 APNsKeyID string
53 // CALLS_APNS_TEAM_ID, the Apple developer team.
54 APNsTeamID string
55 // CALLS_APNS_TOPIC_IOS and CALLS_APNS_TOPIC_MACOS: the two bundle
56 // identifiers. Separate because a token is issued per app, and one
57 // sent under the other app's topic is refused.
58 APNsTopicIOS string
59 APNsTopicMacOS string
60 // CALLS_APNS_SANDBOX sends to Apple's sandbox host, which is where a
61 // development build's tokens live. A production token refused by the
62 // sandbox says nothing useful about why, so this is configuration
63 // rather than something to detect.
64 APNsSandbox bool
65 // Mailer delivers magic links. Nil outside development is refused;
66 // nil in development logs the link instead of sending it.
67 Mailer mail.Sender
68 // Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON
69 // list of regions, keys and username suffixes (platform relay design
70 // §2). Absent means the floor (§5.5). It is parsed once here and the
71 // decoded value is held in memory and never logged (§5.2).
72 Relay string
73 // Zone is the IANA zone start times are entered and shown in.
74 // CALLS_ZONE, default UTC. Per-person zones are deliberately not in
75 // version one.
76 Zone string
77 Logger *slog.Logger
78 // QualityReports is CALLS_QUALITY_REPORTS: "off" turns the automatic
79 // quality reports off (spec §15.26), and the endpoint answers 204 so
80 // clients stop sending. Anything else, unset included, is on.
81 QualityReports string
82 // ReportLogger is where quality and problem reports are written. The
83 // deployed main hands it a JSON handler, because the platform's
84 // `carlos logs --field` reads fields only from JSON lines. Nil is
85 // Logger.
86 ReportLogger *slog.Logger
87}
88
89func (cfg Config) development() bool { return cfg.Env == EnvDevelopment }
90
91const devInstanceKey = "calls-development-instance-key-not-a-secret"
92
93var devDEK = make([]byte, 32)
94
95// resolved is Config after every refusal has been checked and every
96// default applied.
97type resolved struct {
98 home *connect.DecodedCredentials
99 Config
100 instanceKey string
101 dek []byte
102 mailer mail.Sender
103 apns *apns.Config
104 allow allowlist
105 relay []relay.Region
106 relayConfigured bool
107 zone *time.Location
108 qualityReports bool
109}
110
111func resolve(cfg Config) (resolved, error) {
112 if cfg.Logger == nil {
113 cfg.Logger = slog.Default()
114 }
115 out := resolved{Config: cfg, qualityReports: qualityReportsOn(cfg.QualityReports)}
116 if out.ReportLogger == nil {
117 out.ReportLogger = cfg.Logger
118 }
119
120 if strings.TrimSpace(cfg.Origin) == "" {
121 if !cfg.development() {
122 return out, fmt.Errorf("calls: Config.Origin is empty and Env is %q, not %q; set $CALLS_ORIGIN", cfg.Env, EnvDevelopment)
123 }
124 out.Origin = "http://localhost:8080"
125 }
126
127 key := strings.TrimSpace(cfg.InstanceKey)
128 switch {
129 case key != "":
130 out.instanceKey = key
131 case cfg.development():
132 out.instanceKey = devInstanceKey
133 default:
134 return out, fmt.Errorf("calls: Config.InstanceKey is empty and Env is %q, not %q; refusing to start with no seal for the sign-in link and the join cookie; set $CALLS_INSTANCE_KEY", cfg.Env, EnvDevelopment)
135 }
136
137 raw := strings.TrimSpace(cfg.DEK)
138 switch {
139 case raw == "" && cfg.development():
140 out.dek = devDEK
141 case raw == "":
142 return out, fmt.Errorf("calls: Config.DEK is empty and Env is %q, not %q; a meeting's title is sealed at rest and a boot without a key would write plaintext into a replicated file; set $CALLS_DEK to 32 base64 bytes with `carlos secrets set`", cfg.Env, EnvDevelopment)
143 default:
144 b, err := base64.StdEncoding.DecodeString(raw)
145 if err != nil {
146 return out, fmt.Errorf("calls: $CALLS_DEK is not valid base64: %w", err)
147 }
148 if len(b) != 32 {
149 return out, fmt.Errorf("calls: $CALLS_DEK decodes to %d bytes, want exactly 32", len(b))
150 }
151 out.dek = b
152 }
153
154 // Wrapped here rather than at the call site: a delivery error names
155 // the recipient, and rastrillo/auth logs it (review C1-2). Doing it
156 // in resolve makes that unbypassable — every mailer the app ever
157 // holds has been through RedactMailErrors, including the one a test
158 // or another main passes in. The nil check above the wrap, not
159 // below: it is what refuses to start without a relay.
160 switch {
161 case cfg.Mailer != nil:
162 out.mailer = RedactMailErrors(cfg.Mailer, cfg.Logger)
163 case cfg.development():
164 out.mailer = RedactMailErrors(mail.Logged(cfg.Logger), cfg.Logger)
165 default:
166 return out, fmt.Errorf("calls: mail is not configured (set $CALLS_SMTP_HOST and $CALLS_SMTP_FROM) and Env is %q, not %q; a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment)
167 }
168
169 list, err := resolveAllowlist(cfg)
170 if err != nil {
171 return out, err
172 }
173 out.allow = list
174
175 push, err := resolveAPNs(cfg)
176 if err != nil {
177 return out, err
178 }
179 out.apns = push
180
181 regions, err := relay.Parse(cfg.Relay)
182 switch {
183 case err == nil:
184 out.relay = regions
185 out.relayConfigured = len(regions) > 0
186 case cfg.development():
187 // One line, the variable and not the value: the value carries a
188 // key. Development runs without a relay rather than refusing.
189 cfg.Logger.Warn("calls: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error())
190 default:
191 return out, fmt.Errorf("calls: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err)
192 }
193
194 zone := strings.TrimSpace(cfg.Zone)
195 if zone == "" {
196 zone = "UTC"
197 }
198 loc, err := time.LoadLocation(zone)
199 if err != nil {
200 return out, fmt.Errorf("calls: $CALLS_ZONE %q is not an IANA zone: %w", zone, err)
201 }
202 out.zone = loc
203 out.Zone = zone
204 out.home, err = connect.DecodeCredentials(cfg.Home, out.Origin)
205 if err != nil {
206 return out, err
207 }
208 return out, nil
209}
210
211// resolveAPNs reads the push key, or reads that there isn't one.
212//
213// All five settings are required together. A key with no topic, or a
214// topic with no key, is somebody halfway through configuring push, and
215// the moment to say so is boot — not the first knock, in a goroutine,
216// into a log nobody is reading.
217//
218// No key at all is not an error and not a degraded mode. Knocks still
219// reach every member who is in the room, over the streams they always
220// used. It is only the member who is elsewhere who goes untold, which
221// is exactly where this app was before any of this existed.
222func resolveAPNs(cfg Config) (*apns.Config, error) {
223 present := 0
224 for _, v := range []string{cfg.APNsKey, cfg.APNsKeyID, cfg.APNsTeamID, cfg.APNsTopicIOS, cfg.APNsTopicMacOS} {
225 if strings.TrimSpace(v) != "" {
226 present++
227 }
228 }
229 if present == 0 {
230 return nil, nil
231 }
232 if present < 5 {
233 return nil, fmt.Errorf("calls: Apple push is half-configured (%d of 5 settings present); set all of $CALLS_APNS_KEY, $CALLS_APNS_KEY_ID, $CALLS_APNS_TEAM_ID, $CALLS_APNS_TOPIC_IOS and $CALLS_APNS_TOPIC_MACOS, or none", present)
234 }
235 key, err := apns.ParseKey([]byte(cfg.APNsKey))
236 if err != nil {
237 // The error names the shape and never the bytes.
238 return nil, fmt.Errorf("calls: $CALLS_APNS_KEY is not usable: %w", err)
239 }
240 host := apns.HostProduction
241 if cfg.APNsSandbox {
242 host = apns.HostSandbox
243 }
244 return &apns.Config{
245 KeyID: strings.TrimSpace(cfg.APNsKeyID), TeamID: strings.TrimSpace(cfg.APNsTeamID), Key: key,
246 Topics: map[string]string{
247 "ios": strings.TrimSpace(cfg.APNsTopicIOS),
248 "macos": strings.TrimSpace(cfg.APNsTopicMacOS),
249 },
250 Host: host,
251 }, nil
252}
253