feat(retention): felis-api 定时清理过期会话、验证码、挑战、绑定码等表,审计按 [audit] retention 保留,新增 felis db audit-export 导出归档

This commit is contained in:
Lemon-miaow committed 2026-09-25 15:53:34 +08:00
1 parent 38288e1c60
commit 084ba1ed9e
9 files changed
+695 -21

No files matched your search

+63
View File
@@ -9,7 +9,9 @@ import (
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
@@ -25,6 +27,7 @@ type Config struct {
Archive ArchiveConfig `toml:"archive"`
Offsite OffsiteConfig `toml:"offsite"`
SMTP SMTPConfig `toml:"smtp"`
Audit AuditConfig `toml:"audit"`
// AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil
// roots the Felis-nano hasJoined multiplexer federates over, in priority order
// (config order = priority, so array-of-tables not a map — a map would lose order
@@ -256,6 +259,63 @@ type ArchiveS3Config struct {
SecretKeyRef string `toml:"secret_key_ref"`
}
// AuditConfig is the [audit] table. Retention is how long felis-api keeps audit
// rows before deleting them ("365d", "18mo", or "forever" to keep every row);
// empty means DefaultAuditRetention. Export what must outlive it with
// `felis db audit-export` first.
type AuditConfig struct {
Retention string `toml:"retention"`
}
// DefaultAuditRetention keeps a year of audit rows; MinAuditRetention is the
// shortest an install may set, since the manual-backup cooldown and an incident
// investigation both read recent rows.
const (
DefaultAuditRetention = 365 * 24 * time.Hour
MinAuditRetention = 30 * 24 * time.Hour
)
// RetentionPeriod resolves Retention: 0 keeps every row.
func (a AuditConfig) RetentionPeriod() (time.Duration, error) {
switch v := strings.TrimSpace(a.Retention); v {
case "":
return DefaultAuditRetention, nil
case "forever":
return 0, nil
default:
d, err := ParseSpan(v)
if err != nil {
return 0, fmt.Errorf("config: [audit] retention %q must be a span such as 365d or 18mo, or forever", a.Retention)
}
if d < MinAuditRetention {
return 0, fmt.Errorf("config: [audit] retention %q is shorter than the 30d minimum", a.Retention)
}
return d, nil
}
}
// ParseSpan parses the human spans felis.toml uses for retention periods:
// "3mo" (months of 30 days), "15d" (days), or any time.ParseDuration unit ("12h").
func ParseSpan(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
switch {
case strings.HasSuffix(s, "mo"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "mo"))
if err != nil {
return 0, err
}
return time.Duration(n) * 30 * 24 * time.Hour, nil
case strings.HasSuffix(s, "d"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(n) * 24 * time.Hour, nil
default:
return time.ParseDuration(s)
}
}
// OffsiteConfig is the [offsite] table: the S3-compatible bucket, away from
// this machine, that `felis offsite sync` (felis-offsite.timer on the host)
// copies every world archive and the newest database bundles into, encrypted
@@ -488,6 +548,9 @@ func (c *Config) Validate() error {
if err := c.Offsite.validate(); err != nil {
return err
}
if _, err := c.Audit.RetentionPeriod(); err != nil {
return err
}
if h := c.Auth.ClientIPHeader; strings.ContainsAny(h, " :\t\r\n") {
return fmt.Errorf("config: [auth] client_ip_header %q must be a bare header name such as CF-Connecting-IP or X-Forwarded-For", h)
}
+48
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/config"
)
@@ -695,3 +696,50 @@ prefix = "site-a"
}
}
}
// TestAuditRetention: [audit] retention resolves to a year when unset, to 0 for
// "forever", and to the named span otherwise; a span under 30 days or one that
// does not parse fails at load.
func TestAuditRetention(t *testing.T) {
const head = `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
`
const day = 24 * time.Hour
for _, tc := range []struct {
name, table string
want time.Duration
}{
{"unset", "", 365 * day},
{"empty", "[audit]\nretention = \"\"\n", 365 * day},
{"forever", "[audit]\nretention = \"forever\"\n", 0},
{"days", "[audit]\nretention = \"90d\"\n", 90 * day},
{"months", "[audit]\nretention = \"18mo\"\n", 540 * day},
{"hours", "[audit]\nretention = \"720h\"\n", 30 * day},
{"exactly the minimum", "[audit]\nretention = \"30d\"\n", 30 * day},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := config.Load(writeTOML(t, head+tc.table))
if err != nil {
t.Fatalf("Load: %v", err)
}
got, err := cfg.Audit.RetentionPeriod()
if err != nil || got != tc.want {
t.Fatalf("RetentionPeriod = %v, %v; want %v", got, err, tc.want)
}
})
}
for _, tc := range []struct{ value, wantErr string }{
{"29d", `config: [audit] retention "29d" is shorter than the 30d minimum`},
{"719h", `config: [audit] retention "719h" is shorter than the 30d minimum`},
{"a year", `config: [audit] retention "a year" must be a span such as 365d or 18mo, or forever`},
{"Forever", `config: [audit] retention "Forever" must be a span such as 365d or 18mo, or forever`},
} {
_, err := config.Load(writeTOML(t, head+"[audit]\nretention = \""+tc.value+"\"\n"))
if err == nil || err.Error() != tc.wantErr {
t.Errorf("retention %q: err = %v, want %q", tc.value, err, tc.wantErr)
}
}
}