d7a43afd90
Pair.Complete handed the caller's context to the workstation unchanged, so a remote that accepted the connection and then hung spent the whole turn budget. The fallback then ran on an expired context and the floor returned the deadline error instead of an answer, which broke the turn on the workstation being slow. docs/offload.md rules that out. The remote now gets at most half of a deadline that exists, and a context without a deadline is left to the configured workstation timeout. Pair.Stop and worker.Server.Close both closed their channel after a check-then-close, so two concurrent callers could race and the second close panics. Both are sync.Once now, which is what the doc comments already claimed. config.Load names the environment variables it could not resolve. An unset variable still expands to the empty string, because every block reads that as not configured and CI parses deploy/mavend.json with no secrets present. What was missing is the line telling the operator which capability a forgotten env file just turned off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
514 lines
22 KiB
Go
514 lines
22 KiB
Go
// Package config is maven's daemon configuration.
|
|
//
|
|
// The daemon reads a single JSON file at startup (path from the -config flag,
|
|
// default ~/.config/maven/mavend.json). Everything a module needs is wired
|
|
// from this file: the store path, the unix socket path, the tick cadence,
|
|
// and per-sink configs (ntfy/telegram). Credentials live in the file (or a
|
|
// systemd credential that the file points at) — never in the binary.
|
|
//
|
|
// This package is pure data + a loader. It imports the sink config structs
|
|
// so the daemon wires each `Sink` from a single, typed config tree without
|
|
// re-declaring their shapes (the sink constructors own validation).
|
|
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
|
"github.com/kami/maven/internal/update"
|
|
)
|
|
|
|
// Config — the daemon's whole config tree. Loaded once at startup.
|
|
//
|
|
// Fields with omitempty are optional: a missing sink config = that channel
|
|
// not wired (the dispatcher's nil-sink path skips it silently, the same as a
|
|
// deliberately-unwired channel at scaffold time).
|
|
type Config struct {
|
|
// DBPath — sqlite database path. Default applied by Load if empty.
|
|
// When encryption is configured, the file at this path is ciphertext
|
|
// (AES-256-GCM); the daemon works on a tmpfs plaintext copy.
|
|
DBPath string `json:"db_path"`
|
|
|
|
// DBKeyB64 — base64 (std encoding) of a raw 32-byte AES-256 key. Empty ⇒
|
|
// the store is plaintext (dev/CI). Prefer DBKeyEnv over baking the key
|
|
// into the config file. Exactly one of DBKeyB64/DBKeyEnv should be set.
|
|
//
|
|
// ponytail: raw key, no KDF — stdlib has no argon2/scrypt and x/crypto
|
|
// isn't a dep. This is also the seam the L3 passkey cold-start key plugs
|
|
// into later: the passkey op produces the 32 bytes and calls
|
|
// store.OpenEncrypted directly, bypassing config.
|
|
DBKeyB64 string `json:"db_key_b64,omitempty"`
|
|
|
|
// DBKeyEnv — name of an env var holding the base64 32-byte key. Takes
|
|
// precedence over DBKeyB64. Lets systemd credentials / secrets managers
|
|
// inject the key without it touching the config file.
|
|
DBKeyEnv string `json:"db_key_env,omitempty"`
|
|
|
|
// DBTmpfs — plaintext working-copy path (RAM-backed). Empty ⇒ a stable
|
|
// per-db path under /dev/shm. Only used when encryption is configured.
|
|
DBTmpfs string `json:"db_tmpfs,omitempty"`
|
|
|
|
// SocketPath — the unix socket the IPC server listens on. Modules
|
|
// connect here; the dir is created 0700, the socket chmod'd 0600 by
|
|
// ipc.Listen. Default applied by Load if empty.
|
|
SocketPath string `json:"socket_path"`
|
|
|
|
// StateDir — base dir for db + socket if their paths aren't absolute.
|
|
// Default applied by Load if empty (XDG-style: ~/.local/share/maven for
|
|
// the db, /run/user/$UID/maven for the socket).
|
|
StateDir string `json:"state_dir,omitempty"`
|
|
|
|
// TickInterval — the proactive loop cadence. Default 60s. The loop is
|
|
// "dumb + deterministic": most ticks evaluate a few predicates and die
|
|
// for free; raising this saves nothing worth losing responsiveness over.
|
|
TickInterval Duration `json:"tick_interval,omitempty"`
|
|
|
|
// RepeatInterval — how often sev4 telegram sends re-fire until acked.
|
|
// Default 5m. A disk-fire alarm that repeats every tick (60s) is spam;
|
|
// one that repeats never is silent. The default tilts toward "loud."
|
|
RepeatInterval Duration `json:"repeat_interval,omitempty"`
|
|
|
|
// AutotuneInterval — how often the feedback auto-tuner runs: reads
|
|
// store.RecentOutcomes for each rule, calls loop.TuneCooldown, writes the
|
|
// tuned cooldown back as a `facts (kind=config, source=feedback)` row
|
|
// if it changed. Default 10m — slow enough to be cheap + not write every
|
|
// tick (append-only facts churn), fast enough that a weird-afternoon
|
|
// pattern shows up inside a day. 0 ⇒ autotune disabled (the gatherer
|
|
// falls back to the rule's static Base, matching pre-autotune behavior).
|
|
AutotuneInterval Duration `json:"autotune_interval,omitempty"`
|
|
|
|
// FactEnrichmentInterval — how often the fact-entity enrichment worker
|
|
// polls for facts with resolution_state='pending' and resolves their
|
|
// subject against Nexus. Default 30s. Only runs when Nexus is configured;
|
|
// no-ops (harmlessly) otherwise.
|
|
FactEnrichmentInterval Duration `json:"fact_enrichment_interval,omitempty"`
|
|
|
|
// Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired.
|
|
// sev3 (ops soft) away + sev4 (ops hard) present + reminders away all
|
|
// route here; not wiring ntfy means those routes drop silently.
|
|
Ntfy *ntfysink.Config `json:"ntfy,omitempty"`
|
|
|
|
// Telegram — the telegram push sink config. nil ⇒ telegram channel
|
|
// not wired. sev4 away routes here with repeat-til-ack; not wiring
|
|
// telegram means sev4-away alarms silently drop (a disk-fire alarm at
|
|
// 2am that no one sees — wire it).
|
|
Telegram *telegramsink.Config `json:"telegram,omitempty"`
|
|
|
|
// Phraser — the LLM-backed phraser config. nil ⇒ the daemon uses the
|
|
// template-based Stub (deterministic, no model required — good for CI).
|
|
// When configured, the daemon spawns llama-server as a subprocess and
|
|
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
|
Phraser *PhraserConfig `json:"phraser,omitempty"`
|
|
|
|
// Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒
|
|
// the update capability does not exist, which is the state to leave it in
|
|
// unless the operator has read internal/update's package comment.
|
|
//
|
|
// mavend never acts on this block: it constructs no Updater and cannot
|
|
// update itself. Validate below is the one thing the daemon does with it, so
|
|
// a broken update config is caught at startup instead of on the night it is
|
|
// needed. That validation is also why internal/update is linked into mavend
|
|
// at all — linked, with no caller, which is the property that matters. The
|
|
// block lives here because cmd/mavupdate — a CLI the owner runs on the host,
|
|
// the only trigger there is — reads the same config file to find the socket
|
|
// it health-checks.
|
|
Update *update.Config `json:"update,omitempty"`
|
|
|
|
// Voice — the client↔core surface + the stt/tts modules the daemon
|
|
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
|
// down, the dispatcher's Voice slot stays nil (the routing table's
|
|
// ChannelVoice selections drop silently — same as pre-voice behaviour).
|
|
// To enable: voice.enabled = true AND voice.bind = an address inside
|
|
// the wg tunnel; the daemon binds the TCP listener there.
|
|
Voice *VoiceConfig `json:"voice,omitempty"`
|
|
|
|
// QuietHours — time-window schedule for quiet hours. When set, the
|
|
// loop's gatherer sets `quiet = true` in the State during the window,
|
|
// suppressing care nudges (sev1-2). The user can also toggle quiet
|
|
// hours by voice ("тихий режим") — that writes a `quiet_hours` config
|
|
// fact independently; both the schedule AND the toggle activate quiet.
|
|
// nil ⇒ quiet hours only activate via the voice toggle.
|
|
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
|
|
|
|
// DisabledRules — nudge rules that are not wired at all, by name
|
|
// ("service_down", "netdata_critical", "water", "meal", "break").
|
|
//
|
|
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
|
// this only subtracts. It exists because a rule can be right in principle
|
|
// and useless in practice. service_down was the case that forced it: it
|
|
// could not name the service it was nudging about, so being told "a service
|
|
// on homesrv is down" every fifteen minutes was noise with no action
|
|
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
|
// enabled again. The escape hatch stays.
|
|
//
|
|
// A disabled rule is never gathered for, never evaluated, and never
|
|
// delivered on any channel. Unknown names are ignored, so removing a rule
|
|
// from the code does not break a config that still lists it.
|
|
// Empty ⇒ every rule runs, which is the default.
|
|
DisabledRules []string `json:"disabled_rules,omitempty"`
|
|
|
|
// Digest — notification batching / digest mode. nil ⇒ digest disabled
|
|
// (every nudge is sent as it fires — legacy behaviour).
|
|
Digest *DigestConfig `json:"digest,omitempty"`
|
|
|
|
// Routines — scheduled behaviors maven performs on a cron schedule (a
|
|
// morning briefing, an evening wind-down), independent of any request or
|
|
// care predicate. Each fires its Body through the dispatcher on its Cron
|
|
// schedule. Empty ⇒ no routines. See internal/routine for the class
|
|
// distinction from reminders (user-stated) and care rules (world-state).
|
|
Routines []RoutineConfig `json:"routines,omitempty"`
|
|
|
|
// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
|
|
// once near the end of a time window instead of firing one reminder per
|
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
|
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
|
|
|
// PatternProposals — whether a routine the digestion tick inferred on its
|
|
// own may be announced, and how often. nil / absent ⇒ silent detection
|
|
// only: proposals are written for /routines and never announced. See
|
|
// PatternProposalConfig.
|
|
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
|
|
|
|
// MemoryEval — background memory evaluation (internal/memeval). nil /
|
|
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
|
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
|
|
|
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
|
|
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
|
|
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
|
|
// live in the reader (cmd/mavmaild), never here.
|
|
Email *EmailConfig `json:"email,omitempty"`
|
|
|
|
// IntakeJournal — how many entries the unified intake journal keeps
|
|
// (Vikunja #283): one envelope per thing that arrived, whatever direction it
|
|
// came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the
|
|
// journal off entirely, and then there is no decorator on the intake path at
|
|
// all.
|
|
//
|
|
// Not gated behind an "off unless configured" block like feeds or telegram,
|
|
// and the distinction is the one CLAUDE.md draws: that rule exists for
|
|
// capabilities that reach OUT — a fetch, a send, a third party. This reaches
|
|
// nowhere. It is a bounded in-memory log of writes core already performed,
|
|
// it is read only by /events and the simulator, and nothing Maven says
|
|
// depends on it.
|
|
IntakeJournal int `json:"intake_journal,omitempty"`
|
|
|
|
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
|
// ever fetched: reading the outside world is off unless configured, like
|
|
// the weather and telegram. See FeedsConfig.
|
|
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
|
|
|
// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
|
|
// fetches a page: not on request, not on a schedule. See CrawlConfig.
|
|
Crawl *CrawlConfig `json:"crawl,omitempty"`
|
|
|
|
// Kiwix — the offline ZIM reader (Vikunja #122 neighbourhood). nil / absent
|
|
// / url empty ⇒ the query chain has no ZIM source. See KiwixConfig.
|
|
Kiwix *KiwixConfig `json:"kiwix,omitempty"`
|
|
|
|
// Search — the SearXNG metasearch instance. nil / absent / url empty ⇒ the
|
|
// query chain has no web-search source and Kiwix is the only encyclopedia.
|
|
// See SearchConfig.
|
|
Search *SearchConfig `json:"search,omitempty"`
|
|
|
|
// Workstation — the big model on the owner's desktop, preferred over the
|
|
// resident one when its GPU is free. nil / absent / url empty ⇒ homesrv
|
|
// behaves exactly as it does today. See WorkstationConfig.
|
|
Workstation *WorkstationConfig `json:"workstation,omitempty"`
|
|
|
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
|
// component reads another's store). nil ⇒ ecosystem integration disabled.
|
|
Praxis *PraxisConfig `json:"praxis,omitempty"`
|
|
|
|
// Nexus — the canonical identity service. When configured, maven resolves
|
|
// entity references (projects, services, devices, etc.) through Nexus
|
|
// before acting on them. nil ⇒ resolution disabled (maven uses raw text).
|
|
Nexus *NexusConfig `json:"nexus,omitempty"`
|
|
|
|
// Hexis — the capability execution service. When configured, maven
|
|
// discovers and executes capabilities through Hexis for ecosystem actions.
|
|
// nil ⇒ no capability-aware routing.
|
|
Hexis *HexisConfig `json:"hexis,omitempty"`
|
|
|
|
// Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot
|
|
// look at pictures at all: the intake refuses, and no vision server is
|
|
// contacted. See VisionConfig.
|
|
Vision *VisionConfig `json:"vision,omitempty"`
|
|
|
|
// Media — where images and captured audio are kept on disk, and for how
|
|
// long. nil / absent ⇒ no blob store is wired, which is what disables both
|
|
// vision intake and meeting capture regardless of their own blocks: nothing
|
|
// in this repo holds a recording only in memory. See MediaConfig.
|
|
Media *MediaConfig `json:"media,omitempty"`
|
|
|
|
// Capture — meeting recording and summarisation (Vikunja #253). nil /
|
|
// absent ⇒ the recorder does not exist: the start/stop methods are not
|
|
// served at all, so nothing on this box can begin a recording. This is the
|
|
// most invasive capability Maven has and it is the one most firmly off by
|
|
// default. See CaptureConfig.
|
|
Capture *CaptureConfig `json:"capture,omitempty"`
|
|
|
|
// Speaker — voice identification (Vikunja #255). nil / absent ⇒ no
|
|
// voiceprint is ever computed and nobody can be enrolled. Enabling it needs
|
|
// a speaker-embedding model, which is not on this box. See SpeakerConfig.
|
|
Speaker *SpeakerConfig `json:"speaker,omitempty"`
|
|
|
|
// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
|
|
// #251). nil / absent / no enabled server ⇒ no connection is made and no
|
|
// tool is discovered, like every other capability that reaches outside the
|
|
// box. She is a client here, never a server: nothing exposes her own
|
|
// capabilities to an outside caller. See MCPConfig.
|
|
MCP *MCPConfig `json:"mcp,omitempty"`
|
|
|
|
// SmartHome — the Home Assistant instance (Vikunja #256). nil / absent /
|
|
// disabled ⇒ Maven neither reads the house nor touches it, and no house row
|
|
// exists in the act allowlist. See SmartHomeConfig.
|
|
SmartHome *SmartHomeConfig `json:"smarthome,omitempty"`
|
|
|
|
// NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒
|
|
// Maven never puts a packet on the network looking for hosts. See
|
|
// NetScanConfig.
|
|
NetScan *NetScanConfig `json:"netscan,omitempty"`
|
|
}
|
|
|
|
// Duration — a time.Duration that round-trips through JSON as a string
|
|
// ("60s", "5m", "1h30m"). Plain time.Duration marshals as a nanosecond int,
|
|
// which is unreadable in a config file; this wrapper uses ParseDuration.
|
|
type Duration time.Duration
|
|
|
|
func (d Duration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(time.Duration(d).String())
|
|
}
|
|
|
|
func (d *Duration) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
v, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return fmt.Errorf("config: bad duration %q: %w", s, err)
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
|
|
// The core daemon's own defaults, applied when the field is empty or zero.
|
|
// Every other block keeps its defaults in its own file, next to the struct and
|
|
// the normalise that applies them.
|
|
const (
|
|
DefaultTickInterval = 60 * time.Second
|
|
DefaultRepeatInterval = 5 * time.Minute
|
|
DefaultAutotuneInterval = 10 * time.Minute
|
|
// DefaultIntakeJournal — entries kept in the unified intake journal
|
|
// (Vikunja #283). A busy day is a few hundred intake writes, so this is
|
|
// roughly "today and yesterday" at a few hundred KB of memory.
|
|
DefaultIntakeJournal = 512
|
|
|
|
DefaultFactEnrichmentInterval = 30 * time.Second
|
|
)
|
|
|
|
// Load reads the JSON config at path and applies defaults. A missing file is
|
|
// an error — the daemon refuses to start without an explicit config (the
|
|
// default-less state is too permissive: empty db path, no sinks, an idle
|
|
// loop that silently does nothing, etc. — better to surface the gap than to
|
|
// run an idle daemon the user thinks is wired).
|
|
func Load(path string) (*Config, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
|
}
|
|
// Expand ${VAR} or $VAR patterns from environment variables. This lets
|
|
// secrets live in env (docker-compose env_file) rather than the config
|
|
// file committed to git.
|
|
expanded, missing := expandEnv(string(b))
|
|
if len(missing) > 0 {
|
|
// An unset variable expands to "", which every block reads as "not
|
|
// configured" and none of them complains about. That is the intended
|
|
// behaviour and it stays: CI parses this same file with no secrets
|
|
// present. What was missing is the line telling the operator which
|
|
// capability he just turned off by forgetting an env file.
|
|
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
|
|
}
|
|
var c Config
|
|
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
|
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
|
}
|
|
c.applyDefaults()
|
|
if err := c.validate(); err != nil {
|
|
return nil, fmt.Errorf("config: %s: %w", path, err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// expandEnv is os.ExpandEnv plus the names it could not resolve, each reported
|
|
// once and in the order the file mentions them. A variable set to the empty
|
|
// string counts as set: the operator wrote it down, so he meant it.
|
|
func expandEnv(s string) (string, []string) {
|
|
var missing []string
|
|
seen := map[string]bool{}
|
|
out := os.Expand(s, func(name string) string {
|
|
v, ok := os.LookupEnv(name)
|
|
if !ok && !seen[name] {
|
|
seen[name] = true
|
|
missing = append(missing, name)
|
|
}
|
|
return v
|
|
})
|
|
return out, missing
|
|
}
|
|
|
|
func (c *Config) applyDefaults() {
|
|
if c.IntakeJournal == 0 {
|
|
c.IntakeJournal = DefaultIntakeJournal
|
|
}
|
|
if c.TickInterval == 0 {
|
|
c.TickInterval = Duration(DefaultTickInterval)
|
|
}
|
|
if c.RepeatInterval == 0 {
|
|
c.RepeatInterval = Duration(DefaultRepeatInterval)
|
|
}
|
|
if c.AutotuneInterval == 0 {
|
|
c.AutotuneInterval = Duration(DefaultAutotuneInterval)
|
|
}
|
|
if c.FactEnrichmentInterval == 0 {
|
|
c.FactEnrichmentInterval = Duration(DefaultFactEnrichmentInterval)
|
|
}
|
|
// StateDir — when set, use it as the base for both db and socket if their
|
|
// paths are still relative (empty). If StateDir is empty, fall back to the
|
|
// XDG-style defaults (data dir for db, runtime dir for socket).
|
|
if c.StateDir != "" {
|
|
if c.DBPath == "" {
|
|
c.DBPath = filepath.Join(c.StateDir, "maven.db")
|
|
}
|
|
if c.SocketPath == "" {
|
|
c.SocketPath = filepath.Join(c.StateDir, "mavend.sock")
|
|
}
|
|
} else {
|
|
if c.DBPath == "" {
|
|
c.DBPath = filepath.Join(defaultDataDir(), "maven.db")
|
|
}
|
|
if c.SocketPath == "" {
|
|
c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock")
|
|
}
|
|
}
|
|
|
|
// Per-block defaults. Each normalise lives beside the struct it fills in,
|
|
// and none of them reads another block, so this is a list and not an order.
|
|
c.normaliseVoice()
|
|
c.normaliseRoutines()
|
|
c.normaliseDigest()
|
|
c.normalisePatternProposals()
|
|
c.normaliseMemoryEval()
|
|
c.normaliseEmail()
|
|
c.normaliseMCP()
|
|
c.normaliseSmartHome()
|
|
c.normaliseNetScan()
|
|
c.normaliseFeeds()
|
|
c.normaliseCrawl()
|
|
c.normaliseSearch()
|
|
c.normaliseKiwix()
|
|
c.normaliseWorkstation()
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if err := c.validatePhraser(); err != nil {
|
|
return err
|
|
}
|
|
// The update block is validated here even though mavend never acts on it: a
|
|
// half-written update config that is only noticed by cmd/mavupdate is noticed
|
|
// at the worst possible moment, halfway through deploying a new build.
|
|
if c.Update != nil {
|
|
if err := c.Update.Validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := c.validateVoice(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateRoutines(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateMCP(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateSmartHome(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateNetScan(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateMedia(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateVision(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateCapture(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
|
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
|
// never silently downgrade to plaintext).
|
|
func (c *Config) DBEncryptionKey() ([]byte, error) {
|
|
raw := c.DBKeyB64
|
|
if c.DBKeyEnv != "" {
|
|
raw = os.Getenv(c.DBKeyEnv)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv)
|
|
}
|
|
}
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: db key is not valid base64: %w", err)
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key))
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// DefaultWrappedKeyPath returns the conventional path for the wrapped
|
|
// encryption key blob — alongside the StateDir. This is the path checked
|
|
// automatically when --wrapped-key-file is not provided on the command line.
|
|
// The caller may always override via the flag.
|
|
func (c *Config) DefaultWrappedKeyPath() string {
|
|
return filepath.Join(c.StateDir, "db_key.wrapped")
|
|
}
|
|
|
|
func defaultDataDir() string {
|
|
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return filepath.Join(os.TempDir(), "maven")
|
|
}
|
|
return filepath.Join(home, ".local", "share", "maven")
|
|
}
|
|
|
|
func defaultRuntimeDir() string {
|
|
if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
// /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back
|
|
// to the data dir (still works; just not tmpfs-clearance-on-reboot clean).
|
|
return filepath.Join(defaultDataDir())
|
|
}
|