package main import ( "context" "fmt" "io" "log/slog" "os" "os/signal" "strings" "sync" "syscall" _ "time/tzdata" // the browser's zone for a call's title, wherever the host has no zoneinfo "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/mail" "amadan.net/oficina/calls/internal/calls" "amadan.net/oficina/home/connect" ) func main() { // One writer for both handlers, so a text line and a JSON line are // never interleaved mid-write. stderr := &lockedWriter{w: os.Stderr} logger := slog.New(slog.NewTextHandler(stderr, nil)) // Quality and problem reports are JSON lines (spec ยง15.26): the // platform's `carlos logs --field` reads fields only from a line that // is a JSON object. reportLogger := slog.New(slog.NewJSONHandler(stderr, nil)) // The only os.Exit after startup. run owns every resource and // returns its error only after its own defers have run: the sign-in // mail queue's bounded drain, then the database close. An os.Exit // inside it would skip them, and a failed HTTP shutdown (rastrillo's // ten-second deadline passing with a request still running) used to // cost a link already answered ?sent=1 (Astra on #25, round 3). if err := run(logger, reportLogger); err != nil { logger.Error("calls: exiting", "err", err) os.Exit(1) } } // run is main's body. Every failure returns, never exits; see main. func run(logger, reportLogger *slog.Logger) error { // -env is stripped before Resolve sees argv, because Resolve parses // os.Args itself for -socket, -addr and -db and a second flag set // would refuse the platform's own activation arguments. Only the // flag can select development: an env var that could would let a // misconfigured box relax every refusal (config.go). envFlag, rest, err := extractEnvFlag(os.Args[1:]) if err != nil { return fmt.Errorf("read flags: %w", err) } os.Args = append(os.Args[:1], rest...) env := &envFlag opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "calls.db", Logger: logger}) if err != nil { return fmt.Errorf("resolve activation: %w", err) } d, err := db.Open(opts.DBPath, logger) if err != nil { return fmt.Errorf("open database: %w", err) } defer d.Close() // Raw: calls.resolve wraps every mailer in RedactMailErrors, so the // invariant holds for whatever a caller passes, not only for what // this main happens to build (review C1-2). mailer := newMailer(logger) cfg := calls.Config{ Home: connect.CredentialsFromEnv("CALLS"), Env: *env, Origin: os.Getenv("CALLS_ORIGIN"), InstanceKey: os.Getenv("CALLS_INSTANCE_KEY"), DEK: os.Getenv("CALLS_DEK"), AllowedAddresses: strings.Split(os.Getenv("CALLS_ALLOWED_ADDRESSES"), ","), Mailer: mailer, Relay: os.Getenv("CARLOS_RELAY"), Zone: os.Getenv("CALLS_ZONE"), Logger: logger, QualityReports: os.Getenv("CALLS_QUALITY_REPORTS"), ReportLogger: reportLogger, } // Cancelled on SIGTERM, which is how a live SSE stream learns the // process is stopping: rastrillo.Serve handles the same signal with // a ten-second Shutdown, and Shutdown waits for handlers without // cancelling their request contexts, so a stream that watched // nothing would hold that whole deadline and turn a clean stop into // a failed one. signal.Notify fans out, so rastrillo's own handler // still fires. The hourly sweep and the hub tick end here too, at // the moment the process is going down anyway (review C2-3). ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() mux, rt, err := calls.App(ctx, d, cfg) if err != nil { return fmt.Errorf("build app: %w", err) } // After rastrillo.Serve returns, however it returns, and before // d.Close() above (defers run last-first): close the sign-in mail // queue and wait for its bounded drain. Not at SIGTERM: Serve's // Shutdown is still letting accepted requests finish then, and a // link one of them queues must reach the relay rather than be // refused or die with the process. // // The cutoff, when Shutdown times out: a link queued before this // close is drained; a handler still running past rastrillo's // deadline that reaches the queue afterwards is refused, and dies // with the process like the rest of its request. defer rt.Close() opts.DBPath = "" // The mux, the scheduled-wake poll and the error page, through the // same calls.ServeOptions the CSP tests and the browser gate serve // with, so what they check is what this serves. opts = calls.ServeOptions(opts, mux, rt) if err := rastrillo.Serve(opts); err != nil { return fmt.Errorf("serve failed: %w", err) } return nil } // smtpSetting reads one SMTP setting, this app's own variable first and // the platform's as the fallback: $CALLS_SMTP_HOST, else $CARLOS_SMTP_HOST. // Per variable, not per set: `carlos email enable` delivers // CARLOS_SMTP_{HOST,PORT,USER,PASS,FROM}, and the common case is wanting // every one of them except FROM, which $CALLS_SMTP_FROM then overrides on // its own. Trimmed, because these arrive from an env block where a // trailing newline is a routine accident. Slides' shape, verbatim. func smtpSetting(name string) string { if v := strings.TrimSpace(os.Getenv("CALLS_SMTP_" + name)); v != "" { return v } return strings.TrimSpace(os.Getenv("CARLOS_SMTP_" + name)) } // smtpSecret is smtpSetting for the two credentials, untrimmed: a // password's surrounding whitespace may be part of the password. func smtpSecret(name string) string { if v := os.Getenv("CALLS_SMTP_" + name); v != "" { return v } return os.Getenv("CARLOS_SMTP_" + name) } // newMailer builds the magic-link sender. HOST and FROM have no sane // default, so either missing means no relay, and a nil return is what // lets calls.App refuse to start outside development rather than log // live links. func newMailer(logger *slog.Logger) mail.Sender { host, from := smtpSetting("HOST"), smtpSetting("FROM") if host == "" || from == "" { return nil } logger.Info("calls: SMTP configured", "host", host, "from", from) return mail.SMTP(host, smtpSetting("PORT"), from, smtpSecret("USER"), smtpSecret("PASS")) } // extractEnvFlag pulls -env (in its four spellings) out of argv and // returns the rest untouched for rastrillo.Resolve. Slides' shape. func extractEnvFlag(args []string) (env string, rest []string, err error) { for i := 0; i < len(args); i++ { a := args[i] switch { case a == "-env" || a == "--env": if i+1 >= len(args) { return "", nil, fmt.Errorf("%s needs a value, for example: %s development", a, a) } env = args[i+1] i++ continue case strings.HasPrefix(a, "-env="): env = strings.TrimPrefix(a, "-env=") continue case strings.HasPrefix(a, "--env="): env = strings.TrimPrefix(a, "--env=") continue } rest = append(rest, a) } return env, rest, nil } // lockedWriter serializes writes from the two log handlers. type lockedWriter struct { mu sync.Mutex w io.Writer } func (l *lockedWriter) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() return l.w.Write(p) }