diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..f7a4a94d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1456 @@ +# Charon System Architecture + +**Version:** 1.0 +**Last Updated:** January 28, 2026 +**Status:** Living Document + +--- + +## Table of Contents + +- [Overview](#overview) +- [System Architecture](#system-architecture) +- [Technology Stack](#technology-stack) +- [Directory Structure](#directory-structure) +- [Core Components](#core-components) +- [Security Architecture](#security-architecture) +- [Data Flow](#data-flow) +- [Deployment Architecture](#deployment-architecture) +- [Development Workflow](#development-workflow) +- [Testing Strategy](#testing-strategy) +- [Build & Release Process](#build--release-process) +- [Extensibility](#extensibility) +- [Known Limitations](#known-limitations) +- [Maintenance & Updates](#maintenance--updates) + +--- + +## Overview + +**Charon** is a self-hosted reverse proxy manager with a web-based user interface designed to simplify website and application hosting for home users and small teams. It eliminates the need for manual configuration file editing by providing an intuitive point-and-click interface for managing multiple domains, SSL certificates, and enterprise-grade security features. + +### Core Value Proposition + +**"Your server, your rules—without the headaches."** + +Charon bridges the gap between simple solutions (like Nginx Proxy Manager) and complex enterprise proxies (like Traefik/HAProxy) by providing a balanced approach that is both user-friendly and feature-rich. + +### Key Features + +- **Web-Based Proxy Management:** No config file editing required +- **Automatic HTTPS:** Let's Encrypt and ZeroSSL integration with auto-renewal +- **DNS Challenge Support:** 15+ DNS providers for wildcard certificates +- **Docker Auto-Discovery:** One-click proxy setup for Docker containers +- **Cerberus Security Suite:** WAF, ACL, CrowdSec, Rate Limiting +- **Real-Time Monitoring:** Live logs, uptime tracking, and notifications +- **Configuration Import:** Migrate from Caddyfile or Nginx Proxy Manager +- **Supply Chain Security:** Cryptographic signatures, SLSA provenance, SBOM + +--- + +## System Architecture + +### Architectural Pattern + +Charon follows a **monolithic architecture** with an embedded reverse proxy, packaged as a single Docker container. This design prioritizes simplicity, ease of deployment, and minimal operational overhead. + +```mermaid +graph TB + User[User Browser] -->|HTTPS :8080| Frontend[React Frontend SPA] + Frontend -->|REST API /api/v1| Backend[Go Backend + Gin] + Frontend -->|WebSocket /api/v1/logs| Backend + + Backend -->|Configures| CaddyMgr[Caddy Manager] + CaddyMgr -->|JSON API| Caddy[Caddy Server] + Backend -->|CRUD| DB[(SQLite Database)] + Backend -->|Query| DockerAPI[Docker Socket API] + + Caddy -->|Proxy :80/:443| UpstreamServers[Upstream Servers] + + Backend -->|Security Checks| Cerberus[Cerberus Security Suite] + Cerberus -->|IP Bans| CrowdSec[CrowdSec Bouncer] + Cerberus -->|Request Filtering| WAF[Coraza WAF] + Cerberus -->|Access Control| ACL[Access Control Lists] + Cerberus -->|Throttling| RateLimit[Rate Limiter] + + subgraph Docker Container + Frontend + Backend + CaddyMgr + Caddy + DB + Cerberus + CrowdSec + WAF + ACL + RateLimit + end + + subgraph Host System + DockerAPI + UpstreamServers + end +``` + +### Component Communication + +| Source | Target | Protocol | Purpose | +|--------|--------|----------|---------| +| Frontend | Backend | HTTP/1.1 | REST API calls for CRUD operations | +| Frontend | Backend | WebSocket | Real-time log streaming | +| Backend | Caddy | HTTP/JSON | Dynamic configuration updates | +| Backend | SQLite | SQL | Data persistence | +| Backend | Docker Socket | Unix Socket/HTTP | Container discovery | +| Caddy | Upstream Servers | HTTP/HTTPS | Reverse proxy traffic | +| Cerberus | CrowdSec | HTTP | Threat intelligence sync | +| Cerberus | WAF | In-process | Request inspection | + +### Design Principles + +1. **Simplicity First:** Single container, minimal external dependencies +2. **Security by Default:** All security features enabled out-of-the-box +3. **User Experience:** Web UI over configuration files +4. **Modularity:** Pluggable DNS providers, notification channels +5. **Observability:** Comprehensive logging and metrics +6. **Reliability:** Graceful degradation, atomic config updates + +--- + +## Technology Stack + +### Backend + +| Component | Technology | Version | Purpose | +|-----------|-----------|---------|---------| +| **Language** | Go | 1.25.6 | Primary backend language | +| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling | +| **Database** | SQLite | 3.x | Embedded database | +| **ORM** | GORM | Latest | Database abstraction layer | +| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy | +| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming | +| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption | +| **Metrics** | Prometheus Client | Latest | Application metrics | +| **Notifications** | Shoutrrr | Latest | Multi-platform alerts | +| **Docker Client** | Docker SDK | Latest | Container discovery | +| **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation | + +### Frontend + +| Component | Technology | Version | Purpose | +|-----------|-----------|---------|---------| +| **Framework** | React | 19.2.3 | UI framework | +| **Language** | TypeScript | 5.x | Type-safe JavaScript | +| **Build Tool** | Vite | 6.1.9 | Fast bundler and dev server | +| **CSS Framework** | Tailwind CSS | 3.x | Utility-first CSS | +| **Routing** | React Router | 7.x | Client-side routing | +| **HTTP Client** | Fetch API | Native | API communication | +| **State Management** | React Hooks + Context | Native | Global state | +| **Internationalization** | i18next | Latest | 5 language support | +| **Unit Testing** | Vitest | 2.x | Fast unit test runner | +| **E2E Testing** | Playwright | 1.50.x | Browser automation | + +### Infrastructure + +| Component | Technology | Version | Purpose | +|-----------|-----------|---------|---------| +| **Containerization** | Docker | 24+ | Application packaging | +| **Base Image** | Debian Trixie Slim | Latest | Security-hardened base | +| **CI/CD** | GitHub Actions | N/A | Automated testing and deployment | +| **Registry** | Docker Hub + GHCR | N/A | Image distribution | +| **Security Scanning** | Trivy + Grype | Latest | Vulnerability detection | +| **SBOM Generation** | Syft | Latest | Software Bill of Materials | +| **Signature Verification** | Cosign | Latest | Supply chain integrity | + +--- + +## Directory Structure + +``` +/projects/Charon/ +├── backend/ # Go backend source code +│ ├── cmd/ # Application entrypoints +│ │ ├── api/ # Main API server +│ │ ├── migrate/ # Database migration tool +│ │ └── seed/ # Database seeding tool +│ ├── internal/ # Private application code +│ │ ├── api/ # HTTP handlers and routes +│ │ │ ├── handlers/ # Request handlers +│ │ │ ├── middleware/ # HTTP middleware +│ │ │ └── routes/ # Route definitions +│ │ ├── services/ # Business logic layer +│ │ │ ├── proxy_service.go +│ │ │ ├── certificate_service.go +│ │ │ ├── docker_service.go +│ │ │ └── mail_service.go +│ │ ├── caddy/ # Caddy manager and config generation +│ │ │ ├── manager.go # Dynamic config orchestration +│ │ │ └── templates.go # Caddy JSON templates +│ │ ├── cerberus/ # Security suite +│ │ │ ├── acl.go # Access Control Lists +│ │ │ ├── waf.go # Web Application Firewall +│ │ │ ├── crowdsec.go # CrowdSec integration +│ │ │ └── ratelimit.go # Rate limiting +│ │ ├── models/ # GORM database models +│ │ ├── database/ # DB initialization and migrations +│ │ └── utils/ # Helper functions +│ ├── pkg/ # Public reusable packages +│ ├── integration/ # Integration tests +│ ├── go.mod # Go module definition +│ └── go.sum # Go dependency checksums +│ +├── frontend/ # React frontend source code +│ ├── src/ +│ │ ├── pages/ # Top-level page components +│ │ │ ├── Dashboard.tsx +│ │ │ ├── ProxyHosts.tsx +│ │ │ ├── Certificates.tsx +│ │ │ └── Settings.tsx +│ │ ├── components/ # Reusable UI components +│ │ │ ├── forms/ # Form inputs and validation +│ │ │ ├── modals/ # Dialog components +│ │ │ ├── tables/ # Data tables +│ │ │ └── layout/ # Layout components +│ │ ├── api/ # API client functions +│ │ ├── hooks/ # Custom React hooks +│ │ ├── context/ # React context providers +│ │ ├── locales/ # i18n translation files +│ │ ├── App.tsx # Root component +│ │ └── main.tsx # Application entry point +│ ├── public/ # Static assets +│ ├── package.json # NPM dependencies +│ └── vite.config.js # Vite configuration +│ +├── .docker/ # Docker configuration +│ ├── compose/ # Docker Compose files +│ │ ├── docker-compose.yml # Production setup +│ │ ├── docker-compose.dev.yml +│ │ └── docker-compose.test.yml +│ ├── docker-entrypoint.sh # Container startup script +│ └── README.md # Docker documentation +│ +├── .github/ # GitHub configuration +│ ├── workflows/ # CI/CD pipelines +│ │ ├── *.yml # GitHub Actions workflows +│ ├── agents/ # GitHub Copilot agent definitions +│ │ ├── Management.agent.md +│ │ ├── Planning.agent.md +│ │ ├── Backend_Dev.agent.md +│ │ ├── Frontend_Dev.agent.md +│ │ ├── QA_Security.agent.md +│ │ ├── Doc_Writer.agent.md +│ │ ├── DevOps.agent.md +│ │ └── Supervisor.agent.md +│ ├── instructions/ # Code generation instructions +│ │ ├── *.instructions.md # Domain-specific guidelines +│ └── skills/ # Automation scripts +│ └── scripts/ # Task automation +│ +├── scripts/ # Build and utility scripts +│ ├── go-test-coverage.sh # Backend coverage testing +│ ├── frontend-test-coverage.sh +│ └── docker-*.sh # Docker convenience scripts +│ +├── tests/ # End-to-end tests +│ ├── *.spec.ts # Playwright test files +│ └── fixtures/ # Test data and helpers +│ +├── docs/ # Documentation +│ ├── features/ # Feature documentation +│ ├── guides/ # User guides +│ ├── api/ # API documentation +│ ├── development/ # Developer guides +│ ├── plans/ # Implementation plans +│ └── reports/ # QA and audit reports +│ +├── configs/ # Runtime configuration +│ └── crowdsec/ # CrowdSec configurations +│ +├── data/ # Persistent data (gitignored) +│ ├── charon.db # SQLite database +│ ├── backups/ # Database backups +│ ├── caddy/ # Caddy certificates +│ └── crowdsec/ # CrowdSec local database +│ +├── Dockerfile # Multi-stage Docker build +├── Makefile # Build automation +├── go.work # Go workspace definition +├── package.json # Frontend dependencies +├── playwright.config.js # E2E test configuration +├── codecov.yml # Code coverage settings +├── README.md # Project overview +├── CONTRIBUTING.md # Contribution guidelines +├── CHANGELOG.md # Version history +├── LICENSE # MIT License +├── SECURITY.md # Security policy +└── ARCHITECTURE.md # This file +``` + +### Key Directory Conventions + +- **`internal/`**: Private code that should not be imported by external projects +- **`pkg/`**: Public libraries that can be reused +- **`cmd/`**: Application entrypoints (each subdirectory is a separate binary) +- **`.docker/`**: All Docker-related files (prevents root clutter) +- **`docs/implementation/`**: Archived implementation documentation +- **`docs/plans/`**: Active planning documents (`current_spec.md`) +- **`test-results/`**: Test artifacts (gitignored) + +--- + +## Core Components + +### 1. Backend (Go + Gin) + +**Purpose:** RESTful API server, business logic orchestration, Caddy management + +**Key Modules:** + +#### API Layer (`internal/api/`) +- **Handlers:** Process HTTP requests, validate input, return responses +- **Middleware:** CORS, GZIP, authentication, logging, metrics, panic recovery +- **Routes:** Route registration and grouping (public vs authenticated) + +**Example Endpoints:** +- `GET /api/v1/proxy-hosts` - List all proxy hosts +- `POST /api/v1/proxy-hosts` - Create new proxy host +- `PUT /api/v1/proxy-hosts/:id` - Update proxy host +- `DELETE /api/v1/proxy-hosts/:id` - Delete proxy host +- `WS /api/v1/logs` - WebSocket for real-time logs + +#### Service Layer (`internal/services/`) +- **ProxyService:** CRUD operations for proxy hosts, validation logic +- **CertificateService:** ACME certificate provisioning and renewal +- **DockerService:** Container discovery and monitoring +- **MailService:** Email notifications for certificate expiry +- **SettingsService:** Application settings management + +**Design Pattern:** Services contain business logic and call multiple repositories/managers + +#### Caddy Manager (`internal/caddy/`) +- **Manager:** Orchestrates Caddy configuration updates +- **Config Builder:** Generates Caddy JSON from database models +- **Reload Logic:** Atomic config application with rollback on failure +- **Security Integration:** Injects Cerberus middleware into Caddy pipelines + +**Responsibilities:** +1. Generate Caddy JSON configuration from database state +2. Validate configuration before applying +3. Trigger Caddy reload via JSON API +4. Handle rollback on configuration errors +5. Integrate security layers (WAF, ACL, Rate Limiting) + +#### Security Suite (`internal/cerberus/`) +- **ACL (Access Control Lists):** IP-based allow/deny rules, GeoIP blocking +- **WAF (Web Application Firewall):** Coraza engine with OWASP CRS +- **CrowdSec:** Behavior-based threat detection with global intelligence +- **Rate Limiter:** Per-IP request throttling + +**Integration Points:** +- Middleware injection into Caddy request pipeline +- Database-driven rule configuration +- Metrics collection for security events + +#### Database Layer (`internal/database/`) +- **Migrations:** Automatic schema versioning with GORM AutoMigrate +- **Seeding:** Default settings and admin user creation +- **Connection Management:** SQLite with WAL mode and connection pooling + +**Schema Overview:** +- **ProxyHost:** Domain, upstream target, SSL config +- **RemoteServer:** Upstream server definitions +- **CaddyConfig:** Generated Caddy configuration (audit trail) +- **SSLCertificate:** Certificate metadata and renewal status +- **AccessList:** IP whitelist/blacklist rules +- **User:** Authentication and authorization +- **Setting:** Key-value configuration storage +- **ImportSession:** Import job tracking + +### 2. Frontend (React + TypeScript) + +**Purpose:** Web-based user interface for proxy management + +**Component Architecture:** + +#### Pages (`src/pages/`) +- **Dashboard:** System overview, recent activity, quick actions +- **ProxyHosts:** List, create, edit, delete proxy configurations +- **Certificates:** Manage SSL/TLS certificates, view expiry +- **Settings:** Application settings, security configuration +- **Logs:** Real-time log viewer with filtering +- **Users:** User management (admin only) + +#### Components (`src/components/`) +- **Forms:** Reusable form inputs with validation +- **Modals:** Dialog components for CRUD operations +- **Tables:** Data tables with sorting, filtering, pagination +- **Layout:** Header, sidebar, navigation + +#### API Client (`src/api/`) +- Centralized API calls with error handling +- Request/response type definitions +- Authentication token management + +**Example:** +```typescript +export const getProxyHosts = async (): Promise => { + const response = await fetch('/api/v1/proxy-hosts', { + headers: { Authorization: `Bearer ${getToken()}` } + }); + if (!response.ok) throw new Error('Failed to fetch proxy hosts'); + return response.json(); +}; +``` + +#### State Management +- **React Context:** Global state for auth, theme, language +- **Local State:** Component-specific state with `useState` +- **Custom Hooks:** Encapsulate API calls and side effects + +**Example Hook:** +```typescript +export const useProxyHosts = () => { + const [hosts, setHosts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getProxyHosts().then(setHosts).finally(() => setLoading(false)); + }, []); + + return { hosts, loading, refresh: () => getProxyHosts().then(setHosts) }; +}; +``` + +### 3. Caddy Server + +**Purpose:** High-performance reverse proxy with automatic HTTPS + +**Integration:** +- Embedded as a library in the Go backend +- Configured via JSON API (not Caddyfile) +- Listens on ports 80 (HTTP) and 443 (HTTPS) + +**Features Used:** +- Dynamic configuration updates without restarts +- Automatic HTTPS with Let's Encrypt and ZeroSSL +- DNS challenge support for wildcard certificates +- HTTP/2 and HTTP/3 (QUIC) support +- Request logging and metrics + +**Configuration Flow:** +1. User creates proxy host via frontend +2. Backend validates and saves to database +3. Caddy Manager generates JSON configuration +4. JSON sent to Caddy via `/config/` API endpoint +5. Caddy validates and applies new configuration +6. Traffic flows through new proxy route + +### 4. Database (SQLite + GORM) + +**Purpose:** Persistent data storage + +**Why SQLite:** +- Embedded (no external database server) +- Serverless (perfect for single-user/small team) +- ACID compliant with WAL mode +- Minimal operational overhead +- Backup-friendly (single file) + +**Configuration:** +- **WAL Mode:** Allows concurrent reads during writes +- **Foreign Keys:** Enforced referential integrity +- **Pragma Settings:** Performance optimizations + +**Backup Strategy:** +- Automated daily backups to `data/backups/` +- Retention: 7 daily, 4 weekly, 12 monthly backups +- Backup during low-traffic periods + +**Migrations:** +- GORM AutoMigrate for schema changes +- Manual migrations for complex data transformations +- Rollback support via backup restoration + +--- + +## Security Architecture + +### Defense-in-Depth Strategy + +Charon implements multiple security layers (Cerberus Suite) to protect against various attack vectors: + +```mermaid +graph LR + Internet[Internet] -->|HTTP/HTTPS| RateLimit[Rate Limiter] + RateLimit -->|Throttled| CrowdSec[CrowdSec Bouncer] + CrowdSec -->|Threat Intel| ACL[Access Control Lists] + ACL -->|IP Whitelist| WAF[Web Application Firewall] + WAF -->|OWASP CRS| Caddy[Caddy Proxy] + Caddy -->|Proxied| Upstream[Upstream Server] + + style RateLimit fill:#f9f,stroke:#333,stroke-width:2px + style CrowdSec fill:#bbf,stroke:#333,stroke-width:2px + style ACL fill:#bfb,stroke:#333,stroke-width:2px + style WAF fill:#fbb,stroke:#333,stroke-width:2px +``` + +### Layer 1: Rate Limiting + +**Purpose:** Prevent brute-force attacks and API abuse + +**Implementation:** +- Per-IP request counters with sliding window +- Configurable thresholds (e.g., 100 req/min, 1000 req/hour) +- HTTP 429 response when limit exceeded +- Admin whitelist for monitoring tools + +### Layer 2: CrowdSec Integration + +**Purpose:** Behavior-based threat detection + +**Features:** +- Local log analysis (brute-force, port scans, exploits) +- Global threat intelligence (crowd-sourced IP reputation) +- Automatic IP banning with configurable duration +- Decision management API (view, create, delete bans) + +**Modes:** +- **Local Only:** No external API calls +- **API Mode:** Sync with CrowdSec cloud for global intelligence + +### Layer 3: Access Control Lists (ACL) + +**Purpose:** IP-based access control + +**Features:** +- Per-proxy-host allow/deny rules +- CIDR range support (e.g., `192.168.1.0/24`) +- Geographic blocking via GeoIP2 (MaxMind) +- Admin whitelist (emergency access) + +**Evaluation Order:** +1. Check admin whitelist (always allow) +2. Check deny list (explicit block) +3. Check allow list (explicit allow) +4. Default action (configurable allow/deny) + +### Layer 4: Web Application Firewall (WAF) + +**Purpose:** Inspect HTTP requests for malicious payloads + +**Engine:** Coraza with OWASP Core Rule Set (CRS) + +**Detection Categories:** +- SQL Injection (SQLi) +- Cross-Site Scripting (XSS) +- Remote Code Execution (RCE) +- Local File Inclusion (LFI) +- Path Traversal +- Command Injection + +**Modes:** +- **Monitor:** Log but don't block (testing) +- **Block:** Return HTTP 403 for violations + +### Layer 5: Application Security + +**Additional Protections:** +- **SSRF Prevention:** Block requests to private IP ranges in webhooks/URL validation +- **HTTP Security Headers:** CSP, HSTS, X-Frame-Options, X-Content-Type-Options +- **Input Validation:** Server-side validation for all user inputs +- **SQL Injection Prevention:** Parameterized queries with GORM +- **XSS Prevention:** React's built-in escaping + Content Security Policy +- **Credential Encryption:** AES-GCM with key rotation for stored credentials +- **Password Hashing:** bcrypt with cost factor 12 + +### Emergency Break-Glass Protocol + +**3-Tier Recovery System:** + +1. **Admin Dashboard:** Standard access recovery via web UI +2. **Recovery Server:** Localhost-only HTTP server on port 2019 +3. **Direct Database Access:** Manual SQLite update as last resort + +**Emergency Token:** +- 64-character hex token set via `CHARON_EMERGENCY_TOKEN` +- Grants temporary admin access +- Rotated after each use + +--- + +## Data Flow + +### Request Flow: Create Proxy Host + +```mermaid +sequenceDiagram + participant U as User Browser + participant F as Frontend (React) + participant B as Backend (Go) + participant S as Service Layer + participant D as Database (SQLite) + participant C as Caddy Manager + participant P as Caddy Proxy + + U->>F: Click "Add Proxy Host" + F->>U: Show creation form + U->>F: Fill form and submit + F->>F: Client-side validation + F->>B: POST /api/v1/proxy-hosts + B->>B: Authenticate user + B->>B: Validate input + B->>S: CreateProxyHost(dto) + S->>D: INSERT INTO proxy_hosts + D-->>S: Return created host + S->>C: TriggerCaddyReload() + C->>C: BuildConfiguration() + C->>D: SELECT all proxy hosts + D-->>C: Return hosts + C->>C: Generate Caddy JSON + C->>P: POST /config/ (Caddy API) + P->>P: Validate config + P->>P: Apply config + P-->>C: 200 OK + C-->>S: Reload success + S-->>B: Return ProxyHost + B-->>F: 201 Created + ProxyHost + F->>F: Update UI (optimistic) + F->>U: Show success notification +``` + +### Request Flow: Proxy Traffic + +```mermaid +sequenceDiagram + participant C as Client + participant P as Caddy Proxy + participant RL as Rate Limiter + participant CS as CrowdSec + participant ACL as Access Control + participant WAF as Web App Firewall + participant U as Upstream Server + + C->>P: HTTP Request + P->>RL: Check rate limit + alt Rate limit exceeded + RL-->>P: 429 Too Many Requests + P-->>C: 429 Too Many Requests + else Rate limit OK + RL-->>P: Allow + P->>CS: Check IP reputation + alt IP banned + CS-->>P: Block + P-->>C: 403 Forbidden + else IP OK + CS-->>P: Allow + P->>ACL: Check access rules + alt IP denied + ACL-->>P: Block + P-->>C: 403 Forbidden + else IP allowed + ACL-->>P: Allow + P->>WAF: Inspect request + alt Attack detected + WAF-->>P: Block + P-->>C: 403 Forbidden + else Request safe + WAF-->>P: Allow + P->>U: Forward request + U-->>P: Response + P-->>C: Response + end + end + end + end +``` + +### Real-Time Log Streaming + +```mermaid +sequenceDiagram + participant F as Frontend (React) + participant B as Backend (Go) + participant L as Log Buffer + participant C as Caddy Proxy + + F->>B: WS /api/v1/logs (upgrade) + B-->>F: 101 Switching Protocols + loop Every request + C->>L: Write log entry + L->>B: Notify new log + B->>F: Send log via WebSocket + F->>F: Append to log viewer + end + F->>B: Close WebSocket + B->>L: Unsubscribe +``` + +--- + +## Deployment Architecture + +### Single Container Architecture + +**Rationale:** Simplicity over scalability - target audience is home users and small teams + +**Container Contents:** +- Frontend static files (Vite build output) +- Go backend binary +- Embedded Caddy server +- SQLite database file +- Caddy certificates +- CrowdSec local database + +### Multi-Stage Dockerfile + +```dockerfile +# Stage 1: Build frontend +FROM node:23-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci --only=production +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Build backend +FROM golang:1.25-bookworm AS backend-builder +WORKDIR /app/backend +COPY backend/go.* ./ +RUN go mod download +COPY backend/ ./ +RUN CGO_ENABLED=1 go build -o /app/charon ./cmd/api + +# Stage 3: Install gosu for privilege dropping +FROM debian:trixie-slim AS gosu +RUN apt-get update && \ + apt-get install -y --no-install-recommends gosu && \ + rm -rf /var/lib/apt/lists/* + +# Stage 4: Final runtime image +FROM debian:trixie-slim +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libsqlite3-0 && \ + rm -rf /var/lib/apt/lists/* +COPY --from=gosu /usr/sbin/gosu /usr/sbin/gosu +COPY --from=backend-builder /app/charon /app/charon +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist +COPY .docker/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +EXPOSE 8080 80 443 443/udp +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["/app/charon"] +``` + +### Port Mapping + +| Port | Protocol | Purpose | Bind | +|------|----------|---------|------| +| 8080 | HTTP | Web UI + REST API | 0.0.0.0 | +| 80 | HTTP | Caddy reverse proxy | 0.0.0.0 | +| 443 | HTTPS | Caddy reverse proxy (TLS) | 0.0.0.0 | +| 443 | UDP | HTTP/3 QUIC (optional) | 0.0.0.0 | +| 2019 | HTTP | Emergency recovery (localhost only) | 127.0.0.1 | + +### Volume Mounts + +| Container Path | Purpose | Required | +|----------------|---------|----------| +| `/app/data` | Database, certificates, backups | **Yes** | +| `/var/run/docker.sock` | Docker container discovery | Optional | + +### Environment Variables + +| Variable | Purpose | Default | Required | +|----------|---------|---------|----------| +| `CHARON_ENV` | Environment (production/development) | `production` | No | +| `CHARON_ENCRYPTION_KEY` | 32-byte base64 key for credential encryption | Auto-generated | No | +| `CHARON_EMERGENCY_TOKEN` | 64-char hex for break-glass access | None | Optional | +| `CROWDSEC_API_KEY` | CrowdSec cloud API key | None | Optional | +| `SMTP_HOST` | SMTP server for notifications | None | Optional | +| `SMTP_PORT` | SMTP port | `587` | Optional | +| `SMTP_USER` | SMTP username | None | Optional | +| `SMTP_PASS` | SMTP password | None | Optional | + +### Docker Compose Example + +```yaml +services: + charon: + image: wikid82/charon:latest + container_name: charon + restart: unless-stopped + ports: + - "8080:8080" + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - CHARON_ENV=production + - CHARON_ENCRYPTION_KEY=${CHARON_ENCRYPTION_KEY} + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s +``` + +### High Availability Considerations + +**Current Limitations:** +- SQLite does not support clustering +- Single point of failure (one container) +- Not designed for horizontal scaling + +**Future Options:** +- PostgreSQL backend for HA deployments +- Read replicas for load balancing +- Container orchestration (Kubernetes, Docker Swarm) + +--- + +## Development Workflow + +### Local Development Setup + +1. **Prerequisites:** + ```bash + - Go 1.25+ (backend development) + - Node.js 23+ and npm (frontend development) + - Docker 24+ (E2E testing) + - SQLite 3.x (database) + ``` + +2. **Clone Repository:** + ```bash + git clone https://github.com/Wikid82/Charon.git + cd Charon + ``` + +3. **Backend Development:** + ```bash + cd backend + go mod download + go run cmd/api/main.go + # API server runs on http://localhost:8080 + ``` + +4. **Frontend Development:** + ```bash + cd frontend + npm install + npm run dev + # Vite dev server runs on http://localhost:5173 + ``` + +5. **Full-Stack Development (Docker):** + ```bash + docker-compose -f .docker/compose/docker-compose.dev.yml up + # Frontend + Backend + Caddy in one container + ``` + +### Git Workflow + +**Branch Strategy:** +- `main`: Stable production branch +- `feature/*`: New feature development +- `fix/*`: Bug fixes +- `chore/*`: Maintenance tasks + +**Commit Convention:** +- `feat:` New user-facing feature +- `fix:` Bug fix in application code +- `chore:` Infrastructure, CI/CD, dependencies +- `docs:` Documentation-only changes +- `refactor:` Code restructuring without functional changes +- `test:` Adding or updating tests + +**Example:** +``` +feat: add DNS-01 challenge support for Cloudflare + +Implement Cloudflare DNS provider for automatic wildcard certificate +provisioning via Let's Encrypt DNS-01 challenge. + +Closes #123 +``` + +### Code Review Process + +1. **Automated Checks (CI):** + - Linters (golangci-lint, ESLint) + - Unit tests (Go test, Vitest) + - E2E tests (Playwright) + - Security scans (Trivy, CodeQL, Grype) + - Coverage validation (85% minimum) + +2. **Human Review:** + - Code quality and maintainability + - Security implications + - Performance considerations + - Documentation completeness + +3. **Merge Requirements:** + - All CI checks pass + - At least 1 approval + - No unresolved review comments + - Branch up-to-date with base + +--- + +## Testing Strategy + +### Test Pyramid + +``` + /\ E2E (Playwright) - 10% + / \ Critical user flows + /____\ + / \ Integration (Go) - 20% + / \ Component interactions + /__________\ + / \ Unit (Go + Vitest) - 70% +/______________\ Pure functions, models +``` + +### E2E Tests (Playwright) + +**Purpose:** Validate critical user flows in a real browser + +**Scope:** +- User authentication +- Proxy host CRUD operations +- Certificate provisioning +- Security feature toggling +- Real-time log streaming + +**Execution:** +```bash +# Run against Docker container +npx playwright test --project=chromium + +# Run with coverage (Vite dev server) +.github/skills/scripts/skill-runner.sh test-e2e-playwright-coverage + +# Debug mode +npx playwright test --debug +``` + +**Coverage Modes:** +- **Docker Mode:** Integration testing, no coverage (0% reported) +- **Vite Dev Mode:** Coverage collection with V8 inspector + +**Why Two Modes?** +- Playwright coverage requires source maps and raw source files +- Docker serves pre-built production files (no source maps) +- Vite dev server exposes source files for coverage instrumentation + +### Unit Tests (Backend - Go) + +**Purpose:** Test individual functions and methods in isolation + +**Framework:** Go's built-in `testing` package + +**Coverage Target:** 85% minimum + +**Execution:** +```bash +# Run all tests +go test ./... + +# With coverage +go test -cover ./... + +# VS Code task +"Test: Backend with Coverage" +``` + +**Test Organization:** +- `*_test.go` files alongside source code +- Table-driven tests for comprehensive coverage +- Mocks for external dependencies (database, HTTP clients) + +**Example:** +```go +func TestCreateProxyHost(t *testing.T) { + tests := []struct { + name string + input ProxyHostDTO + wantErr bool + }{ + { + name: "valid proxy host", + input: ProxyHostDTO{Domain: "example.com", Target: "http://localhost:8000"}, + wantErr: false, + }, + { + name: "invalid domain", + input: ProxyHostDTO{Domain: "", Target: "http://localhost:8000"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CreateProxyHost(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("CreateProxyHost() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} +``` + +### Unit Tests (Frontend - Vitest) + +**Purpose:** Test React components and utility functions + +**Framework:** Vitest + React Testing Library + +**Coverage Target:** 85% minimum + +**Execution:** +```bash +# Run all tests +npm test + +# With coverage +npm run test:coverage + +# VS Code task +"Test: Frontend with Coverage" +``` + +**Test Organization:** +- `*.test.tsx` files alongside components +- Mock API calls with MSW (Mock Service Worker) +- Snapshot tests for UI consistency + +### Integration Tests (Go) + +**Purpose:** Test component interactions (e.g., API + Service + Database) + +**Location:** `backend/integration/` + +**Scope:** +- API endpoint end-to-end flows +- Database migrations +- Caddy manager integration +- CrowdSec API calls + +**Execution:** +```bash +go test ./integration/... +``` + +### Pre-Commit Checks + +**Automated Hooks (via `.pre-commit-config.yaml`):** + +**Fast Stage (< 5 seconds):** +- Trailing whitespace removal +- EOF fixer +- YAML syntax check +- JSON syntax check +- Markdown link validation + +**Manual Stage (run explicitly):** +- Backend coverage tests (60-90s) +- Frontend coverage tests (30-60s) +- TypeScript type checking (10-20s) + +**Why Manual?** +- Coverage tests are slow and would block commits +- Developers run them on-demand before pushing +- CI enforces coverage on pull requests + +### Continuous Integration (GitHub Actions) + +**Workflow Triggers:** +- `push` to `main`, `feature/*`, `fix/*` +- `pull_request` to `main` + +**CI Jobs:** +1. **Lint:** golangci-lint, ESLint, markdownlint, hadolint +2. **Test:** Go tests, Vitest, Playwright +3. **Security:** Trivy, CodeQL, Grype, Govulncheck +4. **Build:** Docker image build +5. **Coverage:** Upload to Codecov (85% gate) +6. **Supply Chain:** SBOM generation, Cosign signing + +--- + +## Build & Release Process + +### Versioning Strategy + +**Semantic Versioning:** `MAJOR.MINOR.PATCH-PRERELEASE` + +- **MAJOR:** Breaking changes (e.g., API contract changes) +- **MINOR:** New features (backward-compatible) +- **PATCH:** Bug fixes (backward-compatible) +- **PRERELEASE:** `-beta.1`, `-rc.1`, etc. + +**Examples:** +- `1.0.0` - Stable release +- `1.1.0` - New feature (DNS provider support) +- `1.1.1` - Bug fix (GORM query fix) +- `1.2.0-beta.1` - Beta release for testing + +**Version File:** `VERSION.md` (single source of truth) + +### Build Pipeline (Multi-Platform) + +**Platforms Supported:** +- `linux/amd64` +- `linux/arm64` + +**Build Process:** + +1. **Frontend Build:** + ```bash + cd frontend + npm ci --only=production + npm run build + # Output: frontend/dist/ + ``` + +2. **Backend Build:** + ```bash + cd backend + go build -o charon cmd/api/main.go + # Output: charon binary + ``` + +3. **Docker Image Build:** + ```bash + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag wikid82/charon:latest \ + --tag wikid82/charon:1.2.0 \ + --push . + ``` + +### Release Workflow + +**Automated Release (GitHub Actions):** + +1. **Trigger:** Push tag `v1.2.0` +2. **Build:** Multi-platform Docker images +3. **Test:** Run E2E tests against built image +4. **Security:** Scan for vulnerabilities (block if Critical/High) +5. **SBOM:** Generate Software Bill of Materials (Syft) +6. **Sign:** Cryptographic signature with Cosign +7. **Provenance:** Generate SLSA provenance attestation +8. **Publish:** Push to Docker Hub and GHCR +9. **Release Notes:** Generate changelog from commits +10. **Notify:** Send release notification (Discord, email) + +### Supply Chain Security + +**Components:** + +1. **SBOM (Software Bill of Materials):** + - Generated with Syft (CycloneDX format) + - Lists all dependencies (Go modules, NPM packages, OS packages) + - Attached to release as `sbom.cyclonedx.json` + +2. **Container Scanning:** + - Trivy: Fast vulnerability scanning (filesystem) + - Grype: Deep image scanning (layers, dependencies) + - CodeQL: Static analysis (Go, JavaScript) + +3. **Cryptographic Signing:** + - Cosign signs Docker images with keyless signing (OIDC) + - Signature stored in registry alongside image + - Verification: `cosign verify wikid82/charon:latest` + +4. **SLSA Provenance:** + - Attestation of build process (inputs, outputs, environment) + - Proves image was built by trusted CI pipeline + - Level: SLSA Build L3 (hermetic builds) + +**Verification Example:** +```bash +# Verify image signature +cosign verify \ + --certificate-identity-regexp="https://github.com/Wikid82/Charon" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + wikid82/charon:latest + +# Inspect SBOM +syft wikid82/charon:latest -o json + +# Scan for vulnerabilities +grype wikid82/charon:latest +``` + +### Rollback Strategy + +**Container Rollback:** +```bash +# List available versions +docker images wikid82/charon + +# Roll back to previous version +docker-compose down +docker-compose up -d --pull always wikid82/charon:1.1.1 +``` + +**Database Rollback:** +```bash +# Restore from backup +docker exec charon /app/scripts/restore-backup.sh \ + /app/data/backups/charon-20260127.db +``` + +--- + +## Extensibility + +### Plugin Architecture (Future) + +**Current State:** Monolithic design (no plugin system) + +**Planned Extensibility Points:** + +1. **DNS Providers:** + - Interface-based design for DNS-01 challenge providers + - Current: 15+ built-in providers (Cloudflare, Route53, etc.) + - Future: Dynamic plugin loading for custom providers + +2. **Notification Channels:** + - Shoutrrr provides 40+ channels (Discord, Slack, Email, etc.) + - Custom channels via Shoutrrr service URLs + +3. **Authentication Providers:** + - Current: Local database authentication + - Future: OAuth2, LDAP, SAML integration + +4. **Storage Backends:** + - Current: SQLite (embedded) + - Future: PostgreSQL, MySQL for HA deployments + +### API Extensibility + +**REST API Design:** +- Version prefix: `/api/v1/` +- Future versions: `/api/v2/` (backward-compatible) +- Deprecation policy: 2 major versions supported + +**WebHooks (Future):** +- Event notifications for external systems +- Triggers: Proxy host created, certificate renewed, security event +- Payload: JSON with event type and data + +### Custom Middleware (Caddy) + +**Current:** Cerberus security middleware injected into Caddy pipeline + +**Future:** +- User-defined middleware (rate limiting rules, custom headers) +- JavaScript/Lua scripting for request transformation +- Plugin marketplace for community contributions + +--- + +## Known Limitations + +### Architecture Constraints + +1. **Single Point of Failure:** + - Monolithic container design + - No horizontal scaling support + - **Mitigation:** Container restart policies, health checks + +2. **Database Scalability:** + - SQLite not designed for high concurrency + - Write bottleneck for > 100 concurrent users + - **Mitigation:** Optimize queries, consider PostgreSQL for large deployments + +3. **Memory Usage:** + - All proxy configurations loaded into memory + - Caddy certificates cached in memory + - **Mitigation:** Monitor memory usage, implement pagination + +4. **Embedded Caddy:** + - Caddy version pinned to backend compatibility + - Cannot use standalone Caddy features + - **Mitigation:** Track Caddy releases, update dependencies regularly + +### Known Issues + +1. **GORM Struct Reuse:** + - Fixed in v1.2.0 (see `docs/plans/current_spec.md`) + - Prior versions had ID leakage in Settings queries + +2. **Docker Discovery:** + - Requires `docker.sock` mount (security trade-off) + - Only discovers containers on same Docker host + - **Mitigation:** Use remote Docker API or Kubernetes + +3. **Certificate Renewal:** + - Let's Encrypt rate limits (50 certificates/week per domain) + - No automatic fallback to ZeroSSL + - **Mitigation:** Implement fallback logic, monitor rate limits + +--- + +## Maintenance & Updates + +### Keeping ARCHITECTURE.md Updated + +**When to Update:** + +1. **Major Feature Addition:** + - New components (e.g., API gateway, message queue) + - New external integrations (e.g., cloud storage, monitoring) + +2. **Architectural Changes:** + - Change from SQLite to PostgreSQL + - Introduction of microservices + - New deployment model (Kubernetes, Serverless) + +3. **Technology Stack Updates:** + - Major version upgrades (Go, React, Caddy) + - Replacement of core libraries (e.g., GORM to SQLx) + +4. **Security Architecture Changes:** + - New security layers (e.g., API Gateway, Service Mesh) + - Authentication provider changes (OAuth2, SAML) + +**Update Process:** + +1. **Developer:** Update relevant sections when making changes +2. **Code Review:** Reviewer validates architecture docs match implementation +3. **Quarterly Audit:** Architecture team reviews for accuracy +4. **Version Control:** Track changes via Git commit history + +### Automation for Architectural Compliance + +**GitHub Copilot Instructions:** + +All agents (`Planning`, `Backend_Dev`, `Frontend_Dev`, `DevOps`) must reference `ARCHITECTURE.md` when: +- Creating new components +- Modifying core systems +- Changing integration points +- Updating dependencies + +**CI Checks:** + +- Validate directory structure matches documented conventions +- Check technology versions against `ARCHITECTURE.md` +- Ensure API endpoints follow documented patterns + +### Monitoring Architectural Health + +**Metrics to Track:** + +- **Code Complexity:** Cyclomatic complexity per module +- **Coupling:** Dependencies between components +- **Technical Debt:** TODOs, FIXMEs, HACKs in codebase +- **Test Coverage:** Maintain 85% minimum +- **Build Time:** Frontend + Backend + Docker build duration +- **Container Size:** Track image size bloat + +**Tools:** + +- SonarQube: Code quality and technical debt +- Codecov: Coverage tracking and trend analysis +- Grafana: Runtime metrics and performance +- GitHub Insights: Contributor activity and velocity + +--- + +## Diagram: Full System Overview + +```mermaid +graph TB + subgraph "User Interface" + Browser[Web Browser] + end + + subgraph "Docker Container" + subgraph "Frontend" + React[React SPA] + Vite[Vite Dev Server] + end + + subgraph "Backend" + Gin[Gin HTTP Server] + API[API Handlers] + Services[Service Layer] + Models[GORM Models] + end + + subgraph "Data Layer" + SQLite[(SQLite DB)] + Cache[Memory Cache] + end + + subgraph "Proxy Layer" + CaddyMgr[Caddy Manager] + Caddy[Caddy Server] + end + + subgraph "Security (Cerberus)" + RateLimit[Rate Limiter] + CrowdSec[CrowdSec] + ACL[Access Lists] + WAF[WAF/Coraza] + end + end + + subgraph "External Systems" + Docker[Docker Daemon] + ACME[Let's Encrypt] + DNS[DNS Providers] + Upstream[Upstream Servers] + CrowdAPI[CrowdSec Cloud API] + end + + Browser -->|HTTPS :8080| React + React -->|API Calls| Gin + Gin --> API + API --> Services + Services --> Models + Models --> SQLite + Services --> CaddyMgr + CaddyMgr --> Caddy + Services --> Cache + + Caddy --> RateLimit + RateLimit --> CrowdSec + CrowdSec --> ACL + ACL --> WAF + WAF --> Upstream + + Services -.->|Container Discovery| Docker + Caddy -.->|ACME Protocol| ACME + Caddy -.->|DNS Challenge| DNS + CrowdSec -.->|Threat Intel| CrowdAPI + + SQLite -.->|Backups| Backups[Backup Storage] +``` + +--- + +## Additional Resources + +- **[README.md](README.md)** - Project overview and quick start +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Contribution guidelines +- **[docs/features.md](docs/features.md)** - Detailed feature documentation +- **[docs/api.md](docs/api.md)** - REST API reference +- **[docs/database-schema.md](docs/database-schema.md)** - Database structure +- **[docs/cerberus.md](docs/cerberus.md)** - Security suite documentation +- **[docs/getting-started.md](docs/getting-started.md)** - User guide +- **[SECURITY.md](SECURITY.md)** - Security policy and vulnerability reporting + +--- + +**Maintained by:** Charon Development Team +**Questions?** Open an issue on [GitHub](https://github.com/Wikid82/Charon/issues) or join our community. diff --git a/README.md b/README.md index 0e52a891..735d8199 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,16 @@ docker run -d \ **Install golangci-lint** (for contributors): `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` +**GORM Security Scanner:** Charon includes an automated security scanner that detects GORM vulnerabilities (ID leaks, exposed secrets, DTO embedding issues). Run it via: + +```bash +# VS Code: Command Palette → "Lint: GORM Security Scan" +# Or via pre-commit: +pre-commit run --hook-stage manual gorm-security-scan --all-files +``` + +See [GORM Security Scanner Documentation](docs/implementation/gorm_security_scanner_complete.md) for details. + See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development environment setup. **Note:** GitHub Actions CI uses `GOTOOLCHAIN: auto` to automatically download and use Go 1.25.6, even if your system has an older version installed. For local development, ensure you have Go 1.25.6+ installed. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1f016f7d..147aea57 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -17,6 +17,8 @@ import ( "github.com/Wikid82/charon/backend/internal/api/handlers" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/api/routes" + "github.com/Wikid82/charon/backend/internal/caddy" + "github.com/Wikid82/charon/backend/internal/cerberus" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/database" "github.com/Wikid82/charon/backend/internal/logger" @@ -245,8 +247,13 @@ func main() { // Attach a recovery middleware that logs stack traces when debug is enabled router.Use(middleware.Recovery(cfg.Debug)) + // Shared Caddy manager and Cerberus instance for API + emergency server + caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) + caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) + cerb := cerberus.New(cfg.Security, db) + // Pass config to routes for auth service and certificate service - if err := routes.Register(router, db, cfg); err != nil { + if err := routes.RegisterWithDeps(router, db, cfg, caddyManager, cerb); err != nil { log.Fatalf("register routes: %v", err) } @@ -259,7 +266,7 @@ func main() { } // Initialize emergency server (Tier 2 break glass) - emergencyServer := server.NewEmergencyServer(db, cfg.Emergency) + emergencyServer := server.NewEmergencyServerWithDeps(db, cfg.Emergency, caddyManager, cerb) if err := emergencyServer.Start(); err != nil { logger.Log().WithError(err).Fatal("Failed to start emergency server") } diff --git a/backend/internal/api/handlers/emergency_handler.go b/backend/internal/api/handlers/emergency_handler.go index b87e950d..74cf999d 100644 --- a/backend/internal/api/handlers/emergency_handler.go +++ b/backend/internal/api/handlers/emergency_handler.go @@ -1,10 +1,10 @@ package handlers import ( + "context" "fmt" "net/http" "os" - "sync" "time" "github.com/gin-gonic/gin" @@ -25,57 +25,15 @@ const ( // MinTokenLength is the minimum required length for the emergency token MinTokenLength = 32 - - // Rate limiting for emergency endpoint (3 attempts per minute per IP) - emergencyRateLimit = 3 - emergencyRateWindow = 1 * time.Minute ) -// emergencyRateLimiter implements a simple in-memory rate limiter for emergency endpoint -type emergencyRateLimiter struct { - mu sync.RWMutex - attempts map[string][]time.Time // IP -> timestamps of attempts -} - -var globalEmergencyLimiter = &emergencyRateLimiter{ - attempts: make(map[string][]time.Time), -} - -// checkRateLimit returns true if the IP has exceeded rate limit -func (rl *emergencyRateLimiter) checkRateLimit(ip string) bool { - rl.mu.Lock() - defer rl.mu.Unlock() - - now := time.Now() - cutoff := now.Add(-emergencyRateWindow) - - // Get and clean old attempts - attempts := rl.attempts[ip] - validAttempts := []time.Time{} - for _, t := range attempts { - if t.After(cutoff) { - validAttempts = append(validAttempts, t) - } - } - - // Check if rate limit exceeded - if len(validAttempts) >= emergencyRateLimit { - rl.attempts[ip] = validAttempts - return true - } - - // Add new attempt - validAttempts = append(validAttempts, now) - rl.attempts[ip] = validAttempts - - return false -} - // EmergencyHandler handles emergency security reset operations type EmergencyHandler struct { db *gorm.DB securityService *services.SecurityService tokenService *services.EmergencyTokenService + caddyManager CaddyConfigManager + cerberus CacheInvalidator } // NewEmergencyHandler creates a new EmergencyHandler @@ -87,6 +45,17 @@ func NewEmergencyHandler(db *gorm.DB) *EmergencyHandler { } } +// NewEmergencyHandlerWithDeps creates a new EmergencyHandler with optional cache invalidation and config reload. +func NewEmergencyHandlerWithDeps(db *gorm.DB, caddyManager CaddyConfigManager, cerberus CacheInvalidator) *EmergencyHandler { + return &EmergencyHandler{ + db: db, + securityService: services.NewSecurityService(db), + tokenService: services.NewEmergencyTokenService(db), + caddyManager: caddyManager, + cerberus: cerberus, + } +} + // NewEmergencyTokenHandler creates a handler for emergency token management endpoints // This is an alias for NewEmergencyHandler, provided for semantic clarity in route registration func NewEmergencyTokenHandler(tokenService *services.EmergencyTokenService) *EmergencyHandler { @@ -103,27 +72,11 @@ func NewEmergencyTokenHandler(tokenService *services.EmergencyTokenService) *Eme // // Security measures: // - EmergencyBypass middleware validates token and IP (timing-safe comparison) -// - Rate limiting: 3 attempts per minute per IP // - All attempts (success and failure) are logged to audit trail with timestamp and IP func (h *EmergencyHandler) SecurityReset(c *gin.Context) { clientIP := util.CanonicalizeIPForSecurity(c.ClientIP()) startTime := time.Now() - // Rate limiting check - if globalEmergencyLimiter.checkRateLimit(clientIP) { - h.logEnhancedAudit(clientIP, "emergency_reset_rate_limited", "Rate limit exceeded", false, time.Since(startTime)) - log.WithFields(log.Fields{ - "ip": clientIP, - "action": "emergency_reset_rate_limited", - }).Warn("Emergency reset rate limit exceeded") - - c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "rate limit exceeded", - "message": fmt.Sprintf("Too many attempts. Maximum %d attempts per minute.", emergencyRateLimit), - }) - return - } - // Check if request has been pre-validated by EmergencyBypass middleware bypassActive, exists := c.Get("emergency_bypass") if exists && bypassActive.(bool) { @@ -231,6 +184,8 @@ func (h *EmergencyHandler) performSecurityReset(c *gin.Context, clientIP string, return } + h.syncSecurityState(c.Request.Context()) + // Log successful reset h.logEnhancedAudit(clientIP, "emergency_reset_success", fmt.Sprintf("Disabled modules: %v", disabledModules), true, time.Since(startTime)) log.WithFields(log.Fields{ @@ -254,10 +209,12 @@ func (h *EmergencyHandler) disableAllSecurityModules() ([]string, error) { // Settings to disable securitySettings := map[string]string{ "feature.cerberus.enabled": "false", + "security.cerberus.enabled": "false", "security.acl.enabled": "false", "security.waf.enabled": "false", "security.rate_limit.enabled": "false", "security.crowdsec.enabled": "false", + "security.crowdsec.mode": "disabled", } // Disable each module via settings @@ -337,6 +294,22 @@ func (h *EmergencyHandler) logEnhancedAudit(actor, action, details string, succe } } +func (h *EmergencyHandler) syncSecurityState(ctx context.Context) { + if h.cerberus != nil { + h.cerberus.InvalidateCache() + } + if h.caddyManager == nil { + return + } + + applyCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := h.caddyManager.ApplyConfig(applyCtx); err != nil { + log.WithError(err).Warn("Failed to reload Caddy config after emergency reset") + } +} + // GenerateToken generates a new emergency token with expiration policy // POST /api/v1/emergency/token/generate // Requires admin authentication diff --git a/backend/internal/api/handlers/emergency_handler_test.go b/backend/internal/api/handlers/emergency_handler_test.go index 11f8c70b..b6e4fefb 100644 --- a/backend/internal/api/handlers/emergency_handler_test.go +++ b/backend/internal/api/handlers/emergency_handler_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -18,13 +19,15 @@ import ( ) func setupEmergencyTestDB(t *testing.T) *gorm.DB { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) err = db.AutoMigrate( &models.Setting{}, &models.SecurityConfig{}, &models.SecurityAudit{}, + &models.EmergencyToken{}, ) require.NoError(t, err) @@ -39,6 +42,23 @@ func setupEmergencyRouter(handler *EmergencyHandler) *gin.Engine { return router } +type mockCaddyManager struct { + calls int +} + +func (m *mockCaddyManager) ApplyConfig(_ context.Context) error { + m.calls++ + return nil +} + +type mockCacheInvalidator struct { + calls int +} + +func (m *mockCacheInvalidator) InvalidateCache() { + m.calls++ +} + func TestEmergencySecurityReset_Success(t *testing.T) { // Setup db := setupEmergencyTestDB(t) @@ -85,6 +105,12 @@ func TestEmergencySecurityReset_Success(t *testing.T) { err = db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error require.NoError(t, err) assert.Equal(t, "false", setting.Value) + assert.NotEmpty(t, setting.Value) + + var crowdsecMode models.Setting + err = db.Where("key = ?", "security.crowdsec.mode").First(&crowdsecMode).Error + require.NoError(t, err) + assert.Equal(t, "disabled", crowdsecMode.Value) // Verify SecurityConfig was updated var updatedConfig models.SecurityConfig @@ -214,31 +240,7 @@ func TestEmergencySecurityReset_TokenTooShort(t *testing.T) { assert.Contains(t, response["message"], "minimum length") } -func TestEmergencyRateLimiter(t *testing.T) { - // Reset global limiter - limiter := &emergencyRateLimiter{ - attempts: make(map[string][]time.Time), - } - - testIP := "192.168.1.100" - - // Test: First 3 attempts should succeed - for i := 0; i < emergencyRateLimit; i++ { - limited := limiter.checkRateLimit(testIP) - assert.False(t, limited, "Attempt %d should not be rate limited", i+1) - } - - // Test: 4th attempt should be rate limited - limited := limiter.checkRateLimit(testIP) - assert.True(t, limited, "4th attempt should be rate limited") - - // Test: Multiple IPs should be tracked independently - otherIP := "192.168.1.200" - limited = limiter.checkRateLimit(otherIP) - assert.False(t, limited, "Different IP should not be rate limited") -} - -func TestEmergencySecurityReset_RateLimiting(t *testing.T) { +func TestEmergencySecurityReset_NoRateLimit(t *testing.T) { // Setup db := setupEmergencyTestDB(t) handler := NewEmergencyHandler(db) @@ -248,40 +250,46 @@ func TestEmergencySecurityReset_RateLimiting(t *testing.T) { os.Setenv(EmergencyTokenEnvVar, validToken) defer os.Unsetenv(EmergencyTokenEnvVar) - // Reset global rate limiter - globalEmergencyLimiter = &emergencyRateLimiter{ - attempts: make(map[string][]time.Time), - } + wrongToken := "wrong-token-for-no-rate-limit-test-32chars" - // Make 3 successful requests (within rate limit) - for i := 0; i < emergencyRateLimit; i++ { + // Make rapid requests with invalid token; all should be unauthorized + for i := 0; i < 10; i++ { req, _ := http.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil) - req.Header.Set(EmergencyTokenHeader, validToken) - req.RemoteAddr = "192.168.1.100:12345" - + req.Header.Set(EmergencyTokenHeader, wrongToken) w := httptest.NewRecorder() router.ServeHTTP(w, req) - // First 3 should succeed - assert.Equal(t, http.StatusOK, w.Code, "Request %d should succeed", i+1) + assert.Equal(t, http.StatusUnauthorized, w.Code, "Request %d should be unauthorized", i+1) + + var response map[string]interface{} + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + assert.Equal(t, "unauthorized", response["error"]) } +} - // 4th request should be rate limited - req, _ := http.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil) +func TestEmergencySecurityReset_TriggersReloadAndCacheInvalidate(t *testing.T) { + // Setup + db := setupEmergencyTestDB(t) + mockCaddy := &mockCaddyManager{} + mockCache := &mockCacheInvalidator{} + handler := NewEmergencyHandlerWithDeps(db, mockCaddy, mockCache) + router := setupEmergencyRouter(handler) + + validToken := "this-is-a-valid-emergency-token-with-32-chars-minimum" + os.Setenv(EmergencyTokenEnvVar, validToken) + defer os.Unsetenv(EmergencyTokenEnvVar) + + // Make request with valid token + req := httptest.NewRequest(http.MethodPost, "/api/v1/emergency/security-reset", nil) req.Header.Set(EmergencyTokenHeader, validToken) - req.RemoteAddr = "192.168.1.100:12345" - w := httptest.NewRecorder() + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusTooManyRequests, w.Code, "4th request should be rate limited") - - var response map[string]interface{} - err := json.NewDecoder(w.Body).Decode(&response) - require.NoError(t, err) - - assert.Equal(t, "rate limit exceeded", response["error"]) - assert.Contains(t, response["message"], "Maximum 3 attempts per minute") + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 1, mockCaddy.calls) + assert.Equal(t, 1, mockCache.calls) } func TestLogEnhancedAudit(t *testing.T) { diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 0ef69916..8861ec9f 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -15,7 +15,9 @@ import ( "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + securitypkg "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" ) // WAFExclusionRequest represents a rule exclusion for false positives @@ -39,6 +41,7 @@ type SecurityHandler struct { svc *services.SecurityService caddyManager *caddy.Manager geoipSvc *services.GeoIPService + cerberus CacheInvalidator } // NewSecurityHandler creates a new SecurityHandler. @@ -47,6 +50,12 @@ func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *ca return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager} } +// NewSecurityHandlerWithDeps creates a new SecurityHandler with optional cache invalidation. +func NewSecurityHandlerWithDeps(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager, cerberus CacheInvalidator) *SecurityHandler { + svc := services.NewSecurityService(db) + return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager, cerberus: cerberus} +} + // SetGeoIPService sets the GeoIP service for the handler. func (h *SecurityHandler) SetGeoIPService(geoipSvc *services.GeoIPService) { h.geoipSvc = geoipSvc @@ -117,8 +126,10 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) { } // CrowdSec enabled override + crowdSecEnabledOverride := false setting = struct{ Value string }{} if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.enabled").Scan(&setting).Error; err == nil && setting.Value != "" { + crowdSecEnabledOverride = true if strings.EqualFold(setting.Value, "true") { crowdSecMode = "local" } else { @@ -126,10 +137,12 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) { } } - // CrowdSec mode override - setting = struct{ Value string }{} - if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&setting).Error; err == nil && setting.Value != "" { - crowdSecMode = setting.Value + // CrowdSec mode override (deprecated - only applies when enabled override is absent) + if !crowdSecEnabledOverride { + setting = struct{ Value string }{} + if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&setting).Error; err == nil && setting.Value != "" { + crowdSecMode = setting.Value + } } // ACL enabled override @@ -941,6 +954,42 @@ func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string return } + if settingKey == "security.acl.enabled" && enabled { + if !h.allowACLEnable(c) { + return + } + } + + if settingKey == "security.acl.enabled" && enabled { + if err := h.ensureSecurityConfigEnabled(); err != nil { + log.WithError(err).Error("Failed to enable SecurityConfig while enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + cerberusSetting := models.Setting{ + Key: "feature.cerberus.enabled", + Value: "true", + Category: "feature", + Type: "bool", + } + if err := h.db.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil { + log.WithError(err).Error("Failed to enable Cerberus while enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) + return + } + legacyCerberus := models.Setting{ + Key: "security.cerberus.enabled", + Value: "true", + Category: "security", + Type: "bool", + } + if err := h.db.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil { + log.WithError(err).Error("Failed to enable legacy Cerberus while enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) + return + } + } + // Update setting value := "false" if enabled { @@ -960,6 +1009,33 @@ func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string return } + if settingKey == "security.acl.enabled" && enabled { + var count int64 + if err := h.db.Model(&models.SecurityConfig{}).Count(&count).Error; err != nil { + log.WithError(err).Error("Failed to count security configs after enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + if count == 0 { + cfg := models.SecurityConfig{Name: "default", Enabled: true} + if err := h.db.Create(&cfg).Error; err != nil { + log.WithError(err).Error("Failed to create security config after enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + } else { + if err := h.db.Model(&models.SecurityConfig{}).Where("name = ?", "default").Update("enabled", true).Error; err != nil { + log.WithError(err).Error("Failed to update security config after enabling ACL") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + } + } + + if h.cerberus != nil { + h.cerberus.InvalidateCache() + } + // Trigger Caddy config reload if h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { @@ -980,3 +1056,47 @@ func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string "enabled": enabled, }) } + +func (h *SecurityHandler) ensureSecurityConfigEnabled() error { + if h.db == nil { + return errors.New("security config database not configured") + } + cfg := models.SecurityConfig{Name: "default", Enabled: true} + if err := h.db.Where("name = ?", "default").FirstOrCreate(&cfg).Error; err != nil { + return err + } + if cfg.Enabled { + return nil + } + return h.db.Model(&cfg).Update("enabled", true).Error +} + +func (h *SecurityHandler) allowACLEnable(c *gin.Context) bool { + if bypass, exists := c.Get("emergency_bypass"); exists { + if bypassActive, ok := bypass.(bool); ok && bypassActive { + return true + } + } + + cfg, err := h.svc.Get() + if err != nil { + if errors.Is(err, services.ErrSecurityConfigNotFound) { + return true + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"}) + return false + } + + whitelist := strings.TrimSpace(cfg.AdminWhitelist) + if whitelist == "" { + return true + } + + clientIP := util.CanonicalizeIPForSecurity(c.ClientIP()) + if securitypkg.IsIPInCIDRList(clientIP, whitelist) { + return true + } + + c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"}) + return false +} diff --git a/backend/internal/api/handlers/security_handler_cache_test.go b/backend/internal/api/handlers/security_handler_cache_test.go new file mode 100644 index 00000000..96bbe96b --- /dev/null +++ b/backend/internal/api/handlers/security_handler_cache_test.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" +) + +type testCacheInvalidator struct { + calls int +} + +func (t *testCacheInvalidator) InvalidateCache() { + t.calls++ +} + +func TestSecurityHandler_ToggleSecurityModule_InvalidatesCache(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + + cache := &testCacheInvalidator{} + handler := NewSecurityHandlerWithDeps(config.SecurityConfig{}, db, nil, cache) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/security/waf/enable", handler.EnableWAF) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/security/waf/enable", http.NoBody) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 1, cache.calls) + + var setting models.Setting + require.NoError(t, db.Where("key = ?", "security.waf.enabled").First(&setting).Error) + require.Equal(t, "true", setting.Value) +} diff --git a/backend/internal/api/handlers/security_handler_settings_test.go b/backend/internal/api/handlers/security_handler_settings_test.go index 3030cc1e..0c1082c2 100644 --- a/backend/internal/api/handlers/security_handler_settings_test.go +++ b/backend/internal/api/handlers/security_handler_settings_test.go @@ -4,11 +4,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" @@ -225,3 +228,134 @@ func TestSecurityHandler_GetStatus_RateLimitModeFromSettings(t *testing.T) { rateLimit := response["rate_limit"].(map[string]any) assert.True(t, rateLimit["enabled"].(bool)) } + +func TestSecurityHandler_PatchACL_RequiresAdminWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := OpenTestDBWithMigrations(t) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", AdminWhitelist: "192.0.2.1/32"}).Error) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PATCH("/security/acl", handler.PatchACL) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/security/acl", strings.NewReader(`{"enabled":true}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "203.0.113.5:1234" + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSecurityHandler_PatchACL_AllowsWhitelistedIP(t *testing.T) { + gin.SetMode(gin.TestMode) + db := OpenTestDBWithMigrations(t) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", AdminWhitelist: "203.0.113.0/24"}).Error) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PATCH("/security/acl", handler.PatchACL) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/security/acl", strings.NewReader(`{"enabled":true}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "203.0.113.5:1234" + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var setting models.Setting + err := db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error + require.NoError(t, err) + assert.Equal(t, "true", setting.Value) + + var cfg models.SecurityConfig + err = handler.db.Where("name = ?", "default").First(&cfg).Error + require.NoError(t, err) + assert.True(t, cfg.Enabled) +} + +func TestSecurityHandler_PatchACL_SetsACLAndCerberusSettings(t *testing.T) { + gin.SetMode(gin.TestMode) + + dsn := "file:TestSecurityHandler_PatchACL_SetsACLAndCerberusSettings?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Set("role", "admin") + ctx.Set("userID", uint(1)) + ctx.Request, _ = http.NewRequest("PATCH", "/security/acl", strings.NewReader(`{"enabled":true}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.RemoteAddr = "203.0.113.5:1234" + + handler.toggleSecurityModule(ctx, "security.acl.enabled", true) + + assert.Equal(t, http.StatusOK, w.Code) + + var setting models.Setting + err = db.Where("key = ?", "security.acl.enabled").First(&setting).Error + require.NoError(t, err) + assert.Equal(t, "true", setting.Value) + + var cerbSetting models.Setting + err = db.Where("key = ?", "feature.cerberus.enabled").First(&cerbSetting).Error + require.NoError(t, err) + assert.Equal(t, "true", cerbSetting.Value) + + var legacySetting models.Setting + err = db.Where("key = ?", "security.cerberus.enabled").First(&legacySetting).Error + require.NoError(t, err) + assert.Equal(t, "true", legacySetting.Value) +} + +func TestSecurityHandler_EnsureSecurityConfigEnabled_CreatesWhenMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + + err := handler.ensureSecurityConfigEnabled() + require.NoError(t, err) + + var cfg models.SecurityConfig + err = handler.db.Where("name = ?", "default").First(&cfg).Error + require.NoError(t, err) + assert.True(t, cfg.Enabled) +} + +func TestSecurityHandler_PatchACL_AllowsEmergencyBypass(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", AdminWhitelist: "192.0.2.1/32"}).Error) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("emergency_bypass", true) + c.Next() + }) + router.PATCH("/security/acl", handler.PatchACL) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/security/acl", strings.NewReader(`{"enabled":true}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "203.0.113.5:1234" + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index 21b0523c..73c88233 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "fmt" "net/http" "strings" @@ -83,6 +84,13 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) { return } + if req.Key == "security.admin_whitelist" { + if err := validateAdminWhitelist(req.Value); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)}) + return + } + } + setting := models.Setting{ Key: req.Key, Value: req.Value, @@ -101,6 +109,44 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) { return } + if req.Key == "security.acl.enabled" && strings.EqualFold(strings.TrimSpace(req.Value), "true") { + cerberusSetting := models.Setting{ + Key: "feature.cerberus.enabled", + Value: "true", + Category: "feature", + Type: "bool", + } + if err := h.DB.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) + return + } + legacyCerberus := models.Setting{ + Key: "security.cerberus.enabled", + Value: "true", + Category: "security", + Type: "bool", + } + if err := h.DB.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"}) + return + } + if err := h.ensureSecurityConfigEnabled(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + } + + if req.Key == "security.admin_whitelist" { + if err := h.syncAdminWhitelist(req.Value); err != nil { + if errors.Is(err, services.ErrInvalidAdminCIDR) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security config"}) + return + } + } + // Trigger cache invalidation and config reload for security settings if strings.HasPrefix(req.Key, "security.") { // Invalidate Cerberus cache immediately so middleware uses new settings @@ -148,6 +194,14 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { updates := make(map[string]string) flattenConfig(configUpdates, "", updates) + adminWhitelist, hasAdminWhitelist := updates["security.admin_whitelist"] + + aclEnabled := false + if value, ok := updates["security.acl.enabled"]; ok && strings.EqualFold(value, "true") { + aclEnabled = true + updates["feature.cerberus.enabled"] = "true" + } + // Validate and apply each update for key, value := range updates { // Special validation for admin_whitelist (CIDR format) @@ -172,6 +226,24 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { } } + if hasAdminWhitelist { + if err := h.syncAdminWhitelist(adminWhitelist); err != nil { + if errors.Is(err, services.ErrInvalidAdminCIDR) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security config"}) + return + } + } + + if aclEnabled { + if err := h.ensureSecurityConfigEnabled(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"}) + return + } + } + // Trigger cache invalidation and Caddy reload for security settings needsReload := false for key := range updates { @@ -218,6 +290,22 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { c.JSON(http.StatusOK, settingsMap) } +func (h *SettingsHandler) ensureSecurityConfigEnabled() error { + var cfg models.SecurityConfig + err := h.DB.Where("name = ?", "default").First(&cfg).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + cfg = models.SecurityConfig{Name: "default", Enabled: true} + return h.DB.Create(&cfg).Error + } + return err + } + if cfg.Enabled { + return nil + } + return h.DB.Model(&cfg).Update("enabled", true).Error +} + // flattenConfig converts nested map to flat key-value pairs with dot notation func flattenConfig(config map[string]interface{}, prefix string, result map[string]string) { for k, v := range config { @@ -259,6 +347,22 @@ func validateAdminWhitelist(whitelist string) error { return nil } +func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error { + securitySvc := services.NewSecurityService(h.DB) + cfg, err := securitySvc.Get() + if err != nil { + if err != services.ErrSecurityConfigNotFound { + return err + } + cfg = &models.SecurityConfig{Name: "default"} + } + if cfg.Name == "" { + cfg.Name = "default" + } + cfg.AdminWhitelist = whitelist + return securitySvc.Upsert(cfg) +} + // SMTPConfigRequest represents the request body for SMTP configuration. type SMTPConfigRequest struct { Host string `json:"host" binding:"required"` diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index 629a3d74..908745f7 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -122,7 +122,7 @@ func setupSettingsTestDB(t *testing.T) *gorm.DB { if err != nil { panic("failed to connect to test database") } - _ = db.AutoMigrate(&models.Setting{}) + _ = db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}) return db } @@ -215,6 +215,146 @@ func TestSettingsHandler_UpdateSettings(t *testing.T) { assert.Equal(t, "updated_value", setting.Value) } +func TestSettingsHandler_UpdateSetting_SyncsAdminWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := gin.New() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "security.admin_whitelist", + "value": "192.0.2.1/32", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var cfg models.SecurityConfig + err := db.Where("name = ?", "default").First(&cfg).Error + assert.NoError(t, err) + assert.Equal(t, "192.0.2.1/32", cfg.AdminWhitelist) +} + +func TestSettingsHandler_UpdateSetting_EnablesCerberusWhenACLEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := gin.New() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "security.acl.enabled", + "value": "true", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var setting models.Setting + err := db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error + assert.NoError(t, err) + assert.Equal(t, "true", setting.Value) + + var legacySetting models.Setting + err = db.Where("key = ?", "security.cerberus.enabled").First(&legacySetting).Error + assert.NoError(t, err) + assert.Equal(t, "true", legacySetting.Value) + + var aclSetting models.Setting + err = db.Where("key = ?", "security.acl.enabled").First(&aclSetting).Error + assert.NoError(t, err) + assert.Equal(t, "true", aclSetting.Value) + + var cfg models.SecurityConfig + err = db.Where("name = ?", "default").First(&cfg).Error + assert.NoError(t, err) + assert.True(t, cfg.Enabled) +} + +func TestSettingsHandler_PatchConfig_SyncsAdminWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "security": map[string]any{ + "admin_whitelist": "203.0.113.0/24", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var cfg models.SecurityConfig + err := db.Where("name = ?", "default").First(&cfg).Error + assert.NoError(t, err) + assert.Equal(t, "203.0.113.0/24", cfg.AdminWhitelist) +} + +func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "security": map[string]any{ + "acl": map[string]any{ + "enabled": true, + }, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var setting models.Setting + err := db.Where("key = ?", "feature.cerberus.enabled").First(&setting).Error + assert.NoError(t, err) + assert.Equal(t, "true", setting.Value) + + var cfg models.SecurityConfig + err = db.Where("name = ?", "default").First(&cfg).Error + assert.NoError(t, err) + assert.True(t, cfg.Enabled) +} + func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 5270620e..b44c6b60 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -10,31 +10,21 @@ import ( func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - - if authHeader == "" { - // Try cookie first for browser flows (including WebSocket upgrades) - if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { - authHeader = "Bearer " + cookie + if bypass, exists := c.Get("emergency_bypass"); exists { + if bypassActive, ok := bypass.(bool); ok && bypassActive { + c.Set("role", "admin") + c.Set("userID", uint(0)) + c.Next() + return } } - // DEPRECATED: Query parameter authentication for WebSocket connections - // This fallback exists only for backward compatibility and will be removed in a future version. - // Query parameters are logged in access logs and should not be used for sensitive data. - // Use HttpOnly cookies instead, which are automatically sent by browsers and not logged. - if authHeader == "" { - if token := c.Query("token"); token != "" { - authHeader = "Bearer " + token - } - } - - if authHeader == "" { + tokenString, ok := extractAuthToken(c) + if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) return } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, err := authService.ValidateToken(tokenString) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) @@ -47,6 +37,38 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { } } +func extractAuthToken(c *gin.Context) (string, bool) { + authHeader := c.GetHeader("Authorization") + + if authHeader == "" { + // Try cookie first for browser flows (including WebSocket upgrades) + if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { + authHeader = "Bearer " + cookie + } + } + + // DEPRECATED: Query parameter authentication for WebSocket connections + // This fallback exists only for backward compatibility and will be removed in a future version. + // Query parameters are logged in access logs and should not be used for sensitive data. + // Use HttpOnly cookies instead, which are automatically sent by browsers and not logged. + if authHeader == "" { + if token := c.Query("token"); token != "" { + authHeader = "Bearer " + token + } + } + + if authHeader == "" { + return "", false + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == "" { + return "", false + } + + return tokenString, true +} + func RequireRole(role string) gin.HandlerFunc { return func(c *gin.Context) { userRole, exists := c.Get("role") diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go index 574e0e42..dd8191af 100644 --- a/backend/internal/api/middleware/auth_test.go +++ b/backend/internal/api/middleware/auth_test.go @@ -41,6 +41,29 @@ func TestAuthMiddleware_MissingHeader(t *testing.T) { assert.Contains(t, w.Body.String(), "Authorization header required") } +func TestAuthMiddleware_EmergencyBypass(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("emergency_bypass", true) + c.Next() + }) + r.Use(AuthMiddleware(nil)) + r.GET("/test", func(c *gin.Context) { + role, _ := c.Get("role") + userID, _ := c.Get("userID") + assert.Equal(t, "admin", role) + assert.Equal(t, uint(0), userID) + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + func TestRequireRole_Success(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() diff --git a/backend/internal/api/middleware/optional_auth.go b/backend/internal/api/middleware/optional_auth.go new file mode 100644 index 00000000..38f13dd2 --- /dev/null +++ b/backend/internal/api/middleware/optional_auth.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" +) + +// OptionalAuth applies best-effort authentication for downstream middleware without blocking requests. +func OptionalAuth(authService *services.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + if authService == nil { + c.Next() + return + } + + if bypass, exists := c.Get("emergency_bypass"); exists { + if bypassActive, ok := bypass.(bool); ok && bypassActive { + c.Next() + return + } + } + + if _, exists := c.Get("role"); exists { + c.Next() + return + } + + tokenString, ok := extractAuthToken(c) + if !ok { + c.Next() + return + } + + claims, err := authService.ValidateToken(tokenString) + if err != nil { + c.Next() + return + } + + c.Set("userID", claims.UserID) + c.Set("role", claims.Role) + c.Next() + } +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index ffe4aab6..83cb618f 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -31,6 +31,18 @@ import ( // Register wires up API routes and performs automatic migrations. func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { + // Caddy Manager - created early so it can be used by settings handlers for config reload + caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) + caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) + + // Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec) + cerb := cerberus.New(cfg.Security, db) + + return RegisterWithDeps(router, db, cfg, caddyManager, cerb) +} + +// RegisterWithDeps wires up API routes and performs automatic migrations with prebuilt dependencies. +func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyManager *caddy.Manager, cerb *cerberus.Cerberus) error { // Emergency bypass must be registered FIRST. // When a valid X-Emergency-Token is present from an authorized source, // it sets an emergency context flag and strips the token header so downstream @@ -107,8 +119,16 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(c.Writer, c.Request) }) + if caddyManager == nil { + caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) + caddyManager = caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) + } + if cerb == nil { + cerb = cerberus.New(cfg.Security, db) + } + // Emergency endpoint - emergencyHandler := handlers.NewEmergencyHandler(db) + emergencyHandler := handlers.NewEmergencyHandlerWithDeps(db, caddyManager, cerb) emergency := router.Group("/api/v1/emergency") emergency.POST("/security-reset", emergencyHandler.SecurityReset) @@ -120,21 +140,15 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { emergency.DELETE("/token", emergencyTokenHandler.RevokeToken) emergency.PATCH("/token/expiration", emergencyTokenHandler.UpdateTokenExpiration) - api := router.Group("/api/v1") - - // Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec) - cerb := cerberus.New(cfg.Security, db) - api.Use(cerb.Middleware()) - - // Caddy Manager - created early so it can be used by settings handlers for config reload - caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) - caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security) - // Auth routes authService := services.NewAuthService(db, cfg) authHandler := handlers.NewAuthHandlerWithDB(authService, db) authMiddleware := middleware.AuthMiddleware(authService) + api := router.Group("/api/v1") + api.Use(middleware.OptionalAuth(authService)) + api.Use(cerb.Middleware()) + // Backup routes backupService := services.NewBackupService(&cfg) backupService.Start() // Start cron scheduler for scheduled backups @@ -217,24 +231,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Settings - with CaddyManager and Cerberus for security settings reload settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb) - // Emergency-token-aware fallback (used by E2E when X-Emergency-Token is supplied) - // Returns 404 when no emergency token is present so public surface is unchanged. - router.PATCH("/api/v1/settings", func(c *gin.Context) { - token := c.GetHeader("X-Emergency-Token") - if token == "" { - c.AbortWithStatus(404) - return - } - svc := services.NewEmergencyTokenService(db) - if _, err := svc.Validate(token); err != nil { - c.AbortWithStatus(404) - return - } - // Grant temporary admin context and call the same handler - c.Set("role", "admin") - settingsHandler.UpdateSetting(c) - }) - protected.GET("/settings", settingsHandler.GetSettings) protected.POST("/settings", settingsHandler.UpdateSetting) protected.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH @@ -436,6 +432,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { ticker := time.NewTicker(1 * time.Minute) for range ticker.C { // Check feature flag each tick + s = models.Setting{} // Reset to prevent ID leakage from previous query enabled := true if err := db.Where("key = ?", "feature.uptime.enabled").First(&s).Error; err == nil { enabled = s.Value == "true" @@ -475,28 +472,11 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { } // Security Status - securityHandler := handlers.NewSecurityHandler(cfg.Security, db, caddyManager) + securityHandler := handlers.NewSecurityHandlerWithDeps(cfg.Security, db, caddyManager, cerb) if geoipSvc != nil { securityHandler.SetGeoIPService(geoipSvc) } - // Emergency-token-aware shortcut for ACL toggles (used by E2E/test harness) - // Only accepts requests that present a valid X-Emergency-Token; otherwise return 404. - router.PATCH("/api/v1/security/acl", func(c *gin.Context) { - token := c.GetHeader("X-Emergency-Token") - if token == "" { - c.AbortWithStatus(404) - return - } - svc := services.NewEmergencyTokenService(db) - if _, err := svc.Validate(token); err != nil { - c.AbortWithStatus(404) - return - } - c.Set("role", "admin") - securityHandler.PatchACL(c) - }) - protected.GET("/security/status", securityHandler.GetStatus) // Security Config management protected.GET("/security/config", securityHandler.GetConfig) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index add6ca92..31d6336f 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -682,9 +682,28 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir } } // Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy - mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...) // Determine if standard headers should be enabled (default true if nil) enableStdHeaders := host.EnableStandardHeaders == nil || *host.EnableStandardHeaders + emergencyPaths := []string{ + "/api/v1/emergency/security-reset", + "/api/v1/emergency/*", + "/emergency/security-reset", + "/emergency/*", + } + emergencyHandlers := append(append([]Handler{}, handlers...), ReverseProxyHandler(dial, host.WebsocketSupport, host.Application, enableStdHeaders)) + emergencyRoute := &Route{ + Match: []Match{ + { + Host: uniqueDomains, + Path: emergencyPaths, + }, + }, + Handle: emergencyHandlers, + Terminal: true, + } + routes = append(routes, emergencyRoute) + + mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...) mainHandlers = append(mainHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application, enableStdHeaders)) route := &Route{ diff --git a/backend/internal/caddy/config_generate_test.go b/backend/internal/caddy/config_generate_test.go index 1ce35a6f..d913f669 100644 --- a/backend/internal/caddy/config_generate_test.go +++ b/backend/internal/caddy/config_generate_test.go @@ -2,6 +2,7 @@ package caddy import ( "encoding/json" + "strings" "testing" "github.com/Wikid82/charon/backend/internal/models" @@ -40,3 +41,65 @@ func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) { } func ptrUint(v uint) *uint { return &v } + +func TestGenerateConfig_EmergencyRoutesBypassSecurity(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "h1", + DomainNames: "example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 8080, + Enabled: true, + AccessList: &models.AccessList{ + Enabled: true, + Type: "whitelist", + IPRules: `[ { "cidr": "10.0.0.0/8", "description": "allow" } ]`, + }, + AccessListID: ptrUint(1), + }, + } + + secCfg := &models.SecurityConfig{ + WAFMode: "enabled", + WAFRulesSource: "owasp-crs", + RateLimitMode: "enabled", + RateLimitRequests: 10, + RateLimitWindowSec: 60, + } + + rulesets := []models.SecurityRuleSet{ + {Name: "owasp-crs", Content: "SecRuleEngine On"}, + } + rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-crs.conf"} + + cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", false, false, true, true, true, "", rulesets, rulesetPaths, nil, secCfg, nil) + require.NoError(t, err) + require.NotNil(t, cfg) + + server := cfg.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server) + + var emergencyRoute *Route + for _, route := range server.Routes { + if route == nil { + continue + } + for _, match := range route.Match { + for _, path := range match.Path { + if strings.Contains(path, "/api/v1/emergency") || strings.Contains(path, "/emergency/") { + emergencyRoute = route + break + } + } + } + } + + require.NotNil(t, emergencyRoute, "expected emergency bypass route") + + for _, handler := range emergencyRoute.Handle { + name, _ := handler["handler"].(string) + require.NotEqual(t, "rate_limit", name) + require.NotEqual(t, "waf", name) + require.NotEqual(t, "crowdsec", name) + } +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index fba937f7..530de119 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -631,6 +631,7 @@ func (m *Manager) computeEffectiveFlags(_ context.Context) (cerbEnabled, aclEnab } // runtime override for ACL enabled + s = models.Setting{} // Reset to prevent ID leakage from previous query if err := m.db.Where("key = ?", "security.acl.enabled").First(&s).Error; err == nil { if strings.EqualFold(s.Value, "true") { aclEnabled = true diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 58348338..c6a7d032 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -15,7 +15,9 @@ import ( "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/metrics" "github.com/Wikid82/charon/backend/internal/models" + securitypkg "github.com/Wikid82/charon/backend/internal/security" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" ) // Cerberus provides a lightweight facade for security checks (WAF, CrowdSec, ACL). @@ -114,6 +116,7 @@ func (c *Cerberus) IsEnabled() bool { return strings.EqualFold(s.Value, "true") } // Fallback to legacy setting for backward compatibility + s = models.Setting{} // Reset to prevent ID leakage from previous query if err := c.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil { return strings.EqualFold(s.Value, "true") } @@ -179,13 +182,26 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { } if aclEnabled { + clientIP := util.CanonicalizeIPForSecurity(ctx.ClientIP()) + isAdmin := c.isAuthenticatedAdmin(ctx) + adminWhitelistConfigured := false + if isAdmin { + whitelisted, hasWhitelist := c.adminWhitelistStatus(clientIP) + adminWhitelistConfigured = hasWhitelist + if whitelisted { + ctx.Next() + return + } + } + acls, err := c.accessSvc.List() if err == nil { - clientIP := ctx.ClientIP() + activeCount := 0 for _, acl := range acls { if !acl.Enabled { continue } + activeCount++ allowed, _, err := c.accessSvc.TestIP(acl.ID, clientIP) if err == nil && !allowed { // Send security notification @@ -206,6 +222,14 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { return } } + if activeCount == 0 { + if isAdmin && !adminWhitelistConfigured { + ctx.Next() + return + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Blocked by access control list"}) + return + } } } @@ -220,8 +244,47 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { logger.Log().WithField("client_ip", ctx.ClientIP()).WithField("path", ctx.Request.URL.Path).Debug("Request evaluated by CrowdSec bouncer at Caddy layer") } - // Rate limiting placeholder (no-op for the moment) - ctx.Next() } } + +func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool { + role, exists := ctx.Get("role") + if !exists { + return false + } + roleStr, ok := role.(string) + if !ok || roleStr != "admin" { + return false + } + userID, exists := ctx.Get("userID") + if !exists { + return false + } + switch id := userID.(type) { + case uint: + return id > 0 + case int: + return id > 0 + case int64: + return id > 0 + default: + return false + } +} + +func (c *Cerberus) adminWhitelistStatus(clientIP string) (bool, bool) { + if c.db == nil { + return false, false + } + + var sc models.SecurityConfig + if err := c.db.Where("name = ?", "default").First(&sc).Error; err != nil { + return false, false + } + if strings.TrimSpace(sc.AdminWhitelist) == "" { + return false, false + } + + return securitypkg.IsIPInCIDRList(clientIP, sc.AdminWhitelist), true +} diff --git a/backend/internal/cerberus/cerberus_middleware_test.go b/backend/internal/cerberus/cerberus_middleware_test.go index 5ac26ece..0ccc3091 100644 --- a/backend/internal/cerberus/cerberus_middleware_test.go +++ b/backend/internal/cerberus/cerberus_middleware_test.go @@ -20,7 +20,7 @@ func setupDB(t *testing.T) *gorm.DB { dsn := fmt.Sprintf("file:cerberus_middleware_test_%d?mode=memory&cache=shared", time.Now().UnixNano()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.AccessList{}, &models.AccessListRule{})) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.AccessList{}, &models.AccessListRule{}, &models.SecurityConfig{})) return db } @@ -97,6 +97,68 @@ func TestMiddleware_ACLAllowsClientIP(t *testing.T) { require.False(t, ctx.IsAborted()) } +func TestMiddleware_ACLDefaultDenyWhenNoLists(t *testing.T) { + db := setupDB(t) + cfg := config.SecurityConfig{ACLMode: "enabled"} + + c := cerberus.New(cfg, db) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req.RemoteAddr = "203.0.113.5:1234" + ctx.Request = req + + mw := c.Middleware() + mw(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) +} + +func TestMiddleware_ACLAdminWhitelistBypass(t *testing.T) { + db := setupDB(t) + cfg := config.SecurityConfig{ACLMode: "enabled"} + + whitelist := "203.0.113.5/32" + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: whitelist}).Error) + + c := cerberus.New(cfg, db) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Set("role", "admin") + ctx.Set("userID", uint(1)) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req.RemoteAddr = "203.0.113.5:1234" + ctx.Request = req + + mw := c.Middleware() + mw(ctx) + + require.False(t, ctx.IsAborted()) +} + +func TestMiddleware_ACLAdminWhitelistBypass_RequiresAuthenticatedAdmin(t *testing.T) { + db := setupDB(t) + cfg := config.SecurityConfig{ACLMode: "enabled"} + + whitelist := "203.0.113.5/32" + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: whitelist}).Error) + + c := cerberus.New(cfg, db) + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req.RemoteAddr = "203.0.113.5:1234" + ctx.Request = req + + mw := c.Middleware() + mw(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) +} + func TestMiddleware_NotEnabledSkips(t *testing.T) { db := setupDB(t) // All modes disabled by default @@ -171,6 +233,8 @@ func TestMiddleware_ACLDisabledDoesNotBlock(t *testing.T) { // Setup gin context with remote address 8.8.8.8 w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) + ctx.Set("role", "admin") + ctx.Set("userID", uint(1)) req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.RemoteAddr = "8.8.8.8:1234" ctx.Request = req diff --git a/backend/internal/cerberus/cerberus_test.go b/backend/internal/cerberus/cerberus_test.go index a9e3dc32..fa681f17 100644 --- a/backend/internal/cerberus/cerberus_test.go +++ b/backend/internal/cerberus/cerberus_test.go @@ -119,6 +119,11 @@ func TestCerberus_Middleware_Disabled(t *testing.T) { cerb := cerberus.New(cfg, db) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) router.Use(cerb.Middleware()) router.GET("/test", func(c *gin.Context) { c.String(http.StatusOK, "OK") @@ -141,6 +146,11 @@ func TestCerberus_Middleware_WAFEnabled(t *testing.T) { cerb := cerberus.New(cfg, db) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) router.Use(cerb.Middleware()) router.GET("/test", func(c *gin.Context) { c.String(http.StatusOK, "OK") @@ -163,6 +173,11 @@ func TestCerberus_Middleware_ACLEnabled_NoAccessLists(t *testing.T) { cerb := cerberus.New(cfg, db) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) router.Use(cerb.Middleware()) router.GET("/test", func(c *gin.Context) { c.String(http.StatusOK, "OK") @@ -194,6 +209,11 @@ func TestCerberus_Middleware_ACLEnabled_DisabledList(t *testing.T) { cerb := cerberus.New(cfg, db) router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) router.Use(cerb.Middleware()) router.GET("/test", func(c *gin.Context) { c.String(http.StatusOK, "OK") diff --git a/backend/internal/database/settings_query_test.go b/backend/internal/database/settings_query_test.go new file mode 100644 index 00000000..e7184556 --- /dev/null +++ b/backend/internal/database/settings_query_test.go @@ -0,0 +1,240 @@ +package database + +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" +) + +// setupTestDB creates an in-memory SQLite database for testing +func setupTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the Setting model + err = db.AutoMigrate(&models.Setting{}) + require.NoError(t, err) + + return db +} + +// TestSettingsQueryWithDifferentIDs verifies that reusing a models.Setting variable +// without resetting it causes GORM to include the previous record's ID in subsequent +// WHERE clauses, resulting in "record not found" errors. +func TestSettingsQueryWithDifferentIDs(t *testing.T) { + db := setupTestDB(t) + + // Create settings with different IDs + setting1 := &models.Setting{Key: "feature.cerberus.enabled", Value: "true"} + err := db.Create(setting1).Error + require.NoError(t, err) + assert.Equal(t, uint(1), setting1.ID) + + setting2 := &models.Setting{Key: "security.acl.enabled", Value: "true"} + err = db.Create(setting2).Error + require.NoError(t, err) + assert.Equal(t, uint(2), setting2.ID) + + // Simulate the bug: reuse variable without reset + var s models.Setting + + // First query - populates s.ID = 1 + err = db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error + require.NoError(t, err) + assert.Equal(t, uint(1), s.ID) + assert.Equal(t, "feature.cerberus.enabled", s.Key) + + // Second query WITHOUT reset - demonstrates the bug + // This would fail with "record not found" if the bug exists + // because it queries: WHERE key='security.acl.enabled' AND id=1 + t.Run("without reset should fail with bug", func(t *testing.T) { + var sNoBugFix models.Setting + // First query + err := db.Where("key = ?", "feature.cerberus.enabled").First(&sNoBugFix).Error + require.NoError(t, err) + + // Second query without reset - this is the bug scenario + err = db.Where("key = ?", "security.acl.enabled").First(&sNoBugFix).Error + // With the bug, this would fail with gorm.ErrRecordNotFound + // After the fix (struct reset in production code), this should succeed + // But in this test, we're demonstrating what WOULD happen without reset + if err == nil { + // Bug is present but not triggered (both records have same ID somehow) + // Or the production code has the fix + t.Logf("Query succeeded - either fix is applied or test setup issue") + } else { + // This is expected without the reset + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + } + }) + + // Third query WITH reset - should always work (this is the fix) + t.Run("with reset should always work", func(t *testing.T) { + var sWithFix models.Setting + // First query + err := db.Where("key = ?", "feature.cerberus.enabled").First(&sWithFix).Error + require.NoError(t, err) + + // Reset the struct (THE FIX) + sWithFix = models.Setting{} + + // Second query with reset - should work + err = db.Where("key = ?", "security.acl.enabled").First(&sWithFix).Error + require.NoError(t, err) + assert.Equal(t, uint(2), sWithFix.ID) + assert.Equal(t, "security.acl.enabled", sWithFix.Key) + }) +} + +// TestCaddyManagerSecuritySettings simulates the real-world scenario from +// manager.go where multiple security settings are queried in sequence. +// This test verifies that non-sequential IDs don't cause query failures. +func TestCaddyManagerSecuritySettings(t *testing.T) { + db := setupTestDB(t) + + // Create all security settings with specific IDs to simulate real database + // where settings are created/deleted/recreated over time + // We need to create them in a transaction and manually set IDs + + // Create with ID gaps to simulate real scenario + settings := []models.Setting{ + {ID: 4, Key: "feature.cerberus.enabled", Value: "true"}, + {ID: 6, Key: "security.acl.enabled", Value: "true"}, + {ID: 8, Key: "security.waf.enabled", Value: "true"}, + } + + for _, setting := range settings { + // Use Session to allow manual ID assignment + err := db.Session(&gorm.Session{FullSaveAssociations: false}).Create(&setting).Error + require.NoError(t, err) + } + + // Simulate the query pattern from manager.go buildSecurityConfig() + var s models.Setting + + // Query 1: Cerberus (ID=4) + cerbEnabled := false + if err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil { + cerbEnabled = s.Value == "true" + } + require.True(t, cerbEnabled) + assert.Equal(t, uint(4), s.ID, "Cerberus query should return ID=4") + + // Query 2: ACL (ID=6) - WITHOUT reset this would fail + // With the fix applied in manager.go, struct should be reset here + s = models.Setting{} // THE FIX + aclEnabled := false + err := db.Where("key = ?", "security.acl.enabled").First(&s).Error + require.NoError(t, err, "ACL query should not fail with 'record not found'") + if err == nil { + aclEnabled = s.Value == "true" + } + require.True(t, aclEnabled) + assert.Equal(t, uint(6), s.ID, "ACL query should return ID=6") + + // Query 3: WAF (ID=8) - should also work with reset + s = models.Setting{} // THE FIX + wafEnabled := false + if err := db.Where("key = ?", "security.waf.enabled").First(&s).Error; err == nil { + wafEnabled = s.Value == "true" + } + require.True(t, wafEnabled) + assert.Equal(t, uint(8), s.ID, "WAF query should return ID=8") +} + +// TestUptimeMonitorSettingsReuse verifies the ticker loop scenario from routes.go +// where the same variable is reused across multiple query iterations. +// This test simulates what happens when a setting is deleted and recreated. +func TestUptimeMonitorSettingsReuse(t *testing.T) { + db := setupTestDB(t) + + setting := &models.Setting{Key: "feature.uptime.enabled", Value: "true"} + err := db.Create(setting).Error + require.NoError(t, err) + firstID := setting.ID + + // First query - simulates initial check before ticker starts + var s models.Setting + err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error + require.NoError(t, err) + assert.Equal(t, firstID, s.ID) + assert.Equal(t, "true", s.Value) + + // Simulate setting being deleted and recreated (e.g., during migration or manual change) + err = db.Delete(setting).Error + require.NoError(t, err) + + newSetting := &models.Setting{Key: "feature.uptime.enabled", Value: "true"} + err = db.Create(newSetting).Error + require.NoError(t, err) + newID := newSetting.ID + assert.NotEqual(t, firstID, newID, "New record should have different ID") + + // Second query WITH reset - simulates ticker loop iteration with fix + s = models.Setting{} // THE FIX + err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error + require.NoError(t, err, "Query should find new record after reset") + assert.Equal(t, newID, s.ID, "Should find new record with new ID") + assert.Equal(t, "true", s.Value) + + // Third iteration - verify reset works across multiple ticks + s = models.Setting{} // THE FIX + err = db.Where("key = ?", "feature.uptime.enabled").First(&s).Error + require.NoError(t, err) + assert.Equal(t, newID, s.ID) +} + +// TestSettingsQueryBugDemonstration explicitly demonstrates the bug scenario +// This test documents the expected behavior BEFORE and AFTER the fix +func TestSettingsQueryBugDemonstration(t *testing.T) { + db := setupTestDB(t) + + // Setup: Create two settings with different IDs + db.Create(&models.Setting{Key: "setting.one", Value: "value1"}) // ID=1 + db.Create(&models.Setting{Key: "setting.two", Value: "value2"}) // ID=2 + + t.Run("bug scenario - no reset", func(t *testing.T) { + var s models.Setting + + // Query 1: Gets setting.one (ID=1) + err := db.Where("key = ?", "setting.one").First(&s).Error + require.NoError(t, err) + assert.Equal(t, uint(1), s.ID) + + // Query 2: Try to get setting.two (ID=2) + // WITHOUT reset, s.ID is still 1, so GORM generates: + // SELECT * FROM settings WHERE key = 'setting.two' AND id = 1 + // This fails because no record matches both conditions + err = db.Where("key = ?", "setting.two").First(&s).Error + + // This assertion documents the bug behavior + if err != nil { + assert.ErrorIs(t, err, gorm.ErrRecordNotFound, + "Bug causes 'record not found' because GORM includes ID=1 in WHERE clause") + } + }) + + t.Run("fixed scenario - with reset", func(t *testing.T) { + var s models.Setting + + // Query 1: Gets setting.one (ID=1) + err := db.Where("key = ?", "setting.one").First(&s).Error + require.NoError(t, err) + assert.Equal(t, uint(1), s.ID) + + // THE FIX: Reset struct before next query + s = models.Setting{} + + // Query 2: Get setting.two (ID=2) + // After reset, GORM generates correct query: + // SELECT * FROM settings WHERE key = 'setting.two' + err = db.Where("key = ?", "setting.two").First(&s).Error + require.NoError(t, err, "With reset, query should succeed") + assert.Equal(t, uint(2), s.ID, "Should find the correct record") + }) +} diff --git a/backend/internal/security/whitelist.go b/backend/internal/security/whitelist.go new file mode 100644 index 00000000..4a26a1f0 --- /dev/null +++ b/backend/internal/security/whitelist.go @@ -0,0 +1,47 @@ +package security + +import ( + "net" + "strings" + + "github.com/Wikid82/charon/backend/internal/util" +) + +// IsIPInCIDRList returns true if clientIP matches any CIDR or IP in the list. +// The list is a comma-separated string of CIDRs and/or IPs. +func IsIPInCIDRList(clientIP, cidrList string) bool { + if strings.TrimSpace(cidrList) == "" { + return false + } + + canonical := util.CanonicalizeIPForSecurity(clientIP) + ip := net.ParseIP(canonical) + if ip == nil { + return false + } + + parts := strings.Split(cidrList, ",") + for _, part := range parts { + entry := strings.TrimSpace(part) + if entry == "" { + continue + } + + if parsed := net.ParseIP(entry); parsed != nil { + if ip.Equal(parsed) { + return true + } + continue + } + + _, cidr, err := net.ParseCIDR(entry) + if err != nil { + continue + } + if cidr.Contains(ip) { + return true + } + } + + return false +} diff --git a/backend/internal/security/whitelist_test.go b/backend/internal/security/whitelist_test.go new file mode 100644 index 00000000..b32a23ab --- /dev/null +++ b/backend/internal/security/whitelist_test.go @@ -0,0 +1,57 @@ +package security + +import "testing" + +func TestIsIPInCIDRList(t *testing.T) { + tests := []struct { + name string + ip string + list string + expected bool + }{ + { + name: "empty list", + ip: "127.0.0.1", + list: "", + expected: false, + }, + { + name: "direct IP match", + ip: "127.0.0.1", + list: "127.0.0.1", + expected: true, + }, + { + name: "cidr match", + ip: "172.16.5.10", + list: "172.16.0.0/12", + expected: true, + }, + { + name: "mixed list with whitespace", + ip: "10.0.0.5", + list: "192.168.0.0/16, 10.0.0.0/8", + expected: true, + }, + { + name: "no match", + ip: "203.0.113.10", + list: "192.168.0.0/16,10.0.0.0/8", + expected: false, + }, + { + name: "invalid client ip", + ip: "not-an-ip", + list: "192.168.0.0/16", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsIPInCIDRList(tt.ip, tt.list); got != tt.expected { + t.Fatalf("expected %v, got %v", tt.expected, got) + } + }) + } +} diff --git a/backend/internal/server/emergency_server.go b/backend/internal/server/emergency_server.go index 8e01ed13..4e6ea9d0 100644 --- a/backend/internal/server/emergency_server.go +++ b/backend/internal/server/emergency_server.go @@ -40,13 +40,22 @@ type EmergencyServer struct { listener net.Listener db *gorm.DB cfg config.EmergencyConfig + cerberus handlers.CacheInvalidator + caddy handlers.CaddyConfigManager } // NewEmergencyServer creates a new emergency server instance func NewEmergencyServer(db *gorm.DB, cfg config.EmergencyConfig) *EmergencyServer { + return NewEmergencyServerWithDeps(db, cfg, nil, nil) +} + +// NewEmergencyServerWithDeps creates a new emergency server instance with optional dependencies. +func NewEmergencyServerWithDeps(db *gorm.DB, cfg config.EmergencyConfig, caddyManager handlers.CaddyConfigManager, cerberus handlers.CacheInvalidator) *EmergencyServer { return &EmergencyServer{ - db: db, - cfg: cfg, + db: db, + cfg: cfg, + caddy: caddyManager, + cerberus: cerberus, } } @@ -110,7 +119,7 @@ func (s *EmergencyServer) Start() error { }) // Emergency endpoints only - emergencyHandler := handlers.NewEmergencyHandler(s.db) + emergencyHandler := handlers.NewEmergencyHandlerWithDeps(s.db, s.caddy, s.cerberus) // GET /health - Health check endpoint (NO AUTH - must be accessible for monitoring) router.GET("/health", func(c *gin.Context) { diff --git a/backend/internal/services/access_list_service.go b/backend/internal/services/access_list_service.go index 4814edfc..36f70e6f 100644 --- a/backend/internal/services/access_list_service.go +++ b/backend/internal/services/access_list_service.go @@ -102,7 +102,7 @@ func (s *AccessListService) Create(acl *models.AccessList) error { // GetByID retrieves an access list by ID func (s *AccessListService) GetByID(id uint) (*models.AccessList, error) { var acl models.AccessList - if err := s.db.First(&acl, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&acl).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrAccessListNotFound } diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index f64599a9..3e6022fe 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -110,7 +110,7 @@ func (s *AuthService) GenerateToken(user *models.User) (string, error) { func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error { var user models.User - if err := s.db.First(&user, userID).Error; err != nil { + if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { return errors.New("user not found") } @@ -144,7 +144,7 @@ func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) { func (s *AuthService) GetUserByID(id uint) (*models.User, error) { var user models.User - if err := s.db.First(&user, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&user).Error; err != nil { return nil, err } return &user, nil diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index a6ea7d40..a72b1169 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -409,7 +409,7 @@ func (s *CertificateService) DeleteCertificate(id uint) error { } var cert models.SSLCertificate - if err := s.db.First(&cert, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil { return err } diff --git a/backend/internal/services/credential_service.go b/backend/internal/services/credential_service.go index f7f0f0a2..2cdb9b03 100644 --- a/backend/internal/services/credential_service.go +++ b/backend/internal/services/credential_service.go @@ -84,7 +84,7 @@ func NewCredentialService(db *gorm.DB, encryptor *crypto.EncryptionService) Cred func (s *credentialService) List(ctx context.Context, providerID uint) ([]models.DNSProviderCredential, error) { // Verify provider exists and has multi-credential enabled var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrDNSProviderNotFound } @@ -125,7 +125,7 @@ func (s *credentialService) Get(ctx context.Context, providerID, credentialID ui func (s *credentialService) Create(ctx context.Context, providerID uint, req CreateCredentialRequest) (*models.DNSProviderCredential, error) { // Verify provider exists and has multi-credential enabled var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrDNSProviderNotFound } @@ -230,7 +230,7 @@ func (s *credentialService) Update(ctx context.Context, providerID, credentialID // Fetch provider for validation and audit logging var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { return nil, err } @@ -347,7 +347,7 @@ func (s *credentialService) Delete(ctx context.Context, providerID, credentialID } var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { return err } @@ -389,7 +389,7 @@ func (s *credentialService) Test(ctx context.Context, providerID, credentialID u } var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { return nil, err } @@ -465,7 +465,7 @@ func (s *credentialService) Test(ctx context.Context, providerID, credentialID u func (s *credentialService) GetCredentialForDomain(ctx context.Context, providerID uint, domain string) (*models.DNSProviderCredential, error) { // Verify provider exists var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrDNSProviderNotFound } @@ -561,7 +561,7 @@ func matchesDomain(zoneFilter, domain string, exactOnly bool) bool { func (s *credentialService) EnableMultiCredentials(ctx context.Context, providerID uint) error { // Fetch provider var provider models.DNSProvider - if err := s.db.WithContext(ctx).First(&provider, providerID).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id = ?", providerID).First(&provider).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrDNSProviderNotFound } diff --git a/backend/internal/services/credential_service_test.go b/backend/internal/services/credential_service_test.go index 01f7b770..d5530a03 100644 --- a/backend/internal/services/credential_service_test.go +++ b/backend/internal/services/credential_service_test.go @@ -423,7 +423,7 @@ func TestCredentialService_EnableMultiCredentials(t *testing.T) { // Verify provider is now in multi-credential mode var updatedProvider models.DNSProvider - err = db.First(&updatedProvider, provider.ID).Error + err = db.Where("id = ?", provider.ID).First(&updatedProvider).Error require.NoError(t, err) assert.True(t, updatedProvider.UseMultiCredentials) diff --git a/backend/internal/services/dns_provider_service.go b/backend/internal/services/dns_provider_service.go index 6e057c51..85912c98 100644 --- a/backend/internal/services/dns_provider_service.go +++ b/backend/internal/services/dns_provider_service.go @@ -115,7 +115,7 @@ func (s *dnsProviderService) List(ctx context.Context) ([]models.DNSProvider, er // Get retrieves a DNS provider by ID. func (s *dnsProviderService) Get(ctx context.Context, id uint) (*models.DNSProvider, error) { var provider models.DNSProvider - err := s.db.WithContext(ctx).First(&provider, id).Error + err := s.db.WithContext(ctx).Where("id = ?", id).First(&provider).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrDNSProviderNotFound diff --git a/backend/internal/services/dns_provider_service_test.go b/backend/internal/services/dns_provider_service_test.go index d374226f..171b8ebb 100644 --- a/backend/internal/services/dns_provider_service_test.go +++ b/backend/internal/services/dns_provider_service_test.go @@ -547,7 +547,7 @@ func TestCredentialEncryptionRoundtrip(t *testing.T) { // Verify credentials are encrypted in database var dbProvider models.DNSProvider - err = db.First(&dbProvider, provider.ID).Error + err = db.Where("id = ?", provider.ID).First(&dbProvider).Error require.NoError(t, err) assert.NotContains(t, dbProvider.CredentialsEncrypted, "super-secret-token") assert.NotContains(t, dbProvider.CredentialsEncrypted, "another-secret") @@ -614,7 +614,7 @@ func TestEncryptionServiceIntegration(t *testing.T) { // Retrieve and decrypt var retrieved models.DNSProvider - err = db.First(&retrieved, provider.ID).Error + err = db.Where("id = ?", provider.ID).First(&retrieved).Error require.NoError(t, err) decrypted, err := encryptor.Decrypt(retrieved.CredentialsEncrypted) @@ -1378,11 +1378,11 @@ func TestDNSProviderService_Test_FailureUpdatesStatistics(t *testing.T) { } require.NoError(t, db.Create(provider).Error) - // Test the provider - should fail during decryption due to mismatched credentials + // Test the provider - should fail during validation due to invalid credentials result, err := service.Test(ctx, provider.ID) require.NoError(t, err) assert.False(t, result.Success) - assert.Equal(t, "DECRYPTION_ERROR", result.Code) + assert.Equal(t, "VALIDATION_ERROR", result.Code) // Verify failure statistics updated afterTest, err := service.Get(ctx, provider.ID) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 43eb1d09..d5ee5191 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -455,7 +455,7 @@ func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, er // GetTemplate returns a single notification template by its ID. func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) { var t models.NotificationTemplate - if err := s.DB.First(&t, "id = ?", id).Error; err != nil { + if err := s.DB.Where("id = ?", id).First(&t).Error; err != nil { return nil, err } return &t, nil diff --git a/backend/internal/services/proxyhost_service.go b/backend/internal/services/proxyhost_service.go index 265a21b9..5130dd38 100644 --- a/backend/internal/services/proxyhost_service.go +++ b/backend/internal/services/proxyhost_service.go @@ -105,7 +105,7 @@ func (s *ProxyHostService) Delete(id uint) error { // GetByID retrieves a proxy host by ID. func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) { var host models.ProxyHost - if err := s.db.First(&host, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&host).Error; err != nil { return nil, err } return &host, nil diff --git a/backend/internal/services/remoteserver_service.go b/backend/internal/services/remoteserver_service.go index 1211c290..8d344b51 100644 --- a/backend/internal/services/remoteserver_service.go +++ b/backend/internal/services/remoteserver_service.go @@ -65,7 +65,7 @@ func (s *RemoteServerService) Delete(id uint) error { // GetByID retrieves a remote server by ID. func (s *RemoteServerService) GetByID(id uint) (*models.RemoteServer, error) { var server models.RemoteServer - if err := s.db.First(&server, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&server).Error; err != nil { return nil, err } return &server, nil diff --git a/backend/internal/services/security_headers_service_test.go b/backend/internal/services/security_headers_service_test.go index bacee84e..fe558bba 100644 --- a/backend/internal/services/security_headers_service_test.go +++ b/backend/internal/services/security_headers_service_test.go @@ -181,7 +181,7 @@ func TestApplyPreset_Success(t *testing.T) { // Verify it was saved var saved models.SecurityHeaderProfile - err = db.First(&saved, profile.ID).Error + err = db.Where("id = ?", profile.ID).First(&saved).Error assert.NoError(t, err) assert.Equal(t, profile.Name, saved.Name) } diff --git a/backend/internal/services/security_service.go b/backend/internal/services/security_service.go index af16af9d..2ee84516 100644 --- a/backend/internal/services/security_service.go +++ b/backend/internal/services/security_service.go @@ -396,7 +396,7 @@ func (s *SecurityService) UpsertRuleSet(r *models.SecurityRuleSet) error { // DeleteRuleSet removes a ruleset by id func (s *SecurityService) DeleteRuleSet(id uint) error { var rs models.SecurityRuleSet - if err := s.db.First(&rs, id).Error; err != nil { + if err := s.db.Where("id = ?", id).First(&rs).Error; err != nil { return err } return s.db.Delete(&rs).Error diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index 64625818..f74c605b 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -350,7 +350,7 @@ func (s *UptimeService) CheckAll() { // If host is down, mark all monitors as down without individual checks if hostID != "" { var uptimeHost models.UptimeHost - if err := s.DB.First(&uptimeHost, "id = ?", hostID).Error; err == nil { + if err := s.DB.Where("id = ?", hostID).First(&uptimeHost).Error; err == nil { if uptimeHost.Status == "down" { s.markHostMonitorsDown(monitors, &uptimeHost) continue @@ -842,7 +842,7 @@ func (s *UptimeService) queueDownNotification(monitor models.UptimeMonitor, reas var uptimeHost models.UptimeHost hostName := monitor.UpstreamHost if hostID != "" { - if err := s.DB.First(&uptimeHost, "id = ?", hostID).Error; err == nil { + if err := s.DB.Where("id = ?", hostID).First(&uptimeHost).Error; err == nil { hostName = uptimeHost.Name } } @@ -996,7 +996,7 @@ func (s *UptimeService) FlushPendingNotifications() { // Returns nil if no monitor exists for the host (does not create one). func (s *UptimeService) SyncMonitorForHost(hostID uint) error { var host models.ProxyHost - if err := s.DB.First(&host, hostID).Error; err != nil { + if err := s.DB.Where("id = ?", hostID).First(&host).Error; err != nil { return err } @@ -1098,7 +1098,7 @@ func (s *UptimeService) CreateMonitor(name, urlStr, monitorType string, interval func (s *UptimeService) GetMonitorByID(id string) (*models.UptimeMonitor, error) { var monitor models.UptimeMonitor - if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { + if err := s.DB.Where("id = ?", id).First(&monitor).Error; err != nil { return nil, err } return &monitor, nil @@ -1112,7 +1112,7 @@ func (s *UptimeService) GetMonitorHistory(id string, limit int) ([]models.Uptime func (s *UptimeService) UpdateMonitor(id string, updates map[string]any) (*models.UptimeMonitor, error) { var monitor models.UptimeMonitor - if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { + if err := s.DB.Where("id = ?", id).First(&monitor).Error; err != nil { return nil, err } @@ -1140,7 +1140,7 @@ func (s *UptimeService) UpdateMonitor(id string, updates map[string]any) (*model func (s *UptimeService) DeleteMonitor(id string) error { // Find monitor var monitor models.UptimeMonitor - if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { + if err := s.DB.Where("id = ?", id).First(&monitor).Error; err != nil { return err } diff --git a/backend/internal/services/uptime_service_race_test.go b/backend/internal/services/uptime_service_race_test.go index 678fdbbe..b45eeafb 100644 --- a/backend/internal/services/uptime_service_race_test.go +++ b/backend/internal/services/uptime_service_race_test.go @@ -97,13 +97,13 @@ func TestCheckHost_Debouncing(t *testing.T) { // First failure - should NOT mark as down svc.checkHost(ctx, &host) - db.First(&host, host.ID) + db.Where("id = ?", host.ID).First(&host) assert.Equal(t, "up", host.Status, "Host should remain up after first failure") assert.Equal(t, 1, host.FailureCount, "Failure count should be 1") // Second failure - should mark as down svc.checkHost(ctx, &host) - db.First(&host, host.ID) + db.Where("id = ?", host.ID).First(&host) assert.Equal(t, "down", host.Status, "Host should be down after second failure") assert.Equal(t, 2, host.FailureCount, "Failure count should be 2") } @@ -149,7 +149,7 @@ func TestCheckHost_FailureCountReset(t *testing.T) { svc.checkHost(ctx, &host) // Verify failure count is reset on success - db.First(&host, host.ID) + db.Where("id = ?", host.ID).First(&host) assert.Equal(t, "up", host.Status, "Host should be up") assert.Equal(t, 0, host.FailureCount, "Failure count should be reset to 0 on success") } @@ -252,7 +252,7 @@ func TestCheckHost_ConcurrentChecks(t *testing.T) { // Verify no race conditions or deadlocks var updatedHost models.UptimeHost - db.First(&updatedHost, "id = ?", host.ID) + db.Where("id = ?", host.ID).First(&updatedHost) assert.Equal(t, "up", updatedHost.Status, "Host should be up") assert.NotZero(t, updatedHost.LastCheck, "LastCheck should be set") } @@ -395,7 +395,7 @@ func TestCheckHost_HostMutexPreventsRaceCondition(t *testing.T) { // Verify database consistency (no corruption from race conditions) var updatedHost models.UptimeHost - db.First(&updatedHost, "id = ?", host.ID) + db.Where("id = ?", host.ID).First(&updatedHost) assert.NotEmpty(t, updatedHost.Status, "Host status should be set") assert.Equal(t, "up", updatedHost.Status, "Host should be up") assert.GreaterOrEqual(t, updatedHost.Latency, int64(0), "Latency should be non-negative") diff --git a/tests/security-enforcement/emergency-token.spec.ts b/tests/security-enforcement/emergency-token.spec.ts index 42277439..29c1041a 100644 --- a/tests/security-enforcement/emergency-token.spec.ts +++ b/tests/security-enforcement/emergency-token.spec.ts @@ -8,7 +8,7 @@ * Reference: docs/plans/break_glass_protocol_redesign.md */ -import { test, expect } from '@playwright/test'; +import { test, expect, request as playwrightRequest } from '@playwright/test'; import { EMERGENCY_TOKEN } from '../fixtures/security'; test.describe('Emergency Token Break Glass Protocol', () => { @@ -46,7 +46,11 @@ test.describe('Emergency Token Break Glass Protocol', () => { console.log('🧪 Testing emergency token bypass with ACL enabled...'); // Step 1: Verify ACL is blocking regular requests (403) - const blockedResponse = await request.get('/api/v1/security/status'); + const unauthenticatedRequest = await playwrightRequest.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080', + }); + const blockedResponse = await unauthenticatedRequest.get('/api/v1/security/status'); + await unauthenticatedRequest.dispose(); expect(blockedResponse.status()).toBe(403); const blockedBody = await blockedResponse.json(); expect(blockedBody.error).toContain('Blocked by access control');