feat(retention): felis-api 定时清理过期会话、验证码、挑战、绑定码等表,审计按 [audit] retention 保留,新增 felis db audit-export 导出归档
This commit is contained in:
9 files changed
+695
-21
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user