Merge pull request 'mavcaldav is built, documented as running, and deployed nowhere' (#193) from task/644-mavcaldav-is-built-documented-as-running into master

This commit was merged in pull request #193.
This commit is contained in:
2026-08-06 23:22:18 +02:00
4 changed files with 108 additions and 11 deletions
+9 -3
View File
@@ -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 Count against compose, not against the table. Four of the nine daemons are absent, and each
absence has a different reason. absence has a different reason.
`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail `mavmaild` and `mavcaldav` are commented out in compose, each with the reason written
account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other beside it: the first needs a mail account, the second a CalDAV account, and this box has
three that is an oversight rather than a decision (V-644). 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, **`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463,
`docs/plans/17-where-the-voice-loop-runs.md`). `docs/plans/17-where-the-voice-loop-runs.md`).
+35 -8
View File
@@ -50,10 +50,10 @@ func run(args []string) error {
socket := fs.String("socket", "", "core IPC socket path (required)") socket := fs.String("socket", "", "core IPC socket path (required)")
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (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)") 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") 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)") 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") renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
interval := fs.Duration("interval", 5*time.Minute, "poll cadence") interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout") timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
@@ -63,13 +63,22 @@ func run(args []string) error {
if *socket == "" { if *socket == "" {
return fmt.Errorf("-socket is required") return fmt.Errorf("-socket is required")
} }
if *url == "" || *user == "" || *pass == "" { if *url == "" || *user == "" || *passFile == "" {
return fmt.Errorf("-url, -user, -pass are required") return fmt.Errorf("-url, -user, -pass-file are required")
} }
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil { if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
return err 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) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
@@ -85,17 +94,20 @@ func run(args []string) error {
http: hc, http: hc,
url: strings.TrimRight(*url, "/"), url: strings.TrimRight(*url, "/"),
user: *user, user: *user,
pass: *pass, pass: pass,
} }
var rend *renderer var rend *renderer
if *renderURL != "" { if *renderURL != "" {
ru, rp := *renderUser, *renderPass ru, rp := *renderUser, pass
if ru == "" { if ru == "" {
ru = *user ru = *user
} }
if rp == "" { if *renderPassFile != "" {
rp = *pass rp, err = readSecret(*renderPassFile)
if err != nil {
return err
}
} }
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur) rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
log.Printf("mavcaldav: rendering reminders to %s", *renderURL) 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 // 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 // comment is about every calendar maven reads, and a second read target added
// later must not quietly fall outside the check. // 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 { func checkRenderTarget(readURLs []string, renderURL string) error {
if renderURL == "" { if renderURL == "" {
return nil return nil
+26
View File
@@ -5,12 +5,38 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
"github.com/kami/maven/internal/ipc" "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 { type fakeCore struct {
ipc.UnimplementedCoreAPI ipc.UnimplementedCoreAPI
facts map[string]ipc.Fact // composite key "key|source" → Fact facts map[string]ipc.Fact // composite key "key|source" → Fact
+38
View File
@@ -157,6 +157,44 @@ services:
# - maildata:/var/lib/mavmaild # - maildata:/var/lib/mavmaild
# - ./deploy/imap.password:/run/secrets/imap.password:ro # - ./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: volumes:
dbdata: dbdata:
sockets: sockets: