// mavcaldav — the CalDAV module: reads calendars into facts, and renders // maven's own reminders back out to a calendar she owns. // // READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for // today's events and writes `facts (kind=env, source=poll:caldav)` through // core's IPC socket. Key-free, restart-free, fail-independent — crashes can't // touch the store key, worst case a stale calendar_busy fact until the next // poll. Two facts: // // - calendar_busy ("true"/"false") — read by the loop gate to suppress // nudges during meetings // - calendar_event (" @ -") — per-event for query // // Append-only discipline: a fact is written only when its value CHANGED vs the // latest for that key+source. // // RENDER side (Vikunja #127, off unless -render-url is given): publishes each // pending reminder as a single-event iCal resource in a collection maven owns. // The calendar is a view, sqlite is the store — see render.go. The render URL // must differ from the read URL, checked at startup, so the render target can // never be a calendar maven is only supposed to read. package main import ( "context" "flag" "fmt" "io" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" ) func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "mavcaldav:", err) os.Exit(1) } } func run(args []string) error { fs := flag.NewFlagSet("mavcaldav", flag.ContinueOnError) 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)") 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)") 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") if err := fs.Parse(args); err != nil { return err } if *socket == "" { return fmt.Errorf("-socket is 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() core, err := ipc.DialWait(*socket, 60*time.Second) if err != nil { return err } defer core.Close() hc := &http.Client{Timeout: *timeout} p := &poller{ core: core, http: hc, url: strings.TrimRight(*url, "/"), user: *user, pass: pass, } var rend *renderer if *renderURL != "" { ru, rp := *renderUser, pass if ru == "" { ru = *user } 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) } log.Printf("mavcaldav: polling %s every %s", *url, *interval) tick := func() { p.pollOnce(ctx) if rend != nil { rend.renderOnce(ctx) } } tick() // fire immediately t := time.NewTicker(*interval) defer t.Stop() for { select { case <-ctx.Done(): log.Printf("mavcaldav: bye") return nil case <-t.C: tick() } } } // checkRenderTarget refuses a render URL that is also one of the read URLs. // This is the structural half of #127's "cannot write to your work calendar": // the write credential and the write URL are separate flags, and a calendar // maven is known to only read is rejected as a target at startup rather than // trusted at runtime. // // 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 } for _, read := range readURLs { if read == "" { continue } if sameCollection(read, renderURL) { return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read) } } return nil } func sameCollection(a, b string) bool { return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/")) } type poller struct { core ipc.CoreAPI http *http.Client url string user string pass string } func (p *poller) pollOnce(ctx context.Context) { now := time.Now() events, err := p.fetchEvents(ctx, now) if err != nil { log.Printf("mavcaldav: fetch: %v", err) return } busyVal := "false" if calendar.Busy(events, now) { busyVal = "true" } // Write calendar_busy on change. if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil { log.Printf("mavcaldav: write calendar_busy: %v", err) return } // Write per-event facts (one per event, keyed by day + event summary). // This lets the note RAG path answer "what's on my calendar" without // reaching back to Radicale. for _, e := range events { key := calendar.FactKey(e) if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil { log.Printf("mavcaldav: write %s: %v", key, err) } } } // maxResponseBody bounds every CalDAV response this daemon reads (the poller's // GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a // truncated read, not an unbounded one. const maxResponseBody = 4 << 20 // fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response. func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil) if err != nil { return nil, err } req.SetBasicAuth(p.user, p.pass) req.Header.Set("Accept", "text/calendar") resp, err := p.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status) } return calendar.ParseICalDay(body, now), nil } // writeIfChanged writes a fact only when the value differs from the latest. // Everything this poller writes is a calendar read, which is full confidence by // definition; a source that is not, such as the notification relay, does not // come through here. func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error { prev, err := p.core.LatestFactBySource(ctx, key, source) switch { case err == nil && prev.Value == val: return nil // unchanged case err != nil && err != ipc.ErrNoFact && !isNoFact(err): return fmt.Errorf("read %s: %w", key, err) } _, err = p.core.WriteFact(ctx, ipc.WriteFactReq{ Ts: ts, Kind: "env", Key: key, Value: val, Source: source, Confidence: 1.0, }) if err != nil { return fmt.Errorf("write %s: %w", key, err) } log.Printf("mavcaldav: %s=%s (%s)", key, val, source) return nil } // isNoFact unwarps error chains to find ipc.ErrNoFact. func isNoFact(err error) bool { for e := err; e != nil; { if e == ipc.ErrNoFact { return true } u, ok := e.(interface{ Unwrap() error }) if !ok { return false } e = u.Unwrap() } return false }