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