Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/auto-versioning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- name: Calculate Semantic Version
id: semver
uses: paulhatch/semantic-version@f29500c9d60a99ed5168e39ee367e0976884c46e # v6.0.1
uses: paulhatch/semantic-version@9f72830310d5ed81233b641ee59253644cd8a8fc # v6.0.2
with:
# The prefix to use to create tags
tag_prefix: "v"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rate-limit-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:

echo "### Caddy Admin Config (rate_limit handlers)"
echo '```json'
curl -s http://localhost:2119/config 2>/dev/null | grep -A 20 '"handler":"rate_limit"' | head -30 || echo "Could not retrieve Caddy config"
curl -s http://localhost:2119/config/ 2>/dev/null | grep -A 20 '"handler":"rate_limit"' | head -30 || echo "Could not retrieve Caddy config"
echo '```'
echo ""

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevents timeout errors in Firefox/WebKit caused by strict label matching

### Fixed
- **CI: Rate Limit Integration Tests**: Hardened test script reliability — login now validates HTTP status, Caddy admin API readiness gated on `/config/` poll, security config failures are fatal with full diagnostics, and poll interval increased to 5s
- **CI: Rate Limit Integration Tests**: Removed stale GeoIP database SHA256 checksum from Dockerfile non-CI path (hash was perpetually stale due to weekly upstream updates)
- **CI: Rate Limit Integration Tests**: Fixed Caddy admin API debug dump URL to use canonical trailing slash in workflow
- Fixed: Added robust validation and debug logging for Docker image tags to prevent invalid reference errors.
- Fixed: Removed log masking for image references and added manifest validation to debug CI failures.
- **Proxy Hosts**: Fixed ACL and Security Headers dropdown selections so create/edit saves now keep the selected values (including clearing to none) after submit and reload.
Expand Down
13 changes: 4 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -451,16 +451,11 @@ RUN mkdir -p /app/data/geoip && \
else \
echo "Local - full download (30s timeout, 3 retries)"; \
if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \
-T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"; then \
if [ -s /app/data/geoip/GeoLite2-Country.mmdb ] && \
echo "${GEOLITE2_COUNTRY_SHA256} /app/data/geoip/GeoLite2-Country.mmdb" | sha256sum -c -; then \
echo "✅ GeoIP checksum verified"; \
else \
echo "⚠️ Checksum failed"; \
touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
fi; \
-T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
&& [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \
echo "✅ GeoIP downloaded"; \
else \
echo "⚠️ Download failed"; \
echo "⚠️ GeoIP download failed or empty — skipping"; \
touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
fi; \
fi
Expand Down
4 changes: 2 additions & 2 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
Expand Down Expand Up @@ -98,5 +98,5 @@ require (
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.2 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
8 changes: 4 additions & 4 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
Expand Down Expand Up @@ -263,8 +263,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/api/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
}

migrateViewerToPassthrough(db)

// Seed the default SecurityConfig row on every startup (idempotent).
// Missing on fresh installs causes GetStatus to return all-disabled zero values.
if _, err := models.SeedDefaultSecurityConfig(db); err != nil {
logger.Log().WithError(err).Warn("Failed to seed default SecurityConfig — continuing startup")
}

// Let's Encrypt certs are auto-managed by Caddy and should not be assigned via certificate_id
logger.Log().Info("Cleaning up invalid Let's Encrypt certificate associations...")
var hostsWithInvalidCerts []models.ProxyHost
Expand Down
26 changes: 26 additions & 0 deletions backend/internal/api/routes/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1322,3 +1322,29 @@ func TestMigrateViewerToPassthrough(t *testing.T) {
require.NoError(t, db.First(&updated, viewer.ID).Error)
assert.Equal(t, models.RolePassthrough, updated.Role)
}

func TestRegister_CleansLetsEncryptCertAssignments(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()

db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_lecleaner"), &gorm.Config{})
require.NoError(t, err)

// Pre-migrate just the two tables needed to seed test data before Register runs.
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

cert := models.SSLCertificate{Provider: "letsencrypt"}
require.NoError(t, db.Create(&cert).Error)

certID := cert.ID
host := models.ProxyHost{DomainNames: "test.example.com", CertificateID: &certID}
require.NoError(t, db.Create(&host).Error)

cfg := config.Config{JWTSecret: "test-secret"}
err = Register(router, db, cfg)
require.NoError(t, err)

var reloaded models.ProxyHost
require.NoError(t, db.First(&reloaded, host.ID).Error)
assert.Nil(t, reloaded.CertificateID, "letsencrypt cert assignment must be cleared")
}
41 changes: 41 additions & 0 deletions backend/internal/models/seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package models

import (
"github.com/google/uuid"
"gorm.io/gorm"
)

// SeedDefaultSecurityConfig ensures a default SecurityConfig row exists in the database.
// It uses FirstOrCreate so it is safe to call on every startup — existing data is never
// overwritten. Returns the upserted record and any error encountered.
func SeedDefaultSecurityConfig(db *gorm.DB) (*SecurityConfig, error) {
record := SecurityConfig{
UUID: uuid.NewString(),
Name: "default",
Enabled: false,
CrowdSecMode: "disabled",
CrowdSecAPIURL: "http://127.0.0.1:8085",
WAFMode: "disabled",
WAFParanoiaLevel: 1,
RateLimitMode: "disabled",
RateLimitEnable: false,
// Zero values are intentional for the disabled default state.
// cerberus.RateLimitMiddleware guards against zero/negative values by falling
// back to safe operational defaults (requests=100, window=60s, burst=20) before
// computing the token-bucket rate. buildRateLimitHandler (caddy/config.go) also
// returns nil — skipping rate-limit injection — when either value is ≤ 0.
// A user enabling rate limiting via the UI without configuring thresholds will
// therefore receive the safe hardcoded defaults, not a zero-rate limit.
RateLimitBurst: 0,
RateLimitRequests: 0,
RateLimitWindowSec: 0,
}

// FirstOrCreate matches on Name only; if a row with name="default" already exists
// it is loaded into record without modifying any of its fields.
result := db.Where(SecurityConfig{Name: "default"}).FirstOrCreate(&record)
if result.Error != nil {
return nil, result.Error
}
return &record, nil
}
102 changes: 102 additions & 0 deletions backend/internal/models/seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package models_test

import (
"testing"

"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"

"github.com/Wikid82/charon/backend/internal/models"
)

func newSeedTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
return db
}

func TestSeedDefaultSecurityConfig_EmptyDB(t *testing.T) {
db := newSeedTestDB(t)

rec, err := models.SeedDefaultSecurityConfig(db)
require.NoError(t, err)
require.NotNil(t, rec)

assert.Equal(t, "default", rec.Name)
assert.False(t, rec.Enabled)
assert.Equal(t, "disabled", rec.CrowdSecMode)
assert.Equal(t, "http://127.0.0.1:8085", rec.CrowdSecAPIURL)
assert.Equal(t, "disabled", rec.WAFMode)
assert.Equal(t, "disabled", rec.RateLimitMode)
assert.NotEmpty(t, rec.UUID)

var count int64
db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Count(&count)
assert.Equal(t, int64(1), count)
}

func TestSeedDefaultSecurityConfig_Idempotent(t *testing.T) {
db := newSeedTestDB(t)

// First call — creates the row.
rec1, err := models.SeedDefaultSecurityConfig(db)
require.NoError(t, err)
require.NotNil(t, rec1)

// Second call — must not error and must not duplicate.
rec2, err := models.SeedDefaultSecurityConfig(db)
require.NoError(t, err)
require.NotNil(t, rec2)

assert.Equal(t, rec1.ID, rec2.ID, "ID must be identical on subsequent calls")

var count int64
db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Count(&count)
assert.Equal(t, int64(1), count, "exactly one row should exist after two seed calls")
}

func TestSeedDefaultSecurityConfig_DBError(t *testing.T) {
db := newSeedTestDB(t)

sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())

rec, err := models.SeedDefaultSecurityConfig(db)
assert.Error(t, err)
assert.Nil(t, rec)
}

func TestSeedDefaultSecurityConfig_DoesNotOverwriteExisting(t *testing.T) {
db := newSeedTestDB(t)

// Pre-seed a customised row.
existing := models.SecurityConfig{
UUID: "pre-existing-uuid",
Name: "default",
Enabled: true,
CrowdSecMode: "local",
CrowdSecAPIURL: "http://192.168.1.5:8085",
WAFMode: "block",
RateLimitMode: "enabled",
}
require.NoError(t, db.Create(&existing).Error)

// Seed should find the existing row and return it unchanged.
rec, err := models.SeedDefaultSecurityConfig(db)
require.NoError(t, err)
require.NotNil(t, rec)

assert.True(t, rec.Enabled, "existing Enabled flag must not be overwritten")
assert.Equal(t, "local", rec.CrowdSecMode, "existing CrowdSecMode must not be overwritten")
assert.Equal(t, "http://192.168.1.5:8085", rec.CrowdSecAPIURL)
assert.Equal(t, "block", rec.WAFMode)

var count int64
db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Count(&count)
assert.Equal(t, int64(1), count)
}
1 change: 1 addition & 0 deletions backend/internal/services/security_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func (s *SecurityService) Upsert(cfg *models.SecurityConfig) error {
existing.WAFParanoiaLevel = cfg.WAFParanoiaLevel
existing.WAFExclusions = cfg.WAFExclusions
existing.RateLimitEnable = cfg.RateLimitEnable
existing.RateLimitMode = cfg.RateLimitMode
existing.RateLimitBurst = cfg.RateLimitBurst
existing.RateLimitRequests = cfg.RateLimitRequests
existing.RateLimitWindowSec = cfg.RateLimitWindowSec
Expand Down
Loading
Loading