Make log and traffic retention configurable; limit DNS presets to Quad9

- Settings → Data retention: log file size, number of old log files,
  hourly and daily traffic history. Stored as log and stats in
  config.json, validated, and applied without a restart; lowering a
  limit deletes older log files and history after confirmation.
- Traffic history is now pruned by time instead of by bucket count.
- Server page DNS provider list offers only Quad9 and Custom.

Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
Daniel Redetzke
2026-10-03 17:48:33 +03:00
parent b394a6d5d9
commit 1543069089
8 changed files with 217 additions and 27 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ manages peers (add, change, disable, remove) and records traffic per peer.
`inet GHOSTWIRE` table. `inet GHOSTWIRE` table.
- **Live peer changes:** only peers that changed are touched, the same effect - **Live peer changes:** only peers that changed are touched, the same effect
as `wg syncconf`, so connected peers stay connected. as `wg syncconf`, so connected peers stay connected.
- **Logs:** written to `GHOSTWIRE.jsonl` and rotated at 10 MB, keeping 5 files. - **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, daily for 400 days. - **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 - **Client private keys are never stored.** A config is shown once, as a
download or QR code. "Issue new config" makes new keys. download or QR code. "Issue new config" makes new keys.
+15 -2
View File
@@ -25,6 +25,7 @@ type App struct {
auth *Auth auth *Auth
tls *webTLS tls *webTLS
logPath string logPath string
logw *rotatingWriter // nil in tests
started time.Time started time.Time
shutdown func() // graceful stop; systemd restarts the service 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{ writeJSON(w, http.StatusOK, map[string]any{
"web": cfg.Web, "web": cfg.Web,
"log": cfg.Log, "log": cfg.Log,
"stats": cfg.Stats,
"adminUsername": cfg.Admin.Username, "adminUsername": cfg.Admin.Username,
"fingerprint": a.tls.Fingerprint(), "fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath, "logPath": a.logPath,
@@ -828,14 +830,16 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
} }
after, _ := json.Marshal(c.Web) after, _ := json.Marshal(c.Web)
restart = string(before) != string(after) restart = string(before) != string(after)
if err := field(m, "stats", &c.Stats); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
cfg := a.store.Get() a.applyRuntime(a.store.Get())
logLevel.Set(parseLevel(cfg.Log.Level))
a.audit(r, "app settings changed", "restartRequired", restart) a.audit(r, "app settings changed", "restartRequired", restart)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "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)) a.audit(r, "backup restored", "peers", len(in.Peers))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true}) 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)
}
}
+47 -5
View File
@@ -786,10 +786,7 @@
// ---------- server ---------- // ---------- server ----------
const DNS_PRESETS = [ const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
['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'],
];
async function viewServer(wrap) { async function viewServer(wrap) {
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]); 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); } 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))); } }, ['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 // backup
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => { const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
@@ -1059,7 +1090,7 @@
} }); } });
fill(wrap, 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, restartBox,
h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' }, 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' }, 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('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('section', { class: 'card', 'aria-labelledby': 'bk' },
h('h2', { id: 'bk' }, 'Backup & restore'), 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.'), 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.'),
+36
View File
@@ -26,8 +26,23 @@ type Config struct {
Server Server `json:"server"` Server Server `json:"server"`
Peers []Peer `json:"peers"` Peers []Peer `json:"peers"`
Log LogConfig `json:"log"` 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 { type WebConfig struct {
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address 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 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 { if c.Log.MaxFiles == 0 {
c.Log.MaxFiles = 5 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 { if c.APITokens == nil {
c.APITokens = []APIToken{} c.APITokens = []APIToken{}
} }
@@ -269,6 +290,21 @@ func (c *Config) validate() error {
if s.ClientDefaults.Keepalive < 0 || s.ClientDefaults.Keepalive > 3600 { if s.ClientDefaults.Keepalive < 0 || s.ClientDefaults.Keepalive > 3600 {
return errors.New("keepalive must be 0–3600 seconds") 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 { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
+12
View File
@@ -76,6 +76,18 @@ func (w *rotatingWriter) Write(p []byte) (int, error) {
return n, err 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 { func (w *rotatingWriter) Close() error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
+2 -2
View File
@@ -200,7 +200,7 @@ func run(configPath string) error {
auth := newAuth(store) auth := newAuth(store)
app := &App{ app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS, 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 var wg sync.WaitGroup
@@ -270,7 +270,7 @@ func run(configPath string) error {
if err := store.Reload(); err != nil { if err := store.Reload(); err != nil {
slog.Error("reload failed", "err", err) slog.Error("reload failed", "err", err)
} else { } else {
logLevel.Set(parseLevel(store.Get().Log.Level)) app.applyRuntime(store.Get())
recon.Kick() recon.Kick()
slog.Info("config reloaded") slog.Info("config reloaded")
} }
+63
View File
@@ -3,11 +3,13 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -367,3 +369,64 @@ func TestWriteIfChanged(t *testing.T) {
t.Fatal("new content should change") 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)
}
}
}
+32 -8
View File
@@ -17,8 +17,6 @@ import (
const ( const (
sampleInterval = 30 * time.Second sampleInterval = 30 * time.Second
saveInterval = 5 * time.Minute saveInterval = 5 * time.Minute
keepHours = 48
keepDays = 400
onlineWindow = 3 * time.Minute 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() 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 { if n := len(list); n > 0 && list[n-1].T == start {
list[n-1].Rx += rx list[n-1].Rx += rx
list[n-1].Tx += tx list[n-1].Tx += tx
return list return list
} }
list = append(list, bucket{T: start, Rx: rx, Tx: tx}) return append(list, bucket{T: start, Rx: rx, Tx: tx})
if len(list) > keep { }
list = list[len(list)-keep:]
// 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 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
}
}
} }
func (s *Stats) sample() { func (s *Stats) sample() {
@@ -126,8 +149,8 @@ func (s *Stats) sample() {
if dRx > 0 || dTx > 0 { if dRx > 0 || dTx > 0 {
ps.TotalRx += dRx ps.TotalRx += dRx
ps.TotalTx += dTx ps.TotalTx += dTx
ps.Hourly = addTo(ps.Hourly, hourStart(now), dRx, dTx, keepHours) ps.Hourly = addTo(ps.Hourly, hourStart(now), dRx, dTx)
ps.Daily = addTo(ps.Daily, dayStart(now), dRx, dTx, keepDays) ps.Daily = addTo(ps.Daily, dayStart(now), dRx, dTx)
} }
if !smp.LastHandshake.IsZero() { if !smp.LastHandshake.IsZero() {
ps.LastHandshake = smp.LastHandshake ps.LastHandshake = smp.LastHandshake
@@ -143,6 +166,7 @@ func (s *Stats) sample() {
s.dirty = true s.dirty = true
} }
} }
s.prune(cfg.Stats, now)
} }
func (s *Stats) save() { func (s *Stats) save() {