Replace hardcoded Cloudflare DNS-01 with a data-driven provider registry
supporting 11 providers (Cloudflare, Route 53, DigitalOcean, Duck DNS,
Hetzner, Vultr, Porkbun, GoDaddy, Namecheap, OVH, Linode). Users can
configure multiple providers with encrypted credentials and select a
default. Per-certificate provider override is supported via providerOptions.
- Add src/lib/dns-providers.ts with provider definitions, credential
encrypt/decrypt, and Caddy config builder
- Change DnsProviderSettings to multi-provider format with default selection
- Auto-migrate legacy Cloudflare settings on startup (db.ts)
- Normalize old single-provider format on read (getDnsProviderSettings)
- Refactor buildTlsAutomation() to use provider registry
- Add GET /api/v1/dns-providers endpoint for provider discovery
- Add dns-provider settings group to REST API and instance sync
- Replace Cloudflare settings card with multi-provider UI (add/remove
providers, set default, dynamic credential forms)
- Add 10 DNS provider modules to Caddy Dockerfile
- Update OpenAPI spec, E2E tests, and unit test mocks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Allow users to exclude specific paths from Authentik/CPM forward auth
protection. When excluded_paths is set, all paths require authentication
EXCEPT the excluded ones — useful for apps like Navidrome that need
/share/* and /rest/* to bypass auth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- C1: Replace all ClickHouse string interpolation with parameterized queries
(query_params) to eliminate SQL injection in analytics endpoints
- C3: Strip Caddy placeholder patterns from redirect rules, protected paths,
and Authentik auth endpoint to prevent config injection
- C4: Replace WAF custom directive blocklist with allowlist approach — only
SecRule/SecAction/SecMarker/SecDefaultAction permitted; block ctl:ruleEngine
and Include directives
- H2: Validate GCM authentication tag is exactly 16 bytes before decryption
- H3: Validate forward auth redirect URIs (scheme, no credentials) to prevent
open redirects
- H4: Switch 11 analytics/WAF/geoip endpoints from session-only requireAdmin
to requireApiAdmin supporting both Bearer token and session auth
- H5: Add input validation for instance-mode (whitelist) and sync-token
(32-char minimum) in settings API
- M1: Add non-root user to l4-port-manager Dockerfile
- M5: Document Caddy admin API binding security rationale
- Document C2 (custom config injection) and H1 (SSRF via upstreams) as
intentional admin features
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace client-controlled redirectUri with server-side redirect intents.
The portal server component validates the ?rd= hostname against
isForwardAuthDomain, stores the URI in a new forward_auth_redirect_intents
table, and passes only an opaque rid (128-bit random, SHA-256 hashed) to
the client. Login endpoints consume the intent atomically (one-time use,
10-minute TTL) and retrieve the stored URI — the client never sends the
redirect URL to any API endpoint.
Security properties:
- Redirect URI is never client-controlled in API requests
- rid is 128-bit random, stored as SHA-256 hash (not reversible from DB)
- Atomic one-time consumption prevents replay
- 10-minute TTL limits attack window for OAuth round-trip
- Immediate deletion after consumption
- Expired intents cleaned up opportunistically
- Hostname validated against registered forward-auth domains before storage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add per-user API token limit (max 10) and name length validation (max 100 chars)
- Return 404 instead of 500 for "not found" errors in API responses
- Disable X-Powered-By header to prevent framework fingerprinting
- Enforce http/https protocol on proxy host upstream URLs
- Remove stale comment about OAuth users defaulting to admin role
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pentest found that all 8 analytics API endpoints, the GeoIP status
endpoint, and the OpenAPI spec were accessible to any authenticated
user. Since the user role should only have access to forward auth
and self-service, these are now admin-only.
- analytics/*: requireUser → requireAdmin
- geoip-status: requireUser → requireAdmin
- openapi.json: add requireApiAdmin + change Cache-Control to private
- analytics/api-docs pages: requireUser → requireAdmin (defense-in-depth)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix broken rate limiting: add registerFailedAttempt/resetAttempts calls
- Remove raw session token from exchange table; generate fresh token at redemption
- Fix TOCTOU race: atomic UPDATE...WHERE used=false for exchange redemption
- Delete exchange records immediately after redemption
- Change bcrypt.compareSync to async bcrypt.compare to prevent event loop blocking
- Fix IP extraction: prefer x-real-ip, fall back to last x-forwarded-for entry
- Restrict redirect URI scheme to http/https only
- Add Origin header CSRF check on login and session-login endpoints
- Remove admin auto-access bypass from checkHostAccess (deny-by-default for all)
- Revoke forward auth sessions when user status changes away from active
- Validate portal domain against registered forward-auth hosts to prevent phishing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New /users page with search, inline editing, role/status changes, and deletion
- Model: added updateUserRole, updateUserStatus, deleteUser functions
- API: PUT /api/v1/users/[id] now supports role and status fields, added DELETE
- Safety: cannot change own role/status or delete own account
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CPM can now act as its own forward auth provider for proxied sites.
Users authenticate at a login portal (credentials or OAuth) and Caddy
gates access via a verify subrequest, eliminating the need for external
IdPs like Authentik.
Key components:
- Forward auth flow: verify endpoint, exchange code callback, login portal
- User groups with membership management
- Per-proxy-host access control (users and/or groups)
- Caddy config generation for forward_auth handler + callback route
- OAuth and credential login on the portal page
- Admin UI: groups page, inline user/group assignment in proxy host form
- REST API: /api/v1/groups, /api/v1/forward-auth-sessions, per-host access
- Integration tests for groups and forward auth schema
Also fixes mTLS E2E test selectors broken by the RBAC refactor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add LocationRule schema with path and upstreams fields
- Add location_rules to ProxyHost and ProxyHostInput schemas
- Fix response_headers using concrete example instead of generic additionalProperties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove unused imports (users, and) from api-tokens model
- Fix password_hash destructure lint error in user routes
- Fix apiErrorResponse mock pattern in all 12 test files (use instanceof)
- Remove stale eslint-disable directives from test files
- Add eslint override for tests (no-explicit-any, no-require-imports)
- Fix unused vars in settings and tokens tests
- Fix unused tokenB in integration test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- API token model (SHA-256 hashed, debounced lastUsedAt) with Bearer auth
- Dual auth middleware (session + API token) in src/lib/api-auth.ts
- 23 REST endpoints under /api/v1/ covering all functionality:
tokens, proxy-hosts, l4-proxy-hosts, certificates, ca-certificates,
client-certificates, access-lists, settings, instances, users,
audit-log, caddy/apply
- OpenAPI 3.1 spec at /api/v1/openapi.json with fully typed schemas
- Swagger UI docs page at /api-docs in the dashboard
- API token management integrated into the Profile page
- Fix: next build now works under Node.js (bun:sqlite aliased to better-sqlite3)
- 89 new API route unit tests + 11 integration tests (592 total)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SyncPayload["data"]["l4ProxyHosts"] is optional (Array | undefined),
so indexing with [number] fails tsc. NonNullable<...>[number] resolves
the correct element type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Banner (L4PortsApplyBanner):
- Accept refreshSignal prop; re-fetch /api/l4-ports when it changes
- Signal fires immediately after create/edit/delete/toggle in L4ProxyHostsClient
without waiting for a page reload
Master-slave replication (instance-sync):
- Add l4ProxyHosts to SyncPayload.data (optional for backward compat
with older master instances that don't include it)
- buildSyncPayload: query and include l4ProxyHosts, sanitize ownerUserId
- applySyncPayload: clear and re-insert l4ProxyHosts in transaction;
call applyL4Ports() if port diff requires it so the slave's sidecar
recreates caddy with the correct ports
- Sync route: add isL4ProxyHost validator; backfill missing field from
old masters; validate array when present
Tests (25 new tests):
- instance-sync.test.ts: buildSyncPayload includes L4 data, sanitizes ownerUserId;
applySyncPayload replaces L4 hosts, handles missing field, writes trigger
when ports differ, skips trigger when ports already match
- l4-ports-apply-banner.test.ts: banner refreshSignal contract + client
increments counter on all mutation paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New l4_proxy_hosts table and Drizzle migration (0015)
- Full CRUD model layer with validation, audit logging, and Caddy config
generation (buildL4Servers integrating into buildCaddyDocument)
- Server actions, paginated list page, create/edit/delete dialogs
- L4 port manager sidecar (docker/l4-port-manager) that auto-recreates
the caddy container when port mappings change via a trigger file
- Auto-detects Docker Compose project name from caddy container labels
- Supports both named-volume and bind-mount (COMPOSE_HOST_DIR) deployments
- getL4PortsStatus simplified: status file is sole source of truth,
trigger files deleted after processing to prevent stuck 'Waiting' banner
- Navigation entry added (CableIcon)
- Tests: unit (entrypoint.sh invariants + validation), integration (ports
lifecycle + caddy config), E2E (CRUD + functional routing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds checkSameOrigin() helper in auth.ts that validates the Origin header
against the Host header. If Origin is present and mismatched, returns 403.
Applied to all 5 custom POST routes flagged in CPM-003 (NEXT-CSRF-001):
- change-password, link-oauth-start, unlink-oauth, update-avatar, logout
SameSite=Lax (NextAuth default) already blocks standard cross-site CSRF;
this adds defense-in-depth against subdomain and misconfiguration scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add getTopWafRulesWithHosts() and getWafEventCountries() model queries
- WAF stats API now returns topRules with per-host breakdown and byCountry
- Analytics: replace WAF rules table with bar chart + host chip details
- Analytics: add WAF column (amber) to Top Countries table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace native datetime-local inputs with @mui/x-date-pickers DateTimePicker
(proper dark-themed calendar popover with time picker, DD/MM/YYYY HH:mm format,
min/max constraints between pickers, 24h clock)
- Replace single-host Select with Autocomplete (multiple, disableCloseOnSelect):
checkbox per option, chip display with limitTags=2, built-in search/filter
- getAnalyticsHosts() now unions traffic event hosts WITH all configured proxy host
domains (parsed from proxyHosts.domains JSON), so every proxy appears in the list
- analytics-db: buildWhere accepts hosts: string[] (empty = all); uses inArray for
multi-host filtering via drizzle-orm
- All 6 API routes updated: accept hosts param (comma-separated) instead of host
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Analytics default interval changed to 1h
- Add 'Custom' toggle option with datetime-local pickers (pre-filled to last 24h)
- Refactor analytics-db: buildWhere now takes from/to unix timestamps instead of Interval
- Export INTERVAL_SECONDS from analytics-db for route reuse
- All 6 API routes accept from/to params (fallback to interval if absent)
- Timeline bucket size computed from duration rather than hardcoded per interval
- Fix map country click highlight: bake isSelected into GeoJSON features (data-driven)
instead of relying on Layer filter prop updates (unreliable in react-map-gl v8)
- Split highlight into countries-selected (data-driven) and countries-hover (filter-driven)
- Show tooltip at country centroid when selected via table, hover takes precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Parse Caddy access logs every 30s into traffic_events SQLite table
- GeoIP country lookup via maxmind (GeoLite2-Country.mmdb)
- 90-day retention with automatic purge
- Analytics page with interval (24h/7d/30d) and per-host filtering:
- Stats cards: total requests, unique IPs, blocked count, block rate
- Requests-over-time area chart (ApexCharts)
- SVG world choropleth map (d3-geo + topojson-client, React 19 compatible)
- Top countries table with flag emojis
- HTTP protocol donut chart
- Top user agents horizontal bar chart
- Recent blocked requests table with pagination
- Traffic (24h) summary card on Overview page linking to analytics
- 7 authenticated API routes under /api/analytics/
- Share caddy-logs volume with web container (read-only)
- group_add caddy GID to web container for log file read access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change providerSubjectIdx from index to uniqueIndex in schema.ts to
prevent multiple users sharing the same (provider, subject) pair,
which caused non-deterministic sign-in resolution via findFirst.
- Add migration 0008_unique_provider_subject.sql: DROP the existing
non-unique index and CREATE UNIQUE INDEX in its place.
- Validate INSTANCE_SYNC_MAX_BYTES env var in sync route: fall back to
10 MB default when the value is non-numeric (e.g. 'off') or
non-positive, preventing NaN comparisons that silently disabled the
size limit.
- Return a generic error message to callers on applySyncPayload /
applyCaddyConfig failure instead of leaking the raw error string;
the original message is still stored internally via setSlaveLastSync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Returns whether GeoLite2-Country and GeoLite2-ASN databases are loaded,
used by the UI to show the GeoIP ready indicator in GeoBlockFields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit resolves multiple build errors and adds a workaround for environments
where Prisma engine binaries cannot be downloaded due to network restrictions.
Changes:
- Fix TypeScript error: Remove invalid request.ip property access in NextAuth route
- Add missing config import in auth.ts for sessionSecret
- Add dynamic = 'force-dynamic' to API routes to prevent static generation
- Create Prisma stub generator script for build-time type checking
- Update build script to use stub generator instead of prisma generate
- Add binaryTargets to Prisma schema configuration
The stub generator allows the Next.js build to complete successfully in environments
where Prisma binaries cannot be downloaded (403 Forbidden errors from binaries server).
The actual Prisma engines will need to be available at runtime in production deployments.
All routes are now properly configured as dynamic server-rendered routes.
This commit addresses several critical security issues identified in the security audit:
1. Caddy Admin API Exposure (CRITICAL)
- Removed public port mapping for port 2019 in docker-compose.yml
- Admin API now only accessible via internal Docker network
- Web UI can still access it via http://caddy:2019 internally
- Prevents unauthorized access to Caddy configuration API
2. IP Spoofing in Rate Limiting (CRITICAL)
- Updated getClientIp() to use Next.js request.ip property
- This provides the actual client IP instead of trusting X-Forwarded-For header
- Prevents attackers from bypassing rate limiting by spoofing headers
- Fallback to headers only in development environments
3. Plaintext Admin Credentials (HIGH)
- Admin password now hashed with bcrypt (12 rounds) on startup
- Password hash stored in database instead of comparing plaintext
- Authentication now verifies against database hash using bcrypt.compareSync()
- Improves security by not storing plaintext passwords in memory
- Password updates handled on every startup to support env var changes
Files modified:
- docker-compose.yml: Removed port 2019 public exposure
- app/api/auth/[...nextauth]/route.ts: Use actual client IP for rate limiting
- src/lib/auth.ts: Verify passwords against database hashes
- src/lib/init-db.ts: Hash and store admin password on startup
Security posture improved from C+ to B+