diff --git a/CLAUDE.md b/CLAUDE.md index f69a422..dbab7d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,9 +101,15 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g Count against compose, not against the table. Four of the nine daemons are absent, and each absence has a different reason. -`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail -account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other -three that is an oversight rather than a decision (V-644). +`mavmaild` and `mavcaldav` are commented out in compose, each with the reason written +beside it: the first needs a mail account, the second a CalDAV account, and this box has +neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a +recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block +names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar` +query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the +same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is +read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never +taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too. **`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`). diff --git a/cmd/mavcaldav/main.go b/cmd/mavcaldav/main.go index c59fa3b..fbd74a8 100644 --- a/cmd/mavcaldav/main.go +++ b/cmd/mavcaldav/main.go @@ -50,10 +50,10 @@ func run(args []string) error { socket := fs.String("socket", "", "core IPC socket path (required)") url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)") user := fs.String("user", "", "CalDAV basic-auth username (required)") - pass := fs.String("pass", "", "CalDAV basic-auth password (required)") + passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)") renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering") renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)") - renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)") + renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)") renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies") interval := fs.Duration("interval", 5*time.Minute, "poll cadence") timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout") @@ -63,13 +63,22 @@ func run(args []string) error { if *socket == "" { return fmt.Errorf("-socket is required") } - if *url == "" || *user == "" || *pass == "" { - return fmt.Errorf("-url, -user, -pass are required") + if *url == "" || *user == "" || *passFile == "" { + return fmt.Errorf("-url, -user, -pass-file are required") } if err := checkRenderTarget([]string{*url}, *renderURL); err != nil { return err } + // The password is read from a file, never taken as a flag value: an argv + // secret is visible in `ps` to every user on the box and lands in the compose + // file and the shell history. Same rule mavmaild and mavpoll follow. Read + // once at start, so a rotated password means a restart. + pass, err := readSecret(*passFile) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -85,17 +94,20 @@ func run(args []string) error { http: hc, url: strings.TrimRight(*url, "/"), user: *user, - pass: *pass, + pass: pass, } var rend *renderer if *renderURL != "" { - ru, rp := *renderUser, *renderPass + ru, rp := *renderUser, pass if ru == "" { ru = *user } - if rp == "" { - rp = *pass + if *renderPassFile != "" { + rp, err = readSecret(*renderPassFile) + if err != nil { + return err + } } rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur) log.Printf("mavcaldav: rendering reminders to %s", *renderURL) @@ -131,6 +143,21 @@ func run(args []string) error { // It takes the whole read set, not one URL. The guarantee in the package // comment is about every calendar maven reads, and a second read target added // later must not quietly fall outside the check. +// readSecret reads one credential from a file and refuses an empty one. An +// empty file is a deployment mistake, not a password, and CalDAV basic auth +// would send it and get a 401 every poll. +func readSecret(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read password file: %w", err) + } + secret := strings.TrimSpace(string(raw)) + if secret == "" { + return "", fmt.Errorf("password file %s is empty", path) + } + return secret, nil +} + func checkRenderTarget(readURLs []string, renderURL string) error { if renderURL == "" { return nil diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go index c3d82d3..435e145 100644 --- a/cmd/mavcaldav/main_test.go +++ b/cmd/mavcaldav/main_test.go @@ -5,12 +5,38 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" "github.com/kami/maven/internal/ipc" ) +// The password comes from a file so it never reaches argv. An empty or missing +// file must fail at start rather than authenticate as "" against his calendar. +func TestReadSecret(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "ok") + if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := readSecret(good); err != nil || got != "hunter2" { + t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err) + } + + empty := filepath.Join(dir, "empty") + if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readSecret(empty); err == nil { + t.Fatal("readSecret(empty) = nil error, want refusal") + } + if _, err := readSecret(filepath.Join(dir, "absent")); err == nil { + t.Fatal("readSecret(absent) = nil error, want refusal") + } +} + type fakeCore struct { ipc.UnimplementedCoreAPI facts map[string]ipc.Fact // composite key "key|source" → Fact diff --git a/docker-compose.yml b/docker-compose.yml index eb8a7aa..2eedef6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,44 @@ services: # - maildata:/var/lib/mavmaild # - ./deploy/imap.password:/run/secrets/imap.password:ro + # The calendar reader (Vikunja #644) is OFF and commented out: it needs a + # CalDAV account, and there is none on this box. It was built, listed in + # `make build`, and deployed nowhere, which is the worst of the three states — + # this block records the decision instead. + # + # What its absence costs, so the cost is visible from here: + # - Agenda questions route correctly and answer from nothing. Stage 0 sends + # "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query + # source reads facts(kind=env, source=caldav:*) that nobody writes. + # - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by + # those same facts, so "do not nag mid-meeting" is permanently false. + # + # Core never sees the CalDAV password: the reader polls the collection itself + # and hands core one fact per event over WriteFact. Nothing here can create a + # reminder, so a misread event cannot fire. + # + # The password is read from a FILE, so it never appears in `ps`, in this file, + # or in shell history — the same rule mavpoll and mavmaild follow. + # + # To enable: write the password to deploy/caldav.password (0600, gitignored), + # point -url at the collection, and uncomment this service. No mavend.json + # block is needed — events arrive over IPC as facts. -render-url is optional + # and OFF here: it publishes Maven's own reminders back as events, and it must + # not name the collection -url reads, or the poller reads its own writes back + # in (checkRenderTarget refuses that). It takes -render-pass-file, and falls + # back to this password when that is not given. + # mavcaldav: + # <<: *image + # command: ["mavcaldav", "-socket", "/run/maven/mavend.sock", + # "-url", "http://localhost:5232/kami/personal", + # "-user", "kami", + # "-pass-file", "/run/secrets/caldav.password", + # "-interval", "5m"] + # depends_on: [mavend] + # volumes: + # - sockets:/run/maven + # - ./deploy/caldav.password:/run/secrets/caldav.password:ro + volumes: dbdata: sockets: