| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | const 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. |
| 26 | type 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 | |
| 89 | func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| 90 | |
| 91 | const devInstanceKey = "calls-development-instance-key-not-a-secret" |
| 92 | |
| 93 | var devDEK = make([]byte, 32) |
| 94 | |
| 95 | // resolved is Config after every refusal has been checked and every |
| 96 | // default applied. |
| 97 | type 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 | |
| 111 | func 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. |
| 222 | func 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 | |