chore: git cache cleanup

This commit is contained in:
GitHub Actions
2026-03-04 18:34:49 +00:00
parent c32cce2a88
commit 27c252600a
2001 changed files with 683185 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
package models
import (
"time"
)
// AccessList defines IP-based or auth-based access control rules
// that can be applied to proxy hosts.
type AccessList struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name" gorm:"index"`
Description string `json:"description"`
Type string `json:"type" gorm:"index"` // "whitelist", "blacklist", "geo_whitelist", "geo_blacklist"
IPRules string `json:"ip_rules" gorm:"type:text"` // JSON array of IP/CIDR rules
CountryCodes string `json:"country_codes"` // Comma-separated ISO country codes (for geo types)
LocalNetworkOnly bool `json:"local_network_only"` // RFC1918 private networks only
Enabled bool `json:"enabled" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AccessListRule represents a single IP or CIDR rule
type AccessListRule struct {
CIDR string `json:"cidr"` // IP address or CIDR notation
Description string `json:"description"` // Optional description
}
+14
View File
@@ -0,0 +1,14 @@
package models
import (
"time"
)
// CaddyConfig stores an audit trail of Caddy configuration changes.
type CaddyConfig struct {
ID uint `json:"-" gorm:"primaryKey"`
ConfigHash string `json:"-" gorm:"index"`
AppliedAt time.Time `json:"applied_at"`
Success bool `json:"success"`
ErrorMsg string `json:"error_msg"`
}
@@ -0,0 +1,20 @@
package models
import "time"
// CrowdsecConsoleEnrollment stores enrollment status and secrets for console registration.
type CrowdsecConsoleEnrollment struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Status string `json:"status" gorm:"index"`
Tenant string `json:"tenant"`
AgentName string `json:"agent_name"`
EncryptedEnrollKey string `json:"-" gorm:"type:text"`
LastError string `json:"last_error" gorm:"type:text"`
LastCorrelationID string `json:"last_correlation_id" gorm:"index"`
LastAttemptAt *time.Time `json:"last_attempt_at"`
EnrolledAt *time.Time `json:"enrolled_at"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,16 @@
package models
import "time"
// CrowdsecPresetEvent captures audit trail for preset pull/apply events.
type CrowdsecPresetEvent struct {
ID uint `gorm:"primarykey" json:"-"`
Slug string `json:"slug"`
Action string `json:"action"`
Status string `json:"status"`
CacheKey string `json:"cache_key"`
BackupPath string `json:"backup_path"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+48
View File
@@ -0,0 +1,48 @@
// Package models defines the database schema and domain types.
package models
import (
"time"
)
// DNSProvider represents a DNS provider configuration for ACME DNS-01 challenges.
// Credentials are stored encrypted at rest using AES-256-GCM.
type DNSProvider struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
Name string `json:"name" gorm:"index;not null;size:255"`
ProviderType string `json:"provider_type" gorm:"index;not null;size:50"`
Enabled bool `json:"enabled" gorm:"default:true;index"`
IsDefault bool `json:"is_default" gorm:"default:false"`
// Multi-credential mode (enables zone-specific credentials)
UseMultiCredentials bool `json:"use_multi_credentials" gorm:"default:false"`
// Relationship to zone-specific credentials
Credentials []DNSProviderCredential `json:"credentials,omitempty" gorm:"foreignKey:DNSProviderID"`
// Encrypted credentials (JSON blob, encrypted with AES-256-GCM)
// Kept for backward compatibility when UseMultiCredentials=false
CredentialsEncrypted string `json:"-" gorm:"type:text;column:credentials_encrypted"`
// Encryption key version used for credentials (supports key rotation)
KeyVersion int `json:"key_version" gorm:"default:1;index"`
// Propagation settings
PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
// Usage tracking
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
SuccessCount int `json:"success_count" gorm:"default:0"`
FailureCount int `json:"failure_count" gorm:"default:0"`
LastError string `json:"last_error,omitempty" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the database table name.
func (DNSProvider) TableName() string {
return "dns_providers"
}
@@ -0,0 +1,44 @@
// Package models defines the database schema and domain types.
package models
import (
"time"
)
// DNSProviderCredential represents a zone-specific credential set for a DNS provider.
// This allows different credentials to be used for different domains/zones within the same provider.
type DNSProviderCredential struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
DNSProviderID uint `json:"dns_provider_id" gorm:"index;not null"`
DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
// Credential metadata
Label string `json:"label" gorm:"not null;size:255"`
ZoneFilter string `json:"zone_filter" gorm:"type:text"` // Comma-separated list of domains (e.g., "example.com,*.example.org")
Enabled bool `json:"enabled" gorm:"default:true;index"`
// Encrypted credentials (JSON blob, encrypted with AES-256-GCM)
CredentialsEncrypted string `json:"-" gorm:"type:text;not null"`
// Encryption key version used for credentials (supports key rotation)
KeyVersion int `json:"key_version" gorm:"default:1;index"`
// Propagation settings (overrides provider defaults if non-zero)
PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
// Usage tracking
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
SuccessCount int `json:"success_count" gorm:"default:0"`
FailureCount int `json:"failure_count" gorm:"default:0"`
LastError string `json:"last_error,omitempty" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the database table name.
func (DNSProviderCredential) TableName() string {
return "dns_provider_credentials"
}
@@ -0,0 +1,51 @@
package models_test
import (
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
)
func TestDNSProviderCredential_TableName(t *testing.T) {
cred := &models.DNSProviderCredential{}
assert.Equal(t, "dns_provider_credentials", cred.TableName())
}
func TestDNSProviderCredential_Struct(t *testing.T) {
now := time.Now()
cred := &models.DNSProviderCredential{
ID: 1,
UUID: "test-uuid",
DNSProviderID: 1,
Label: "Test Credential",
ZoneFilter: "example.com,*.example.org",
CredentialsEncrypted: "encrypted_data",
Enabled: true,
KeyVersion: 1,
PropagationTimeout: 120,
PollingInterval: 5,
SuccessCount: 10,
FailureCount: 2,
LastError: "",
LastUsedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
assert.Equal(t, uint(1), cred.ID)
assert.Equal(t, "test-uuid", cred.UUID)
assert.Equal(t, uint(1), cred.DNSProviderID)
assert.Equal(t, "Test Credential", cred.Label)
assert.Equal(t, "example.com,*.example.org", cred.ZoneFilter)
assert.Equal(t, "encrypted_data", cred.CredentialsEncrypted)
assert.True(t, cred.Enabled)
assert.Equal(t, 1, cred.KeyVersion)
assert.Equal(t, 120, cred.PropagationTimeout)
assert.Equal(t, 5, cred.PollingInterval)
assert.Equal(t, 10, cred.SuccessCount)
assert.Equal(t, 2, cred.FailureCount)
assert.Equal(t, "", cred.LastError)
assert.NotNil(t, cred.LastUsedAt)
}
@@ -0,0 +1,58 @@
package models
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDNSProvider_TableName(t *testing.T) {
provider := DNSProvider{}
assert.Equal(t, "dns_providers", provider.TableName())
}
func TestDNSProvider_Fields(t *testing.T) {
provider := DNSProvider{
UUID: "test-uuid",
Name: "Test Provider",
ProviderType: "cloudflare",
Enabled: true,
IsDefault: false,
PropagationTimeout: 120,
PollingInterval: 5,
SuccessCount: 0,
FailureCount: 0,
}
assert.Equal(t, "test-uuid", provider.UUID)
assert.Equal(t, "Test Provider", provider.Name)
assert.Equal(t, "cloudflare", provider.ProviderType)
assert.True(t, provider.Enabled)
assert.False(t, provider.IsDefault)
assert.Equal(t, 120, provider.PropagationTimeout)
assert.Equal(t, 5, provider.PollingInterval)
assert.Equal(t, 0, provider.SuccessCount)
assert.Equal(t, 0, provider.FailureCount)
}
func TestDNSProvider_CredentialsEncrypted_NotSerialized(t *testing.T) {
// This test verifies that the CredentialsEncrypted field has the json:"-" tag
// by checking that it's not included in JSON serialization
provider := DNSProvider{
Name: "Test",
ProviderType: "cloudflare",
CredentialsEncrypted: "encrypted-data-should-not-appear-in-json",
}
// Marshal to JSON
jsonData, err := json.Marshal(provider)
assert.NoError(t, err)
// Verify credentials are not in JSON
jsonString := string(jsonData)
assert.NotContains(t, jsonString, "credentials_encrypted")
assert.NotContains(t, jsonString, "encrypted-data-should-not-appear-in-json")
assert.Contains(t, jsonString, "Test")
assert.Contains(t, jsonString, "cloudflare")
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Domain struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (d *Domain) BeforeCreate(tx *gorm.DB) (err error) {
if d.UUID == "" {
d.UUID = uuid.New().String()
}
return
}
+28
View File
@@ -0,0 +1,28 @@
package models
import (
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestDomain_BeforeCreate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
assert.NoError(t, err)
_ = db.AutoMigrate(&Domain{})
// Case 1: UUID is empty, should be generated
d1 := &Domain{Name: "example.com"}
err = db.Create(d1).Error
assert.NoError(t, err)
assert.NotEmpty(t, d1.UUID)
// Case 2: UUID is provided, should be kept
uuid := "123e4567-e89b-12d3-a456-426614174000"
d2 := &Domain{Name: "test.com", UUID: uuid}
err = db.Create(d2).Error
assert.NoError(t, err)
assert.Equal(t, uuid, d2.UUID)
}
@@ -0,0 +1,41 @@
package models
import (
"time"
)
// EmergencyToken stores metadata for database-backed emergency access tokens.
// Tokens are stored as bcrypt hashes for security.
type EmergencyToken struct {
ID uint `json:"-" gorm:"primaryKey"`
TokenHash string `json:"-" gorm:"type:text;not null"` // bcrypt hash, never exposed in JSON
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at"` // NULL = never expires
ExpirationPolicy string `json:"expiration_policy" gorm:"type:text;not null"` // "30_days", "60_days", "90_days", "custom", "never"
CreatedByUserID *uint `json:"created_by_user_id"` // User who generated token (NULL for env var tokens)
LastUsedAt *time.Time `json:"last_used_at"`
UseCount int `json:"use_count" gorm:"default:0"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for GORM
func (EmergencyToken) TableName() string {
return "emergency_tokens"
}
// IsExpired checks if the token has expired
func (et *EmergencyToken) IsExpired() bool {
if et.ExpiresAt == nil {
return false // Never expires
}
return time.Now().After(*et.ExpiresAt)
}
// DaysUntilExpiration returns the number of days until expiration (negative if expired)
func (et *EmergencyToken) DaysUntilExpiration() int {
if et.ExpiresAt == nil {
return -1 // Special value for "never expires"
}
duration := time.Until(*et.ExpiresAt)
return int(duration.Hours() / 24)
}
@@ -0,0 +1,146 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestEmergencyToken_TableName(t *testing.T) {
token := EmergencyToken{}
assert.Equal(t, "emergency_tokens", token.TableName())
}
func TestEmergencyToken_IsExpired(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expiresAt *time.Time
expected bool
}{
{
name: "nil expiration (never expires)",
expiresAt: nil,
expected: false,
},
{
name: "expired token (1 hour ago)",
expiresAt: ptrTime(now.Add(-1 * time.Hour)),
expected: true,
},
{
name: "expired token (1 day ago)",
expiresAt: ptrTime(now.Add(-24 * time.Hour)),
expected: true,
},
{
name: "valid token (1 hour from now)",
expiresAt: ptrTime(now.Add(1 * time.Hour)),
expected: false,
},
{
name: "valid token (30 days from now)",
expiresAt: ptrTime(now.Add(30 * 24 * time.Hour)),
expected: false,
},
{
name: "expired by 1 second",
expiresAt: ptrTime(now.Add(-1 * time.Second)),
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &EmergencyToken{
ExpiresAt: tt.expiresAt,
}
result := token.IsExpired()
assert.Equal(t, tt.expected, result)
})
}
}
func TestEmergencyToken_DaysUntilExpiration(t *testing.T) {
// Test with actual time.Now() since the method uses it internally
now := time.Now()
tests := []struct {
name string
expires *time.Time
minDays int
maxDays int
}{
{
name: "nil expiration",
expires: nil,
minDays: -1,
maxDays: -1,
},
{
name: "expires in ~1 day",
expires: ptrTime(now.Add(24 * time.Hour)),
minDays: 0,
maxDays: 1,
},
{
name: "expires in ~30 days",
expires: ptrTime(now.Add(30 * 24 * time.Hour)),
minDays: 29,
maxDays: 30,
},
{
name: "expires in ~60 days",
expires: ptrTime(now.Add(60 * 24 * time.Hour)),
minDays: 59,
maxDays: 60,
},
{
name: "expires in ~90 days",
expires: ptrTime(now.Add(90 * 24 * time.Hour)),
minDays: 89,
maxDays: 90,
},
{
name: "expired ~1 day ago",
expires: ptrTime(now.Add(-24 * time.Hour)),
minDays: -2,
maxDays: -1,
},
{
name: "expired ~10 days ago",
expires: ptrTime(now.Add(-10 * 24 * time.Hour)),
minDays: -11,
maxDays: -10,
},
{
name: "expires in ~12 hours (partial day)",
expires: ptrTime(now.Add(12 * time.Hour)),
minDays: 0,
maxDays: 1,
},
{
name: "expires in ~36 hours (1.5 days)",
expires: ptrTime(now.Add(36 * time.Hour)),
minDays: 1,
maxDays: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &EmergencyToken{ExpiresAt: tt.expires}
result := token.DaysUntilExpiration()
assert.GreaterOrEqual(t, result, tt.minDays, "days should be >= min")
assert.LessOrEqual(t, result, tt.maxDays, "days should be <= max")
})
}
}
// ptrTime is a helper to create a pointer to a time.Time
func ptrTime(t time.Time) *time.Time {
return &t
}
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
if err := db.AutoMigrate(&NotificationTemplate{}, &UptimeHost{}, &UptimeNotificationEvent{}); err != nil {
t.Fatalf("auto migrate failed: %v", err)
}
return db
}
func TestNotificationTemplate_BeforeCreate(t *testing.T) {
db := setupTestDB(t)
tmpl := &NotificationTemplate{
Name: "hook-test",
}
if err := db.Create(tmpl).Error; err != nil {
t.Fatalf("create failed: %v", err)
}
if tmpl.ID == "" {
t.Fatalf("expected ID to be populated by BeforeCreate")
}
}
func TestUptimeHost_BeforeCreate(t *testing.T) {
db := setupTestDB(t)
h := &UptimeHost{
Host: "127.0.0.1",
}
if err := db.Create(h).Error; err != nil {
t.Fatalf("create failed: %v", err)
}
if h.ID == "" {
t.Fatalf("expected ID to be populated by BeforeCreate")
}
if h.Status != "pending" {
t.Fatalf("expected default Status 'pending', got %q", h.Status)
}
}
func TestUptimeNotificationEvent_BeforeCreate(t *testing.T) {
db := setupTestDB(t)
e := &UptimeNotificationEvent{
HostID: "host-1",
EventType: "down",
}
if err := db.Create(e).Error; err != nil {
t.Fatalf("create failed: %v", err)
}
if e.ID == "" {
t.Fatalf("expected ID to be populated by BeforeCreate")
}
}
+21
View File
@@ -0,0 +1,21 @@
package models
import (
"time"
)
// ImportSession tracks Caddyfile import operations with pending state
// until user reviews and confirms via UI.
type ImportSession struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
SourceFile string `json:"source_file"` // Path to original Caddyfile
Status string `json:"status" gorm:"default:'pending'"` // "pending", "reviewing", "committed", "rejected", "failed"
ParsedData string `json:"parsed_data" gorm:"type:text"` // JSON representation of detected hosts
ConflictReport string `json:"conflict_report" gorm:"type:text"` // JSON array of conflicts
UserResolutions string `json:"user_resolutions" gorm:"type:text"` // JSON map of conflict resolutions
ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CommittedAt *time.Time `json:"committed_at,omitempty"`
}
+18
View File
@@ -0,0 +1,18 @@
package models
import (
"time"
)
// Location represents a custom path-based proxy configuration within a ProxyHost.
type Location struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
ProxyHostID uint `json:"proxy_host_id" gorm:"not null;index"`
Path string `json:"path" gorm:"not null;index"` // e.g., /api, /admin
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
ForwardHost string `json:"forward_host" gorm:"not null;index"`
ForwardPort int `json:"forward_port" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+43
View File
@@ -0,0 +1,43 @@
package models
// CaddyAccessLog represents a structured log entry from Caddy's JSON access logs.
type CaddyAccessLog struct {
Level string `json:"level"`
Ts float64 `json:"ts"`
Logger string `json:"logger"`
Msg string `json:"msg"`
Request struct {
RemoteIP string `json:"remote_ip"`
RemotePort string `json:"remote_port"`
ClientIP string `json:"client_ip"`
Proto string `json:"proto"`
Method string `json:"method"`
Host string `json:"host"`
URI string `json:"uri"`
Headers map[string][]string `json:"headers"`
TLS struct {
Resumed bool `json:"resumed"`
Version int `json:"version"`
CipherSuite int `json:"cipher_suite"`
Proto string `json:"proto"`
ServerName string `json:"server_name"`
} `json:"tls"`
} `json:"request"`
BytesRead int `json:"bytes_read"`
UserID string `json:"user_id"`
Duration float64 `json:"duration"`
Size int `json:"size"`
Status int `json:"status"`
RespHeaders map[string][]string `json:"resp_headers"`
}
// LogFilter defines criteria for filtering logs.
type LogFilter struct {
Search string `form:"search"`
Host string `form:"host"`
Status string `form:"status"` // e.g., "200", "4xx", "5xx"
Level string `form:"level"`
Limit int `form:"limit"`
Offset int `form:"offset"`
Sort string `form:"sort"`
}
@@ -0,0 +1,96 @@
// Package models defines the database schema and domain types.
package models
import (
"time"
)
// ChallengeStatus represents the state of a manual DNS challenge.
type ChallengeStatus string
const (
// ChallengeStatusCreated indicates the challenge has been created but not yet processed.
ChallengeStatusCreated ChallengeStatus = "created"
// ChallengeStatusPending indicates the challenge is waiting for DNS propagation.
ChallengeStatusPending ChallengeStatus = "pending"
// ChallengeStatusVerifying indicates DNS record was found, verification in progress.
ChallengeStatusVerifying ChallengeStatus = "verifying"
// ChallengeStatusVerified indicates the challenge was successfully verified.
ChallengeStatusVerified ChallengeStatus = "verified"
// ChallengeStatusExpired indicates the challenge timed out.
ChallengeStatusExpired ChallengeStatus = "expired"
// ChallengeStatusFailed indicates the challenge failed.
ChallengeStatusFailed ChallengeStatus = "failed"
)
// ManualChallenge represents a manual DNS challenge for ACME DNS-01 validation.
// Users manually create the required TXT record at their DNS provider.
type ManualChallenge struct {
// ID is the primary key (UUIDv4, cryptographically random).
ID string `json:"id" gorm:"primaryKey;size:36"`
// ProviderID is the foreign key to the DNS provider.
ProviderID uint `json:"provider_id" gorm:"index;not null"`
// UserID is the foreign key for ownership validation.
UserID uint `json:"user_id" gorm:"index;not null"`
// FQDN is the fully qualified domain name for the TXT record.
// Example: "_acme-challenge.example.com"
FQDN string `json:"fqdn" gorm:"index;not null;size:255"`
// Token is the ACME challenge token (for identification), never exposed in JSON.
Token string `json:"-" gorm:"size:255"`
// Value is the TXT record value that must be created.
Value string `json:"value" gorm:"not null;size:255"`
// Status is the current state of the challenge.
Status ChallengeStatus `json:"status" gorm:"index;not null;size:20;default:'created'"`
// ErrorMessage stores any error message if the challenge failed.
ErrorMessage string `json:"error_message,omitempty" gorm:"type:text"`
// DNSPropagated indicates if the DNS record has been detected.
DNSPropagated bool `json:"dns_propagated" gorm:"default:false"`
// CreatedAt is when the challenge was created.
CreatedAt time.Time `json:"created_at"`
// ExpiresAt is when the challenge will expire.
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
// LastCheckAt is when DNS was last checked for propagation.
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
// VerifiedAt is when the challenge was successfully verified.
VerifiedAt *time.Time `json:"verified_at,omitempty"`
}
// TableName specifies the database table name.
func (ManualChallenge) TableName() string {
return "manual_challenges"
}
// IsTerminal returns true if the challenge is in a terminal state.
func (c *ManualChallenge) IsTerminal() bool {
return c.Status == ChallengeStatusVerified ||
c.Status == ChallengeStatusExpired ||
c.Status == ChallengeStatusFailed
}
// IsActive returns true if the challenge is in an active state.
func (c *ManualChallenge) IsActive() bool {
return c.Status == ChallengeStatusCreated ||
c.Status == ChallengeStatusPending ||
c.Status == ChallengeStatusVerifying
}
// TimeRemaining returns the duration until the challenge expires.
func (c *ManualChallenge) TimeRemaining() time.Duration {
remaining := time.Until(c.ExpiresAt)
if remaining < 0 {
return 0
}
return remaining
}
@@ -0,0 +1,159 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestChallengeStatus_Constants(t *testing.T) {
// Verify all expected status values exist
assert.Equal(t, ChallengeStatus("created"), ChallengeStatusCreated)
assert.Equal(t, ChallengeStatus("pending"), ChallengeStatusPending)
assert.Equal(t, ChallengeStatus("verifying"), ChallengeStatusVerifying)
assert.Equal(t, ChallengeStatus("verified"), ChallengeStatusVerified)
assert.Equal(t, ChallengeStatus("expired"), ChallengeStatusExpired)
assert.Equal(t, ChallengeStatus("failed"), ChallengeStatusFailed)
}
func TestManualChallenge_TableName(t *testing.T) {
challenge := ManualChallenge{}
assert.Equal(t, "manual_challenges", challenge.TableName())
}
func TestManualChallenge_IsTerminal(t *testing.T) {
tests := []struct {
name string
status ChallengeStatus
expected bool
}{
{"created is not terminal", ChallengeStatusCreated, false},
{"pending is not terminal", ChallengeStatusPending, false},
{"verifying is not terminal", ChallengeStatusVerifying, false},
{"verified is terminal", ChallengeStatusVerified, true},
{"expired is terminal", ChallengeStatusExpired, true},
{"failed is terminal", ChallengeStatusFailed, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenge := &ManualChallenge{Status: tt.status}
assert.Equal(t, tt.expected, challenge.IsTerminal())
})
}
}
func TestManualChallenge_IsActive(t *testing.T) {
tests := []struct {
name string
status ChallengeStatus
expected bool
}{
{"created is active", ChallengeStatusCreated, true},
{"pending is active", ChallengeStatusPending, true},
{"verifying is active", ChallengeStatusVerifying, true},
{"verified is not active", ChallengeStatusVerified, false},
{"expired is not active", ChallengeStatusExpired, false},
{"failed is not active", ChallengeStatusFailed, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenge := &ManualChallenge{Status: tt.status}
assert.Equal(t, tt.expected, challenge.IsActive())
})
}
}
func TestManualChallenge_TimeRemaining(t *testing.T) {
tests := []struct {
name string
expiresAt time.Time
expectPositive bool
}{
{
name: "future expiration returns positive duration",
expiresAt: time.Now().Add(10 * time.Minute),
expectPositive: true,
},
{
name: "past expiration returns zero",
expiresAt: time.Now().Add(-5 * time.Minute),
expectPositive: false,
},
{
name: "just expired returns zero",
expiresAt: time.Now().Add(-1 * time.Second),
expectPositive: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
challenge := &ManualChallenge{ExpiresAt: tt.expiresAt}
remaining := challenge.TimeRemaining()
if tt.expectPositive {
assert.Greater(t, remaining, time.Duration(0))
} else {
assert.Equal(t, time.Duration(0), remaining)
}
})
}
}
func TestManualChallenge_StructFields(t *testing.T) {
now := time.Now()
lastCheck := now.Add(-1 * time.Minute)
verified := now.Add(-30 * time.Second)
challenge := &ManualChallenge{
ID: "test-uuid-123",
ProviderID: 1,
UserID: 2,
FQDN: "_acme-challenge.example.com",
Token: "token123",
Value: "txtvalue456",
Status: ChallengeStatusPending,
ErrorMessage: "",
DNSPropagated: false,
CreatedAt: now,
ExpiresAt: now.Add(10 * time.Minute),
LastCheckAt: &lastCheck,
VerifiedAt: &verified,
}
assert.Equal(t, "test-uuid-123", challenge.ID)
assert.Equal(t, uint(1), challenge.ProviderID)
assert.Equal(t, uint(2), challenge.UserID)
assert.Equal(t, "_acme-challenge.example.com", challenge.FQDN)
assert.Equal(t, "token123", challenge.Token)
assert.Equal(t, "txtvalue456", challenge.Value)
assert.Equal(t, ChallengeStatusPending, challenge.Status)
assert.Empty(t, challenge.ErrorMessage)
assert.False(t, challenge.DNSPropagated)
assert.Equal(t, now, challenge.CreatedAt)
assert.NotNil(t, challenge.LastCheckAt)
assert.NotNil(t, challenge.VerifiedAt)
}
func TestManualChallenge_NilOptionalFields(t *testing.T) {
challenge := &ManualChallenge{
ID: "test-uuid",
ProviderID: 1,
UserID: 1,
FQDN: "_acme-challenge.example.com",
Value: "value",
Status: ChallengeStatusCreated,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
LastCheckAt: nil,
VerifiedAt: nil,
}
assert.Nil(t, challenge.LastCheckAt)
assert.Nil(t, challenge.VerifiedAt)
assert.True(t, challenge.IsActive())
assert.False(t, challenge.IsTerminal())
}
+33
View File
@@ -0,0 +1,33 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationType string
const (
NotificationTypeInfo NotificationType = "info"
NotificationTypeSuccess NotificationType = "success"
NotificationTypeWarning NotificationType = "warning"
NotificationTypeError NotificationType = "error"
)
type Notification struct {
ID string `gorm:"primaryKey" json:"id"`
Type NotificationType `json:"type" gorm:"index"`
Title string `json:"title"`
Message string `json:"message"`
Read bool `json:"read" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"index"`
}
func (n *Notification) BeforeCreate(tx *gorm.DB) (err error) {
if n.ID == "" {
n.ID = uuid.New().String()
}
return
}
@@ -0,0 +1,51 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// NotificationConfig stores configuration for security notifications.
type NotificationConfig struct {
ID string `gorm:"primaryKey" json:"id"`
Enabled bool `json:"enabled"`
MinLogLevel string `json:"min_log_level"` // error, warn, info, debug
WebhookURL string `json:"webhook_url"`
// Blocker 2 Fix: API surface uses security_* field names per spec (internal fields remain notify_*)
NotifyWAFBlocks bool `json:"security_waf_enabled"`
NotifyACLDenies bool `json:"security_acl_enabled"`
NotifyRateLimitHits bool `json:"security_rate_limit_enabled"`
NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"`
EmailRecipients string `json:"email_recipients"`
// Legacy destination fields (compatibility, not stored in DB)
DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"`
SlackWebhookURL string `gorm:"-" json:"slack_webhook_url,omitempty"`
GotifyURL string `gorm:"-" json:"gotify_url,omitempty"`
GotifyToken string `gorm:"-" json:"-"` // Security: Never expose token in JSON (OWASP A02)
DestinationAmbiguous bool `gorm:"-" json:"destination_ambiguous,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate sets the ID if not already set.
func (nc *NotificationConfig) BeforeCreate(tx *gorm.DB) error {
if nc.ID == "" {
nc.ID = uuid.New().String()
}
return nil
}
// SecurityEvent represents a security event for notification dispatch.
type SecurityEvent struct {
EventType string `json:"event_type"` // waf_block, acl_deny, etc.
Severity string `json:"severity"` // error, warn, info
Message string `json:"message"`
ClientIP string `json:"client_ip"`
Path string `json:"path"`
Timestamp time.Time `json:"timestamp"`
Metadata map[string]any `json:"metadata"`
}
@@ -0,0 +1,65 @@
package models
import (
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationProvider struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `json:"name" gorm:"index"`
Type string `json:"type" gorm:"index"` // discord (only supported type in current rollout)
URL string `json:"url"` // Discord webhook URL (HTTPS format required)
Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API
HasToken bool `json:"has_token" gorm:"-"` // Computed: indicates whether a token is set (never exposes raw value)
Engine string `json:"engine,omitempty" gorm:"index"` // notify_v1 (notify-only runtime)
Config string `json:"config"` // JSON payload template for custom webhooks
ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config
LegacyURL string `json:"legacy_url,omitempty"` // Audit field: preserved original URL during migration
Template string `json:"template" gorm:"default:minimal"` // minimal|detailed|custom
MigrationState string `json:"migration_state,omitempty" gorm:"index"` // pending | migrated | deprecated
MigrationError string `json:"migration_error,omitempty" gorm:"type:text"`
LastMigratedAt *time.Time `json:"last_migrated_at,omitempty"`
Enabled bool `json:"enabled" gorm:"index"`
// Notification Preferences
NotifyProxyHosts bool `json:"notify_proxy_hosts" gorm:"default:true"`
NotifyRemoteServers bool `json:"notify_remote_servers" gorm:"default:true"`
NotifyDomains bool `json:"notify_domains" gorm:"default:true"`
NotifyCerts bool `json:"notify_certs" gorm:"default:true"`
NotifyUptime bool `json:"notify_uptime" gorm:"default:true"`
// Security Event Notifications (Provider-based)
NotifySecurityWAFBlocks bool `json:"notify_security_waf_blocks" gorm:"default:false"`
NotifySecurityACLDenies bool `json:"notify_security_acl_denies" gorm:"default:false"`
NotifySecurityRateLimitHits bool `json:"notify_security_rate_limit_hits" gorm:"default:false"`
NotifySecurityCrowdSecDecisions bool `json:"notify_security_crowdsec_decisions" gorm:"column:notify_security_crowdsec_decisions;default:false"`
// Managed Legacy Provider Marker
ManagedLegacySecurity bool `json:"managed_legacy_security" gorm:"index;default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (n *NotificationProvider) BeforeCreate(tx *gorm.DB) (err error) {
if n.ID == "" {
n.ID = uuid.New().String()
}
// Set defaults if not explicitly set (though gorm default tag handles DB side)
// We can't easily distinguish between false and unset for bools here without pointers,
// but for new creations via API, we can assume the frontend sends what it wants.
// If we wanted to force defaults in Go:
// n.NotifyProxyHosts = true ...
if strings.TrimSpace(n.Template) == "" {
if strings.TrimSpace(n.Config) != "" {
n.Template = "custom"
} else {
n.Template = "minimal"
}
}
return
}
@@ -0,0 +1,36 @@
package models_test
import (
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestNotificationProvider_BeforeCreate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
provider := models.NotificationProvider{
Name: "Test",
}
err = db.Create(&provider).Error
require.NoError(t, err)
assert.NotEmpty(t, provider.ID)
// Check default template is minimal if Config is empty
assert.Equal(t, "minimal", provider.Template)
// If Config is present, Template default should be 'custom'
provider2 := models.NotificationProvider{
Name: "Test2",
Config: `{"custom":"ok"}`,
}
err = db.Create(&provider2).Error
require.NoError(t, err)
assert.Equal(t, "custom", provider2.Template)
}
@@ -0,0 +1,30 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// NotificationTemplate represents a reusable external notification template
// that can be applied when sending webhooks or other external notifications.
type NotificationTemplate struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `json:"name"`
Description string `json:"description"`
// Config holds the JSON/template body for external webhook payloads
Config string `json:"config"`
// Template is a hint: minimal|detailed|custom (optional)
Template string `json:"template" gorm:"default:minimal"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (t *NotificationTemplate) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {
t.ID = uuid.New().String()
}
return
}
@@ -0,0 +1,47 @@
package models
import (
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestNotification_BeforeCreate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
assert.NoError(t, err)
_ = db.AutoMigrate(&Notification{})
// Case 1: ID is empty, should be generated
n1 := &Notification{Title: "Test", Message: "Test Message"}
err = db.Create(n1).Error
assert.NoError(t, err)
assert.NotEmpty(t, n1.ID)
// Case 2: ID is provided, should be kept
id := "123e4567-e89b-12d3-a456-426614174000"
n2 := &Notification{ID: id, Title: "Test 2", Message: "Test Message 2"}
err = db.Create(n2).Error
assert.NoError(t, err)
assert.Equal(t, id, n2.ID)
}
func TestNotificationConfig_BeforeCreate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
assert.NoError(t, err)
_ = db.AutoMigrate(&NotificationConfig{})
// Case 1: ID is empty, should be generated
nc1 := &NotificationConfig{Enabled: true, MinLogLevel: "error"}
err = db.Create(nc1).Error
assert.NoError(t, err)
assert.NotEmpty(t, nc1.ID)
// Case 2: ID is provided, should be kept
id := "custom-config-id"
nc2 := &NotificationConfig{ID: id, Enabled: false, MinLogLevel: "warn"}
err = db.Create(nc2).Error
assert.NoError(t, err)
assert.Equal(t, id, nc2.ID)
}
+35
View File
@@ -0,0 +1,35 @@
package models
import "time"
// Plugin represents an installed DNS provider plugin.
// This tracks both external .so plugins and their load status.
type Plugin struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
Name string `json:"name" gorm:"not null;size:255"`
Type string `json:"type" gorm:"uniqueIndex;not null;size:100"`
FilePath string `json:"file_path" gorm:"not null;size:500"`
Signature string `json:"signature" gorm:"size:100"`
Enabled bool `json:"enabled" gorm:"default:true"`
Status string `json:"status" gorm:"default:'pending';size:50"` // pending, loaded, error
Error string `json:"error,omitempty" gorm:"type:text"`
Version string `json:"version,omitempty" gorm:"size:50"`
Author string `json:"author,omitempty" gorm:"size:255"`
LoadedAt *time.Time `json:"loaded_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the database table name for GORM.
func (Plugin) TableName() string {
return "plugins"
}
// PluginStatus constants define the possible status values for a plugin.
const (
PluginStatusPending = "pending" // Plugin registered but not yet loaded
PluginStatusLoaded = "loaded" // Plugin successfully loaded and registered
PluginStatusError = "error" // Plugin failed to load
)
+63
View File
@@ -0,0 +1,63 @@
package models
import (
"time"
)
// ProxyHost represents a reverse proxy configuration.
type ProxyHost struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"index"`
DomainNames string `json:"domain_names" gorm:"not null;index"` // Comma-separated list
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
ForwardHost string `json:"forward_host" gorm:"not null;index"`
ForwardPort int `json:"forward_port" gorm:"not null"`
SSLForced bool `json:"ssl_forced" gorm:"default:false"`
HTTP2Support bool `json:"http2_support" gorm:"default:true"`
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"`
HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"`
BlockExploits bool `json:"block_exploits" gorm:"default:true"`
WebsocketSupport bool `json:"websocket_support" gorm:"default:false"`
Application string `json:"application" gorm:"default:none"` // none, plex, jellyfin, emby, homeassistant, nextcloud, vaultwarden
Enabled bool `json:"enabled" gorm:"default:true;index"`
CertificateID *uint `json:"certificate_id" gorm:"index"`
Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"`
AccessListID *uint `json:"access_list_id" gorm:"index"`
AccessList *AccessList `json:"access_list" gorm:"foreignKey:AccessListID"`
Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"`
AdvancedConfig string `json:"advanced_config" gorm:"type:text"`
AdvancedConfigBackup string `json:"advanced_config_backup" gorm:"type:text"`
// Forward Auth / User Gateway settings
// When enabled, Caddy will use forward_auth to verify user access via Charon
ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"`
// WAF override - when true, disables WAF for this specific host
WAFDisabled bool `json:"waf_disabled" gorm:"default:false"`
// Security Headers Configuration
// Either reference a profile OR use inline settings
SecurityHeaderProfileID *uint `json:"security_header_profile_id" gorm:"index"`
SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"`
// Inline security header settings (used when no profile is selected)
// These override profile settings if both are set
SecurityHeadersEnabled bool `json:"security_headers_enabled" gorm:"default:true"`
SecurityHeadersCustom string `json:"security_headers_custom" gorm:"type:text"` // JSON for custom headers
// EnableStandardHeaders controls whether standard proxy headers are added
// Default: true for NEW hosts, false for EXISTING hosts (via migration/seed update)
// When true: Adds X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port
// When false: Old behavior (headers only with WebSocket or application-specific)
// X-Forwarded-For is handled natively by Caddy (not explicitly set)
EnableStandardHeaders *bool `json:"enable_standard_headers,omitempty" gorm:"default:true"`
// DNS Challenge configuration
DNSProviderID *uint `json:"dns_provider_id,omitempty" gorm:"index"`
DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
UseDNSChallenge bool `json:"use_dns_challenge" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
)
// RemoteServer represents a known backend server that can be selected
// when creating proxy hosts, eliminating manual IP/port entry.
type RemoteServer struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name" gorm:"index"`
Provider string `json:"provider" gorm:"index"` // e.g., "docker", "vm", "cloud", "manual"
Host string `json:"host" gorm:"index"` // IP address or hostname
Port int `json:"port"`
Scheme string `json:"scheme"` // http/https
Tags string `json:"tags"` // comma-separated tags for filtering
Description string `json:"description"`
Enabled bool `json:"enabled" gorm:"default:true;index"`
LastChecked *time.Time `json:"last_checked,omitempty"`
Reachable bool `json:"reachable" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+20
View File
@@ -0,0 +1,20 @@
package models
import (
"time"
)
// SecurityAudit records admin actions or important changes related to security.
type SecurityAudit struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Actor string `json:"actor" gorm:"index"`
Action string `json:"action"`
EventCategory string `json:"event_category" gorm:"index"`
ResourceID *uint `json:"resource_id,omitempty"`
ResourceUUID string `json:"resource_uuid,omitempty" gorm:"index"`
Details string `json:"details" gorm:"type:text"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at" gorm:"index"`
}
@@ -0,0 +1,31 @@
package models
import (
"time"
)
// SecurityConfig represents global Cerberus/CrowdSec/WAF/RateLimit settings
// used by the server and propagated into the generated Caddy config.
type SecurityConfig struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name" gorm:"index"`
Enabled bool `json:"enabled" gorm:"index"`
AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs
BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"`
CrowdSecMode string `json:"crowdsec_mode"` // "disabled" or "local"
CrowdSecAPIURL string `json:"crowdsec_api_url" gorm:"type:text"`
WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block"
WAFRulesSource string `json:"waf_rules_source" gorm:"type:text"` // URL or name of ruleset
WAFLearning bool `json:"waf_learning"`
WAFParanoiaLevel int `json:"waf_paranoia_level" gorm:"default:1"` // 1-4, OWASP CRS paranoia level
WAFExclusions string `json:"waf_exclusions" gorm:"type:text"` // JSON array of rule exclusions
RateLimitMode string `json:"rate_limit_mode"` // "disabled", "enabled"
RateLimitEnable bool `json:"rate_limit_enable"`
RateLimitBurst int `json:"rate_limit_burst"`
RateLimitRequests int `json:"rate_limit_requests"`
RateLimitWindowSec int `json:"rate_limit_window_sec"`
RateLimitBypassList string `json:"rate_limit_bypass_list" gorm:"type:text"` // Comma-separated CIDRs
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,19 @@
package models
import (
"time"
)
// SecurityDecision stores a decision/action taken by CrowdSec/WAF/RateLimit or manual
// override so it can be audited and surfaced in the UI.
type SecurityDecision struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Source string `json:"source" gorm:"index"` // e.g., crowdsec, waf, ratelimit, manual
Action string `json:"action" gorm:"index"` // allow, block, challenge
IP string `json:"ip" gorm:"index"`
Host string `json:"host" gorm:"index"` // optional
RuleID string `json:"rule_id" gorm:"index"`
Details string `json:"details" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"index"`
}
@@ -0,0 +1,71 @@
package models
import (
"time"
)
// SecurityHeaderProfile stores reusable security header configurations.
// Users can create profiles and assign them to proxy hosts.
type SecurityHeaderProfile struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"index;not null"`
// HSTS Configuration
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:true"`
HSTSMaxAge int `json:"hsts_max_age" gorm:"default:31536000"` // 1 year in seconds
HSTSIncludeSubdomains bool `json:"hsts_include_subdomains" gorm:"default:true"`
HSTSPreload bool `json:"hsts_preload" gorm:"default:false"`
// Content-Security-Policy
CSPEnabled bool `json:"csp_enabled" gorm:"default:false"`
CSPDirectives string `json:"csp_directives" gorm:"type:text"` // JSON object of CSP directives
CSPReportOnly bool `json:"csp_report_only" gorm:"default:false"`
CSPReportURI string `json:"csp_report_uri"`
// X-Frame-Options
XFrameOptions string `json:"x_frame_options" gorm:"default:DENY"` // DENY, SAMEORIGIN, or empty
// X-Content-Type-Options
XContentTypeOptions bool `json:"x_content_type_options" gorm:"default:true"` // nosniff
// Referrer-Policy
ReferrerPolicy string `json:"referrer_policy" gorm:"default:strict-origin-when-cross-origin"`
// Permissions-Policy (formerly Feature-Policy)
PermissionsPolicy string `json:"permissions_policy" gorm:"type:text"` // JSON array of policies
// Cross-Origin Headers
CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" gorm:"default:same-origin"`
CrossOriginResourcePolicy string `json:"cross_origin_resource_policy" gorm:"default:same-origin"`
CrossOriginEmbedderPolicy string `json:"cross_origin_embedder_policy"` // require-corp or empty
// X-XSS-Protection (legacy but still useful)
XSSProtection bool `json:"xss_protection" gorm:"default:true"`
// Cache-Control for security
CacheControlNoStore bool `json:"cache_control_no_store" gorm:"default:false"`
// Computed Security Score (0-100)
SecurityScore int `json:"security_score" gorm:"default:0"`
// Metadata
IsPreset bool `json:"is_preset" gorm:"default:false"` // System presets can't be deleted
PresetType string `json:"preset_type"` // "basic", "strict", "paranoid", or empty for custom
Description string `json:"description" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CSPDirective represents a single CSP directive for the builder
type CSPDirective struct {
Directive string `json:"directive"` // e.g., "default-src", "script-src"
Values []string `json:"values"` // e.g., ["'self'", "https:"]
}
// PermissionsPolicyItem represents a single Permissions-Policy entry
type PermissionsPolicyItem struct {
Feature string `json:"feature"` // e.g., "camera", "microphone"
Allowlist []string `json:"allowlist"` // e.g., ["self"], ["*"], []
}
@@ -0,0 +1,244 @@
package models
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupSecurityHeaderProfileDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
err = db.AutoMigrate(&SecurityHeaderProfile{})
assert.NoError(t, err)
return db
}
func TestSecurityHeaderProfile_Create(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
profile := SecurityHeaderProfile{
UUID: uuid.New().String(),
Name: "Test Profile",
HSTSEnabled: true,
HSTSMaxAge: 31536000,
HSTSIncludeSubdomains: true,
HSTSPreload: false,
CSPEnabled: false,
XFrameOptions: "DENY",
XContentTypeOptions: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
XSSProtection: true,
SecurityScore: 65,
IsPreset: false,
}
err := db.Create(&profile).Error
assert.NoError(t, err)
assert.NotZero(t, profile.ID)
}
func TestSecurityHeaderProfile_JSONSerialization(t *testing.T) {
profile := SecurityHeaderProfile{
ID: 1,
UUID: "test-uuid",
Name: "Test Profile",
HSTSEnabled: true,
HSTSMaxAge: 31536000,
HSTSIncludeSubdomains: true,
XFrameOptions: "DENY",
SecurityScore: 85,
}
data, err := json.Marshal(profile)
assert.NoError(t, err)
assert.Contains(t, string(data), `"hsts_enabled":true`)
assert.Contains(t, string(data), `"hsts_max_age":31536000`)
assert.Contains(t, string(data), `"x_frame_options":"DENY"`)
var decoded SecurityHeaderProfile
err = json.Unmarshal(data, &decoded)
assert.NoError(t, err)
assert.Equal(t, profile.Name, decoded.Name)
assert.Equal(t, profile.HSTSEnabled, decoded.HSTSEnabled)
assert.Equal(t, profile.SecurityScore, decoded.SecurityScore)
}
func TestCSPDirective_JSONSerialization(t *testing.T) {
directive := CSPDirective{
Directive: "default-src",
Values: []string{"'self'", "https:"},
}
data, err := json.Marshal(directive)
assert.NoError(t, err)
assert.Contains(t, string(data), `"directive":"default-src"`)
assert.Contains(t, string(data), `"values":["'self'","https:"]`)
var decoded CSPDirective
err = json.Unmarshal(data, &decoded)
assert.NoError(t, err)
assert.Equal(t, directive.Directive, decoded.Directive)
assert.Equal(t, directive.Values, decoded.Values)
}
func TestPermissionsPolicyItem_JSONSerialization(t *testing.T) {
item := PermissionsPolicyItem{
Feature: "camera",
Allowlist: []string{"self"},
}
data, err := json.Marshal(item)
assert.NoError(t, err)
assert.Contains(t, string(data), `"feature":"camera"`)
assert.Contains(t, string(data), `"allowlist":["self"]`)
var decoded PermissionsPolicyItem
err = json.Unmarshal(data, &decoded)
assert.NoError(t, err)
assert.Equal(t, item.Feature, decoded.Feature)
assert.Equal(t, item.Allowlist, decoded.Allowlist)
}
func TestSecurityHeaderProfile_Defaults(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
profile := SecurityHeaderProfile{
UUID: uuid.New().String(),
Name: "Default Test",
}
err := db.Create(&profile).Error
assert.NoError(t, err)
// Reload to check defaults
var reloaded SecurityHeaderProfile
err = db.First(&reloaded, profile.ID).Error
assert.NoError(t, err)
assert.True(t, reloaded.HSTSEnabled)
assert.Equal(t, 31536000, reloaded.HSTSMaxAge)
assert.True(t, reloaded.HSTSIncludeSubdomains)
assert.False(t, reloaded.HSTSPreload)
assert.False(t, reloaded.CSPEnabled)
assert.Equal(t, "DENY", reloaded.XFrameOptions)
assert.True(t, reloaded.XContentTypeOptions)
assert.Equal(t, "strict-origin-when-cross-origin", reloaded.ReferrerPolicy)
assert.True(t, reloaded.XSSProtection)
assert.False(t, reloaded.CacheControlNoStore)
assert.Equal(t, 0, reloaded.SecurityScore)
assert.False(t, reloaded.IsPreset)
}
func TestSecurityHeaderProfile_UniqueUUID(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
testUUID := uuid.New().String()
profile1 := SecurityHeaderProfile{
UUID: testUUID,
Name: "Profile 1",
}
err := db.Create(&profile1).Error
assert.NoError(t, err)
profile2 := SecurityHeaderProfile{
UUID: testUUID,
Name: "Profile 2",
}
err = db.Create(&profile2).Error
assert.Error(t, err) // Should fail due to unique constraint
}
func TestSecurityHeaderProfile_CSPDirectivesStorage(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
cspDirectives := map[string][]string{
"default-src": {"'self'"},
"script-src": {"'self'", "'unsafe-inline'"},
"style-src": {"'self'", "https:"},
}
cspJSON, err := json.Marshal(cspDirectives)
assert.NoError(t, err)
profile := SecurityHeaderProfile{
UUID: uuid.New().String(),
Name: "CSP Test",
CSPEnabled: true,
CSPDirectives: string(cspJSON),
}
err = db.Create(&profile).Error
assert.NoError(t, err)
// Reload and verify
var reloaded SecurityHeaderProfile
err = db.First(&reloaded, profile.ID).Error
assert.NoError(t, err)
var decoded map[string][]string
err = json.Unmarshal([]byte(reloaded.CSPDirectives), &decoded)
assert.NoError(t, err)
assert.Equal(t, cspDirectives, decoded)
}
func TestSecurityHeaderProfile_PermissionsPolicyStorage(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
permissions := []PermissionsPolicyItem{
{Feature: "camera", Allowlist: []string{}},
{Feature: "microphone", Allowlist: []string{"self"}},
{Feature: "geolocation", Allowlist: []string{"*"}},
}
permJSON, err := json.Marshal(permissions)
assert.NoError(t, err)
profile := SecurityHeaderProfile{
UUID: uuid.New().String(),
Name: "Permissions Test",
PermissionsPolicy: string(permJSON),
}
err = db.Create(&profile).Error
assert.NoError(t, err)
// Reload and verify
var reloaded SecurityHeaderProfile
err = db.First(&reloaded, profile.ID).Error
assert.NoError(t, err)
var decoded []PermissionsPolicyItem
err = json.Unmarshal([]byte(reloaded.PermissionsPolicy), &decoded)
assert.NoError(t, err)
assert.Equal(t, permissions, decoded)
}
func TestSecurityHeaderProfile_PresetFields(t *testing.T) {
db := setupSecurityHeaderProfileDB(t)
profile := SecurityHeaderProfile{
UUID: uuid.New().String(),
Name: "Basic Security",
IsPreset: true,
PresetType: "basic",
Description: "Essential security headers for most websites",
}
err := db.Create(&profile).Error
assert.NoError(t, err)
// Reload
var reloaded SecurityHeaderProfile
err = db.First(&reloaded, profile.ID).Error
assert.NoError(t, err)
assert.True(t, reloaded.IsPreset)
assert.Equal(t, "basic", reloaded.PresetType)
assert.Equal(t, "Essential security headers for most websites", reloaded.Description)
}
@@ -0,0 +1,23 @@
// Package models defines the data types used throughout the application.
package models
// SecurityLogEntry represents a security-relevant log entry for live streaming.
// This struct is used by the LogWatcher service to broadcast parsed Caddy access logs
// with security event annotations to WebSocket clients.
type SecurityLogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Logger string `json:"logger"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
URI string `json:"uri"`
Status int `json:"status"`
Duration float64 `json:"duration"`
Size int64 `json:"size"`
UserAgent string `json:"user_agent"`
Host string `json:"host"`
Source string `json:"source"` // "waf", "crowdsec", "ratelimit", "acl", "normal"
Blocked bool `json:"blocked"` // True if request was blocked
BlockReason string `json:"block_reason,omitempty"` // Reason for blocking
Details map[string]any `json:"details,omitempty"` // Additional metadata
}
@@ -0,0 +1,16 @@
package models
import (
"time"
)
// SecurityRuleSet stores metadata about WAF/CrowdSec rule sets that the server can download and apply.
type SecurityRuleSet struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name" gorm:"index"`
SourceURL string `json:"source_url" gorm:"type:text"`
Mode string `json:"mode"` // optional e.g., 'owasp', 'custom'
LastUpdated time.Time `json:"last_updated"`
Content string `json:"content" gorm:"type:text"`
}
+16
View File
@@ -0,0 +1,16 @@
package models
import (
"time"
)
// Setting stores global application configuration as key-value pairs.
// Used for system-wide preferences, feature flags, and runtime config.
type Setting struct {
ID uint `json:"-" gorm:"primaryKey"`
Key string `json:"key" gorm:"uniqueIndex"`
Value string `json:"value" gorm:"type:text"`
Type string `json:"type" gorm:"index"` // "string", "int", "bool", "json"
Category string `json:"category" gorm:"index"` // "general", "security", "caddy", "smtp", etc.
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,21 @@
package models
import (
"time"
)
// SSLCertificate represents TLS certificates managed by Charon.
// Can be Let's Encrypt auto-generated or custom uploaded certs.
type SSLCertificate struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name" gorm:"index"`
Provider string `json:"provider" gorm:"index"` // "letsencrypt", "custom", "self-signed"
Domains string `json:"domains" gorm:"index"` // comma-separated list of domains
Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+55
View File
@@ -0,0 +1,55 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UptimeMonitor struct {
ID string `gorm:"primaryKey" json:"id"`
ProxyHostID *uint `json:"proxy_host_id" gorm:"index"` // Optional link to proxy host
ProxyHost *ProxyHost `json:"proxy_host,omitempty" gorm:"foreignKey:ProxyHostID"` // Relationship for automatic loading
RemoteServerID *uint `json:"remote_server_id" gorm:"index"` // Optional link to remote server
UptimeHostID *string `json:"uptime_host_id" gorm:"index"` // Link to parent host for grouping
Name string `json:"name" gorm:"index"`
Type string `json:"type"` // http, tcp, ping
URL string `json:"url"`
UpstreamHost string `json:"upstream_host" gorm:"index"` // The actual backend host/IP (for grouping)
Interval int `json:"interval"` // seconds
Enabled bool `json:"enabled" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Current Status (Cached)
Status string `json:"status" gorm:"index"` // up, down, maintenance, pending
LastCheck time.Time `json:"last_check"`
Latency int64 `json:"latency"` // ms
FailureCount int `json:"failure_count"`
LastStatusChange time.Time `json:"last_status_change"`
MaxRetries int `json:"max_retries" gorm:"default:3"`
// Notification tracking
LastNotifiedDown time.Time `json:"last_notified_down"` // Prevent duplicate notifications
NotifiedInBatch bool `json:"notified_in_batch"` // Was this included in a batch notification?
}
type UptimeHeartbeat struct {
ID uint `gorm:"primaryKey" json:"-"`
MonitorID string `json:"monitor_id" gorm:"index"`
Status string `json:"status"` // up, down
Latency int64 `json:"latency"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at" gorm:"index"`
}
func (m *UptimeMonitor) BeforeCreate(tx *gorm.DB) (err error) {
if m.ID == "" {
m.ID = uuid.New().String()
}
if m.Status == "" {
m.Status = "pending"
}
return
}
+57
View File
@@ -0,0 +1,57 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// UptimeHost represents a unique upstream host/IP that may have multiple services.
// This enables host-level health checks to avoid notification storms when a whole server goes down.
type UptimeHost struct {
ID string `gorm:"primaryKey" json:"id"`
Host string `json:"host" gorm:"uniqueIndex;not null"` // IP address or hostname
Name string `json:"name"` // Friendly name (auto-generated or from first service)
Status string `json:"status"` // up, down, pending
LastCheck time.Time `json:"last_check"`
Latency int64 `json:"latency"` // ms for ping/TCP check
// Notification tracking
LastNotifiedDown time.Time `json:"last_notified_down"` // When we last sent DOWN notification
LastNotifiedUp time.Time `json:"last_notified_up"` // When we last sent UP notification
NotifiedServiceCount int `json:"notified_service_count"` // Number of services in last notification
LastStatusChange time.Time `json:"last_status_change"` // When status last changed
FailureCount int `json:"failure_count" gorm:"default:0"` // Consecutive failures for debouncing
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (h *UptimeHost) BeforeCreate(tx *gorm.DB) (err error) {
if h.ID == "" {
h.ID = uuid.New().String()
}
if h.Status == "" {
h.Status = "pending"
}
return
}
// UptimeNotificationEvent tracks notification batches to prevent duplicates
type UptimeNotificationEvent struct {
ID string `gorm:"primaryKey" json:"id"`
HostID string `json:"host_id" gorm:"index"`
EventType string `json:"event_type"` // down, up, partial_recovery
MonitorIDs string `json:"monitor_ids"` // JSON array of monitor IDs included in this notification
Message string `json:"message"`
SentAt time.Time `json:"sent_at"`
CreatedAt time.Time `json:"created_at"`
}
func (e *UptimeNotificationEvent) BeforeCreate(tx *gorm.DB) (err error) {
if e.ID == "" {
e.ID = uuid.New().String()
}
return
}
+26
View File
@@ -0,0 +1,26 @@
package models_test
import (
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestUptimeMonitor_BeforeCreate(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}))
monitor := models.UptimeMonitor{
Name: "Test",
}
err = db.Create(&monitor).Error
require.NoError(t, err)
assert.NotEmpty(t, monitor.ID)
assert.Equal(t, "pending", monitor.Status)
}
+132
View File
@@ -0,0 +1,132 @@
// Package models defines the database schema and domain types.
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
)
// UserRole represents an authenticated user's privilege tier.
type UserRole string
const (
// RoleAdmin has full access to all Charon features and management.
RoleAdmin UserRole = "admin"
// RoleUser can access the Charon management UI with restricted permissions.
RoleUser UserRole = "user"
// RolePassthrough can only authenticate for forward-auth proxy access.
RolePassthrough UserRole = "passthrough"
)
// IsValid returns true when the role is one of the recognised privilege tiers.
func (r UserRole) IsValid() bool {
switch r {
case RoleAdmin, RoleUser, RolePassthrough:
return true
}
return false
}
// PermissionMode determines how user access to proxy hosts is evaluated.
type PermissionMode string
const (
// PermissionModeAllowAll grants access to all hosts except those in the exception list.
PermissionModeAllowAll PermissionMode = "allow_all"
// PermissionModeDenyAll denies access to all hosts except those in the exception list.
PermissionModeDenyAll PermissionMode = "deny_all"
)
// User represents authenticated users with role-based access control.
// Supports local auth, SSO integration, and invite-based onboarding.
type User struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Email string `json:"email" gorm:"uniqueIndex"`
APIKey string `json:"-" gorm:"uniqueIndex"` // For external API access, never exposed in JSON
PasswordHash string `json:"-"` // Never serialize password hash
Name string `json:"name"`
Role UserRole `json:"role" gorm:"default:'user'"`
Enabled bool `json:"enabled" gorm:"default:true"`
FailedLoginAttempts int `json:"-" gorm:"default:0"`
LockedUntil *time.Time `json:"-"`
LastLogin *time.Time `json:"last_login,omitempty"`
SessionVersion uint `json:"-" gorm:"default:0"`
// Invite system fields
InviteToken string `json:"-" gorm:"index"` // Token sent via email for account setup
InviteExpires *time.Time `json:"-"` // When the invite token expires
InvitedAt *time.Time `json:"invited_at,omitempty"` // When the invite was sent
InvitedBy *uint `json:"invited_by,omitempty"` // ID of user who sent the invite
InviteStatus string `json:"invite_status,omitempty"` // "pending", "accepted", "expired"
// Permission system for forward auth / user gateway
PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"` // "allow_all" or "deny_all"
PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SetPassword hashes and sets the user's password.
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
return nil
}
// CheckPassword compares the provided password with the stored hash.
func (u *User) CheckPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
return err == nil
}
// HasPendingInvite returns true if the user has a pending invite that hasn't expired.
func (u *User) HasPendingInvite() bool {
if u.InviteToken == "" || u.InviteExpires == nil {
return false
}
return u.InviteExpires.After(time.Now()) && u.InviteStatus == "pending"
}
// CanAccessHost determines if the user can access a given proxy host based on their permission mode.
// - allow_all mode: User can access everything EXCEPT hosts in PermittedHosts (blacklist)
// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist)
func (u *User) CanAccessHost(hostID uint) bool {
// Admins always have access
if u.Role == RoleAdmin {
return true
}
// Check if host is in the permitted hosts list
hostInList := false
for _, h := range u.PermittedHosts {
if h.ID == hostID {
hostInList = true
break
}
}
switch u.PermissionMode {
case PermissionModeAllowAll:
// Allow all except those in the list (blacklist)
return !hostInList
case PermissionModeDenyAll:
// Deny all except those in the list (whitelist)
return hostInList
default:
// Default to allow_all behavior
return !hostInList
}
}
// UserPermittedHost is the join table for the many-to-many relationship.
// This is auto-created by GORM but defined here for clarity.
type UserPermittedHost struct {
UserID uint `gorm:"primaryKey"`
ProxyHostID uint `gorm:"primaryKey"`
}
+221
View File
@@ -0,0 +1,221 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestUser_SetPassword(t *testing.T) {
u := &User{}
err := u.SetPassword("password123")
assert.NoError(t, err)
assert.NotEmpty(t, u.PasswordHash)
assert.NotEqual(t, "password123", u.PasswordHash)
// Test with empty password (should still work but hash empty string)
u2 := &User{}
err = u2.SetPassword("")
assert.NoError(t, err)
assert.NotEmpty(t, u2.PasswordHash)
// Test with special characters
u3 := &User{}
err = u3.SetPassword("P@ssw0rd!#$%^&*()")
assert.NoError(t, err)
assert.NotEmpty(t, u3.PasswordHash)
assert.True(t, u3.CheckPassword("P@ssw0rd!#$%^&*()"))
}
func TestUser_CheckPassword(t *testing.T) {
u := &User{}
_ = u.SetPassword("password123")
assert.True(t, u.CheckPassword("password123"))
assert.False(t, u.CheckPassword("wrongpassword"))
}
func TestUser_HasPendingInvite(t *testing.T) {
tests := []struct {
name string
user User
expected bool
}{
{
name: "no invite token",
user: User{InviteToken: "", InviteStatus: ""},
expected: false,
},
{
name: "expired invite",
user: User{
InviteToken: "token123",
InviteExpires: timePtr(time.Now().Add(-1 * time.Hour)),
InviteStatus: "pending",
},
expected: false,
},
{
name: "valid pending invite",
user: User{
InviteToken: "token123",
InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
InviteStatus: "pending",
},
expected: true,
},
{
name: "already accepted invite",
user: User{
InviteToken: "token123",
InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
InviteStatus: "accepted",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.user.HasPendingInvite()
assert.Equal(t, tt.expected, result)
})
}
}
func TestUser_CanAccessHost_AllowAll(t *testing.T) {
// User with allow_all mode (blacklist) - can access everything except listed hosts
user := User{
Role: RoleUser,
PermissionMode: PermissionModeAllowAll,
PermittedHosts: []ProxyHost{
{ID: 1}, // Blocked host
{ID: 2}, // Blocked host
},
}
// Should NOT be able to access hosts in the blacklist
assert.False(t, user.CanAccessHost(1))
assert.False(t, user.CanAccessHost(2))
// Should be able to access other hosts
assert.True(t, user.CanAccessHost(3))
assert.True(t, user.CanAccessHost(100))
}
func TestUser_CanAccessHost_DenyAll(t *testing.T) {
// User with deny_all mode (whitelist) - can only access listed hosts
user := User{
Role: RoleUser,
PermissionMode: PermissionModeDenyAll,
PermittedHosts: []ProxyHost{
{ID: 5}, // Allowed host
{ID: 6}, // Allowed host
},
}
// Should be able to access hosts in the whitelist
assert.True(t, user.CanAccessHost(5))
assert.True(t, user.CanAccessHost(6))
// Should NOT be able to access other hosts
assert.False(t, user.CanAccessHost(1))
assert.False(t, user.CanAccessHost(100))
}
func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
// Admin users should always have access regardless of permission mode
adminUser := User{
Role: RoleAdmin,
PermissionMode: PermissionModeDenyAll,
PermittedHosts: []ProxyHost{}, // No hosts in whitelist
}
// Admin should still be able to access any host
assert.True(t, adminUser.CanAccessHost(1))
assert.True(t, adminUser.CanAccessHost(999))
}
func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) {
// User with empty/default permission mode should behave like allow_all
user := User{
Role: RoleUser,
PermissionMode: "", // Empty = default
PermittedHosts: []ProxyHost{
{ID: 1}, // Should be blocked
},
}
assert.False(t, user.CanAccessHost(1))
assert.True(t, user.CanAccessHost(2))
}
func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) {
tests := []struct {
name string
permissionMode PermissionMode
hostID uint
expected bool
}{
{
name: "allow_all with no exceptions allows all",
permissionMode: PermissionModeAllowAll,
hostID: 1,
expected: true,
},
{
name: "deny_all with no exceptions denies all",
permissionMode: PermissionModeDenyAll,
hostID: 1,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := User{
Role: RoleUser,
PermissionMode: tt.permissionMode,
PermittedHosts: []ProxyHost{},
}
result := user.CanAccessHost(tt.hostID)
assert.Equal(t, tt.expected, result)
})
}
}
func TestPermissionMode_Constants(t *testing.T) {
assert.Equal(t, PermissionMode("allow_all"), PermissionModeAllowAll)
assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
}
func TestUserRole_Constants(t *testing.T) {
assert.Equal(t, UserRole("admin"), RoleAdmin)
assert.Equal(t, UserRole("user"), RoleUser)
assert.Equal(t, UserRole("passthrough"), RolePassthrough)
}
func TestUserRole_IsValid(t *testing.T) {
tests := []struct {
role UserRole
expected bool
}{
{RoleAdmin, true},
{RoleUser, true},
{RolePassthrough, true},
{UserRole("viewer"), false},
{UserRole("superadmin"), false},
{UserRole(""), false},
}
for _, tt := range tests {
t.Run(string(tt.role), func(t *testing.T) {
assert.Equal(t, tt.expected, tt.role.IsValid())
})
}
}
// Helper function to create time pointers
func timePtr(t time.Time) *time.Time {
return &t
}