package calls import ( "amadan.net/oficina/calls/internal/calls/apns" "encoding/base64" "fmt" "log/slog" "strings" "time" "amadan.net/oficina/home/connect" "amadan.net/rastrillo/rastrillo/mail" "amadan.net/oficina/calls/internal/calls/relay" ) // EnvDevelopment is the one Env value that relaxes startup refusals. // Only the -env flag can select it (cmd/calls/main.go); an environment // variable cannot, so a misconfigured production box fails loudly // rather than quietly running with a zero key. const EnvDevelopment = "development" // Config is everything the app needs to start. Every field that is a // secret or a boundary is a startup refusal when it is missing outside // development, per spec §7.1. type Config struct { Home *connect.Credentials // Env is "development" or anything else (treated as production). Env string // Origin is the app's external origin, scheme included // ("https://calls.oficina.works"). CALLS_ORIGIN. Origin string // InstanceKey seals rastrillo/auth's pending blob and signs the // pending-join cookie. CALLS_INSTANCE_KEY. InstanceKey string // DEK is the base64 data encryption key, exactly 32 bytes decoded. // CALLS_DEK, set with `carlos secrets set`. DEK string // AllowedAddresses is the team: the addresses that are members. // CALLS_ALLOWED_ADDRESSES, comma-separated. AllowedAddresses []string // The Apple push key, for telling a member who is not in the room // that somebody is waiting. All four are required together or none // of them: a half-configured push is a boot-time mistake, not a // runtime surprise on the first knock. // // APNsKey is the .p8 Apple issued, PEM as downloaded, set with // `carlos secrets set` and never committed. CALLS_APNS_KEY. APNsKey string // CALLS_APNS_KEY_ID, the ten characters Apple names the key by. APNsKeyID string // CALLS_APNS_TEAM_ID, the Apple developer team. APNsTeamID string // CALLS_APNS_TOPIC_IOS and CALLS_APNS_TOPIC_MACOS: the two bundle // identifiers. Separate because a token is issued per app, and one // sent under the other app's topic is refused. APNsTopicIOS string APNsTopicMacOS string // CALLS_APNS_SANDBOX sends to Apple's sandbox host, which is where a // development build's tokens live. A production token refused by the // sandbox says nothing useful about why, so this is configuration // rather than something to detect. APNsSandbox bool // Mailer delivers magic links. Nil outside development is refused; // nil in development logs the link instead of sending it. Mailer mail.Sender // Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON // list of regions, keys and username suffixes (platform relay design // §2). Absent means the floor (§5.5). It is parsed once here and the // decoded value is held in memory and never logged (§5.2). Relay string // Zone is the IANA zone start times are entered and shown in. // CALLS_ZONE, default UTC. Per-person zones are deliberately not in // version one. Zone string Logger *slog.Logger // QualityReports is CALLS_QUALITY_REPORTS: "off" turns the automatic // quality reports off (spec §15.26), and the endpoint answers 204 so // clients stop sending. Anything else, unset included, is on. QualityReports string // ReportLogger is where quality and problem reports are written. The // deployed main hands it a JSON handler, because the platform's // `carlos logs --field` reads fields only from JSON lines. Nil is // Logger. ReportLogger *slog.Logger } func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } const devInstanceKey = "calls-development-instance-key-not-a-secret" var devDEK = make([]byte, 32) // resolved is Config after every refusal has been checked and every // default applied. type resolved struct { home *connect.DecodedCredentials Config instanceKey string dek []byte mailer mail.Sender apns *apns.Config allow allowlist relay []relay.Region relayConfigured bool zone *time.Location qualityReports bool } func resolve(cfg Config) (resolved, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() } out := resolved{Config: cfg, qualityReports: qualityReportsOn(cfg.QualityReports)} if out.ReportLogger == nil { out.ReportLogger = cfg.Logger } if strings.TrimSpace(cfg.Origin) == "" { if !cfg.development() { return out, fmt.Errorf("calls: Config.Origin is empty and Env is %q, not %q; set $CALLS_ORIGIN", cfg.Env, EnvDevelopment) } out.Origin = "http://localhost:8080" } key := strings.TrimSpace(cfg.InstanceKey) switch { case key != "": out.instanceKey = key case cfg.development(): out.instanceKey = devInstanceKey default: 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) } raw := strings.TrimSpace(cfg.DEK) switch { case raw == "" && cfg.development(): out.dek = devDEK case raw == "": 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) default: b, err := base64.StdEncoding.DecodeString(raw) if err != nil { return out, fmt.Errorf("calls: $CALLS_DEK is not valid base64: %w", err) } if len(b) != 32 { return out, fmt.Errorf("calls: $CALLS_DEK decodes to %d bytes, want exactly 32", len(b)) } out.dek = b } // Wrapped here rather than at the call site: a delivery error names // the recipient, and rastrillo/auth logs it (review C1-2). Doing it // in resolve makes that unbypassable — every mailer the app ever // holds has been through RedactMailErrors, including the one a test // or another main passes in. The nil check above the wrap, not // below: it is what refuses to start without a relay. switch { case cfg.Mailer != nil: out.mailer = RedactMailErrors(cfg.Mailer, cfg.Logger) case cfg.development(): out.mailer = RedactMailErrors(mail.Logged(cfg.Logger), cfg.Logger) default: 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) } list, err := resolveAllowlist(cfg) if err != nil { return out, err } out.allow = list push, err := resolveAPNs(cfg) if err != nil { return out, err } out.apns = push regions, err := relay.Parse(cfg.Relay) switch { case err == nil: out.relay = regions out.relayConfigured = len(regions) > 0 case cfg.development(): // One line, the variable and not the value: the value carries a // key. Development runs without a relay rather than refusing. cfg.Logger.Warn("calls: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error()) default: 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) } zone := strings.TrimSpace(cfg.Zone) if zone == "" { zone = "UTC" } loc, err := time.LoadLocation(zone) if err != nil { return out, fmt.Errorf("calls: $CALLS_ZONE %q is not an IANA zone: %w", zone, err) } out.zone = loc out.Zone = zone out.home, err = connect.DecodeCredentials(cfg.Home, out.Origin) if err != nil { return out, err } return out, nil } // resolveAPNs reads the push key, or reads that there isn't one. // // All five settings are required together. A key with no topic, or a // topic with no key, is somebody halfway through configuring push, and // the moment to say so is boot — not the first knock, in a goroutine, // into a log nobody is reading. // // No key at all is not an error and not a degraded mode. Knocks still // reach every member who is in the room, over the streams they always // used. It is only the member who is elsewhere who goes untold, which // is exactly where this app was before any of this existed. func resolveAPNs(cfg Config) (*apns.Config, error) { present := 0 for _, v := range []string{cfg.APNsKey, cfg.APNsKeyID, cfg.APNsTeamID, cfg.APNsTopicIOS, cfg.APNsTopicMacOS} { if strings.TrimSpace(v) != "" { present++ } } if present == 0 { return nil, nil } if present < 5 { 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) } key, err := apns.ParseKey([]byte(cfg.APNsKey)) if err != nil { // The error names the shape and never the bytes. return nil, fmt.Errorf("calls: $CALLS_APNS_KEY is not usable: %w", err) } host := apns.HostProduction if cfg.APNsSandbox { host = apns.HostSandbox } return &apns.Config{ KeyID: strings.TrimSpace(cfg.APNsKeyID), TeamID: strings.TrimSpace(cfg.APNsTeamID), Key: key, Topics: map[string]string{ "ios": strings.TrimSpace(cfg.APNsTopicIOS), "macos": strings.TrimSpace(cfg.APNsTopicMacOS), }, Host: host, }, nil }