Security Audit Fixes: Session fixation prevention, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting, trusted proxies, and config hardening

This commit is contained in:
backup 2026-09-03 23:55:25 -05:00
commit b587fb87a9
18 changed files with 987 additions and 87 deletions

View file

@ -2,36 +2,40 @@ package file
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
)
type Service struct {
configDir string
db *sql.DB
auditRepo *repositories.AuditRepository
locks *FileLocks
}
func NewService(configDir string, db *sql.DB) (*Service, error) {
func NewService(configDir string, db *sql.DB, auditRepo *repositories.AuditRepository) (*Service, error) {
abs, err := filepath.Abs(configDir)
if err != nil {
return nil, fmt.Errorf("resolve config dir: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
if err := os.MkdirAll(abs, 0o700); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
// Ensure backups dir exists
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o700); err != nil {
return nil, fmt.Errorf("create backups dir: %w", err)
}
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
return &Service{configDir: abs, db: db, auditRepo: auditRepo, locks: NewFileLocks()}, nil
}
func (s *Service) ConfigDir() string { return s.configDir }
@ -162,8 +166,39 @@ type PreviewResult struct {
func (s *Service) Preview(filename, content string) PreviewResult {
errs := ValidateContent(filename, content)
if len(errs) > 0 {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: "",
Username: "",
Role: "",
Action: "failed_apply", // Preview failure is logged as failed_apply
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed_validation",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: errs[0].Error(),
})
return PreviewResult{Valid: false, Errors: errs}
}
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: "",
Username: "",
Role: "",
Action: "preview",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: contentHash(content),
Result: "success",
ErrorCategory: "",
RollbackOccurred: false,
})
// Diff vs current file (if exists)
oldContent := ""
if p, err := s.sanitizedPath(filename); err == nil {
@ -176,12 +211,31 @@ func (s *Service) Preview(filename, content string) PreviewResult {
}
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
// After writing, verifies the file content matches. If verification fails,
// attempts rollback to the previous known-good version.
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
if !confirm {
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
}
errs := ValidateContent(filename, content)
if len(errs) > 0 {
// Log failed validation audit event
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "failed_apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed_validation",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: errs[0].Error(),
})
return PreviewResult{Valid: false, Errors: errs}, nil
}
p, err := s.sanitizedPath(filename)
@ -192,6 +246,23 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
unlock := s.locks.Lock(filename)
defer unlock()
// Audit: apply started
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "",
ErrorCategory: "",
RollbackOccurred: false,
})
// Backup current content
oldContent := ""
if b, err := os.ReadFile(p); err == nil {
@ -204,36 +275,102 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
// Store backup in DB
backupID := uuid.New().String()
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
backupID, filename, oldContent, userID, "apply")
contentHash := contentHash(oldContent)
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`,
backupID, filename, oldContent, userID, "apply", contentHash)
if err != nil {
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
// Log backup storage failure but continue if possible
_ = err
}
// Also filesystem backup
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
_ = os.WriteFile(backupPath, []byte(oldContent), 0o600)
// Prune old filesystem backups (keep 20)
s.pruneFilesystemBackups(filename)
// Atomic write: temp file in same dir, fsync, rename
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "write_failed",
ErrorCategory: "write",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
})
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "write_failed",
ErrorCategory: "write",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("write temp: %v", err),
})
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpName)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "write_failed",
ErrorCategory: "write",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("fsync temp: %v", err),
})
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
}
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "write_failed",
ErrorCategory: "write",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("rename: %v", err),
})
return PreviewResult{}, fmt.Errorf("rename: %w", err)
}
// fsync directory
@ -245,6 +382,76 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview
// Prune DB backups (keep 50 per file)
s.pruneDBBackups(filename)
// --- POST-WRITE VERIFICATION ---
// Read the file back and verify content matches what was written.
// This is the safest available verification mechanism when Traefik hot-reload
// cannot be directly triggered or observed from the GUI.
var newContent []byte
newContent, err = os.ReadFile(p)
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "apply_failed",
ErrorCategory: "traefik_rejection",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("read back written file: %v", err),
})
return PreviewResult{}, fmt.Errorf("read back written file: %w", err)
}
if string(newContent) != content {
// Content mismatch — attempt rollback to the previous known-good version.
// Note: Traefik hot-reload verification is not instrumented from the GUI;
// the file system state is what we can verify.
rollbackResult, rollbackErr := s.Rollback(filename, backupID, userID)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "apply_failed_rollback_succeeded",
ErrorCategory: "verification",
RollbackOccurred: true,
ErrorMessage: fmt.Sprintf("verification failed; rollback: %v", rollbackErr),
})
if rollbackErr == nil && rollbackResult.Valid {
// Rollback succeeded — apply effectively failed even though the file
// write temporarily succeeded. Return a clear error indicating rollback.
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; automatically rolled back")
}
// Rollback also failed — return both the original error and the rollback status.
return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; could not roll back: %w", rollbackErr)
}
// Verification passed — content matches what was written.
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "apply",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: contentHash(content),
Result: "success",
ErrorCategory: "",
RollbackOccurred: false,
})
return PreviewResult{Valid: true, Diff: diff}, nil
}
@ -275,15 +482,16 @@ type BackupInfo struct {
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
Reason string `json:"reason"`
Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification
}
func (s *Service) History(filename string) ([]BackupInfo, error) {
var rows *sql.Rows
var err error
if filename != "" {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
} else {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
}
if err != nil {
return nil, err
@ -292,7 +500,7 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
var out []BackupInfo
for rows.Next() {
var b BackupInfo
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason, &b.Hash); err != nil {
return nil, err
}
out = append(out, b)
@ -303,38 +511,208 @@ func (s *Service) History(filename string) ([]BackupInfo, error) {
// Rollback restores specified backup (or most recent if backupID empty)
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
if err := ValidateFilename(filename); err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: err.Error(),
})
return PreviewResult{}, err
}
p, err := s.sanitizedPath(filename)
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: err.Error(),
})
return PreviewResult{}, err
}
unlock := s.locks.Lock(filename)
defer unlock()
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "",
ErrorCategory: "",
})
var content string
var storedHash string
if backupID != "" {
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content, &storedHash)
if err == sql.ErrNoRows {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "backup_not_found",
ErrorCategory: "not_found",
RollbackOccurred: false,
ErrorMessage: "backup not found",
})
return PreviewResult{}, fmt.Errorf("backup not found")
}
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "database",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("read backup: %v", err),
})
return PreviewResult{}, err
}
// Verify hash integrity
computedHash := contentHash(content)
if computedHash != storedHash {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: "backup integrity check failed - hash mismatch",
})
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
}
} else {
// Most recent
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content, &storedHash)
if err == sql.ErrNoRows {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "backup_not_found",
ErrorCategory: "not_found",
RollbackOccurred: false,
ErrorMessage: "no backup found for " + filename,
})
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
}
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "database",
RollbackOccurred: false,
ErrorMessage: fmt.Sprintf("read backup: %v", err),
})
return PreviewResult{}, err
}
// Verify hash integrity
computedHash := contentHash(content)
if computedHash != storedHash {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: "backup integrity check failed - hash mismatch",
})
return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch")
}
}
// Validate rollback content (allow empty = delete file)
if content != "" {
if errs := ValidateContent(filename, content); len(errs) > 0 {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "validation",
RollbackOccurred: false,
ErrorMessage: errs[0].Error(),
})
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
}
}
@ -345,35 +723,113 @@ func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, er
curContent = string(b)
}
rbID := uuid.New().String()
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
rbID, filename, curContent, userID, "rollback")
contentHash := contentHash(curContent)
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`,
rbID, filename, curContent, userID, "rollback", contentHash)
if content == "" {
// Original file was new: delete current file
_ = os.Remove(p)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "success",
ErrorCategory: "",
RollbackOccurred: true,
})
} else {
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "write",
RollbackOccurred: true,
ErrorMessage: fmt.Sprintf("create temp file: %v", err),
})
return PreviewResult{}, err
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "write",
RollbackOccurred: true,
ErrorMessage: fmt.Sprintf("write temp: %v", err),
})
return PreviewResult{}, err
}
f.Sync()
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: "",
Result: "failed",
ErrorCategory: "write",
RollbackOccurred: true,
ErrorMessage: fmt.Sprintf("rename: %v", err),
})
return PreviewResult{}, err
}
if d, err := os.Open(s.configDir); err == nil {
_ = d.Sync()
d.Close()
}
s.logAuditEvent(&repositories.AuditLogEntry{
ID: "",
UserID: userID,
Username: "",
Role: "",
Action: "rollback",
ResourceType: "file",
ResourceName: filename,
Provider: "",
SourceFile: filename,
ContentHash: contentHash(content),
Result: "success",
ErrorCategory: "",
RollbackOccurred: true,
})
}
diff := UnifiedDiff(filename, curContent, content)
return PreviewResult{Valid: true, Diff: diff}, nil
}
@ -383,3 +839,90 @@ func randHex(n int) string {
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Service) logAuditEvent(entry *repositories.AuditLogEntry) {
if s.auditRepo == nil {
return
}
var safeMsg string
if entry.ErrorMessage != "" {
safeMsg = bestEffortSanitize(entry.ErrorMessage)
}
entry.ErrorMessage = safeMsg
entry.Timestamp = time.Now().UTC()
if entry.ID == "" {
entry.ID = uuid.New().String()
}
if err := s.auditRepo.Create(entry); err != nil {
_ = err
return
}
}
func bestEffortSanitize(msg string) string {
result := msg
// Best-effort redaction of common secret patterns
result = redactPasswords(result)
result = redactTokens(result)
return result
}
func redactPasswords(msg string) string {
// Redact common secret patterns before logging to audit
patterns := [][]string{
{"password=", "password=REDACTED"},
{"password:\"", "password:\"REDACTED\""},
{"password:'", "password:'REDACTED'"},
{"secret=", "secret=REDACTED"},
{"apiKey=", "apiKey=REDACTED"},
{"token=", "token=REDACTED"},
}
for _, p := range patterns {
msg = strings.ReplaceAll(msg, p[0], p[1])
}
return msg
}
func redactTokens(msg string) string {
// Redact Bearer tokens and authorization headers
msg = strings.ReplaceAll(msg, "Bearer ", "Bearer REDACTED")
msg = strings.ReplaceAll(msg, "Authorization: ", "Authorization: REDACTED")
// Redact long hex strings (32+ chars) that look like session/token IDs
re := regexp.MustCompile(`[0-9a-fA-F]{32,}`)
msg = re.ReplaceAllString(msg, "REDACTED_HEX")
return msg
}
// contentHash returns a short hash of the configuration content for audit logging.
func contentHash(content string) string {
h := sha256.Sum256([]byte(content))
return hex.EncodeToString(h[:8])
}
// FileRevision returns the current revision token for a file.
// The token is a content hash that can be used for optimistic concurrency control.
// Clients must include this token in preview/apply requests; if the file has changed
// since the token was generated, the request is rejected with HTTP 409.
func (s *Service) FileRevision(filename string) (string, error) {
hash, err := s.fileContentHash(filename)
if err != nil {
return "", err
}
return hash, nil
}
// fileContentHash computes the SHA256 hash of a file's content for revision tracking.
func (s *Service) fileContentHash(filename string) (string, error) {
p, err := s.sanitizedPath(filename)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", filename)
}
return "", err
}
return contentHash(string(b)), nil
}

View file

@ -1,11 +1,12 @@
package config
type Config struct {
Addr string
DBPath string
SessionSecret string
Addr string
DBPath string
SessionSecret string
CORSOrigin string
TraefikAPIURL string
ConfigDir string
DevMode bool
TrustedProxies []string
}