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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.'),
|
||||||
|
|||||||
@@ -19,15 +19,30 @@ import (
|
|||||||
// config.json and is the single source of truth: the kernel (interface, peers,
|
// config.json and is the single source of truth: the kernel (interface, peers,
|
||||||
// firewall) is reconciled to match it.
|
// firewall) is reconciled to match it.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Web WebConfig `json:"web"`
|
Web WebConfig `json:"web"`
|
||||||
Admin Admin `json:"admin"`
|
Admin Admin `json:"admin"`
|
||||||
APITokens []APIToken `json:"apiTokens"`
|
APITokens []APIToken `json:"apiTokens"`
|
||||||
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
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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() {
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user