beaa24754c
mavcaldav took -pass and -render-pass as flag values, so enabling it would have put his calendar password in `ps` inside the container, in the compose file, and in shell history. mavpoll and mavmaild both read their secret from a file for exactly that reason. readSecret reads once at start, trims, and refuses an empty or missing file. An empty file is a deployment mistake, not a password, and basic auth would otherwise send "" and collect a 401 every poll. A rotated password means a restart, which is cheaper than re-reading the credential every five minutes. Nothing called the old flags: no compose service, no systemd unit, no test. So they are replaced rather than kept beside the new ones.
290 lines
8.7 KiB
Go
290 lines
8.7 KiB
Go
// 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 ("<summary> @ <start>-<end>") — 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
|
|
}
|