diff --git a/README.md b/README.md index 116122c..0a3e02c 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ manages peers (add, change, disable, remove) and records traffic per peer. `inet GHOSTWIRE` table. - **Live peer changes:** only peers that changed are touched, the same effect as `wg syncconf`, so connected peers stay connected. -- **Logs:** written to `GHOSTWIRE.jsonl` and rotated at 10 MB, keeping 5 files. -- **Traffic history:** kept in `stats.json`: hourly for 48 h, daily for 400 days. +- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files kept by default (Settings → Data retention). +- **Traffic history:** kept in `stats.json`: hourly for 48 h and daily for 400 days by default (Settings → Data retention). - **Client private keys are never stored.** A config is shown once, as a download or QR code. "Issue new config" makes new keys. diff --git a/api.go b/api.go index afb8dfd..02cabf2 100644 --- a/api.go +++ b/api.go @@ -25,6 +25,7 @@ type App struct { auth *Auth tls *webTLS logPath string + logw *rotatingWriter // nil in tests started time.Time shutdown func() // graceful stop; systemd restarts the service } @@ -802,6 +803,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "web": cfg.Web, "log": cfg.Log, + "stats": cfg.Stats, "adminUsername": cfg.Admin.Username, "fingerprint": a.tls.Fingerprint(), "logPath": a.logPath, @@ -828,14 +830,16 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) { } after, _ := json.Marshal(c.Web) restart = string(before) != string(after) + if err := field(m, "stats", &c.Stats); err != nil { + return err + } return field(m, "log", &c.Log) }) if err != nil { writeErr(w, err) return } - cfg := a.store.Get() - logLevel.Set(parseLevel(cfg.Log.Level)) + a.applyRuntime(a.store.Get()) a.audit(r, "app settings changed", "restartRequired", restart) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restartRequired": restart}) } @@ -962,3 +966,12 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) { a.audit(r, "backup restored", "peers", len(in.Peers)) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true}) } + +// applyRuntime applies the settings that take effect without a restart: log +// level and log rotation. Traffic retention is read by the stats sampler. +func (a *App) applyRuntime(c *Config) { + logLevel.Set(parseLevel(c.Log.Level)) + if a.logw != nil { + a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles) + } +} diff --git a/app.js b/app.js index c9c1735..800b622 100644 --- a/app.js +++ b/app.js @@ -786,10 +786,7 @@ // ---------- server ---------- - const DNS_PRESETS = [ - ['Quad9', '9.9.9.9, 149.112.112.112'], ['Cloudflare', '1.1.1.1, 1.0.0.1'], ['Google', '8.8.8.8, 8.8.4.4'], - ['OpenDNS', '208.67.222.222, 208.67.220.220'], ['DNS.WATCH', '84.200.69.80, 84.200.70.40'], - ]; + const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']]; async function viewServer(wrap) { const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]); @@ -1043,6 +1040,40 @@ try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); } } }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l))); + // data retention + const presetSelect = (id, value, presets, unit) => { + const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]); + return h('select', { id }, opts.map(([v, t]) => h('option', { value: String(v), selected: v === value }, t))); + }; + const logSize = h('input', { id: 'rs', type: 'number', min: '1', max: '1000', value: s.log.maxSizeMB, inputMode: 'numeric' }); + const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' }); + const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours'); + const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days'); + const diskHint = h('span', { class: 'hint' }); + const drawDiskHint = () => { + const mb = Number(logSize.value) * (Number(logFiles.value) + 1); + diskHint.textContent = mb > 0 ? 'The log uses up to ' + mb + ' MB on disk (current file plus kept files).' : ''; + }; + logSize.addEventListener('input', drawDiskHint); + logFiles.addEventListener('input', drawDiskHint); + drawDiskHint(); + const retErr = h('p', { class: 'err-text', role: 'alert' }); + const saveRetention = async (e) => { + e.preventDefault(); + retErr.textContent = ''; + const next = { maxSizeMB: Number(logSize.value), maxFiles: Number(logFiles.value), hourlyHours: Number(hourly.value), dailyDays: Number(daily.value) }; + const shrinks = next.maxFiles < s.log.maxFiles || next.hourlyHours < s.stats.hourlyHours || next.dailyDays < s.stats.dailyDays; + if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return; + try { + await api('PATCH', '/settings', { + log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles }, + stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays }, + }); + toast('Retention saved'); + render(); + } catch (x) { retErr.textContent = x.message; } + }; + // backup const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => { const f = e.target.files[0]; @@ -1059,7 +1090,7 @@ } }); fill(wrap, - h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Web interface, API access for the iOS app, logs and backups')), + h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Web interface, API access for the iOS app, logs, data retention and backups')), restartBox, h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' }, @@ -1104,6 +1135,17 @@ h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel), h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log')))), + h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' }, + h('h2', { id: 'ret' }, 'Data retention'), + h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'), + h('div', { class: 'grid' }, + h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')), + h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint), + h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')), + h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts. All-time totals are always kept'))), + retErr, + h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))), + h('section', { class: 'card', 'aria-labelledby': 'bk' }, h('h2', { id: 'bk' }, 'Backup & restore'), h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'), diff --git a/config.go b/config.go index 7303db9..06201f2 100644 --- a/config.go +++ b/config.go @@ -19,15 +19,30 @@ import ( // config.json and is the single source of truth: the kernel (interface, peers, // firewall) is reconciled to match it. type Config struct { - Version int `json:"version"` - Web WebConfig `json:"web"` - Admin Admin `json:"admin"` - APITokens []APIToken `json:"apiTokens"` - Server Server `json:"server"` - Peers []Peer `json:"peers"` - Log LogConfig `json:"log"` + Version int `json:"version"` + Web WebConfig `json:"web"` + Admin Admin `json:"admin"` + APITokens []APIToken `json:"apiTokens"` + Server Server `json:"server"` + Peers []Peer `json:"peers"` + Log LogConfig `json:"log"` + Stats StatsConfig `json:"stats"` } +// StatsConfig sets how long traffic history is kept in stats.json. +type StatsConfig struct { + HourlyHours int `json:"hourlyHours"` // hourly buckets, for the 24 h charts + DailyDays int `json:"dailyDays"` // daily buckets, for the 7/30/90 day charts +} + +// Limits for the retention settings. +const ( + minLogSizeMB, maxLogSizeMB = 1, 1000 + minLogFiles, maxLogFiles = 1, 100 + minHourlyHours, maxHourlyHrs = 24, 24 * 31 + minDailyDays, maxDailyDays = 7, 3660 +) + type WebConfig struct { Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables @@ -158,6 +173,12 @@ func (c *Config) applyDefaults() { if c.Log.MaxFiles == 0 { c.Log.MaxFiles = 5 } + if c.Stats.HourlyHours == 0 { + c.Stats.HourlyHours = 48 + } + if c.Stats.DailyDays == 0 { + c.Stats.DailyDays = 400 + } if c.APITokens == nil { c.APITokens = []APIToken{} } @@ -269,6 +290,21 @@ func (c *Config) validate() error { if s.ClientDefaults.Keepalive < 0 || s.ClientDefaults.Keepalive > 3600 { return errors.New("keepalive must be 0–3600 seconds") } + if l := c.Log; l.MaxSizeMB < minLogSizeMB || l.MaxSizeMB > maxLogSizeMB { + return fmt.Errorf("log file size must be %d–%d MB", minLogSizeMB, maxLogSizeMB) + } else if l.MaxFiles < minLogFiles || l.MaxFiles > maxLogFiles { + return fmt.Errorf("kept log files must be %d–%d", minLogFiles, maxLogFiles) + } + switch c.Log.Level { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("log level must be debug, info, warn or error") + } + if st := c.Stats; st.HourlyHours < minHourlyHours || st.HourlyHours > maxHourlyHrs { + return fmt.Errorf("hourly traffic history must be %d–%d hours", minHourlyHours, maxHourlyHrs) + } else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays { + return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays) + } switch c.Web.TLS.Mode { case "acme": if c.Web.TLS.Domain == "" { diff --git a/logging.go b/logging.go index f82949a..05e3205 100644 --- a/logging.go +++ b/logging.go @@ -76,6 +76,18 @@ func (w *rotatingWriter) Write(p []byte) (int, error) { return n, err } +// SetLimits changes rotation size and file count at runtime. Rotated files +// beyond the new count are deleted. +func (w *rotatingWriter) SetLimits(maxMB, maxFiles int) { + w.mu.Lock() + defer w.mu.Unlock() + w.maxBytes = int64(maxMB) << 20 + for i := maxFiles + 1; i <= maxLogFiles+1; i++ { + _ = os.Remove(fmt.Sprintf("%s.%d", w.path, i)) + } + w.maxFiles = maxFiles +} + func (w *rotatingWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() diff --git a/main.go b/main.go index 6d76d6a..f5935b9 100644 --- a/main.go +++ b/main.go @@ -200,7 +200,7 @@ func run(configPath string) error { auth := newAuth(store) app := &App{ store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS, - logPath: logPath, started: time.Now(), shutdown: shutdown, + logPath: logPath, logw: logw, started: time.Now(), shutdown: shutdown, } var wg sync.WaitGroup @@ -270,7 +270,7 @@ func run(configPath string) error { if err := store.Reload(); err != nil { slog.Error("reload failed", "err", err) } else { - logLevel.Set(parseLevel(store.Get().Log.Level)) + app.applyRuntime(store.Get()) recon.Kick() slog.Info("config reloaded") } diff --git a/main_test.go b/main_test.go index e790cd7..2982454 100644 --- a/main_test.go +++ b/main_test.go @@ -3,11 +3,13 @@ package main import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/netip" + "os" "path/filepath" "strings" "testing" @@ -367,3 +369,64 @@ func TestWriteIfChanged(t *testing.T) { t.Fatal("new content should change") } } + +func TestStatsRetention(t *testing.T) { + s := &Stats{data: statsFile{Peers: map[string]*peerStats{}}} + now := time.Date(2026, 10, 3, 15, 30, 0, 0, time.Local) + ps := &peerStats{} + for h := 0; h < 72; h++ { + ps.Hourly = append(ps.Hourly, bucket{T: hourStart(now.Add(-time.Duration(71-h) * time.Hour)), Rx: 1}) + } + for d := 0; d < 30; d++ { + ps.Daily = append(ps.Daily, bucket{T: dayStart(now.AddDate(0, 0, d-29)), Rx: 1}) + } + s.data.Peers["p"] = ps + s.prune(StatsConfig{HourlyHours: 24, DailyDays: 7}, now) + if len(ps.Hourly) != 24 || ps.Hourly[23].T != hourStart(now) { + t.Fatalf("hourly kept %d", len(ps.Hourly)) + } + if len(ps.Daily) != 7 || ps.Daily[6].T != dayStart(now) { + t.Fatalf("daily kept %d", len(ps.Daily)) + } + if !s.dirty { + t.Fatal("pruning should mark stats dirty") + } +} + +func TestLogSetLimits(t *testing.T) { + path := filepath.Join(t.TempDir(), "x.jsonl") + w, err := newRotatingWriter(path, 1, 5) + if err != nil { + t.Fatal(err) + } + defer w.Close() + for i := 1; i <= 5; i++ { + if err := os.WriteFile(fmt.Sprintf("%s.%d", path, i), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + w.SetLimits(2, 2) + for i := 1; i <= 5; i++ { + _, err := os.Stat(fmt.Sprintf("%s.%d", path, i)) + if exists := err == nil; exists != (i <= 2) { + t.Errorf("file .%d exists=%v", i, exists) + } + } +} + +func TestRetentionValidation(t *testing.T) { + c := testConfig(t) + for name, mutate := range map[string]func(c *Config){ + "log size": func(c *Config) { c.Log.MaxSizeMB = 0 }, + "log files": func(c *Config) { c.Log.MaxFiles = 101 }, + "log level": func(c *Config) { c.Log.Level = "loud" }, + "hourly": func(c *Config) { c.Stats.HourlyHours = 12 }, + "daily": func(c *Config) { c.Stats.DailyDays = 5000 }, + } { + cc := c.clone() + mutate(cc) + if cc.validate() == nil { + t.Errorf("%s: expected an error", name) + } + } +} diff --git a/stats.go b/stats.go index 8e125db..3b3407b 100644 --- a/stats.go +++ b/stats.go @@ -17,8 +17,6 @@ import ( const ( sampleInterval = 30 * time.Second saveInterval = 5 * time.Minute - keepHours = 48 - keepDays = 400 onlineWindow = 3 * time.Minute ) @@ -79,17 +77,42 @@ func dayStart(t time.Time) int64 { return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).Unix() } -func addTo(list []bucket, start, rx, tx int64, keep int) []bucket { +func addTo(list []bucket, start, rx, tx int64) []bucket { if n := len(list); n > 0 && list[n-1].T == start { list[n-1].Rx += rx list[n-1].Tx += tx return list } - list = append(list, bucket{T: start, Rx: rx, Tx: tx}) - if len(list) > keep { - list = list[len(list)-keep:] + return append(list, bucket{T: start, Rx: rx, Tx: tx}) +} + +// dropBefore removes buckets that start before cutoff. Lists are in time +// order, so only a prefix is removed. +func dropBefore(list []bucket, cutoff int64) []bucket { + i := 0 + for i < len(list) && list[i].T < cutoff { + i++ + } + if i == 0 { + return list + } + return append([]bucket(nil), list[i:]...) +} + +// prune applies the retention settings to every peer's history; the oldest +// kept bucket is the one that holds the start of the retention window. +func (s *Stats) prune(c StatsConfig, now time.Time) { + hourCut := hourStart(now.Add(-time.Duration(c.HourlyHours-1) * time.Hour)) + y, m, d := now.Date() + dayCut := time.Date(y, m, d-(c.DailyDays-1), 0, 0, 0, 0, now.Location()).Unix() + for _, ps := range s.data.Peers { + h, dl := len(ps.Hourly), len(ps.Daily) + ps.Hourly = dropBefore(ps.Hourly, hourCut) + ps.Daily = dropBefore(ps.Daily, dayCut) + if len(ps.Hourly) != h || len(ps.Daily) != dl { + s.dirty = true + } } - return list } func (s *Stats) sample() { @@ -126,8 +149,8 @@ func (s *Stats) sample() { if dRx > 0 || dTx > 0 { ps.TotalRx += dRx ps.TotalTx += dTx - ps.Hourly = addTo(ps.Hourly, hourStart(now), dRx, dTx, keepHours) - ps.Daily = addTo(ps.Daily, dayStart(now), dRx, dTx, keepDays) + ps.Hourly = addTo(ps.Hourly, hourStart(now), dRx, dTx) + ps.Daily = addTo(ps.Daily, dayStart(now), dRx, dTx) } if !smp.LastHandshake.IsZero() { ps.LastHandshake = smp.LastHandshake @@ -143,6 +166,7 @@ func (s *Stats) sample() { s.dirty = true } } + s.prune(cfg.Stats, now) } func (s *Stats) save() {