diff --git a/.github/workflows/production-smokes.yml b/.github/workflows/production-smokes.yml index d7dd212c..477185b8 100644 --- a/.github/workflows/production-smokes.yml +++ b/.github/workflows/production-smokes.yml @@ -40,6 +40,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Production appliance, backup and restore + run: bun --no-orphans scripts/production-smoke-ci.ts production + - name: Proxy runtime run: bun --no-orphans scripts/production-smoke-ci.ts proxy @@ -49,9 +52,6 @@ jobs: - name: Upstream TLS run: bun --no-orphans scripts/production-smoke-ci.ts upstream-tls - - name: Production appliance, backup and restore - run: bun --no-orphans scripts/production-smoke-ci.ts production - - name: Clean up this run and summarize if: always() && steps.bun.outcome == 'success' run: bun scripts/production-smoke-ci.ts cleanup diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f5eeb1e4..8477f02d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,6 +19,9 @@ flowchart TD C --> D D --> X[Managed upstreams] C --> A[ACME and DNS providers] + C --> S[Managed CrowdSec engine] + D <--> S + D <--> E[External CrowdSec Local API] ``` The production image and its pinned base components are assembled in @@ -88,6 +91,28 @@ It sends a database-derived snapshot to the controller, requires the returned active revision to match, then checks the latest desired revision. A periodic drift check queues another apply when the controller does not match the desired snapshot. +### CrowdSec control and enforcement + +PostgreSQL stores the desired CrowdSec mode and an encrypted external bouncer credential. The +credential is write-only at the browser boundary. A dedicated reconciliation worker in +[`web/src/server/Admin/CrowdSec/`](./web/src/server/Admin/CrowdSec/) restores that desired state +after controller or web-process restarts. The controller validates a target before replacing the +active provider, retains the prior provider on failure, and renders only a Caddy environment +placeholder for the secret. + +Managed mode starts the pinned CrowdSec engine through +[`docker/crowdsec/runtime/supervisor.sh`](./docker/crowdsec/runtime/supervisor.sh). It runs as UID +10003, reads the shared Caddy access log through a dedicated group-readable, setgid log +directory, and stores its database and credentials under +`/var/lib/rentnerproxy/crowdsec`, exposes its Local API only at `127.0.0.1:18080`, and exposes +acquisition metrics only on loopback `127.0.0.1:6060`. Each new +managed activation rotates the internal bouncer key before Caddy starts using it; a supervised +engine restart during an active session keeps that key. External mode does not proxy or redirect +the configured endpoint. Both modes use the same Caddy HTTP +bouncer and Caddy's native effective client address. The ACME HTTP-01 route precedes the bouncer; +other public HTTP and HTTPS handlers follow it. The bouncer streams decisions with hard failures +disabled, so a Local API outage is reported as degraded while proxy traffic remains available. + ## Certificate and trust boundaries PostgreSQL is the web application's durable record of certificate metadata, @@ -145,8 +170,8 @@ replaced, restored, or its replay window advanced. ## Persistence and appliance recovery The Compose `rentnerproxy` volume is mounted at `/var/lib/rentnerproxy`. It holds -the PostgreSQL cluster, controller/Caddy state, certificate material, access-log -files, and bootstrap runtime secret state. The entrypoint creates private directory +the PostgreSQL cluster, controller/Caddy state, certificate material, managed CrowdSec +state, access-log files, and bootstrap runtime secret state. The entrypoint creates private directory ownership and generates or restores validated runtime secrets in [`docker/web/bootstrap-secrets.mjs`](./docker/web/bootstrap-secrets.mjs). The application encryption key is used by the web service and copied to the controller @@ -162,6 +187,9 @@ trusted proxy CIDRs, port mappings, SMTP settings, and Compose project/file, mus be retained by the operator alongside the backup. Restore and rollback are separate operations in [`scripts/production-restore.ts`](./scripts/production-restore.ts); the documented upgrade and recovery constraints are in [`README.md`](./README.md). +The v3 controller archive currently covers the proxy state directory, not the sibling managed +CrowdSec directory; [issue #71](https://github.com/RentnerKev/RentnerProxy/issues/71) owns that +backup/restore expansion. ## Change and verification boundaries diff --git a/ASSURANCE_CASE.md b/ASSURANCE_CASE.md index efeef6f3..ed4cac9b 100644 --- a/ASSURANCE_CASE.md +++ b/ASSURANCE_CASE.md @@ -12,16 +12,16 @@ reproducible-build claim. The system and reporting process remain subject to The bounded claim is: under the assumptions below, the linked implementation is designed to enforce authentication and authorization at management/control entry points, validate and constrain untrusted data before it reaches PostgreSQL or -Caddy, fail closed when trust or durable state is unavailable, and preserve the -durable state needed for documented recovery. This claim is limited to the linked -code paths and tested configurations. It makes no claim of absolute security or -availability. +Caddy, fail closed when trust or durable state is unavailable except for explicitly +documented availability-first controls, and preserve the durable state needed for +documented recovery. This claim is limited to the linked code paths and tested +configurations. It makes no claim of absolute security or availability. ## Scope and assumptions The case covers the production appliance path: the Bun/TanStack web service, -PostgreSQL, Redis, the Rust controller, Caddy, the persistent appliance volume, -and the repository's backup/restore scripts. It assumes the operator protects the +PostgreSQL, Redis, the Rust controller, Caddy, the optional managed CrowdSec engine, +the persistent appliance volume, and the repository's backup/restore scripts. It assumes the operator protects the host, Docker socket, volume, application encryption key, controller token, SMTP credentials, deployment environment, and backup directory. ACME/DNS providers, upstreams, SMTP, the host kernel, Docker, and base images are external dependencies @@ -47,9 +47,10 @@ and are not proved correct here. configuration and certificate material; a non-loopback controller requires a token at startup. 5. **Controller to Caddy and providers.** The controller launches Caddy, talks to - its private Unix admin/probe sockets, and verifies the active revision. ACME, - DNS, SMTP, and upstream services remain external dependencies and can fail or - return hostile data. + its private Unix admin/probe sockets, and verifies the active revision. The + managed CrowdSec Local API is loopback-only; configured external CrowdSec APIs, + ACME, DNS, SMTP, and upstream services remain external dependencies and can fail + or return hostile data. ## Authentication, validation, and transport protections @@ -91,17 +92,18 @@ listener do not themselves provide transport confidentiality. Use the deployment ## Goals and claims -| ID | Claim | Main evidence | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| G1 | A desired proxy revision is applied only after controller validation, Caddy acceptance, and a matching runtime probe. | [`controller/src/runtime/apply.rs`](./controller/src/runtime/apply.rs), [`controller/src/runtime/engine.rs`](./controller/src/runtime/engine.rs), [`controller/src/runtime/configuration.rs`](./controller/src/runtime/configuration.rs), [`web/src/server/ProxyRuntime/proxy-reconcile.ts`](./web/src/server/ProxyRuntime/proxy-reconcile.ts), [`controller/src/tests/runtime.rs`](./controller/src/tests/runtime.rs), [`web/src/tests/proxy-runtime.test.ts`](./web/src/tests/proxy-runtime.test.ts) | -| G2 | Management and controller operations have explicit authentication, authorization, origin, input, and forwarding-trust checks. | [`controller/src/server/auth.rs`](./controller/src/server/auth.rs), [`controller/src/config.rs`](./controller/src/config.rs), [`web/src/server/Auth/Access/authorization.service.ts`](./web/src/server/Auth/Access/authorization.service.ts), [`web/src/server/env.server.ts`](./web/src/server/env.server.ts), [`web/src/websockets/Server/realtimeWebSocket.ts`](./web/src/websockets/Server/realtimeWebSocket.ts), [`controller/src/tests/config.rs`](./controller/src/tests/config.rs), [`controller/src/tests/proxy_validation.rs`](./controller/src/tests/proxy_validation.rs), [`web/src/tests/env.server.test.ts`](./web/src/tests/env.server.test.ts), [`web/src/tests/runtime-peer-ip.test.ts`](./web/src/tests/runtime-peer-ip.test.ts) | -| G3 | Certificate private material and activation are controller-owned, validated, staged, and recoverable without silently replacing a valid active certificate. | [`controller/src/runtime/certificates/material_files.rs`](./controller/src/runtime/certificates/material_files.rs), [`controller/src/runtime/certificates/staging.rs`](./controller/src/runtime/certificates/staging.rs), [`controller/src/runtime/certificates/activation.rs`](./controller/src/runtime/certificates/activation.rs), [`controller/src/runtime/certificates/recovery.rs`](./controller/src/runtime/certificates/recovery.rs), [`controller/src/tests/certificates.rs`](./controller/src/tests/certificates.rs), [`controller/src/tests/certificate_operations.rs`](./controller/src/tests/certificate_operations.rs), [`scripts/certificate-state-restore-smoke.ts`](./scripts/certificate-state-restore-smoke.ts) | -| G4 | Web certificate jobs bind only current, authorized host state and retain idempotent, encrypted work data across worker restarts. | [`web/src/db/Schema/certificateJobs.ts`](./web/src/db/Schema/certificateJobs.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.creation.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.creation.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.worker.server.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.worker.server.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.binding.server.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.binding.server.ts), [`web/src/tests/certificate-jobs-worker-postgresql.integration.test.ts`](./web/src/tests/certificate-jobs-worker-postgresql.integration.test.ts), [`web/src/tests/certificate-jobs-creation-postgresql.integration.test.ts`](./web/src/tests/certificate-jobs-creation-postgresql.integration.test.ts) | -| G5 | Certificate operation events are cursorable and deduplicated into PostgreSQL receipts and audit rows; ephemeral live notifications do not serve as durable state. | [`controller/src/runtime/certificates/operations.rs`](./controller/src/runtime/certificates/operations.rs), [`web/src/server/Admin/CertificateManagement/certificate-events.worker.ts`](./web/src/server/Admin/CertificateManagement/certificate-events.worker.ts), [`web/src/db/Schema/certificates.ts`](./web/src/db/Schema/certificates.ts), [`web/src/server/Audit/audit.service.ts`](./web/src/server/Audit/audit.service.ts), [`web/src/tests/certificate-events.test.ts`](./web/src/tests/certificate-events.test.ts), [`web/src/tests/certificate-events-postgresql.integration.test.ts`](./web/src/tests/certificate-events-postgresql.integration.test.ts), [`web/src/tests/audit-events-postgresql.integration.test.ts`](./web/src/tests/audit-events-postgresql.integration.test.ts) | -| G6 | The appliance's documented backup contains the durable database, controller state, and application key, while transient Redis/cache/log state is identified as excluded. | [`scripts/production-backup.ts`](./scripts/production-backup.ts), [`scripts/controller-state-archive.ts`](./scripts/controller-state-archive.ts), [`scripts/production-restore.ts`](./scripts/production-restore.ts), [`scripts/restore-rollback-smoke.ts`](./scripts/restore-rollback-smoke.ts), [`scripts/appliance-compose-smoke.ts`](./scripts/appliance-compose-smoke.ts), [`README.md`](./README.md) | -| G7 | State and external input have bounded formats and failure handling, including database migration locking, secret validation, proxy model validation, and controller request limits. | [`web/src/db/migrate.ts`](./web/src/db/migrate.ts), [`docker/web/bootstrap-secrets.mjs`](./docker/web/bootstrap-secrets.mjs), [`controller/src/server/mod.rs`](./controller/src/server/mod.rs), [`controller/src/models.rs`](./controller/src/models.rs), [`controller/src/proxy/mod.rs`](./controller/src/proxy/mod.rs), [`controller/src/tests/config.rs`](./controller/src/tests/config.rs), [`controller/src/tests/proxy_validation.rs`](./controller/src/tests/proxy_validation.rs), [`web/src/tests/env.server.test.ts`](./web/src/tests/env.server.test.ts) | -| G8 | Web security controls protect sessions, secret values, rendered HTML, and HTTPS upstream identity when verification is enabled. | [`web/src/server/Auth/Access/cookies.server.ts`](./web/src/server/Auth/Access/cookies.server.ts), [`web/src/server/Auth/Core/encryption.server.ts`](./web/src/server/Auth/Core/encryption.server.ts), [`web/src/server/security-headers.ts`](./web/src/server/security-headers.ts), [`controller/src/runtime/renderer/proxy.rs`](./controller/src/runtime/renderer/proxy.rs), [`web/src/tests/auth-security-encryption.test.ts`](./web/src/tests/auth-security-encryption.test.ts), [`web/src/tests/security-headers.test.ts`](./web/src/tests/security-headers.test.ts), [`web/src/tests/mail-templates.test.ts`](./web/src/tests/mail-templates.test.ts), [`scripts/upstream-tls-smoke.ts`](./scripts/upstream-tls-smoke.ts) | -| G9 | Repository automation runs formatting, lint, type, migration, web, fuzz, Rust, CodeQL, secret-scanning, and dependency-review jobs. | [`.github/workflows/ci.yml`](./.github/workflows/ci.yml), [`.github/workflows/codeql.yml`](./.github/workflows/codeql.yml), [`.github/workflows/gitleaks.yml`](./.github/workflows/gitleaks.yml), [`.github/workflows/dependency-review.yml`](./.github/workflows/dependency-review.yml) | +| ID | Claim | Main evidence | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| G1 | A desired proxy revision is applied only after controller validation, Caddy acceptance, and a matching runtime probe. | [`controller/src/runtime/apply.rs`](./controller/src/runtime/apply.rs), [`controller/src/runtime/engine.rs`](./controller/src/runtime/engine.rs), [`controller/src/runtime/configuration.rs`](./controller/src/runtime/configuration.rs), [`web/src/server/ProxyRuntime/proxy-reconcile.ts`](./web/src/server/ProxyRuntime/proxy-reconcile.ts), [`controller/src/tests/runtime.rs`](./controller/src/tests/runtime.rs), [`web/src/tests/proxy-runtime.test.ts`](./web/src/tests/proxy-runtime.test.ts) | +| G2 | Management and controller operations have explicit authentication, authorization, origin, input, and forwarding-trust checks. | [`controller/src/server/auth.rs`](./controller/src/server/auth.rs), [`controller/src/config.rs`](./controller/src/config.rs), [`web/src/server/Auth/Access/authorization.service.ts`](./web/src/server/Auth/Access/authorization.service.ts), [`web/src/server/env.server.ts`](./web/src/server/env.server.ts), [`web/src/websockets/Server/realtimeWebSocket.ts`](./web/src/websockets/Server/realtimeWebSocket.ts), [`controller/src/tests/config.rs`](./controller/src/tests/config.rs), [`controller/src/tests/proxy_validation.rs`](./controller/src/tests/proxy_validation.rs), [`web/src/tests/env.server.test.ts`](./web/src/tests/env.server.test.ts), [`web/src/tests/runtime-peer-ip.test.ts`](./web/src/tests/runtime-peer-ip.test.ts) | +| G3 | Certificate private material and activation are controller-owned, validated, staged, and recoverable without silently replacing a valid active certificate. | [`controller/src/runtime/certificates/material_files.rs`](./controller/src/runtime/certificates/material_files.rs), [`controller/src/runtime/certificates/staging.rs`](./controller/src/runtime/certificates/staging.rs), [`controller/src/runtime/certificates/activation.rs`](./controller/src/runtime/certificates/activation.rs), [`controller/src/runtime/certificates/recovery.rs`](./controller/src/runtime/certificates/recovery.rs), [`controller/src/tests/certificates.rs`](./controller/src/tests/certificates.rs), [`controller/src/tests/certificate_operations.rs`](./controller/src/tests/certificate_operations.rs), [`scripts/certificate-state-restore-smoke.ts`](./scripts/certificate-state-restore-smoke.ts) | +| G4 | Web certificate jobs bind only current, authorized host state and retain idempotent, encrypted work data across worker restarts. | [`web/src/db/Schema/certificateJobs.ts`](./web/src/db/Schema/certificateJobs.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.creation.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.creation.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.worker.server.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.worker.server.ts), [`web/src/server/Admin/ProxyHostManagement/certificate-jobs.binding.server.ts`](./web/src/server/Admin/ProxyHostManagement/certificate-jobs.binding.server.ts), [`web/src/tests/certificate-jobs-worker-postgresql.integration.test.ts`](./web/src/tests/certificate-jobs-worker-postgresql.integration.test.ts), [`web/src/tests/certificate-jobs-creation-postgresql.integration.test.ts`](./web/src/tests/certificate-jobs-creation-postgresql.integration.test.ts) | +| G5 | Certificate operation events are cursorable and deduplicated into PostgreSQL receipts and audit rows; ephemeral live notifications do not serve as durable state. | [`controller/src/runtime/certificates/operations.rs`](./controller/src/runtime/certificates/operations.rs), [`web/src/server/Admin/CertificateManagement/certificate-events.worker.ts`](./web/src/server/Admin/CertificateManagement/certificate-events.worker.ts), [`web/src/db/Schema/certificates.ts`](./web/src/db/Schema/certificates.ts), [`web/src/server/Audit/audit.service.ts`](./web/src/server/Audit/audit.service.ts), [`web/src/tests/certificate-events.test.ts`](./web/src/tests/certificate-events.test.ts), [`web/src/tests/certificate-events-postgresql.integration.test.ts`](./web/src/tests/certificate-events-postgresql.integration.test.ts), [`web/src/tests/audit-events-postgresql.integration.test.ts`](./web/src/tests/audit-events-postgresql.integration.test.ts) | +| G6 | The appliance's documented backup contains the durable database, controller state, and application key, while transient Redis/cache/log state is identified as excluded. | [`scripts/production-backup.ts`](./scripts/production-backup.ts), [`scripts/controller-state-archive.ts`](./scripts/controller-state-archive.ts), [`scripts/production-restore.ts`](./scripts/production-restore.ts), [`scripts/restore-rollback-smoke.ts`](./scripts/restore-rollback-smoke.ts), [`tests/production/appliance-compose-smoke.ts`](./tests/production/appliance-compose-smoke.ts), [`README.md`](./README.md) | +| G7 | State and external input have bounded formats and failure handling, including database migration locking, secret validation, proxy model validation, and controller request limits. | [`web/src/db/migrate.ts`](./web/src/db/migrate.ts), [`docker/web/bootstrap-secrets.mjs`](./docker/web/bootstrap-secrets.mjs), [`controller/src/server/mod.rs`](./controller/src/server/mod.rs), [`controller/src/models.rs`](./controller/src/models.rs), [`controller/src/proxy/mod.rs`](./controller/src/proxy/mod.rs), [`controller/src/tests/config.rs`](./controller/src/tests/config.rs), [`controller/src/tests/proxy_validation.rs`](./controller/src/tests/proxy_validation.rs), [`web/src/tests/env.server.test.ts`](./web/src/tests/env.server.test.ts) | +| G8 | Web security controls protect sessions, secret values, rendered HTML, and HTTPS upstream identity when verification is enabled. | [`web/src/server/Auth/Access/cookies.server.ts`](./web/src/server/Auth/Access/cookies.server.ts), [`web/src/server/Auth/Core/encryption.server.ts`](./web/src/server/Auth/Core/encryption.server.ts), [`web/src/server/security-headers.ts`](./web/src/server/security-headers.ts), [`controller/src/runtime/renderer/proxy.rs`](./controller/src/runtime/renderer/proxy.rs), [`web/src/tests/auth-security-encryption.test.ts`](./web/src/tests/auth-security-encryption.test.ts), [`web/src/tests/security-headers.test.ts`](./web/src/tests/security-headers.test.ts), [`web/src/tests/mail-templates.test.ts`](./web/src/tests/mail-templates.test.ts), [`scripts/upstream-tls-smoke.ts`](./scripts/upstream-tls-smoke.ts) | +| G9 | Repository automation runs formatting, lint, type, migration, web, fuzz, Rust, CodeQL, secret-scanning, and dependency-review jobs. | [`.github/workflows/ci.yml`](./.github/workflows/ci.yml), [`.github/workflows/codeql.yml`](./.github/workflows/codeql.yml), [`.github/workflows/gitleaks.yml`](./.github/workflows/gitleaks.yml), [`.github/workflows/dependency-review.yml`](./.github/workflows/dependency-review.yml) | +| G10 | CrowdSec is disabled by default; enabled modes share Caddy client-IP semantics, validate a replacement before switching, keep credentials server-side, and fail open during temporary Local API loss. | [`controller/src/runtime/crowdsec.rs`](./controller/src/runtime/crowdsec.rs), [`controller/src/runtime/renderer/config.rs`](./controller/src/runtime/renderer/config.rs), [`web/src/server/Admin/CrowdSec/crowdsec.service.ts`](./web/src/server/Admin/CrowdSec/crowdsec.service.ts), [`web/src/server/Admin/CrowdSec/crowdsec-settings.ts`](./web/src/server/Admin/CrowdSec/crowdsec-settings.ts), [`controller/src/tests/runtime.rs`](./controller/src/tests/runtime.rs), [`controller/src/tests/renderer.rs`](./controller/src/tests/renderer.rs), [`web/src/tests/crowdsec-settings.test.ts`](./web/src/tests/crowdsec-settings.test.ts), [`tests/production/proxy-smoke.ts`](./tests/production/proxy-smoke.ts), [`tests/production/appliance-compose-smoke.ts`](./tests/production/appliance-compose-smoke.ts) | The [Bun audit workflow](.github/workflows/bun-audit.yml) checks JavaScript dependencies, and [Production Smokes](.github/workflows/production-smokes.yml) exercises selected appliance and @@ -176,6 +178,17 @@ hostile load but do not provide availability against a hostile host, exhausted disk, PostgreSQL failure, Redis failure, DDoS, ACME/DNS outage, SMTP outage, or upstream failure. +### T8: CrowdSec is bypassed, leaks a credential, or causes a proxy outage + +Disabled mode omits the Caddy handler. Enabled modes use Caddy's effective client address after +strict trusted-proxy processing, so untrusted forwarding headers do not select the evaluated +identity. External credentials are context-encrypted in PostgreSQL and never returned to the +browser; the managed bouncer key is a narrowly permissioned file. Provider transitions validate +the target before Caddy replacement, while streamed enforcement deliberately uses hard-fail-off +semantics so a temporary Local API outage does not halt public traffic. This supports G10. It +also means an outage can create a temporary detection/enforcement gap, and cached decisions are +only as current as the last successful stream. + ## Residual risks and limits - Redis is intentionally ephemeral and is not part of the backup contract. A restart @@ -185,6 +198,14 @@ upstream failure. - Proxy request logs and in-memory access-log pagination snapshots are bounded and excluded from production backups. They are operational telemetry, not an audit archive. +- Managed CrowdSec state is stored in the appliance volume but is not included in the current + v3 repository backup archive. Volume-level preservation is required until issue #71 extends + backup/restore coverage. +- CrowdSec's fail-open behavior favors proxy availability during Local API failures. New or + refreshed decisions cannot be guaranteed while the selected API is unavailable. +- The Caddy CrowdSec bouncer is a pinned community module rather than an official Caddy or + CrowdSec component. The build strips AppSec and Layer 4 packages and verifies source and Hub + digests, but future Caddy upgrades still require compatibility and advisory review. - The application encryption key, controller token, public origin, trusted proxy CIDRs, SMTP configuration, port mappings, image reference, and Compose project are deployment inputs. A database dump alone cannot recreate a working appliance. diff --git a/README.md b/README.md index 15c2cd4f..75d2498f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ versions. - **Upstream security:** HTTPS upstream verification and reusable custom trusted CAs. - **Access control:** reusable Access Policies with Basic Authentication and IPv4/IPv6 allow/deny rules. +- **CrowdSec protection:** disabled, appliance-managed, or external Local API modes with + Caddy-native enforcement and health reporting. - **Administration:** users, roles, granular permissions, TOTP two-factor authentication, passkeys, and a read-only audit log. - **Visibility:** recent proxy access logs, runtime status, certificate operation progress, @@ -134,6 +136,9 @@ flowchart TD D --> E[Caddy data plane] E --> F[Managed upstreams] C <--> G[ACME CA / DNS provider] + C --> H[Managed CrowdSec lifecycle] + E <--> H + E <--> I[External CrowdSec Local API] ``` The management service owns user-facing state and permissions. The controller validates the @@ -142,6 +147,63 @@ the active revision. Certificate private material and ACME lifecycle state remai controller-owned. See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the complete service, trust, and persistence boundaries. +## CrowdSec protection + +> [!NOTE] +> CrowdSec is part of the upcoming `v1.0.0-beta.1` development line. It is not included in +> the `v1.0.0-alpha.6` image pinned by the current quick-start example. + +Open **Security → CrowdSec** to select exactly one operating mode: + +- **Disabled** is the default for fresh installations and Alpha 6 upgrades. Caddy has no + CrowdSec handler in this mode, so existing traffic behavior is unchanged. +- **Managed by RentnerProxy** is the recommended appliance experience. The pinned CrowdSec + engine, Caddy collection, Local API, and bouncer credential are provisioned inside the + existing container and `rentnerproxy` volume. No second Compose service, volume, socket, + public port, or mandatory environment variable is required. The Local API listens only on + `127.0.0.1:18080`, acquisition metrics stay on loopback `127.0.0.1:6060`, and the engine runs + as its own unprivileged user. On upgrade, the startup checks an existing regular Caddy access + log before granting the CrowdSec group read access. Only the proxy state root is traversable + by that group and only its `logs` child is group-readable; certificates and other state + remain private. The access log never becomes world-readable. +- **External CrowdSec** connects directly to an existing Local API. Enter an absolute HTTP or + HTTPS endpoint and bouncer key. RentnerProxy rejects redirects, bounds connection attempts, + encrypts the key at rest, and treats it as write-only after saving. Prefer HTTPS unless the + endpoint is confined to a trusted private network. + +RentnerProxy validates the target before switching. A failed external test or managed startup +leaves the last working mode active. Switching modes stops components that are no longer +needed but retains managed engine data and the saved external provider so a later switch does +not silently delete security state. +Each new managed activation, including an appliance restart, rotates the internal bouncer key +before Caddy uses it. A supervised engine restart while managed mode remains active keeps the +current key so requests continue to authenticate during recovery. +External key replacement creates a credential-free `rotate` event in the administration audit +log. Managed activation records credential rotation in the appliance log without printing the key. + +Enforcement uses Caddy's resolved client address on both public HTTP and HTTPS listeners, +including HTTP/2, HTTP/3, and WebSocket requests. ACME HTTP-01 is handled before CrowdSec; +CrowdSec runs before Force HTTPS, redirects, Access Policies, Basic Authentication, and proxy +forwarding. AppSec and Layer 4 modules are intentionally not bundled. + +By default, forwarded client-IP headers are untrusted. If RentnerProxy is behind another proxy, +configure only that proxy's direct socket-peer networks with +`RENTNERPROXY_PROXY_TRUSTED_PROXY_CIDRS` as described below. Caddy then resolves the effective +client IP before CrowdSec evaluates it; arbitrary `X-Forwarded-For` or `X-Real-IP` values from +untrusted peers cannot select a different identity. + +CrowdSec enforcement is fail-open for availability: established proxy traffic continues if the +selected Local API is temporarily unavailable, while the UI reports a degraded state and the +managed supervisor retries a bounded number of times. This is an explicit availability tradeoff, +not a guarantee that unavailable security intelligence can block requests. + +Managed CrowdSec files live under `/var/lib/rentnerproxy/crowdsec` in the existing appliance +volume. The current v3 repository backup format does not yet include that directory; complete +appliance backup/restore compatibility is tracked by +[issue #71](https://github.com/RentnerKev/RentnerProxy/issues/71). Until that work lands, preserve +the Docker volume or a volume-level snapshot when managed CrowdSec history must survive disaster +recovery. + ## Security and trust RentnerProxy uses typed proxy configuration rather than accepting arbitrary raw Caddy JSON. @@ -172,7 +234,6 @@ complete appliance state before every upgrade. The following are planned work, not current feature claims: -- [CrowdSec integration](https://github.com/RentnerKev/RentnerProxy/issues/64). - [Forward Auth access policies](https://github.com/RentnerKev/RentnerProxy/issues/65). - [Nginx Proxy Manager importer](https://github.com/RentnerKev/RentnerProxy/issues/66). - Broader upgrade, recovery, compatibility, reliability, scale, security, and accessibility diff --git a/ROADMAP.md b/ROADMAP.md index 192bce99..9222b665 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,11 +19,14 @@ compatibility, and regression coverage rather than another runtime redesign. Completed Alpha 6 work is part of the current baseline and is not listed below as unfinished roadmap scope. -## Planned Beta 1 features +## Beta 1 features -The following capabilities are planned, not currently available: +CrowdSec integration is implemented on the Beta 1 development line through +[issue #64](https://github.com/RentnerKev/RentnerProxy/issues/64). It keeps the single-appliance +deployment and supports disabled, managed, and external Local API modes. + +The following capabilities remain planned and are not currently available: -- [CrowdSec integration](https://github.com/RentnerKev/RentnerProxy/issues/64). - [Forward Auth access policies](https://github.com/RentnerKev/RentnerProxy/issues/65). - [Nginx Proxy Manager importer](https://github.com/RentnerKev/RentnerProxy/issues/66). diff --git a/controller/src/application.rs b/controller/src/application.rs index 58defc17..e30ec19d 100644 --- a/controller/src/application.rs +++ b/controller/src/application.rs @@ -91,6 +91,8 @@ async fn serve() -> Result<(), Box> { settings.trusted_proxy_cidrs = config.proxy_trusted_proxy_cidrs; settings.system_ca_bundle = config.system_ca_bundle; settings.controller_port = local_addr.port(); + settings.crowdsec_control_dir = "/run/rentnerproxy/crowdsec".into(); + settings.crowdsec_state_dir = "/var/lib/rentnerproxy/crowdsec".into(); let engine = config.caddy_bin.map(|binary| { Arc::new(CaddyProcess::new(binary, config.proxy_state_dir.clone())) as Arc }); diff --git a/controller/src/models.rs b/controller/src/models.rs index bcfbe86b..b95cc254 100644 --- a/controller/src/models.rs +++ b/controller/src/models.rs @@ -1,5 +1,77 @@ use serde::{Deserialize, Serialize}; +#[derive(Clone, PartialEq, Eq, Deserialize)] +#[serde(transparent)] +pub(crate) struct SecretString(String); + +impl SecretString { + pub(crate) fn new(value: String) -> Self { + Self(value) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for SecretString { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SecretString(REDACTED)") + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CrowdSecMode { + Disabled, + Managed, + External, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct CrowdSecConfigRequest { + pub(crate) mode: CrowdSecMode, + #[serde(default)] + pub(crate) api_url: Option, + #[serde(default)] + pub(crate) api_key: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CrowdSecHealth { + Disabled, + Starting, + Connected, + Degraded, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CrowdSecManagedEngineState { + Stopped, + Starting, + Ready, + Restarting, + Degraded, + Unavailable, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CrowdSecRuntimeStatus { + pub(crate) mode: CrowdSecMode, + pub(crate) state: CrowdSecHealth, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) api_url: Option, + pub(crate) credential_configured: bool, + pub(crate) enforcement_active: bool, + pub(crate) managed_engine: CrowdSecManagedEngineState, + pub(crate) failure_behavior: &'static str, + pub(crate) client_ip_source: &'static str, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ProxyConfigRequest { diff --git a/controller/src/runtime/apply.rs b/controller/src/runtime/apply.rs index a8bd97ef..9644baba 100644 --- a/controller/src/runtime/apply.rs +++ b/controller/src/runtime/apply.rs @@ -203,7 +203,12 @@ impl ProxyRuntime { } self.mark_unavailable().await; let _ = engine.shutdown().await; - if self.start_engine(engine, json, revision).await.is_ok() { + let environment = self.active_crowdsec_provider().await.environment(); + if self + .start_engine(engine, json, revision, environment) + .await + .is_ok() + { self.state.lock().await.engine_available = true; info!( stage = "recovery", diff --git a/controller/src/runtime/configuration.rs b/controller/src/runtime/configuration.rs index 489a44ce..2bc53936 100644 --- a/controller/src/runtime/configuration.rs +++ b/controller/src/runtime/configuration.rs @@ -2,8 +2,9 @@ use super::{ ACTIVE_CONFIGURATION_FILE, ProxyRuntime, RenderPurpose, RuntimeError, StagedCertificate, renderer::{ MAX_RENDERED_PROXY_CONFIG_BYTES, MAX_RENDERED_PROXY_HOST_SOURCE_BYTES, RenderError, - TlsMaterial, TlsRenderSettings, UpstreamTlsRenderSettings, render_config_with_tls, - render_host_config_for_runtime, render_host_sources_for_runtime, + TlsMaterial, TlsRenderSettings, UpstreamTlsRenderSettings, + render_config_with_tls_and_crowdsec, render_host_config_for_runtime, + render_host_sources_for_runtime, }, state::{atomic_write, open_absolute_regular_file, state_dir}, }; @@ -129,6 +130,18 @@ impl ProxyRuntime { configuration: &ValidatedProxyConfig, staged: Option<&StagedCertificate>, purpose: RenderPurpose, + ) -> Result { + let provider = self.active_crowdsec_provider().await; + self.render_proxy_config_for_provider(configuration, staged, purpose, &provider) + .await + } + + pub(super) async fn render_proxy_config_for_provider( + &self, + configuration: &ValidatedProxyConfig, + staged: Option<&StagedCertificate>, + purpose: RenderPurpose, + provider: &super::crowdsec::ActiveProvider, ) -> Result { let mut materials = BTreeMap::new(); let certificate_hosts = configuration @@ -178,7 +191,8 @@ impl ProxyRuntime { } let upstream_tls = self.upstream_tls_render_settings(configuration, purpose != RenderPurpose::Preview)?; - let rendered = render_config_with_tls( + let crowdsec = provider.render_settings(); + let rendered = render_config_with_tls_and_crowdsec( configuration, &self.settings.render_settings(), &TlsRenderSettings { @@ -188,6 +202,7 @@ impl ProxyRuntime { }, &materials, &upstream_tls, + crowdsec.as_ref(), ) .map_err(|error| match error { RenderError::ConfigTooLarge => RuntimeError::ConfigTooLarge, diff --git a/controller/src/runtime/crowdsec.rs b/controller/src/runtime/crowdsec.rs new file mode 100644 index 00000000..4ae081f5 --- /dev/null +++ b/controller/src/runtime/crowdsec.rs @@ -0,0 +1,679 @@ +use std::{ + io::Read, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use reqwest::{StatusCode, Url, redirect::Policy}; +use serde::{Deserialize, Serialize}; +use tokio::time::{Instant, sleep, timeout}; +use tracing::{info, warn}; + +use crate::models::{ + CrowdSecConfigRequest, CrowdSecHealth, CrowdSecManagedEngineState, CrowdSecMode, + CrowdSecRuntimeStatus, SecretString, +}; + +use super::{ + ACTIVE_CROWDSEC_CONFIGURATION_FILE, BASELINE_PROBE_REVISION, EngineEnvironment, ProxyRuntime, + RenderPurpose, + renderer::render_config_with_crowdsec, + state::{atomic_write, open_absolute_regular_file, state_dir}, +}; + +const MANAGED_API_URL: &str = "http://127.0.0.1:18080/"; +const DESIRED_MODE_FILE: &str = "desired-mode"; +const SUPERVISOR_STATUS_FILE: &str = "status.json"; +const BOUNCER_KEY_PATH: &str = "bouncer/caddy-bouncer-key"; +const MAX_STATUS_BYTES: u64 = 4_096; +const MAX_SECRET_BYTES: u64 = 1_024; +const MAX_LAPI_RESPONSE_BYTES: usize = 64 * 1_024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CrowdSecError { + Busy, + InvalidConfiguration, + ConnectionFailed, + RuntimeUnavailable, + ApplyFailed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DesiredProvider { + Disabled, + Managed, + External { api_url: String }, +} + +impl DesiredProvider { + fn mode(&self) -> CrowdSecMode { + match self { + Self::Disabled => CrowdSecMode::Disabled, + Self::Managed => CrowdSecMode::Managed, + Self::External { .. } => CrowdSecMode::External, + } + } + + fn api_url(&self) -> Option { + match self { + Self::External { api_url } => Some(api_url.clone()), + Self::Disabled | Self::Managed => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ActiveProvider { + Disabled, + Managed { + api_key: SecretString, + }, + External { + api_url: String, + api_key: SecretString, + }, +} + +impl ActiveProvider { + fn is_enabled(&self) -> bool { + !matches!(self, Self::Disabled) + } + + fn is_managed(&self) -> bool { + matches!(self, Self::Managed { .. }) + } + + fn api_url(&self) -> Option<&str> { + match self { + Self::Disabled => None, + Self::Managed { .. } => Some(MANAGED_API_URL), + Self::External { api_url, .. } => Some(api_url), + } + } + + fn api_key(&self) -> Option<&SecretString> { + match self { + Self::Disabled => None, + Self::Managed { api_key } | Self::External { api_key, .. } => Some(api_key), + } + } + + pub(super) fn render_settings(&self) -> Option { + Some(super::renderer::CrowdSecRenderSettings { + api_url: self.api_url()?.to_owned(), + }) + } + + pub(super) fn environment(&self) -> EngineEnvironment { + self.api_key() + .map_or_else(EngineEnvironment::default, |key| { + EngineEnvironment::with_crowdsec_bouncer_key(key.expose().to_owned()) + }) + } +} + +pub(super) struct CrowdSecState { + desired: DesiredProvider, + active: ActiveProvider, +} + +impl Default for CrowdSecState { + fn default() -> Self { + Self { + desired: DesiredProvider::Disabled, + active: ActiveProvider::Disabled, + } + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PersistedConfiguration { + version: u8, + mode: CrowdSecMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + api_url: Option, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(deny_unknown_fields)] +struct SupervisorStatus { + state: SupervisorState, + restarts: u8, +} + +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum SupervisorState { + Stopped, + Starting, + Ready, + Restarting, + Degraded, +} + +impl SupervisorState { + fn public(self) -> CrowdSecManagedEngineState { + match self { + Self::Stopped => CrowdSecManagedEngineState::Stopped, + Self::Starting => CrowdSecManagedEngineState::Starting, + Self::Ready => CrowdSecManagedEngineState::Ready, + Self::Restarting => CrowdSecManagedEngineState::Restarting, + Self::Degraded => CrowdSecManagedEngineState::Degraded, + } + } +} + +impl ProxyRuntime { + pub(crate) async fn apply_crowdsec( + self: &Arc, + request: CrowdSecConfigRequest, + ) -> Result { + let runtime = Arc::clone(self); + tokio::spawn(async move { + let guard = timeout(runtime.settings.lock_wait, runtime.apply_lock.lock()) + .await + .map_err(|_| CrowdSecError::Busy)?; + if runtime.stopping.load(Ordering::SeqCst) { + return Err(CrowdSecError::RuntimeUnavailable); + } + if !runtime.state.lock().await.initialized { + return Err(CrowdSecError::RuntimeUnavailable); + } + + let previous = { + let state = runtime.crowdsec.lock().await; + (state.desired.clone(), state.active.clone()) + }; + let (desired, target) = runtime.validate_request(request)?; + let target = match runtime.prepare_provider(&target).await { + Ok(target) => target, + Err(error) => { + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(error); + } + }; + if previous.0 == desired && previous.1 == target { + drop(guard); + return Ok(runtime.crowdsec_status().await); + } + + let Some(engine) = &runtime.engine else { + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(CrowdSecError::RuntimeUnavailable); + }; + let (configuration, previous_json, revision) = { + let state = runtime.state.lock().await; + ( + runtime.active_configuration.lock().await.clone(), + state.active_json.clone(), + state + .active_revision + .clone() + .unwrap_or_else(|| BASELINE_PROBE_REVISION.to_owned()), + ) + }; + let target_json = match configuration.as_ref() { + Some(configuration) => match runtime + .render_proxy_config_for_provider( + configuration, + None, + RenderPurpose::Activation, + &target, + ) + .await + { + Ok(target_json) => target_json, + Err(_) => { + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(CrowdSecError::ApplyFailed); + } + }, + None => { + let render_settings = target.render_settings(); + match render_config_with_crowdsec( + None, + &runtime.settings.render_settings(), + render_settings.as_ref(), + ) { + Ok(target_json) => target_json, + Err(_) => { + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(CrowdSecError::ApplyFailed); + } + } + } + }; + + let _ = engine.shutdown().await; + if runtime + .start_engine(engine, &target_json, &revision, target.environment()) + .await + .is_err() + { + let restored = runtime + .start_engine(engine, &previous_json, &revision, previous.1.environment()) + .await + .is_ok(); + if !restored { + runtime.mark_unavailable().await; + } + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(CrowdSecError::ApplyFailed); + } + + if let Err(error) = runtime.persist_crowdsec_configuration(&desired) { + warn!( + ?error, + stage = "crowdsec_snapshot", + "CrowdSec provider state was not persisted" + ); + let _ = engine.shutdown().await; + let restored = runtime + .start_engine(engine, &previous_json, &revision, previous.1.environment()) + .await + .is_ok(); + if !restored { + runtime.mark_unavailable().await; + } + runtime.rollback_managed_start(&previous.1, &target).await; + return Err(CrowdSecError::ApplyFailed); + } + { + let mut state = runtime.state.lock().await; + state.active_json = target_json; + state.engine_available = true; + } + { + let mut state = runtime.crowdsec.lock().await; + state.desired = desired.clone(); + state.active = target.clone(); + } + if previous.1.is_managed() && !target.is_managed() { + runtime.stop_managed_best_effort().await; + } + info!(mode = ?desired.mode(), "CrowdSec provider switched"); + drop(guard); + Ok(runtime.crowdsec_status().await) + }) + .await + .unwrap_or(Err(CrowdSecError::ApplyFailed)) + } + + pub(crate) async fn test_crowdsec_connection( + &self, + request: CrowdSecConfigRequest, + ) -> Result<(), CrowdSecError> { + let (desired, provider) = self.validate_request(request)?; + if !matches!(desired, DesiredProvider::External { .. }) { + return Err(CrowdSecError::InvalidConfiguration); + } + let (Some(api_url), Some(api_key)) = (provider.api_url(), provider.api_key()) else { + return Err(CrowdSecError::InvalidConfiguration); + }; + self.probe_lapi(api_url, api_key).await + } + + pub(crate) async fn crowdsec_status(&self) -> CrowdSecRuntimeStatus { + let (desired, active) = { + let state = self.crowdsec.lock().await; + (state.desired.clone(), state.active.clone()) + }; + let supervisor = self.read_supervisor_status().ok(); + let managed_engine = supervisor.map_or(CrowdSecManagedEngineState::Unavailable, |status| { + let _ = status.restarts; + status.state.public() + }); + let state = match &desired { + DesiredProvider::Disabled => CrowdSecHealth::Disabled, + DesiredProvider::Managed => match (&active, supervisor.map(|status| status.state)) { + (ActiveProvider::Managed { api_key }, Some(SupervisorState::Ready)) => { + if self.probe_lapi(MANAGED_API_URL, api_key).await.is_ok() { + CrowdSecHealth::Connected + } else { + CrowdSecHealth::Degraded + } + } + (_, Some(SupervisorState::Starting | SupervisorState::Restarting)) => { + CrowdSecHealth::Starting + } + _ => CrowdSecHealth::Degraded, + }, + DesiredProvider::External { api_url } => match &active { + ActiveProvider::External { + api_url: active_url, + api_key, + } if active_url == api_url => { + if self.probe_lapi(api_url, api_key).await.is_ok() { + CrowdSecHealth::Connected + } else { + CrowdSecHealth::Degraded + } + } + _ => CrowdSecHealth::Degraded, + }, + }; + CrowdSecRuntimeStatus { + mode: desired.mode(), + state, + api_url: desired.api_url(), + credential_configured: matches!(active, ActiveProvider::External { .. }), + enforcement_active: active.is_enabled(), + managed_engine, + failure_behavior: "fail_open", + client_ip_source: "caddy", + } + } + + pub(super) async fn initialize_crowdsec_locked(&self) { + let desired = self + .restore_crowdsec_configuration() + .unwrap_or(DesiredProvider::Disabled); + let active = match desired { + DesiredProvider::Managed => match self.prepare_managed_provider().await { + Ok(provider) => provider, + Err(error) => { + warn!( + ?error, + stage = "crowdsec_startup", + "Managed CrowdSec will retry through reconciliation" + ); + ActiveProvider::Disabled + } + }, + DesiredProvider::Disabled | DesiredProvider::External { .. } => { + self.stop_managed_best_effort().await; + ActiveProvider::Disabled + } + }; + let mut state = self.crowdsec.lock().await; + state.desired = desired; + state.active = active; + } + + pub(super) async fn active_crowdsec_provider(&self) -> ActiveProvider { + self.crowdsec.lock().await.active.clone() + } + + async fn prepare_provider( + &self, + provider: &ActiveProvider, + ) -> Result { + match provider { + ActiveProvider::Managed { .. } => { + let managed = self.prepare_managed_provider().await?; + Ok(managed) + } + ActiveProvider::External { api_url, api_key } => { + self.probe_lapi(api_url, api_key).await?; + Ok(provider.clone()) + } + ActiveProvider::Disabled => Ok(provider.clone()), + } + } + + fn validate_request( + &self, + request: CrowdSecConfigRequest, + ) -> Result<(DesiredProvider, ActiveProvider), CrowdSecError> { + match request.mode { + CrowdSecMode::Disabled if request.api_url.is_none() && request.api_key.is_none() => { + Ok((DesiredProvider::Disabled, ActiveProvider::Disabled)) + } + CrowdSecMode::Managed if request.api_url.is_none() && request.api_key.is_none() => { + Ok(( + DesiredProvider::Managed, + ActiveProvider::Managed { + api_key: SecretString::new(String::new()), + }, + )) + } + CrowdSecMode::External => { + let api_url = normalize_api_url( + request + .api_url + .as_deref() + .ok_or(CrowdSecError::InvalidConfiguration)?, + )?; + let api_key = request.api_key.ok_or(CrowdSecError::InvalidConfiguration)?; + validate_api_key(&api_key)?; + Ok(( + DesiredProvider::External { + api_url: api_url.clone(), + }, + ActiveProvider::External { api_url, api_key }, + )) + } + CrowdSecMode::Disabled | CrowdSecMode::Managed => { + Err(CrowdSecError::InvalidConfiguration) + } + } + } + + async fn prepare_managed_provider(&self) -> Result { + self.set_supervisor_mode("managed")?; + let deadline = Instant::now() + self.settings.crowdsec_start_timeout; + loop { + match self.read_supervisor_status() { + Ok(SupervisorStatus { + state: SupervisorState::Ready, + .. + }) => break, + Ok(SupervisorStatus { + state: SupervisorState::Degraded, + .. + }) => return Err(CrowdSecError::ConnectionFailed), + Ok(_) | Err(_) if Instant::now() < deadline => { + sleep(Duration::from_millis(100)).await; + } + Ok(_) | Err(_) => return Err(CrowdSecError::ConnectionFailed), + } + } + let api_key = self.read_managed_bouncer_key()?; + self.probe_lapi(MANAGED_API_URL, &api_key).await?; + Ok(ActiveProvider::Managed { api_key }) + } + + async fn rollback_managed_start(&self, previous: &ActiveProvider, target: &ActiveProvider) { + if !previous.is_managed() && target.is_managed() { + self.stop_managed_best_effort().await; + } + } + + async fn stop_managed_best_effort(&self) { + if !self.settings.crowdsec_control_dir.exists() { + return; + } + if self.set_supervisor_mode("stopped").is_err() { + warn!( + stage = "crowdsec_stop", + "CrowdSec supervisor could not be signalled" + ); + return; + } + let deadline = Instant::now() + self.settings.stage_timeout; + loop { + if self + .read_supervisor_status() + .is_ok_and(|status| status.state == SupervisorState::Stopped) + { + return; + } + if Instant::now() >= deadline { + warn!( + stage = "crowdsec_stop", + "CrowdSec supervisor did not stop within the bounded wait" + ); + return; + } + sleep(Duration::from_millis(100)).await; + } + } + + fn set_supervisor_mode(&self, mode: &str) -> Result<(), CrowdSecError> { + if !matches!(mode, "managed" | "stopped") { + return Err(CrowdSecError::InvalidConfiguration); + } + atomic_write( + &self.settings.crowdsec_control_dir.join(DESIRED_MODE_FILE), + format!("{mode}\n").as_bytes(), + ) + .map_err(|_| CrowdSecError::RuntimeUnavailable) + } + + fn read_supervisor_status(&self) -> Result { + let file = open_absolute_regular_file( + &self + .settings + .crowdsec_control_dir + .join(SUPERVISOR_STATUS_FILE), + ) + .map_err(|_| CrowdSecError::RuntimeUnavailable)?; + let mut bytes = Vec::new(); + file.take(MAX_STATUS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| CrowdSecError::RuntimeUnavailable)?; + if bytes.len() as u64 > MAX_STATUS_BYTES { + return Err(CrowdSecError::RuntimeUnavailable); + } + serde_json::from_slice(&bytes).map_err(|_| CrowdSecError::RuntimeUnavailable) + } + + fn read_managed_bouncer_key(&self) -> Result { + let file = + open_absolute_regular_file(&self.settings.crowdsec_state_dir.join(BOUNCER_KEY_PATH)) + .map_err(|_| CrowdSecError::RuntimeUnavailable)?; + let mut bytes = Vec::new(); + file.take(MAX_SECRET_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| CrowdSecError::RuntimeUnavailable)?; + if bytes.len() as u64 > MAX_SECRET_BYTES { + return Err(CrowdSecError::RuntimeUnavailable); + } + let value = String::from_utf8(bytes).map_err(|_| CrowdSecError::RuntimeUnavailable)?; + let key = SecretString::new(value.trim().to_owned()); + validate_api_key(&key).map_err(|_| CrowdSecError::RuntimeUnavailable)?; + Ok(key) + } + + async fn probe_lapi(&self, api_url: &str, api_key: &SecretString) -> Result<(), CrowdSecError> { + let _ = rustls::crypto::ring::default_provider().install_default(); + let endpoint = Url::parse(api_url) + .and_then(|url| url.join("v1/decisions?ip=192.0.2.1")) + .map_err(|_| CrowdSecError::InvalidConfiguration)?; + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(3)) + .build() + .map_err(|_| CrowdSecError::ConnectionFailed)?; + let mut response = client + .get(endpoint) + .header("X-Api-Key", api_key.expose()) + .header("Accept", "application/json") + .send() + .await + .map_err(|_| CrowdSecError::ConnectionFailed)?; + if response.status() != StatusCode::OK + || response + .content_length() + .is_some_and(|length| length > MAX_LAPI_RESPONSE_BYTES as u64) + { + return Err(CrowdSecError::ConnectionFailed); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| CrowdSecError::ConnectionFailed)? + { + if body.len().saturating_add(chunk.len()) > MAX_LAPI_RESPONSE_BYTES { + return Err(CrowdSecError::ConnectionFailed); + } + body.extend_from_slice(&chunk); + } + serde_json::from_slice::(&body) + .map(|_| ()) + .map_err(|_| CrowdSecError::ConnectionFailed) + } + + fn persist_crowdsec_configuration( + &self, + desired: &DesiredProvider, + ) -> Result<(), CrowdSecError> { + let persisted = PersistedConfiguration { + version: 1, + mode: desired.mode(), + api_url: desired.api_url(), + }; + let bytes = serde_json::to_vec(&persisted).map_err(|_| CrowdSecError::ApplyFailed)?; + atomic_write( + &self + .settings + .state_dir + .join(ACTIVE_CROWDSEC_CONFIGURATION_FILE), + &bytes, + ) + .map_err(|_| CrowdSecError::ApplyFailed) + } + + fn restore_crowdsec_configuration(&self) -> Option { + let bytes = state_dir(&self.settings.state_dir) + .ok()? + .read_file(ACTIVE_CROWDSEC_CONFIGURATION_FILE, 4_096) + .ok()?; + let persisted = serde_json::from_slice::(&bytes).ok()?; + if persisted.version != 1 { + return None; + } + match persisted.mode { + CrowdSecMode::Disabled if persisted.api_url.is_none() => { + Some(DesiredProvider::Disabled) + } + CrowdSecMode::Managed if persisted.api_url.is_none() => Some(DesiredProvider::Managed), + CrowdSecMode::External => normalize_api_url(persisted.api_url.as_deref()?) + .ok() + .map(|api_url| DesiredProvider::External { api_url }), + CrowdSecMode::Disabled | CrowdSecMode::Managed => None, + } + } +} + +fn normalize_api_url(value: &str) -> Result { + if value.is_empty() || value.len() > 2_048 || value != value.trim() { + return Err(CrowdSecError::InvalidConfiguration); + } + let mut url = Url::parse(value).map_err(|_| CrowdSecError::InvalidConfiguration)?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(CrowdSecError::InvalidConfiguration); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(url.to_string()) +} + +fn validate_api_key(value: &SecretString) -> Result<(), CrowdSecError> { + let value = value.expose(); + if !(16..=512).contains(&value.len()) + || !value.is_ascii() + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(CrowdSecError::InvalidConfiguration); + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/crowdsec.rs"] +mod tests; diff --git a/controller/src/runtime/engine.rs b/controller/src/runtime/engine.rs index 1500b2ca..09e8ab85 100644 --- a/controller/src/runtime/engine.rs +++ b/controller/src/runtime/engine.rs @@ -24,7 +24,33 @@ const MAX_CONTROL_RESPONSE_BYTES: usize = 4_096; pub(crate) type EngineFuture<'a> = Pin> + Send + 'a>>; +#[derive(Clone, Default, PartialEq, Eq)] +pub(crate) struct EngineEnvironment { + crowdsec_bouncer_key: Option, +} + +impl EngineEnvironment { + pub(crate) fn with_crowdsec_bouncer_key(value: String) -> Self { + Self { + crowdsec_bouncer_key: Some(value), + } + } +} + +impl std::fmt::Debug for EngineEnvironment { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EngineEnvironment") + .field( + "crowdsec_bouncer_key", + &self.crowdsec_bouncer_key.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + pub(crate) trait ProxyEngine: Send + Sync { + fn set_environment(&self, _environment: EngineEnvironment) {} fn start<'a>(&'a self, configuration: &'a str, expected_revision: &'a str) -> EngineFuture<'a>; fn load<'a>(&'a self, configuration: &'a str) -> EngineFuture<'a>; fn probe<'a>(&'a self, expected_revision: &'a str) -> EngineFuture<'a>; @@ -46,6 +72,7 @@ pub(crate) struct CaddyProcess { state_dir: PathBuf, admin_socket: PathBuf, probe_socket: PathBuf, + environment: std::sync::Mutex, child: Mutex>, } @@ -56,6 +83,7 @@ impl CaddyProcess { admin_socket: state_dir.join("caddy-admin.sock"), probe_socket: state_dir.join("runtime-probe.sock"), state_dir, + environment: std::sync::Mutex::new(EngineEnvironment::default()), child: Mutex::new(None), } } @@ -154,6 +182,15 @@ impl CaddyProcess { .stdout(Stdio::null()) .stderr(Stdio::inherit()) .kill_on_drop(true); + if let Some(key) = self + .environment + .lock() + .map_err(|_| EngineError::CommandFailed)? + .crowdsec_bouncer_key + .clone() + { + command.env("RENTNERPROXY_CROWDSEC_BOUNCER_KEY", key); + } #[cfg(unix)] command.process_group(0); #[cfg(windows)] @@ -200,6 +237,12 @@ impl CaddyProcess { } impl ProxyEngine for CaddyProcess { + fn set_environment(&self, environment: EngineEnvironment) { + if let Ok(mut current) = self.environment.lock() { + *current = environment; + } + } + fn start<'a>(&'a self, configuration: &'a str, revision: &'a str) -> EngineFuture<'a> { Box::pin(self.start_owned(configuration, revision)) } diff --git a/controller/src/runtime/lifecycle.rs b/controller/src/runtime/lifecycle.rs index f1887861..aaa9a1c1 100644 --- a/controller/src/runtime/lifecycle.rs +++ b/controller/src/runtime/lifecycle.rs @@ -1,7 +1,7 @@ use super::{ BASELINE_PROBE_REVISION, CertificateStoreReadiness, EngineError, EngineFuture, ProxyEngine, ProxyRuntime, RenderPurpose, RuntimeError, - renderer::render_config, + renderer::render_config_with_crowdsec, state::{LAST_APPLY_FILE, prepare_state_dir, read_trimmed, state_dir}, }; use crate::models::ProxyRuntimeStatus; @@ -80,8 +80,12 @@ impl ProxyRuntime { self.trusted_ca_store .initialize() .map_err(|_| RuntimeError::Unavailable)?; - let baseline = render_config(None, &self.settings.render_settings()) - .map_err(|_| RuntimeError::ApplyFailed)?; + self.initialize_crowdsec_locked().await; + let provider = self.active_crowdsec_provider().await; + let crowdsec = provider.render_settings(); + let baseline = + render_config_with_crowdsec(None, &self.settings.render_settings(), crowdsec.as_ref()) + .map_err(|_| RuntimeError::ApplyFailed)?; let restored = self.restore_active_configuration(); let restored = if let Some(configuration) = restored { self.render_proxy_config_for_apply(&configuration, None, RenderPurpose::Recovery) @@ -100,14 +104,21 @@ impl ProxyRuntime { let Some(engine) = &self.engine else { return Err(RuntimeError::Unavailable); }; - let started = self.start_engine(engine, &json, expected).await; + let started = self + .start_engine(engine, &json, expected, provider.environment()) + .await; let (configuration, json) = if started.is_ok() { (configuration, json) } else { let _ = engine.shutdown().await; - self.start_engine(engine, &baseline, BASELINE_PROBE_REVISION) - .await - .map_err(|_| RuntimeError::Unavailable)?; + self.start_engine( + engine, + &baseline, + BASELINE_PROBE_REVISION, + provider.environment(), + ) + .await + .map_err(|_| RuntimeError::Unavailable)?; (None, baseline) }; let host_sources = configuration @@ -199,7 +210,12 @@ impl ProxyRuntime { return Ok(()); } let _ = engine.shutdown().await; - if self.start_engine(engine, &json, &revision).await.is_err() { + let environment = self.active_crowdsec_provider().await.environment(); + if self + .start_engine(engine, &json, &revision, environment) + .await + .is_err() + { self.mark_unavailable().await; return Err(RuntimeError::Unavailable); } @@ -281,7 +297,9 @@ impl ProxyRuntime { engine: &Arc, json: &str, revision: &str, + environment: super::EngineEnvironment, ) -> Result<(), EngineError> { + engine.set_environment(environment); let result = self.run_stage(engine.start(json, revision)).await; if result.is_err() { let _ = engine.shutdown().await; diff --git a/controller/src/runtime/mod.rs b/controller/src/runtime/mod.rs index 7160754d..062ae668 100644 --- a/controller/src/runtime/mod.rs +++ b/controller/src/runtime/mod.rs @@ -5,6 +5,7 @@ mod certificate_management; mod certificates; pub(crate) mod clock; mod configuration; +mod crowdsec; mod dns; mod engine; mod lifecycle; @@ -21,7 +22,8 @@ pub(crate) use certificates::{ CertificateError, CertificateEventPage, CertificateImportRequest, CertificateIssueRequest, CertificateMetadata, CertificateOperationStage, CertificateStore, CertificateStoreReadiness, }; -pub(crate) use engine::{CaddyProcess, EngineError, EngineFuture, ProxyEngine}; +pub(crate) use crowdsec::CrowdSecError; +pub(crate) use engine::{CaddyProcess, EngineEnvironment, EngineError, EngineFuture, ProxyEngine}; use renderer::RenderSettings; use std::{ collections::BTreeMap, @@ -37,6 +39,7 @@ use trusted_cas::TrustedCaStore; const BASELINE_PROBE_REVISION: &str = "none"; const ACTIVE_CONFIGURATION_FILE: &str = "active-proxy-snapshot.json"; +const ACTIVE_CROWDSEC_CONFIGURATION_FILE: &str = "active-crowdsec-configuration.json"; const MAX_CANDIDATE_ACTIVATIONS_PER_TICK: usize = 4; #[derive(Clone, Debug)] @@ -51,9 +54,13 @@ pub(crate) struct RuntimeSettings { pub(crate) stage_timeout: Duration, pub(crate) recovery_interval: Duration, pub(crate) system_ca_bundle: PathBuf, + pub(crate) crowdsec_control_dir: PathBuf, + pub(crate) crowdsec_state_dir: PathBuf, + pub(crate) crowdsec_start_timeout: Duration, } impl RuntimeSettings { pub(crate) fn new(state_dir: PathBuf, http_port: u16) -> Self { + let crowdsec_root = state_dir.join("crowdsec-test-runtime"); Self { state_dir, http_port, @@ -65,6 +72,9 @@ impl RuntimeSettings { stage_timeout: Duration::from_secs(15), recovery_interval: Duration::from_secs(5), system_ca_bundle: PathBuf::from("/etc/ssl/certs/ca-certificates.crt"), + crowdsec_control_dir: crowdsec_root.join("control"), + crowdsec_state_dir: crowdsec_root.join("state"), + crowdsec_start_timeout: Duration::from_secs(45), } } pub(crate) fn probe_socket(&self) -> Option { @@ -122,6 +132,7 @@ pub(crate) struct ProxyRuntime { recovery_task: Mutex>>, candidate_cursor: AtomicUsize, dns_cleanup_cursor: AtomicUsize, + crowdsec: Mutex, } impl ProxyRuntime { @@ -160,6 +171,7 @@ impl ProxyRuntime { recovery_task: Mutex::new(None), candidate_cursor: AtomicUsize::new(0), dns_cleanup_cursor: AtomicUsize::new(0), + crowdsec: Mutex::new(crowdsec::CrowdSecState::default()), }) } } diff --git a/controller/src/runtime/renderer.rs b/controller/src/runtime/renderer.rs index b2990caf..6ab773e1 100644 --- a/controller/src/runtime/renderer.rs +++ b/controller/src/runtime/renderer.rs @@ -38,6 +38,11 @@ pub(crate) struct UpstreamTlsRenderSettings { pub(crate) trusted_ca_paths: BTreeMap, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CrowdSecRenderSettings { + pub(crate) api_url: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum RenderError { InvalidProbeSocket, @@ -48,19 +53,40 @@ pub(crate) enum RenderError { ConfigTooLarge, } +#[cfg(test)] pub(crate) fn render_config( config: Option<&crate::models::ValidatedProxyConfig>, settings: &RenderSettings, ) -> Result { - config::render_config_inner(config, settings, None, None, None) + config::render_config_inner(config, settings, None, None, None, None) +} + +pub(crate) fn render_config_with_crowdsec( + config: Option<&crate::models::ValidatedProxyConfig>, + settings: &RenderSettings, + crowdsec: Option<&CrowdSecRenderSettings>, +) -> Result { + config::render_config_inner(config, settings, None, None, None, crowdsec) } +#[cfg(test)] pub(crate) fn render_config_with_tls( config: &crate::models::ValidatedProxyConfig, settings: &RenderSettings, tls: &TlsRenderSettings, materials: &BTreeMap, upstream_tls: &UpstreamTlsRenderSettings, +) -> Result { + render_config_with_tls_and_crowdsec(config, settings, tls, materials, upstream_tls, None) +} + +pub(crate) fn render_config_with_tls_and_crowdsec( + config: &crate::models::ValidatedProxyConfig, + settings: &RenderSettings, + tls: &TlsRenderSettings, + materials: &BTreeMap, + upstream_tls: &UpstreamTlsRenderSettings, + crowdsec: Option<&CrowdSecRenderSettings>, ) -> Result { config::render_config_inner( Some(config), @@ -68,6 +94,7 @@ pub(crate) fn render_config_with_tls( Some(tls), Some(materials), Some(upstream_tls), + crowdsec, ) } diff --git a/controller/src/runtime/renderer/config.rs b/controller/src/runtime/renderer/config.rs index 0f64da34..dbded8f8 100644 --- a/controller/src/runtime/renderer/config.rs +++ b/controller/src/runtime/renderer/config.rs @@ -5,24 +5,29 @@ use serde_json::{Value, json}; use crate::models::{ProxyHttpSettings, ValidatedProxyConfig}; use super::{ - MAX_RENDERED_PROXY_CONFIG_BYTES, RenderError, RenderSettings, TlsMaterial, TlsRenderSettings, - UpstreamTlsRenderSettings, + CrowdSecRenderSettings, MAX_RENDERED_PROXY_CONFIG_BYTES, RenderError, RenderSettings, + TlsMaterial, TlsRenderSettings, UpstreamTlsRenderSettings, model::{ Admin, AdminConfig, Apps, AutoHttps, CaddyConfig, CertificateFile, CertificateLoaders, - CertificateSelection, ErrorConfig, ErrorResponse, ErrorStatus, Handler, Headers, HttpApp, - HttpServer, MatcherSet, ResponseHeaders, Route, ServerLogs, StaticResponse, Storage, - TlsApp, TlsConnectionPolicy, TlsMatcher, + CertificateSelection, CrowdSecApp, ErrorConfig, ErrorResponse, ErrorStatus, Handler, + Headers, HttpApp, HttpServer, MatcherSet, ResponseHeaders, Route, ServerLogs, + StaticResponse, Storage, TlsApp, TlsConnectionPolicy, TlsMatcher, }, proxy::{admin_listen, certificate_path, path_string, probe_listen, seconds, trusted_proxies}, - routes::{challenge_route, host_routes, http_routes, not_found_route, redirect_route}, + routes::{ + challenge_route, crowdsec_route, host_routes, http_routes, not_found_route, redirect_route, + }, }; +const CROWSEC_BOUNCER_KEY_PLACEHOLDER: &str = "{env.RENTNERPROXY_CROWDSEC_BOUNCER_KEY}"; + pub(super) fn render_config_inner( config: Option<&ValidatedProxyConfig>, settings: &RenderSettings, tls: Option<&TlsRenderSettings>, materials: Option<&BTreeMap>, upstream_tls: Option<&UpstreamTlsRenderSettings>, + crowdsec: Option<&CrowdSecRenderSettings>, ) -> Result { let config = config.cloned().unwrap_or_else(empty_config); let revision = if config.revision.is_empty() { @@ -42,6 +47,7 @@ pub(super) fn render_config_inner( settings, tls.map_or(443, |v| v.public_https_port), upstream_tls, + crowdsec.is_some(), )?, automatic_https: AutoHttps { disable: true }, protocols: vec!["h1".to_owned(), "h2".to_owned()], @@ -107,6 +113,9 @@ pub(super) fn render_config_inner( alt_svc_route(tls.public_https_port), challenge_route(settings.controller_port, "https"), ]; + if crowdsec.is_some() { + https_routes.push(crowdsec_route()); + } for host in &config.proxy_hosts { let Some(certificate_id) = host.certificate_id.as_ref() else { continue; @@ -196,7 +205,7 @@ pub(super) fn render_config_inner( let output = serde_json::to_string(&CaddyConfig { admin: Admin { listen: admin_listen, - config: AdminConfig { persist: true }, + config: AdminConfig { persist: false }, }, storage: Storage { module: "file_system".to_owned(), @@ -206,6 +215,15 @@ pub(super) fn render_config_inner( apps: Apps { http: HttpApp { servers }, tls: tls_app, + crowdsec: crowdsec.map(|settings| CrowdSecApp { + api_url: settings.api_url.clone(), + api_key: CROWSEC_BOUNCER_KEY_PLACEHOLDER.to_owned(), + ticker_interval: "15s".to_owned(), + enable_streaming: true, + enable_hard_fails: false, + enable_caddy_error: false, + enable_caddy_metrics: false, + }), }, }) .map_err(|_| RenderError::ConfigTooLarge)?; @@ -239,8 +257,8 @@ fn logging_config(state_root: &str) -> Value { let file = json!({ "output": "file", "filename": format!("{state_root}/logs/access.log"), - "mode": "0600", - "dir_mode": "0700", + "mode": "0640", + "dir_mode": "0750", "roll_size_mb": 4, "roll_keep": 4, "roll_keep_days": 7, diff --git a/controller/src/runtime/renderer/model.rs b/controller/src/runtime/renderer/model.rs index e6e587cd..9ace8a49 100644 --- a/controller/src/runtime/renderer/model.rs +++ b/controller/src/runtime/renderer/model.rs @@ -33,6 +33,19 @@ pub(super) struct Apps { pub(super) http: HttpApp, #[serde(skip_serializing_if = "Option::is_none")] pub(super) tls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) crowdsec: Option, +} + +#[derive(Serialize)] +pub(super) struct CrowdSecApp { + pub(super) api_url: String, + pub(super) api_key: String, + pub(super) ticker_interval: String, + pub(super) enable_streaming: bool, + pub(super) enable_hard_fails: bool, + pub(super) enable_caddy_error: bool, + pub(super) enable_caddy_metrics: bool, } #[derive(Serialize)] @@ -146,10 +159,15 @@ pub(super) enum Handler { LogAppend(LogAppend), #[serde(rename = "headers")] Headers(Headers), + #[serde(rename = "crowdsec")] + CrowdSec(CrowdSecHandler), #[serde(rename = "static_response")] ErrorResponse(ErrorResponse), } +#[derive(Serialize)] +pub(super) struct CrowdSecHandler {} + #[derive(Serialize)] pub(super) struct Headers { pub(super) response: ResponseHeaders, diff --git a/controller/src/runtime/renderer/routes.rs b/controller/src/runtime/renderer/routes.rs index 0be6d578..e858d99d 100644 --- a/controller/src/runtime/renderer/routes.rs +++ b/controller/src/runtime/renderer/routes.rs @@ -24,8 +24,12 @@ pub(super) fn http_routes( settings: &RenderSettings, public_https_port: u16, upstream_tls: Option<&UpstreamTlsRenderSettings>, + crowdsec_enabled: bool, ) -> Result, RenderError> { let mut routes = vec![challenge_route(settings.controller_port, "http")]; + if crowdsec_enabled { + routes.push(crowdsec_route()); + } for host in &config.proxy_hosts { routes.extend(host_routes( host, @@ -43,6 +47,14 @@ pub(super) fn http_routes( Ok(routes) } +pub(super) fn crowdsec_route() -> Route { + Route { + matchers: Vec::new(), + handle: vec![Handler::CrowdSec(super::model::CrowdSecHandler {})], + terminal: false, + } +} + pub(super) fn challenge_route(controller_port: u16, forwarded_proto: &str) -> Route { Route { matchers: vec![MatcherSet::path(vec![ diff --git a/controller/src/runtime/state.rs b/controller/src/runtime/state.rs index 298570b3..26b74310 100644 --- a/controller/src/runtime/state.rs +++ b/controller/src/runtime/state.rs @@ -15,10 +15,24 @@ pub(super) const LAST_APPLY_FILE: &str = "last-apply-at"; #[derive(Clone, Debug)] pub(super) struct SafeDir { path: PathBuf, + #[cfg(unix)] + shared_log_owner_group: Option<(u32, u32)>, } impl SafeDir { pub(super) fn open(path: &Path) -> std::io::Result { + Self::open_with_shared_logs(path, None) + } + + #[cfg(unix)] + pub(super) fn open_shared_logs(path: &Path, owner: u32, group: u32) -> std::io::Result { + Self::open_with_shared_logs(path, Some((owner, group))) + } + + fn open_with_shared_logs( + path: &Path, + shared_log_owner_group: Option<(u32, u32)>, + ) -> std::io::Result { let path = resolve_existing_path(path)?; let metadata = fs::symlink_metadata(&path)?; if is_link(&metadata) || !metadata.file_type().is_dir() { @@ -27,9 +41,19 @@ impl SafeDir { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + if let Some((owner, group)) = shared_log_owner_group { + verify_shared_directory(&metadata, 0o710, owner, group)?; + } else { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + } } - Ok(Self { path }) + #[cfg(not(unix))] + let _ = shared_log_owner_group; + Ok(Self { + path, + #[cfg(unix)] + shared_log_owner_group, + }) } pub(super) fn path(&self) -> &Path { @@ -48,12 +72,12 @@ impl SafeDir { if is_link(&metadata) || !metadata.file_type().is_dir() { return Err(invalid_state_path()); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; - } - Ok(Self { path }) + self.secure_child_directory(&path, &metadata, component)?; + Ok(Self { + path, + #[cfg(unix)] + shared_log_owner_group: None, + }) } pub(super) fn ensure_dir(&self, component: &str) -> std::io::Result { @@ -69,12 +93,12 @@ impl SafeDir { if is_link(&metadata) || !metadata.file_type().is_dir() { return Err(invalid_state_path()); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; - } - Ok(Self { path }) + self.secure_child_directory(&path, &metadata, component)?; + Ok(Self { + path, + #[cfg(unix)] + shared_log_owner_group: None, + }) } Err(error) if error.kind() == ErrorKind::NotFound => { create_private_directory(&candidate)?; @@ -84,12 +108,12 @@ impl SafeDir { if is_link(&metadata) || !metadata.file_type().is_dir() { return Err(invalid_state_path()); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; - } - Ok(Self { path }) + self.secure_child_directory(&path, &metadata, component)?; + Ok(Self { + path, + #[cfg(unix)] + shared_log_owner_group: None, + }) } Err(error) => Err(error), } @@ -238,6 +262,27 @@ impl SafeDir { Ok(path) } + fn secure_child_directory( + &self, + path: &Path, + metadata: &fs::Metadata, + component: &str, + ) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if component == "logs" + && let Some((owner, group)) = self.shared_log_owner_group + { + return verify_shared_directory(metadata, 0o2750, owner, group); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + #[cfg(not(unix))] + let _ = (path, metadata, component); + Ok(()) + } + fn replace_paths(&self, source: &Path, destination: &Path) -> std::io::Result<()> { fs::rename(source, destination)?; #[cfg(unix)] @@ -246,6 +291,23 @@ impl SafeDir { } } +#[cfg(unix)] +fn verify_shared_directory( + metadata: &fs::Metadata, + expected_mode: u32, + expected_owner: u32, + expected_group: u32, +) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != expected_owner + || metadata.gid() != expected_group + || metadata.mode() & 0o7777 != expected_mode + { + return Err(invalid_state_path()); + } + Ok(()) +} + pub(super) fn open_absolute_regular_file(path: &Path) -> std::io::Result { let path = canonical_absolute_entry(path)?; let metadata = fs::symlink_metadata(&path)?; @@ -268,31 +330,32 @@ pub(super) fn open_absolute_regular_file(path: &Path) -> std::io::Result { } pub(super) fn prepare_state_dir(path: &Path) -> std::io::Result<()> { - let directory = match SafeDir::open(path) { - Ok(directory) => directory, + match state_dir(path) { + Ok(_) => Ok(()), Err(error) if error.kind() == ErrorKind::NotFound => { let path = create_missing_state_directory(path)?; - SafeDir::open(&path)? + state_dir(&path).map(|_| ()) } - Err(error) => return Err(error), - }; - #[cfg(not(unix))] - let _ = &directory; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?; + Err(error) => Err(error), } - Ok(()) } pub(super) fn state_dir(path: &Path) -> std::io::Result { + #[cfg(unix)] + if path == Path::new("/var/lib/rentnerproxy/proxy") + && std::env::var_os("RENTNERPROXY_INTERNAL_SHARED_CROWDSEC_LOGS").as_deref() + == Some(std::ffi::OsStr::new("1")) + { + // Only the production entrypoint enables this layout after creating the + // restricted directories. Other controller images keep their private root. + return SafeDir::open_shared_logs(path, 10001, 10003); + } SafeDir::open(path) } pub(super) fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> { let parent = path.parent().ok_or_else(invalid_state_path)?; - let directory = SafeDir::open(parent)?; + let directory = state_dir(parent)?; let component = path .file_name() .and_then(|value| value.to_str()) @@ -303,7 +366,7 @@ pub(super) fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> pub(super) fn read_trimmed(path: &Path) -> Option { let parent = path.parent()?; let component = path.file_name()?.to_str()?; - let bytes = SafeDir::open(parent) + let bytes = state_dir(parent) .ok()? .read_file(component, 64 * 1024) .ok()?; @@ -329,3 +392,7 @@ pub(super) fn validate_component(component: &str) -> std::io::Result<()> { } Ok(()) } + +#[cfg(all(test, unix))] +#[path = "../tests/state.rs"] +mod tests; diff --git a/controller/src/server/error.rs b/controller/src/server/error.rs index a47b5020..ccbf0dfe 100644 --- a/controller/src/server/error.rs +++ b/controller/src/server/error.rs @@ -81,6 +81,20 @@ impl ApiError { error: "invalid_trusted_ca", } } + + pub(super) fn invalid_crowdsec_configuration() -> Self { + Self { + status: StatusCode::UNPROCESSABLE_ENTITY, + error: "invalid_crowdsec_configuration", + } + } + + pub(super) fn crowdsec_connection_failed() -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + error: "crowdsec_connection_failed", + } + } pub(super) fn certificate(error: CertificateError) -> Self { let status = match error { CertificateError::InvalidCertificate diff --git a/controller/src/server/handlers.rs b/controller/src/server/handlers.rs index e2756fce..c0c90fbf 100644 --- a/controller/src/server/handlers.rs +++ b/controller/src/server/handlers.rs @@ -12,14 +12,17 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - models::{ApplyOutcome, ProxyConfigRequest, ProxyRuntimeStatus, ValidatedProxyConfig}, + models::{ + ApplyOutcome, CrowdSecConfigRequest, ProxyConfigRequest, ProxyRuntimeStatus, + ValidatedProxyConfig, + }, proxy::{ TrustedCaValidationRequest, is_canonical_domain, is_canonical_uuid, is_canonical_uuid_v7, validate_proxy_config, validate_trusted_ca_pem, }, runtime::{ CertificateError, CertificateImportRequest, CertificateIssueRequest, CertificateMetadata, - RuntimeError, access_logs::AccessLogQuery, + CrowdSecError, RuntimeError, access_logs::AccessLogQuery, }, }; @@ -106,6 +109,56 @@ pub(super) async fn proxy_status(state: AppState) -> Result Response { + no_store_json(state.runtime.crowdsec_status().await) +} + +pub(super) async fn apply_crowdsec_config( + state: AppState, + body: Result, +) -> Result { + let request = crowdsec_request(body)?; + state + .runtime + .apply_crowdsec(request) + .await + .map(no_store_json) + .map_err(crowdsec_error) +} + +pub(super) async fn test_crowdsec_connection( + state: AppState, + body: Result, +) -> Result { + let request = crowdsec_request(body)?; + state + .runtime + .test_crowdsec_connection(request) + .await + .map_err(crowdsec_error)?; + Ok(no_store_json(serde_json::json!({ "status": "connected" }))) +} + +fn crowdsec_request( + body: Result, +) -> Result { + let body = body.map_err(|_| ApiError::payload_too_large())?; + if body.len() > 8 * 1_024 { + return Err(ApiError::payload_too_large()); + } + serde_json::from_slice(&body).map_err(|_| ApiError::invalid_crowdsec_configuration()) +} + +fn crowdsec_error(error: CrowdSecError) -> ApiError { + match error { + CrowdSecError::Busy => ApiError::busy(), + CrowdSecError::InvalidConfiguration => ApiError::invalid_crowdsec_configuration(), + CrowdSecError::ConnectionFailed => ApiError::crowdsec_connection_failed(), + CrowdSecError::RuntimeUnavailable => ApiError::runtime_unavailable(), + CrowdSecError::ApplyFailed => ApiError::apply_failed(), + } +} + pub(super) async fn access_logs(request: Request, state: AppState) -> Result { if request .uri() diff --git a/controller/src/server/mod.rs b/controller/src/server/mod.rs index c2428c1a..1a81adf7 100644 --- a/controller/src/server/mod.rs +++ b/controller/src/server/mod.rs @@ -16,10 +16,11 @@ use challenges::ChallengeStore; use auth::{authorize_certificate_request, authorize_internal_request}; use handlers::{ - access_logs, apply_proxy_config, certificate_events, certificate_store_status, - challenge_response, delete_certificate, get_certificate, health, import_certificate, - issue_certificate, list_certificates, preview_proxy_config, preview_proxy_host_config, - proxy_status, read_proxy_config, read_proxy_host_config, readiness, renew_certificate, + access_logs, apply_crowdsec_config, apply_proxy_config, certificate_events, + certificate_store_status, challenge_response, crowdsec_status, delete_certificate, + get_certificate, health, import_certificate, issue_certificate, list_certificates, + preview_proxy_config, preview_proxy_host_config, proxy_status, read_proxy_config, + read_proxy_host_config, readiness, renew_certificate, test_crowdsec_connection, validate_trusted_ca, }; @@ -116,6 +117,27 @@ pub(crate) fn app_with_state(state: AppState) -> Router { authorize_certificate_request, )); let internal = Router::new() + .route( + "/internal/v1/crowdsec/status", + get({ + let state = state.clone(); + move || crowdsec_status(state.clone()) + }), + ) + .route( + "/internal/v1/crowdsec/config", + axum::routing::put({ + let state = state.clone(); + move |body| apply_crowdsec_config(state.clone(), body) + }), + ) + .route( + "/internal/v1/crowdsec/test", + post({ + let state = state.clone(); + move |body| test_crowdsec_connection(state.clone(), body) + }), + ) .route( "/internal/v1/proxy/status", get({ diff --git a/controller/src/tests/crowdsec.rs b/controller/src/tests/crowdsec.rs new file mode 100644 index 00000000..f67801ed --- /dev/null +++ b/controller/src/tests/crowdsec.rs @@ -0,0 +1,59 @@ +use super::*; + +#[test] +fn external_endpoint_is_canonicalized_without_weakening_its_origin() { + assert_eq!( + normalize_api_url("https://crowdsec.example.test/lapi").unwrap(), + "https://crowdsec.example.test/lapi/" + ); + assert_eq!( + normalize_api_url("http://192.0.2.10:8080/").unwrap(), + "http://192.0.2.10:8080/" + ); + for invalid in [ + "ftp://crowdsec.example.test/", + "https://user:password@crowdsec.example.test/", + "https://crowdsec.example.test/?token=secret", + "https://crowdsec.example.test/#fragment", + " https://crowdsec.example.test/", + ] { + assert_eq!( + normalize_api_url(invalid), + Err(CrowdSecError::InvalidConfiguration) + ); + } +} + +#[test] +fn bouncer_keys_are_bounded_ascii_tokens_and_redacted_in_debug_output() { + let valid = SecretString::new("0123456789abcdef".to_owned()); + assert_eq!(validate_api_key(&valid), Ok(())); + assert_eq!(format!("{valid:?}"), "SecretString(REDACTED)"); + + for invalid in [ + "short", + "0123456789abcde ", + "0123456789abcde\n", + "0123456789abcdeä", + ] { + assert_eq!( + validate_api_key(&SecretString::new(invalid.to_owned())), + Err(CrowdSecError::InvalidConfiguration) + ); + } +} + +#[test] +fn persisted_provider_metadata_cannot_contain_a_bouncer_key() { + let persisted = PersistedConfiguration { + version: 1, + mode: CrowdSecMode::External, + api_url: Some("https://crowdsec.example.test/".to_owned()), + }; + let json = serde_json::to_string(&persisted).unwrap(); + assert_eq!( + json, + r#"{"version":1,"mode":"external","apiUrl":"https://crowdsec.example.test/"}"# + ); + assert!(!json.contains("key")); +} diff --git a/controller/src/tests/renderer.rs b/controller/src/tests/renderer.rs index 46d67bfd..cfcf1f43 100644 --- a/controller/src/tests/renderer.rs +++ b/controller/src/tests/renderer.rs @@ -11,8 +11,9 @@ use crate::{ }, proxy::revision_from_config, runtime::renderer::{ - RenderSettings, TlsMaterial, TlsRenderSettings, UpstreamTlsRenderSettings, render_config, - render_config_with_tls, + CrowdSecRenderSettings, RenderSettings, TlsMaterial, TlsRenderSettings, + UpstreamTlsRenderSettings, render_config, render_config_with_crowdsec, + render_config_with_tls, render_config_with_tls_and_crowdsec, }, }; @@ -44,7 +45,7 @@ fn renders_typed_caddy_servers_and_probe() { let configuration = config(); let rendered = render_config(Some(&configuration), &settings()).unwrap(); let json: Value = serde_json::from_str(&rendered).unwrap(); - assert_eq!(json["admin"]["config"]["persist"], true); + assert_eq!(json["admin"]["config"]["persist"], false); assert_eq!(json["storage"]["module"], "file_system"); assert_eq!( json["apps"]["http"]["servers"]["rentnerproxy-http"]["protocols"], @@ -61,6 +62,103 @@ fn renders_typed_caddy_servers_and_probe() { ); } +#[test] +fn crowdsec_is_absent_when_disabled_and_wraps_only_public_listeners_when_enabled() { + let configuration = config(); + let disabled = render_config(Some(&configuration), &settings()).unwrap(); + let disabled_json: Value = serde_json::from_str(&disabled).unwrap(); + assert!(disabled_json["apps"]["crowdsec"].is_null()); + assert!(!disabled.contains("RENTNERPROXY_CROWDSEC_BOUNCER_KEY")); + + let crowdsec = CrowdSecRenderSettings { + api_url: "http://127.0.0.1:18080/".to_owned(), + }; + let rendered = + render_config_with_crowdsec(Some(&configuration), &settings(), Some(&crowdsec)).unwrap(); + let json: Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!( + json["apps"]["crowdsec"]["api_url"], + "http://127.0.0.1:18080/" + ); + assert_eq!( + json["apps"]["crowdsec"]["api_key"], + "{env.RENTNERPROXY_CROWDSEC_BOUNCER_KEY}" + ); + assert_eq!(json["apps"]["crowdsec"]["enable_streaming"], true); + assert_eq!(json["apps"]["crowdsec"]["enable_hard_fails"], false); + let http_routes = json["apps"]["http"]["servers"]["rentnerproxy-http"]["routes"] + .as_array() + .unwrap(); + assert_eq!( + http_routes[0]["match"][0]["path"][0], + "/.well-known/acme-challenge/*" + ); + assert_eq!(http_routes[1]["handle"][0]["handler"], "crowdsec"); + assert_eq!(http_routes[1]["terminal"], false); + let probe_routes = json["apps"]["http"]["servers"]["rentnerproxy-probe"]["routes"] + .as_array() + .unwrap(); + assert!( + probe_routes + .iter() + .all(|route| route["handle"][0]["handler"] != "crowdsec") + ); + assert_eq!(json["logging"]["logs"]["access"]["writer"]["mode"], "0640"); + assert_eq!( + json["logging"]["logs"]["access"]["writer"]["dir_mode"], + "0750" + ); +} + +#[test] +fn crowdsec_wraps_https_after_transport_headers_and_acme_bypass() { + let mut configuration = config(); + let certificate_id = "018f4b4a-7d1f-7abc-8def-2123456789ab".to_owned(); + configuration.proxy_hosts[0].certificate_id = Some(certificate_id.clone()); + let materials = BTreeMap::from([( + certificate_id, + TlsMaterial { + fullchain_path: std::env::temp_dir().join("fullchain.pem"), + private_key_path: std::env::temp_dir().join("private-key.pem"), + }, + )]); + let upstream = UpstreamTlsRenderSettings { + system_ca_bundle: std::env::temp_dir().join("ca-certificates.crt"), + trusted_ca_paths: BTreeMap::new(), + }; + let rendered = render_config_with_tls_and_crowdsec( + &configuration, + &settings(), + &TlsRenderSettings { + https_port: 8443, + public_https_port: 443, + controller_port: 8081, + }, + &materials, + &upstream, + Some(&CrowdSecRenderSettings { + api_url: "https://crowdsec.example.test/lapi/".to_owned(), + }), + ) + .unwrap(); + let json: Value = serde_json::from_str(&rendered).unwrap(); + let routes = json["apps"]["http"]["servers"]["rentnerproxy-https"]["routes"] + .as_array() + .unwrap(); + assert_eq!(routes[0]["handle"][0]["handler"], "headers"); + assert_eq!( + routes[1]["match"][0]["path"][0], + "/.well-known/acme-challenge/*" + ); + assert_eq!(routes[2]["handle"][0]["handler"], "crowdsec"); + assert_eq!(routes[2]["terminal"], false); + assert!( + rendered.contains("{env.RENTNERPROXY_CROWDSEC_BOUNCER_KEY}") + && !rendered.contains("super-secret-bouncer-key") + ); +} + #[test] fn challenge_route_precedes_host_routes_and_public_unknowns_are_404() { let configuration = config(); @@ -769,7 +867,7 @@ fn redirect_handlers_keep_exact_destination_and_encoded_request_uri_semantics() } #[test] -fn access_logging_is_private_bounded_and_excludes_probe() { +fn access_logging_is_runtime_group_private_bounded_and_excludes_probe() { let json: Value = serde_json::from_str(&render_config(None, &settings()).unwrap()).unwrap(); let logs = &json["logging"]["logs"]; assert!( @@ -778,8 +876,8 @@ fn access_logging_is_private_bounded_and_excludes_probe() { .unwrap() .ends_with("/logs/access.log") ); - assert_eq!(logs["access"]["writer"]["mode"], "0600"); - assert_eq!(logs["access"]["writer"]["dir_mode"], "0700"); + assert_eq!(logs["access"]["writer"]["mode"], "0640"); + assert_eq!(logs["access"]["writer"]["dir_mode"], "0750"); assert_eq!(logs["access"]["writer"]["roll_size_mb"], 4); assert_eq!(logs["access"]["writer"]["roll_keep"], 4); assert_eq!(logs["access"]["writer"]["roll_keep_days"], 7); diff --git a/controller/src/tests/runtime.rs b/controller/src/tests/runtime.rs index ea63a983..424dfb4b 100644 --- a/controller/src/tests/runtime.rs +++ b/controller/src/tests/runtime.rs @@ -1,8 +1,9 @@ use super::fixtures::{host, request}; use crate::{ models::{ - AccessPolicy, AccessPolicyMode, ApplyOutcome, BasicAuth, BasicAuthAccount, IpDefaultAction, - IpRules, ProxyConfigRequest, ProxyHttpSettings, TrustedCa, UpstreamTls, + AccessPolicy, AccessPolicyMode, ApplyOutcome, BasicAuth, BasicAuthAccount, + CrowdSecConfigRequest, CrowdSecHealth, CrowdSecMode, IpDefaultAction, IpRules, + ProxyConfigRequest, ProxyHttpSettings, SecretString, TrustedCa, UpstreamTls, ValidatedProxyConfig, }, proxy::{ @@ -11,8 +12,8 @@ use crate::{ }, runtime::{ CertificateEnvironment, CertificateError, CertificateImportRequest, - CertificateIssueRequest, CertificateStore, EngineError, EngineFuture, ProxyEngine, - ProxyRuntime, RuntimeError, RuntimeSettings, + CertificateIssueRequest, CertificateStore, CrowdSecError, EngineError, EngineFuture, + ProxyEngine, ProxyRuntime, RuntimeError, RuntimeSettings, clock::{civil_from_days, utc_now}, }, }; @@ -146,6 +147,194 @@ fn configuration(port: u16) -> ValidatedProxyConfig { .unwrap() } +async fn crowdsec_lapi(expected_key: &'static str) -> (String, tokio::task::JoinHandle<()>) { + use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::get, + }; + + async fn decisions( + State(expected_key): State<&'static str>, + headers: HeaderMap, + ) -> axum::response::Response { + if headers + .get("X-Api-Key") + .and_then(|value| value.to_str().ok()) + != Some(expected_key) + { + return StatusCode::UNAUTHORIZED.into_response(); + } + Json(serde_json::json!([])).into_response() + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/v1/decisions", get(decisions)) + .with_state(expected_key), + ) + .await + .unwrap(); + }); + (format!("http://{address}/"), task) +} + +#[tokio::test] +async fn external_crowdsec_switch_is_validated_redacted_persisted_and_reversible() { + const KEY: &str = "0123456789abcdef0123456789abcdef"; + let (api_url, lapi) = crowdsec_lapi(KEY).await; + let engine = FakeCaddy::new(); + let (runtime, settings) = runtime(Some(engine.clone())); + runtime.initialize().await; + + let status = runtime + .apply_crowdsec(CrowdSecConfigRequest { + mode: CrowdSecMode::External, + api_url: Some(api_url.clone()), + api_key: Some(SecretString::new(KEY.to_owned())), + }) + .await + .unwrap(); + assert_eq!(status.mode, CrowdSecMode::External); + assert_eq!(status.state, CrowdSecHealth::Connected); + assert!(status.credential_configured); + assert!(status.enforcement_active); + assert_eq!(status.api_url.as_deref(), Some(api_url.as_str())); + + let caddy_json = engine.configuration.lock().await.clone(); + assert!(caddy_json.contains("{env.RENTNERPROXY_CROWDSEC_BOUNCER_KEY}")); + assert!(!caddy_json.contains(KEY)); + let persisted = std::fs::read_to_string( + settings + .state_dir + .join("active-crowdsec-configuration.json"), + ) + .unwrap(); + assert!(persisted.contains("\"mode\":\"external\"")); + assert!(persisted.contains(&api_url)); + assert!(!persisted.contains(KEY)); + assert!(!persisted.contains("apiKey")); + + let start_count = engine.start_count.load(Ordering::SeqCst); + assert_eq!( + runtime + .apply_crowdsec(CrowdSecConfigRequest { + mode: CrowdSecMode::External, + api_url: Some(api_url.clone()), + api_key: Some(SecretString::new( + "fedcba9876543210fedcba9876543210".to_owned() + )), + }) + .await, + Err(CrowdSecError::ConnectionFailed) + ); + assert_eq!(engine.start_count.load(Ordering::SeqCst), start_count); + assert_eq!( + runtime.crowdsec_status().await.state, + CrowdSecHealth::Connected + ); + + let disabled = runtime + .apply_crowdsec(CrowdSecConfigRequest { + mode: CrowdSecMode::Disabled, + api_url: None, + api_key: None, + }) + .await + .unwrap(); + assert_eq!(disabled.mode, CrowdSecMode::Disabled); + assert_eq!(disabled.state, CrowdSecHealth::Disabled); + assert!(!disabled.credential_configured); + assert!(!disabled.enforcement_active); + let caddy_json = engine.configuration.lock().await.clone(); + assert!(!caddy_json.contains("RENTNERPROXY_CROWDSEC_BOUNCER_KEY")); + + runtime.shutdown().await; + lapi.abort(); + std::fs::remove_dir_all(&settings.state_dir).unwrap(); +} + +#[tokio::test] +async fn crowdsec_persistence_failure_restores_the_previous_proxy() { + const KEY: &str = "0123456789abcdef0123456789abcdef"; + let (api_url, lapi) = crowdsec_lapi(KEY).await; + let engine = FakeCaddy::new(); + let (runtime, settings) = runtime(Some(engine.clone())); + runtime.initialize().await; + std::fs::create_dir( + settings + .state_dir + .join("active-crowdsec-configuration.json"), + ) + .unwrap(); + + assert_eq!( + runtime + .apply_crowdsec(CrowdSecConfigRequest { + mode: CrowdSecMode::External, + api_url: Some(api_url), + api_key: Some(SecretString::new(KEY.to_owned())), + }) + .await, + Err(CrowdSecError::ApplyFailed) + ); + let status = runtime.crowdsec_status().await; + assert_eq!(status.mode, CrowdSecMode::Disabled); + assert!(!status.enforcement_active); + assert!( + !engine + .configuration + .lock() + .await + .contains("RENTNERPROXY_CROWDSEC_BOUNCER_KEY") + ); + assert!(runtime.status().await.running); + + runtime.shutdown().await; + lapi.abort(); + std::fs::remove_dir_all(&settings.state_dir).unwrap(); +} + +#[tokio::test] +async fn managed_crowdsec_preparation_failure_stops_a_new_supervisor_request() { + let engine = FakeCaddy::new(); + let (runtime, settings) = runtime(Some(engine.clone())); + runtime.initialize().await; + let start_count = engine.start_count.load(Ordering::SeqCst); + std::fs::create_dir_all(&settings.crowdsec_control_dir).unwrap(); + std::fs::write( + settings.crowdsec_control_dir.join("status.json"), + r#"{"state":"degraded","restarts":5}"#, + ) + .unwrap(); + + assert_eq!( + runtime + .apply_crowdsec(CrowdSecConfigRequest { + mode: CrowdSecMode::Managed, + api_url: None, + api_key: None, + }) + .await, + Err(CrowdSecError::ConnectionFailed) + ); + assert_eq!( + std::fs::read_to_string(settings.crowdsec_control_dir.join("desired-mode")).unwrap(), + "stopped\n" + ); + assert_eq!(runtime.crowdsec_status().await.mode, CrowdSecMode::Disabled); + assert_eq!(engine.start_count.load(Ordering::SeqCst), start_count); + + runtime.shutdown().await; + std::fs::remove_dir_all(&settings.state_dir).unwrap(); +} + #[cfg(unix)] #[tokio::test] async fn startup_rejects_dangling_access_log_symlink_before_starting_caddy() { diff --git a/controller/src/tests/state.rs b/controller/src/tests/state.rs new file mode 100644 index 00000000..28474dce --- /dev/null +++ b/controller/src/tests/state.rs @@ -0,0 +1,42 @@ +use super::SafeDir; +use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[test] +fn shared_access_logs_preserve_only_the_approved_directory_permissions() { + let root = std::env::temp_dir().join(format!( + "rentnerproxy-shared-logs-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let logs = root.join("logs"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&logs).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o710)).unwrap(); + fs::set_permissions(&logs, fs::Permissions::from_mode(0o2750)).unwrap(); + let owner = fs::metadata(&root).unwrap().uid(); + let group = fs::metadata(&root).unwrap().gid(); + + let shared = SafeDir::open_shared_logs(&root, owner, group).unwrap(); + shared.open_dir("logs").unwrap(); + shared.ensure_dir("logs").unwrap(); + SafeDir::open_shared_logs(&root, owner, group).unwrap(); + assert_eq!(fs::metadata(&root).unwrap().mode() & 0o7777, 0o710); + assert_eq!(fs::metadata(&logs).unwrap().mode() & 0o7777, 0o2750); + + let private = shared.ensure_dir("certificates").unwrap(); + assert_eq!(fs::metadata(private.path()).unwrap().mode() & 0o7777, 0o700); + assert!(SafeDir::open_shared_logs(&root, owner, group + 1).is_err()); + fs::set_permissions(&logs, fs::Permissions::from_mode(0o2700)).unwrap(); + assert!(shared.open_dir("logs").is_err()); + fs::set_permissions(&logs, fs::Permissions::from_mode(0o2750)).unwrap(); + shared.open_dir("logs").unwrap(); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/docker/crowdsec/hub/data.sha256 b/docker/crowdsec/hub/data.sha256 new file mode 100644 index 00000000..397a6569 --- /dev/null +++ b/docker/crowdsec/hub/data.sha256 @@ -0,0 +1,12 @@ +a6c9702f39f996945cb28713ed1e20d8cef69c6a9c5d7a07e4605c9e67dde44d admin_interfaces.txt +79a4d8f88d7de2edd446bbc7acea2a853ab6fbfcb868e2bce3b1e39739fde1e2 backdoors.txt +01043130f44d9b51a162390e1326fb4fe558b80ca0cbdbd4d29d1135b5c8728c bad_user_agents.regex.txt +361ab570962fa2bf129957fb3c9d931ab17b6d11670852b8075930813b8ea499 http_path_traversal.txt +e72fe2cbfbcda9cc27e6be508054db45aa0bb8349c670ea1256c5f987f8af4ed jira_cve_2021-26086.txt +2c01a9fb9f8f27f3901eb0e194457af0684accaaa291db3bf9db1408e96ad4a3 log4j2_cve_2021_44228.txt +be1fad9b6d99346c07fd5f7f6110d497397383c14fdfa6fe59236fa6e52c3a13 sensitive_data.txt +ec59ad6e6c523c98a7c3859d25ed22c4a798346efbd43d95c85ed3215b2be923 sqli_probe_patterns.txt +cd236e2a6ee3535ecf3f8ed6979f5b87a8e22a229b00984dd0f4d4ddefa92368 technology_probing.json +cf1c75ce2e89d5a982ff37ee8da477cfcef9744492be31ab9464c36a0cd489c5 thinkphp_cve_2018-20062.txt +0c327d6fef8e5d5791e7efb29fe8d24093b522f3dd849ae37f7aea9155d50170 trendy_cves_uris.json +d3bbc4c0716e79a4c6e8af2f6546a6a9d23ba8268f27e6cad9abd24e8cbfc31b xss_probe_patterns.txt diff --git a/docker/crowdsec/runtime/acquis.yaml b/docker/crowdsec/runtime/acquis.yaml new file mode 100644 index 00000000..054bedee --- /dev/null +++ b/docker/crowdsec/runtime/acquis.yaml @@ -0,0 +1,6 @@ +source: file +filename: /var/lib/rentnerproxy/proxy/logs/access.log +force_inotify: true +max_buffer_size: 131072 +labels: + type: caddy diff --git a/docker/crowdsec/runtime/config.yaml b/docker/crowdsec/runtime/config.yaml new file mode 100644 index 00000000..c8cf89c1 --- /dev/null +++ b/docker/crowdsec/runtime/config.yaml @@ -0,0 +1,52 @@ +common: + daemonize: false + log_media: stdout + log_level: info +config_paths: + config_dir: /usr/share/rentnerproxy/crowdsec/config/ + data_dir: /usr/share/rentnerproxy/crowdsec/data/ + simulation_path: /usr/share/rentnerproxy/crowdsec/config/simulation.yaml + hub_dir: /usr/share/rentnerproxy/crowdsec/config/hub/ + index_path: /usr/share/rentnerproxy/crowdsec/config/hub/.index.json + notification_dir: /usr/share/rentnerproxy/crowdsec/config/notifications/ + plugin_dir: /usr/share/rentnerproxy/crowdsec/plugins/ +crowdsec_service: + acquisition_path: /usr/share/rentnerproxy/crowdsec/acquis.yaml + parser_routines: 1 + buckets_routines: 1 + output_routines: 1 +cscli: + output: json + color: 'no' + hub_branch: 435f7d81caf247241bd6068f0a778bafa8dfe975 +db_config: + log_level: info + type: sqlite + db_path: /var/lib/rentnerproxy/crowdsec/data/crowdsec.db + max_open_conns: 4 + use_wal: true + flush: + max_items: 5000 + max_age: 7d +plugin_config: + user: crowdsec + group: crowdsec +api: + client: + credentials_path: /var/lib/rentnerproxy/crowdsec/credentials/local_api_credentials.yaml + insecure_skip_verify: false + unregister_on_exit: false + server: + enable: true + listen_uri: 127.0.0.1:18080 + profiles_path: /usr/share/rentnerproxy/crowdsec/config/profiles.yaml + trusted_ips: + - 127.0.0.1 + - ::1 + disable_remote_lapi_registration: true + disable_usage_metrics_export: true +prometheus: + enabled: true + level: full + listen_addr: 127.0.0.1 + listen_port: 6060 diff --git a/docker/crowdsec/runtime/supervisor.sh b/docker/crowdsec/runtime/supervisor.sh new file mode 100644 index 00000000..69f050aa --- /dev/null +++ b/docker/crowdsec/runtime/supervisor.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly config_file=/usr/share/rentnerproxy/crowdsec/config.yaml +readonly control_directory=/run/rentnerproxy/crowdsec +readonly status_staging_directory=/run/rentnerproxy/crowdsec-supervisor +readonly desired_mode_file="$control_directory/desired-mode" +readonly status_file="$control_directory/status.json" +readonly state_directory=/var/lib/rentnerproxy/crowdsec +readonly bouncer_staging_directory=/var/lib/rentnerproxy/crowdsec-supervisor +readonly bouncer_key_file="$state_directory/bouncer/caddy-bouncer-key" +readonly bouncer_name=rentnerproxy-caddy +readonly machine_name=rentnerproxy-managed +readonly lapi_health_url=http://127.0.0.1:18080/health +readonly max_restarts=5 +readonly stable_ready_seconds=60 + +engine_pid='' +stopping=false + +log() { + printf '%s\n' "[rentnerproxy:crowdsec] $*" >&2 +} + +write_status() { + local state=$1 + local restarts=$2 + local temporary + temporary=$(mktemp "$status_staging_directory/status.XXXXXX") + printf '{"state":"%s","restarts":%d}\n' "$state" "$restarts" > "$temporary" + chown root:rentnerproxy "$temporary" + chmod 0440 "$temporary" + mv -fT -- "$temporary" "$status_file" +} + +desired_mode() { + local value + value=$(<"$desired_mode_file") + if [[ $value == managed ]]; then + printf '%s' managed + else + printf '%s' stopped + fi +} + +stop_engine() { + local deadline + [[ -n $engine_pid ]] || return 0 + if kill -0 "$engine_pid" 2>/dev/null; then + kill -TERM "$engine_pid" 2>/dev/null || true + deadline=$((SECONDS + 12)) + while ((SECONDS < deadline)) && kill -0 "$engine_pid" 2>/dev/null; do + sleep 1 + done + if kill -0 "$engine_pid" 2>/dev/null; then + kill -KILL "$engine_pid" 2>/dev/null || true + fi + fi + wait "$engine_pid" 2>/dev/null || true + engine_pid='' +} + +shutdown() { + stopping=true + stop_engine + exit 0 +} + +generate_bouncer_key() { + local temporary + if [[ -e $bouncer_key_file ]]; then + [[ ! -L $bouncer_key_file && -f $bouncer_key_file ]] || return 1 + [[ $(stat -c '%U:%G:%a' "$bouncer_key_file") == root:rentnerproxy:440 ]] || return 1 + [[ $(<"$bouncer_key_file") =~ ^[a-f0-9]{64}$ ]] || return 1 + return 0 + fi + temporary=$(mktemp "$bouncer_staging_directory/bouncer-key.XXXXXX") + if ! openssl rand -hex 32 > "$temporary" \ + || ! chown root:rentnerproxy "$temporary" \ + || ! chmod 0440 "$temporary" \ + || ! mv -fT -- "$temporary" "$bouncer_key_file"; then + rm -f -- "$temporary" + return 1 + fi +} + +rotate_bouncer_key() { + local temporary + if [[ ! -e $bouncer_key_file ]]; then + generate_bouncer_key + return + fi + generate_bouncer_key || return 1 + temporary=$(mktemp "$bouncer_staging_directory/bouncer-key.XXXXXX") + if ! openssl rand -hex 32 > "$temporary" \ + || ! chown root:rentnerproxy "$temporary" \ + || ! chmod 0440 "$temporary" \ + || ! mv -fT -- "$temporary" "$bouncer_key_file"; then + rm -f -- "$temporary" + return 1 + fi +} + +initialize_credentials() { + local key + generate_bouncer_key || return 1 + if ! gosu crowdsec cscli -c "$config_file" machines list -o json 2>/dev/null \ + | grep -Fq "\"machineId\": \"$machine_name\""; then + gosu crowdsec cscli -c "$config_file" machines delete "$machine_name" \ + >/dev/null 2>&1 || true + gosu crowdsec cscli -c "$config_file" machines add "$machine_name" --auto --force \ + >/dev/null 2>&1 || return 1 + fi + key=$(<"$bouncer_key_file") + gosu crowdsec cscli -c "$config_file" bouncers delete "$bouncer_name" \ + >/dev/null 2>&1 || true + gosu crowdsec cscli -c "$config_file" bouncers add "$bouncer_name" --key "$key" \ + >/dev/null 2>&1 || return 1 +} + +start_engine() { + initialize_credentials || return 1 + gosu crowdsec crowdsec -t -c "$config_file" >/dev/null 2>&1 || return 1 + gosu crowdsec crowdsec -c "$config_file" -no-capi & + engine_pid=$! +} + +wait_until_ready() { + local attempt + for attempt in $(seq 1 30); do + [[ $(desired_mode) == managed ]] || return 2 + kill -0 "$engine_pid" 2>/dev/null || return 1 + if curl --fail --silent --show-error --max-time 1 "$lapi_health_url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +trap shutdown INT TERM + +write_status stopped 0 +restarts=0 +blocked=false +rotate_on_activation=true + +while ! $stopping; do + if [[ $(desired_mode) != managed ]]; then + stop_engine + restarts=0 + blocked=false + rotate_on_activation=true + write_status stopped 0 + sleep 1 + continue + fi + + if $blocked; then + write_status degraded "$restarts" + sleep 1 + continue + fi + + if $rotate_on_activation; then + if ! rotate_bouncer_key; then + log 'managed bouncer credential rotation failed' + blocked=true + write_status degraded "$restarts" + continue + fi + log 'managed bouncer credential rotated' + rotate_on_activation=false + fi + + write_status starting "$restarts" + if ! start_engine; then + log 'managed engine initialization failed' + blocked=true + write_status degraded "$restarts" + continue + fi + if ! wait_until_ready; then + stop_engine + if [[ $(desired_mode) != managed ]]; then + continue + fi + restarts=$((restarts + 1)) + if ((restarts >= max_restarts)); then + log 'managed engine restart budget exhausted' + blocked=true + write_status degraded "$restarts" + continue + fi + sleep $((1 << (restarts - 1))) + continue + fi + + write_status ready "$restarts" + ready_since=$SECONDS + while [[ $(desired_mode) == managed ]] && kill -0 "$engine_pid" 2>/dev/null; do + sleep 1 + done + if [[ $(desired_mode) != managed ]]; then + stop_engine + continue + fi + wait "$engine_pid" 2>/dev/null || true + engine_pid='' + if ((SECONDS - ready_since >= stable_ready_seconds)); then + restarts=0 + fi + restarts=$((restarts + 1)) + if ((restarts >= max_restarts)); then + log 'managed engine restart budget exhausted' + blocked=true + write_status degraded "$restarts" + else + write_status restarting "$restarts" + sleep $((1 << (restarts - 1))) + fi +done diff --git a/docker/crowdsec/tooling/caddy-http-only.sh b/docker/crowdsec/tooling/caddy-http-only.sh new file mode 100644 index 00000000..1aa5d060 --- /dev/null +++ b/docker/crowdsec/tooling/caddy-http-only.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +set -eu + +source_root=$1 + +sed -i \ + '/always include AppSec module when HTTP is added/d' \ + "$source_root/http/http.go" +sed -i \ + -e '/^[[:space:]]*"fmt"$/d' \ + -e '/^[[:space:]]*l4 "github.com\/mholt\/caddy-l4\/layer4"$/d' \ + -e '/^\/\/ FromConnection extracts the current server name from the$/,/^}$/d' \ + "$source_root/internal/servername/servername.go" + +gofmt -w \ + "$source_root/http/http.go" \ + "$source_root/internal/servername/servername.go" diff --git a/docker/licenses/CaddyCrowdSecBouncer-NOTICE b/docker/licenses/CaddyCrowdSecBouncer-NOTICE new file mode 100644 index 00000000..0b035206 --- /dev/null +++ b/docker/licenses/CaddyCrowdSecBouncer-NOTICE @@ -0,0 +1,10 @@ +Caddy CrowdSec Bouncer +Version: v0.14.1 +Commit: 6b35f6be25e457aa0b94e32c6ae519123e50e7b7 +Source: https://github.com/hslatman/caddy-crowdsec-bouncer +License: Apache License 2.0 (see LICENSE) + +This is a community-maintained Caddy module and is not an official CrowdSec +or Caddy component. RentnerProxy applies the documented source transform shipped +at docker/crowdsec/tooling/caddy-http-only.sh so that only its HTTP enforcement module +is linked; AppSec and Layer 4 are not included. diff --git a/docker/licenses/CrowdSec-LICENSE b/docker/licenses/CrowdSec-LICENSE new file mode 100644 index 00000000..de4dbbfb --- /dev/null +++ b/docker/licenses/CrowdSec-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2023 Crowdsec + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker/licenses/CrowdSec-NOTICE b/docker/licenses/CrowdSec-NOTICE new file mode 100644 index 00000000..6e8576fb --- /dev/null +++ b/docker/licenses/CrowdSec-NOTICE @@ -0,0 +1,14 @@ +CrowdSec Security Engine +Version: v1.8.1 (commit 909b5157986a2b2c2163300fdaef5ed01289f7d2) +Source: https://github.com/crowdsecurity/crowdsec +License: MIT (see LICENSE) + +CrowdSec Hub content +Snapshot: 435f7d81caf247241bd6068f0a778bafa8dfe975 +Source: https://github.com/crowdsecurity/hub +License: MIT + +RentnerProxy installs the official crowdsecurity/caddy collection and its +transitive HTTP parser/scenario dependencies at image build time. Downloaded +Hub data files are accepted only when every SHA-256 digest matches the +repository manifest. diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 1b55bd8e..675e2aa5 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -47,7 +47,46 @@ COPY controller/Cargo.toml controller/Cargo.lock ./ COPY controller/src ./src RUN cargo build --locked --release -FROM caddy:2.11.4@sha256:14a9c00d4e833ebc2b65d36515b37bde3b73f0b323a2663aaafc88953d8c4e3f AS caddy-runtime +# The builder digest pins xcaddy v0.4.5. The module revision is the signed v0.14.1 release. +# Upstream's HTTP package imports AppSec and a shared Layer 4 helper. Remove those +# registrations so the appliance exposes only the required HTTP bouncer. +FROM caddy:2.11.4-builder@sha256:401121e61853cb9c7df83cba43e532e49a68e30f32921c7bdcb7a4912168c067 AS caddy-build + +ARG CADDY_CROWDSEC_BOUNCER_COMMIT=6b35f6be25e457aa0b94e32c6ae519123e50e7b7 +ARG CADDY_CROWDSEC_BOUNCER_SOURCE_SHA256=5ff0fd225d1d14cb29633516f0dfe084835dc99dc065ae11db852c661e9ced0d +COPY docker/crowdsec/tooling/caddy-http-only.sh /tmp/caddy-crowdsec-http-only.sh +WORKDIR /tmp/caddy-crowdsec-bouncer +RUN wget -qO /tmp/caddy-crowdsec-bouncer.tar.gz \ + "https://codeload.github.com/hslatman/caddy-crowdsec-bouncer/tar.gz/${CADDY_CROWDSEC_BOUNCER_COMMIT}" \ + && echo "${CADDY_CROWDSEC_BOUNCER_SOURCE_SHA256} /tmp/caddy-crowdsec-bouncer.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/caddy-crowdsec-bouncer.tar.gz --strip-components=1 \ + && /bin/sh /tmp/caddy-crowdsec-http-only.sh /tmp/caddy-crowdsec-bouncer \ + && xcaddy build v2.11.4 --output /usr/bin/caddy \ + --with github.com/hslatman/caddy-crowdsec-bouncer/http@${CADDY_CROWDSEC_BOUNCER_COMMIT} \ + --replace github.com/hslatman/caddy-crowdsec-bouncer=/tmp/caddy-crowdsec-bouncer \ + && /usr/bin/caddy version \ + && /usr/bin/caddy list-modules | grep -Fx crowdsec \ + && /usr/bin/caddy list-modules | grep -Fx http.handlers.crowdsec \ + && ! /usr/bin/caddy list-modules | grep -Eq '(^|\.)layer4($|\.)|^http\.handlers\.appsec$' + +FROM ghcr.io/crowdsecurity/crowdsec:v1.8.1-debian@sha256:ab1bdb61a072154d478ad6c625b8e62c7f79a727b2740209d2128dd51d69d58d AS crowdsec-assets + +COPY docker/crowdsec/hub/data.sha256 /tmp/rentnerproxy-crowdsec-hub-data.sha256 +RUN set -eux; \ + mkdir -p /etc/crowdsec /var/lib/crowdsec /opt/rentnerproxy/crowdsec-config; \ + cp -a /staging/etc/crowdsec/. /etc/crowdsec/; \ + cp -a /staging/var/lib/crowdsec/. /var/lib/crowdsec/; \ + sed -i '/^cscli:/a\ hub_branch: 435f7d81caf247241bd6068f0a778bafa8dfe975' /etc/crowdsec/config.yaml; \ + cscli -c /etc/crowdsec/config.yaml collections remove crowdsecurity/linux --force >/dev/null; \ + rm -rf /var/lib/crowdsec/data/*; \ + cscli -c /etc/crowdsec/config.yaml parsers install \ + crowdsecurity/syslog-logs crowdsecurity/whitelists --error >/dev/null; \ + cscli -c /etc/crowdsec/config.yaml collections install crowdsecurity/caddy --error >/dev/null; \ + cd /var/lib/crowdsec/data; \ + sha256sum --check /tmp/rentnerproxy-crowdsec-hub-data.sha256; \ + rm -f /etc/crowdsec/local_api_credentials.yaml \ + /etc/crowdsec/online_api_credentials.yaml; \ + cp -aL /etc/crowdsec/. /opt/rentnerproxy/crowdsec-config/ FROM redis:8.10.2-trixie@sha256:d5ac52db24d4e70566fe9944f22cf5bdc2bc739b05c0f426335161ea6c23f3b3 AS redis-runtime @@ -72,9 +111,16 @@ RUN apt-get update \ && useradd --uid 10001 --gid rentnerproxy --no-create-home --shell /usr/sbin/nologin rentnerproxy \ && groupadd --gid 10002 rentnerproxy-web \ && useradd --uid 10002 --gid rentnerproxy-web --no-create-home --shell /usr/sbin/nologin rentnerproxy-web \ + && groupadd --gid 10003 crowdsec \ + && useradd --uid 10003 --gid crowdsec --no-create-home --shell /usr/sbin/nologin crowdsec \ && install -d -m 0711 -o root -g root /var/lib/rentnerproxy \ && install -d -m 0700 -o postgres -g postgres /var/lib/rentnerproxy/postgres-data \ - && install -d -m 0700 -o rentnerproxy -g rentnerproxy /var/lib/rentnerproxy/proxy \ + && install -d -m 0710 -o rentnerproxy -g crowdsec /var/lib/rentnerproxy/proxy \ + && install -d -m 2750 -o rentnerproxy -g crowdsec /var/lib/rentnerproxy/proxy/logs \ + && install -d -m 0711 -o crowdsec -g crowdsec /var/lib/rentnerproxy/crowdsec \ + && install -d -m 0700 -o crowdsec -g crowdsec /var/lib/rentnerproxy/crowdsec/credentials \ + && install -d -m 0700 -o crowdsec -g crowdsec /var/lib/rentnerproxy/crowdsec/data \ + && install -d -m 0710 -o root -g rentnerproxy /var/lib/rentnerproxy/crowdsec/bouncer \ && install -d -m 0700 -o root -g root /var/lib/rentnerproxy/bootstrap \ && install -d -m 0700 -o postgres -g postgres /var/run/postgresql \ && chmod 00700 /var/run/postgresql \ @@ -83,15 +129,37 @@ RUN apt-get update \ COPY --from=web-runtime /usr/local/bin/bun /usr/local/bin/bun COPY --from=web-runtime /app /opt/rentnerproxy/web COPY --from=controller-build /build/target/release/rentnerproxy-controller /usr/local/bin/rentnerproxy-controller -COPY --from=caddy-runtime /usr/bin/caddy /usr/bin/caddy +COPY --from=caddy-build /usr/bin/caddy /usr/bin/caddy +COPY --from=crowdsec-assets /usr/local/bin/crowdsec /usr/local/bin/crowdsec +COPY --from=crowdsec-assets /usr/local/bin/cscli /usr/local/bin/cscli +COPY --from=crowdsec-assets /opt/rentnerproxy/crowdsec-config /usr/share/rentnerproxy/crowdsec/config +COPY --from=crowdsec-assets /var/lib/crowdsec/data /usr/share/rentnerproxy/crowdsec/data +COPY docker/crowdsec/runtime/config.yaml /usr/share/rentnerproxy/crowdsec/config.yaml +COPY docker/crowdsec/runtime/acquis.yaml /usr/share/rentnerproxy/crowdsec/acquis.yaml COPY docker/licenses/Caddy-LICENSE /usr/share/licenses/caddy/LICENSE +COPY docker/licenses/Caddy-LICENSE /usr/share/licenses/caddy-crowdsec-bouncer/LICENSE +COPY docker/licenses/CaddyCrowdSecBouncer-NOTICE /usr/share/licenses/caddy-crowdsec-bouncer/NOTICE +COPY docker/licenses/CrowdSec-LICENSE /usr/share/licenses/crowdsec/LICENSE +COPY docker/licenses/CrowdSec-NOTICE /usr/share/licenses/crowdsec/NOTICE COPY --from=redis-runtime /usr/local/bin/redis-server /usr/local/bin/redis-cli /opt/rentnerproxy/redis/bin/ COPY docker/production/entrypoint.sh /usr/local/bin/rentnerproxy-production +COPY docker/crowdsec/runtime/supervisor.sh /usr/local/bin/rentnerproxy-crowdsec-supervisor RUN chmod 0555 /usr/local/bin/rentnerproxy-production \ + && chmod 0555 /usr/local/bin/rentnerproxy-crowdsec-supervisor \ && chmod 0555 /usr/local/bin/rentnerproxy-controller \ + && chmod 0555 /usr/local/bin/crowdsec /usr/local/bin/cscli \ && chmod 0555 /usr/local/bin/bun \ && chmod 0555 /opt/rentnerproxy/redis/bin/redis-server /opt/rentnerproxy/redis/bin/redis-cli \ + && install -d -m 0555 -o root -g root /usr/share/rentnerproxy/crowdsec/plugins \ + && find /usr/share/rentnerproxy/crowdsec -type d -exec chmod 0555 {} + \ + && find /usr/share/rentnerproxy/crowdsec -type f -exec chmod 0444 {} + \ + && ! find /usr/share/rentnerproxy/crowdsec -type l -print -quit | grep -q . \ + && gosu crowdsec cscli -c /usr/share/rentnerproxy/crowdsec/config.yaml collections list -o json | grep -F 'crowdsecurity/caddy' \ + && /usr/bin/caddy list-modules | grep -Fx crowdsec \ + && /usr/bin/caddy list-modules | grep -Fx http.handlers.crowdsec \ + && ! /usr/bin/caddy list-modules | grep -Eq '(^|\.)layer4($|\.)|^http\.handlers\.appsec$' \ + && /usr/local/bin/crowdsec -version | grep -F 'version: v1.8.1-909b5157' \ && setcap -r /usr/bin/caddy \ && apt-get purge -y --auto-remove libcap2-bin \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/production/Dockerfile.dockerignore b/docker/production/Dockerfile.dockerignore index 400a8efc..93a520fb 100644 --- a/docker/production/Dockerfile.dockerignore +++ b/docker/production/Dockerfile.dockerignore @@ -19,8 +19,13 @@ !docker/web/** !docker/production/ !docker/production/entrypoint.sh +!docker/crowdsec/ +!docker/crowdsec/** !docker/licenses/ !docker/licenses/Caddy-LICENSE +!docker/licenses/CaddyCrowdSecBouncer-NOTICE +!docker/licenses/CrowdSec-LICENSE +!docker/licenses/CrowdSec-NOTICE !scripts/ !scripts/** diff --git a/docker/production/entrypoint.sh b/docker/production/entrypoint.sh index 6ee84311..379fdc08 100644 --- a/docker/production/entrypoint.sh +++ b/docker/production/entrypoint.sh @@ -6,6 +6,11 @@ readonly app_directory=/opt/rentnerproxy/web readonly data_directory=/var/lib/rentnerproxy readonly postgres_directory="$data_directory/postgres-data" readonly proxy_directory="$data_directory/proxy" +readonly access_log_file="$proxy_directory/logs/access.log" +readonly crowdsec_directory="$data_directory/crowdsec" +readonly crowdsec_control_directory=/run/rentnerproxy/crowdsec +readonly crowdsec_status_staging_directory=/run/rentnerproxy/crowdsec-supervisor +readonly crowdsec_bouncer_staging_directory="$data_directory/crowdsec-supervisor" readonly bootstrap_directory="$data_directory/bootstrap" readonly app_key_file=/run/rentnerproxy/app-key/value readonly controller_app_key_file=/run/rentnerproxy/controller-app-key/value @@ -100,7 +105,34 @@ initialize_layout() { install -d -m 0711 -o root -g root "$data_directory" install -d -m 0700 -o postgres -g postgres "$postgres_directory" - install -d -m 0700 -o rentnerproxy -g rentnerproxy "$proxy_directory" + install -d -m 0710 -o rentnerproxy -g crowdsec "$proxy_directory" + [[ ! -L $proxy_directory/logs ]] || fatal 'the Caddy log directory is a symbolic link' + install -d -m 0750 -o rentnerproxy -g crowdsec "$proxy_directory/logs" + # chown inside install can clear setgid; apply it only after ownership is final. + chmod 2750 "$proxy_directory/logs" + if [[ -e $access_log_file || -L $access_log_file ]]; then + [[ -f $access_log_file && ! -L $access_log_file ]] \ + || fatal 'the existing Caddy access log is not a regular file' + [[ $(stat -c '%u:%h' -- "$access_log_file") == 10001:1 ]] \ + || fatal 'the existing Caddy access log has unsafe ownership or links' + chgrp crowdsec -- "$access_log_file" + chmod 0640 -- "$access_log_file" + fi + install -d -m 0711 -o crowdsec -g crowdsec "$crowdsec_directory" + install -d -m 0700 -o crowdsec -g crowdsec "$crowdsec_directory/credentials" + install -d -m 0700 -o crowdsec -g crowdsec "$crowdsec_directory/data" + install -d -m 0710 -o root -g rentnerproxy "$crowdsec_directory/bouncer" + install -d -m 0700 -o root -g root "$crowdsec_bouncer_staging_directory" + rm -rf -- "$crowdsec_control_directory" + install -d -m 0700 -o rentnerproxy -g rentnerproxy "$crowdsec_control_directory" + install -d -m 0700 -o root -g root "$crowdsec_status_staging_directory" + [[ $(stat -c '%d' -- "$crowdsec_bouncer_staging_directory") == $(stat -c '%d' -- "$crowdsec_directory/bouncer") ]] \ + || fatal 'the CrowdSec bouncer staging directory must share its destination filesystem' + [[ $(stat -c '%d' -- "$crowdsec_status_staging_directory") == $(stat -c '%d' -- "$crowdsec_control_directory") ]] \ + || fatal 'the CrowdSec status staging directory must share its destination filesystem' + printf '%s\n' stopped > "$crowdsec_control_directory/desired-mode" + chown rentnerproxy:rentnerproxy "$crowdsec_control_directory/desired-mode" + chmod 0600 "$crowdsec_control_directory/desired-mode" install -d -m 0700 -o root -g root "$bootstrap_directory" install -d -m 0700 -o postgres -g postgres /var/run/postgresql chmod 00700 /var/run/postgresql @@ -217,9 +249,15 @@ start_redis() { wait_for_command Redis gosu rentnerproxy "$redis_directory/bin/redis-cli" -h 127.0.0.1 ping } +start_crowdsec_supervisor() { + start_child crowdsec-supervisor /usr/local/bin/rentnerproxy-crowdsec-supervisor + wait_for_command 'CrowdSec supervisor' test -s "$crowdsec_control_directory/status.json" +} + start_controller() { start_child controller env \ APP_ENCRYPTION_KEY_FILE="$controller_app_key_file" \ + RENTNERPROXY_INTERNAL_SHARED_CROWDSEC_LOGS=1 \ RENTNERPROXY_CONTROLLER_LISTEN_ADDR=127.0.0.1:8081 \ RENTNERPROXY_CONTROLLER_TOKEN_FILE="$controller_token_file" \ RENTNERPROXY_CADDY_BIN=/usr/bin/caddy \ @@ -277,6 +315,7 @@ initialize_secrets initialize_postgres_data_directory start_postgres start_redis +start_crowdsec_supervisor start_controller run_migrations start_web diff --git a/docker/proxy-runtime/Dockerfile b/docker/proxy-runtime/Dockerfile index 6b23b1d2..79d5a35b 100644 --- a/docker/proxy-runtime/Dockerfile +++ b/docker/proxy-runtime/Dockerfile @@ -5,17 +5,42 @@ COPY controller/Cargo.toml controller/Cargo.lock ./ COPY controller/src ./src RUN cargo build --locked --release -FROM caddy:2.11.4@sha256:14a9c00d4e833ebc2b65d36515b37bde3b73f0b323a2663aaafc88953d8c4e3f AS caddy-runtime +# The builder digest pins xcaddy v0.4.5. The module revision is the signed v0.14.1 release. +# Upstream's HTTP package imports AppSec and a shared Layer 4 helper. Remove those +# registrations so the appliance exposes only the required HTTP bouncer. +FROM caddy:2.11.4-builder@sha256:401121e61853cb9c7df83cba43e532e49a68e30f32921c7bdcb7a4912168c067 AS caddy-build + +ARG CADDY_CROWDSEC_BOUNCER_COMMIT=6b35f6be25e457aa0b94e32c6ae519123e50e7b7 +ARG CADDY_CROWDSEC_BOUNCER_SOURCE_SHA256=5ff0fd225d1d14cb29633516f0dfe084835dc99dc065ae11db852c661e9ced0d +COPY docker/crowdsec/tooling/caddy-http-only.sh /tmp/caddy-crowdsec-http-only.sh +WORKDIR /tmp/caddy-crowdsec-bouncer +RUN wget -qO /tmp/caddy-crowdsec-bouncer.tar.gz \ + "https://codeload.github.com/hslatman/caddy-crowdsec-bouncer/tar.gz/${CADDY_CROWDSEC_BOUNCER_COMMIT}" \ + && echo "${CADDY_CROWDSEC_BOUNCER_SOURCE_SHA256} /tmp/caddy-crowdsec-bouncer.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/caddy-crowdsec-bouncer.tar.gz --strip-components=1 \ + && /bin/sh /tmp/caddy-crowdsec-http-only.sh /tmp/caddy-crowdsec-bouncer \ + && xcaddy build v2.11.4 --output /usr/bin/caddy \ + --with github.com/hslatman/caddy-crowdsec-bouncer/http@${CADDY_CROWDSEC_BOUNCER_COMMIT} \ + --replace github.com/hslatman/caddy-crowdsec-bouncer=/tmp/caddy-crowdsec-bouncer \ + && /usr/bin/caddy version \ + && /usr/bin/caddy list-modules | grep -Fx crowdsec \ + && /usr/bin/caddy list-modules | grep -Fx http.handlers.crowdsec \ + && ! /usr/bin/caddy list-modules | grep -Eq '(^|\.)layer4($|\.)|^http\.handlers\.appsec$' FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 -COPY --from=caddy-runtime /usr/bin/caddy /usr/bin/caddy +COPY --from=caddy-build /usr/bin/caddy /usr/bin/caddy COPY docker/licenses/Caddy-LICENSE /usr/share/licenses/caddy/LICENSE +COPY docker/licenses/Caddy-LICENSE /usr/share/licenses/caddy-crowdsec-bouncer/LICENSE +COPY docker/licenses/CaddyCrowdSecBouncer-NOTICE /usr/share/licenses/caddy-crowdsec-bouncer/NOTICE COPY --from=builder /build/target/release/rentnerproxy-controller /usr/local/bin/rentnerproxy-controller RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl openssl procps libcap2-bin \ && update-ca-certificates \ + && /usr/bin/caddy list-modules | grep -Fx crowdsec \ + && /usr/bin/caddy list-modules | grep -Fx http.handlers.crowdsec \ + && ! /usr/bin/caddy list-modules | grep -Eq '(^|\.)layer4($|\.)|^http\.handlers\.appsec$' \ && setcap -r /usr/bin/caddy \ && apt-get purge -y --auto-remove libcap2-bin \ && rm -rf /var/lib/apt/lists/* \ diff --git a/docker/proxy-runtime/Dockerfile.dockerignore b/docker/proxy-runtime/Dockerfile.dockerignore index 2901bb86..39380dfa 100644 --- a/docker/proxy-runtime/Dockerfile.dockerignore +++ b/docker/proxy-runtime/Dockerfile.dockerignore @@ -6,5 +6,9 @@ !controller/src/** !docker/ +!docker/crowdsec/ +!docker/crowdsec/tooling/ +!docker/crowdsec/tooling/caddy-http-only.sh !docker/licenses/ !docker/licenses/Caddy-LICENSE +!docker/licenses/CaddyCrowdSecBouncer-NOTICE diff --git a/package.json b/package.json index 28adc7e0..e3542ae3 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "dev:web": "bun --bun vite --config web/vite.config.ts", "dev:controller": "cargo run --manifest-path controller/Cargo.toml", "proxy:test-upstream": "bun scripts/proxy-test-upstream.ts", - "proxy:smoke": "bun --no-orphans scripts/proxy-smoke.ts", + "proxy:smoke": "bun --no-orphans tests/production/proxy-smoke.ts", "certificates:smoke": "bun --no-orphans scripts/certificate-smoke.ts", "upstream-tls:smoke": "bun --no-orphans scripts/upstream-tls-smoke.ts", "backup:production": "bun scripts/production-backup.ts", "restore:production": "bun scripts/production-restore.ts", - "production:smoke": "bun --no-orphans scripts/appliance-compose-smoke.ts", + "production:smoke": "bun --no-orphans tests/production/appliance-compose-smoke.ts", "build": "bun --no-orphans scripts/build.ts", "build:web": "bun --bun vite build --config web/vite.config.ts && tsc --noEmit -p web/tsconfig.json", "build:controller": "cargo build --release --manifest-path controller/Cargo.toml", diff --git a/scripts/production-smoke-ci.ts b/scripts/production-smoke-ci.ts index ca754e8b..076a6c78 100644 --- a/scripts/production-smoke-ci.ts +++ b/scripts/production-smoke-ci.ts @@ -69,25 +69,43 @@ export function smokeProgress(suite: Suite) { if (suite === 'production' && isRestoreSmokeDiagnostic(line)) { diagnostic = line } - const location = line.match(/(?:scripts[/\\])([a-z0-9-]+\.ts):(\d+):(\d+)/u) + const acquisitionProbe = + suite === 'production' + ? /^Managed CrowdSec acquisition probe: \{"httpStatus":(-?\d{1,3}),"metricsReachable":(true|false),"accessLogHits":(\d{1,10}),"allFileHits":(\d{1,10}),"caddyParserHits":(\d{1,10}),"allParserHits":(\d{1,10}),"accessLogBytes":(-?\d{1,12}),"accessLogMode":"(\d{3,4}:\d{1,6}:\d{1,6}|unknown)","accessLogReadable":(true|false)\}$/u.exec( + line, + ) + : null + if (acquisitionProbe) { + diagnostic = + `Managed acquisition HTTP ${acquisitionProbe[1]}, metrics ${acquisitionProbe[2]}, ` + + `access-log hits ${acquisitionProbe[3]}, all file hits ${acquisitionProbe[4]}, ` + + `Caddy parser hits ${acquisitionProbe[5]}, all parser hits ${acquisitionProbe[6]}, ` + + `access-log bytes ${acquisitionProbe[7]}, mode ${acquisitionProbe[8]}, ` + + `readable ${acquisitionProbe[9]}` + return diagnostic + } + const location = line.match( + /((?:scripts|tests[/\\]production)[/\\])([a-z0-9-]+\.ts):(\d+):(\d+)/u, + ) if ( location && - (location[1] === specification.source || + (location[2] === specification.source || (suite === 'production' && [ 'alpha1-upgrade-smoke.ts', 'alpha1-upgrade-fixture.ts', 'restore-rollback-smoke.ts', - ].includes(location[1]!))) + ].includes(location[2]!))) ) { diagnostic = ( diagnostic + - ' at scripts/' + + ' at ' + location[1] + - ':' + location[2] + ':' + - location[3] + location[3] + + ':' + + location[4] ).slice(0, 300) } return undefined diff --git a/scripts/appliance-compose-smoke.ts b/tests/production/appliance-compose-smoke.ts similarity index 70% rename from scripts/appliance-compose-smoke.ts rename to tests/production/appliance-compose-smoke.ts index d77f89ef..1c28f96d 100644 --- a/scripts/appliance-compose-smoke.ts +++ b/tests/production/appliance-compose-smoke.ts @@ -9,19 +9,28 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { restoreSmokeDiagnostic, smokeCompose, smokeDockerArguments } from './smoke-resources' -import { buildHttp3Client, requestHttp3Client, assertHttp3Response } from './http3-client' -import { verifyAlpha1Upgrade, verifyAlpha3Upgrade } from './alpha1-upgrade-smoke' +import { + restoreSmokeDiagnostic, + smokeCompose, + smokeDockerArguments, +} from '../../scripts/smoke-resources' +import { + buildHttp3Client, + requestHttp3Client, + assertHttp3Response, +} from '../../scripts/http3-client' +import { verifyAlpha1Upgrade, verifyAlpha3Upgrade } from '../../scripts/alpha1-upgrade-smoke' import { seedAlpha4PersistenceFixture, readAlpha4PersistenceSnapshot, assertAlpha4PersistenceFixture, assertAlpha4PersistenceRequestDecrypts, -} from './alpha4-persistence-fixture' +} from '../../scripts/alpha4-persistence-fixture' -const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)) const rootComposeFile = join(repositoryRoot, 'docker-compose.yml') const productionDockerfile = join(repositoryRoot, 'docker', 'production', 'Dockerfile') +const accessLogPath = '/var/lib/rentnerproxy/proxy/logs/access.log' const runId = randomUUID().replaceAll('-', '').slice(0, 12) const project = 'rentnerproxy-appliance-smoke-' + runId const publicOrigin = 'https://management.appliance-smoke.invalid' @@ -232,21 +241,106 @@ async function controllerCall( ) as { status: number; body: string } } +type CrowdSecMode = 'disabled' | 'managed' + +type CrowdSecRuntimeStatus = Readonly<{ + mode: 'disabled' | 'managed' | 'external' + state: 'disabled' | 'starting' | 'connected' | 'degraded' + apiUrl?: string + credentialConfigured: boolean + enforcementActive: boolean + managedEngine: 'stopped' | 'starting' | 'ready' | 'restarting' | 'degraded' | 'unavailable' + failureBehavior: 'fail_open' + clientIpSource: 'caddy' +}> + +function parseCrowdSecStatus(response: { status: number; body: string }): CrowdSecRuntimeStatus { + assert.equal(response.status, 200) + return JSON.parse(response.body) as CrowdSecRuntimeStatus +} + +async function waitForCrowdSec( + id: string, + predicate: (status: CrowdSecRuntimeStatus) => boolean, + label: string, +): Promise { + let observed: CrowdSecRuntimeStatus | undefined + await waitFor(async () => { + observed = parseCrowdSecStatus( + await controllerCall(id, '/internal/v1/crowdsec/status', 'GET'), + ) + return predicate(observed) + }, label) + assert.ok(observed) + return observed +} + +async function persistCrowdSecMode(id: string, mode: CrowdSecMode): Promise { + const value = JSON.stringify({ version: 1, mode }) + await command([ + 'docker', + 'exec', + id, + 'gosu', + 'postgres', + 'psql', + '--no-psqlrc', + '--no-password', + '--host=/var/run/postgresql', + '--username=postgres', + '--dbname=rentnerproxy', + '--command', + `INSERT INTO rentnerproxy.system_settings (key, value) VALUES ('crowdsec_configuration_v1', '${value}'::jsonb) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP;`, + ]) +} + +async function applyCrowdSecMode(id: string, mode: CrowdSecMode): Promise { + await persistCrowdSecMode(id, mode) + const applied = await controllerCall(id, '/internal/v1/crowdsec/config', 'PUT', { mode }) + assert.equal(applied.status, 200) + return waitForCrowdSec( + id, + (status) => + status.mode === mode && + (mode === 'managed' + ? status.state === 'connected' && status.managedEngine === 'ready' + : status.state === 'disabled' && status.managedEngine === 'stopped'), + 'CrowdSec ' + mode, + ) +} + +async function crowdSecEngineUids(id: string): Promise { + const output = await command([ + 'docker', + 'exec', + id, + 'sh', + '-c', + 'for status_file in /proc/[0-9]*/status; do if grep -q "^Name:[[:space:]]*crowdsec$" "$status_file"; then awk "/^Uid:/{print \\$2}" "$status_file"; fi; done', + ]) + return output.split(/\r?\n/u).filter(Boolean) +} + function digest(value: string | Uint8Array): string { return createHash('sha256').update(value).digest('hex') } -function assertLoopbackListeners(procNet: string, port: number): void { +function listenerAddresses(procNet: string, port: number): string[] { const portHex = port.toString(16).toUpperCase().padStart(4, '0') - const listeners = procNet + return procNet .split(/\r?\n/u) .map((line) => line.trim().split(/\s+/u)) .filter((fields) => fields.length >= 4 && fields[3] === '0A') .map((fields) => fields[1]!.split(':')) .filter(([, localPort]) => localPort === portHex) + .map(([address]) => address!) +} + +function assertLoopbackListeners(procNet: string, port: number): void { + const listeners = listenerAddresses(procNet, port) assert.ok(listeners.length > 0, 'expected a listener on loopback port ' + port) - for (const [address] of listeners) { + for (const address of listeners) { assert.ok( address === '0100007F' || address === '0000000000000000FFFF00000100007F' || @@ -426,13 +520,73 @@ async function runSmoke(): Promise { 'every appliance volume must belong to this smoke; anonymous volumes cannot be recovered by run label', ) passed('empty appliance volume builds and starts healthy') + const initialCrowdSecStatus = parseCrowdSecStatus( + await controllerCall(id, '/internal/v1/crowdsec/status', 'GET'), + ) + assert.deepEqual(initialCrowdSecStatus, { + mode: 'disabled', + state: 'disabled', + credentialConfigured: false, + enforcementActive: false, + managedEngine: 'stopped', + failureBehavior: 'fail_open', + clientIpSource: 'caddy', + }) + assert.equal( + await command(['docker', 'exec', id, 'cat', '/run/rentnerproxy/crowdsec/desired-mode']), + 'stopped', + ) + assert.deepEqual(await crowdSecEngineUids(id), []) + const initialProcNet = await command([ + 'docker', + 'exec', + id, + 'sh', + '-c', + 'cat /proc/net/tcp /proc/net/tcp6', + ]) + assert.deepEqual(listenerAddresses(initialProcNet, 18080), []) + assert.deepEqual(listenerAddresses(initialProcNet, 6060), []) + passed('CrowdSec defaults to disabled without starting its managed engine or LAPI') + + const caddyModules = ( + await command(['docker', 'exec', id, '/usr/bin/caddy', 'list-modules']) + ) + .split(/\r?\n/u) + .filter( + (module) => + module === 'crowdsec' || + module === 'admin.api.crowdsec' || + module === 'http.handlers.crowdsec', + ) + .toSorted() + assert.deepEqual(caddyModules, ['admin.api.crowdsec', 'crowdsec', 'http.handlers.crowdsec']) + assert.doesNotMatch( + await command(['docker', 'exec', id, '/usr/bin/caddy', 'list-modules']), + /(?:^|\.)appsec(?:$|\.)|(?:^|\.)layer4(?:$|\.)/mu, + ) + assert.match( + await command(['docker', 'exec', id, '/usr/local/bin/crowdsec', '-version']), + /version: v1\.8\.1-909b5157/u, + ) assert.equal( await command(['docker', 'exec', id, 'sha256sum', '/usr/share/licenses/caddy/LICENSE']), digest(await readFile(join(repositoryRoot, 'docker', 'licenses', 'Caddy-LICENSE'))) + ' /usr/share/licenses/caddy/LICENSE', ) - passed('redistributed Caddy binary includes the exact Apache-2.0 license') + assert.equal( + await command([ + 'docker', + 'exec', + id, + 'sha256sum', + '/usr/share/licenses/crowdsec/LICENSE', + ]), + digest(await readFile(join(repositoryRoot, 'docker', 'licenses', 'CrowdSec-LICENSE'))) + + ' /usr/share/licenses/crowdsec/LICENSE', + ) + passed('pinned CrowdSec runtime, minimal Caddy modules, and redistributed licenses match') const roleState = await command([ 'docker', 'exec', @@ -752,6 +906,11 @@ async function runSmoke(): Promise { 'sha256sum', '/var/lib/rentnerproxy/appliance-smoke-marker', ]) + await command(['docker', 'exec', id, 'chgrp', 'rentnerproxy', accessLogPath]) + assert.equal( + await command(['docker', 'exec', id, 'stat', '-c', '%a:%u:%g', accessLogPath]), + '640:10001:10001', + ) await command([...compose, 'up', '--force-recreate', '--detach']) const recreatedId = await containerId(compose) await waitForHealthy(recreatedId) @@ -857,22 +1016,27 @@ async function runSmoke(): Promise { httpSettings: {}, trustedCas: [], } - const realConfigCanonical = JSON.stringify(realConfig) - const realConfigWithRevision = { - ...realConfig, - revision: 'sha256:' + digest(realConfigCanonical), + const applyRealConfiguration = async ( + configuration: Record, + label: string, + ): Promise => { + const request = { + ...configuration, + revision: 'sha256:' + digest(JSON.stringify(configuration)), + } + const response = await controllerCall( + recreatedId, + '/internal/v1/proxy/config', + 'PUT', + request, + ) + assert.equal(response.status, 200) + await waitFor(async () => { + const status = await controllerCall(recreatedId, '/internal/v1/proxy/status', 'GET') + return status.status === 200 && status.body.includes(request.revision) + }, label) } - const realApply = await controllerCall( - recreatedId, - '/internal/v1/proxy/config', - 'PUT', - realConfigWithRevision, - ) - assert.equal(realApply.status, 200) - await waitFor(async () => { - const status = await controllerCall(recreatedId, '/internal/v1/proxy/status', 'GET') - return status.status === 200 && status.body.includes(realConfigWithRevision.revision) - }, 'managed certificate configuration apply') + await applyRealConfiguration(realConfig, 'managed certificate configuration apply') const assertRealTraffic = async (container: string, label: string): Promise => { const httpBody = await command([ 'docker', @@ -931,14 +1095,420 @@ async function runSmoke(): Promise { passed( 'production appliance serves verified HTTP/3 through published UDP with the public Alt-Svc port', ) + + const internalHttpStatus = async (path = '/appliance-real-path'): Promise => + Number( + await command([ + 'docker', + 'exec', + recreatedId, + 'curl', + '--silent', + '--show-error', + '--noproxy', + '*', + '--output', + '/dev/null', + '--write-out', + '%{http_code}', + '--header', + 'Host: ' + hostDomain, + 'http://127.0.0.1:8080' + path, + ]), + ) + const requestPublishedProtocol = async ( + protocol: '--http1.1' | '--http2' | '--http3-only', + expectedStatus: number, + ) => { + const response = await requestHttp3Client(http3Command, { + image: http3Image, + caFile: http3CaFile, + hostname: hostDomain, + port: httpsPort, + path: '/appliance-real-path', + protocol, + }) + assert.equal(response.status, expectedStatus) + assert.equal( + response.protocol, + protocol === '--http1.1' ? '1.1' : protocol === '--http2' ? '2' : '3', + ) + return response + } + + const managedStatus = await applyCrowdSecMode(recreatedId, 'managed') + assert.deepEqual(managedStatus, { + mode: 'managed', + state: 'connected', + credentialConfigured: false, + enforcementActive: true, + managedEngine: 'ready', + failureBehavior: 'fail_open', + clientIpSource: 'caddy', + }) + assert.deepEqual(await crowdSecEngineUids(recreatedId), ['10003']) + const managedProcNet = await command([ + 'docker', + 'exec', + recreatedId, + 'sh', + '-c', + 'cat /proc/net/tcp /proc/net/tcp6', + ]) + assertLoopbackListeners(managedProcNet, 18080) + assertLoopbackListeners(managedProcNet, 6060) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%a:%u:%g', + '/var/lib/rentnerproxy/proxy', + ]), + '710:10001:10003', + ) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%a:%u:%g', + '/var/lib/rentnerproxy/proxy/logs', + ]), + '2750:10001:10003', + ) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%a:%u', + '/var/lib/rentnerproxy/proxy/caddy', + ]), + '700:10001', + ) + assert.equal( + await command(['docker', 'exec', recreatedId, 'stat', '-c', '%a:%u:%g', accessLogPath]), + '640:10001:10003', + ) + await command([ + 'docker', + 'exec', + '--user', + '10003:10003', + recreatedId, + 'test', + '-r', + accessLogPath, + ]) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%a:%U:%G', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]), + '440:root:rentnerproxy', + ) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%U:%G', + '/var/lib/rentnerproxy/crowdsec/data/crowdsec.db', + ]), + 'crowdsec:crowdsec', + ) + const acquisitionProbe = { + httpStatus: -1, + metricsReachable: false, + accessLogHits: 0, + allFileHits: 0, + caddyParserHits: 0, + allParserHits: 0, + } + try { + await waitFor( + async () => { + acquisitionProbe.httpStatus = await internalHttpStatus() + if (acquisitionProbe.httpStatus !== 200) return false + acquisitionProbe.metricsReachable = false + const metrics = await command([ + 'docker', + 'exec', + recreatedId, + 'curl', + '--fail', + '--silent', + '--show-error', + '--max-time', + '2', + 'http://127.0.0.1:6060/metrics', + ]) + acquisitionProbe.metricsReachable = true + const fileHits = + /^cs_filesource_hits_total\{[^}\n]*source="[^"]*\/var\/lib\/rentnerproxy\/proxy\/logs\/access\.log"[^}\n]*\}\s+(\d+(?:\.\d+)?)/mu.exec( + metrics, + ) + const parserHits = + /^cs_node_hits_ok_total\{[^}\n]*name="crowdsecurity\/caddy-logs"[^}\n]*\}\s+(\d+(?:\.\d+)?)/mu.exec( + metrics, + ) + acquisitionProbe.accessLogHits = Number(fileHits?.[1] ?? 0) + acquisitionProbe.caddyParserHits = Number(parserHits?.[1] ?? 0) + acquisitionProbe.allFileHits = [ + ...metrics.matchAll( + /^cs_filesource_hits_total\{[^}\n]*\}\s+(\d+(?:\.\d+)?)/gmu, + ), + ].reduce((total, match) => total + Number(match[1]), 0) + acquisitionProbe.allParserHits = [ + ...metrics.matchAll( + /^cs_node_hits_ok_total\{[^}\n]*\}\s+(\d+(?:\.\d+)?)/gmu, + ), + ].reduce((total, match) => total + Number(match[1]), 0) + return ( + acquisitionProbe.accessLogHits > 0 && acquisitionProbe.caddyParserHits > 0 + ) + }, + 'managed CrowdSec acquisition and Caddy parsing', + 30_000, + ) + } catch (error) { + const accessLogBytes = await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%s', + accessLogPath, + ]).catch(() => '-1') + const accessLogMode = await command([ + 'docker', + 'exec', + recreatedId, + 'stat', + '-c', + '%a:%u:%g', + accessLogPath, + ]).catch(() => 'unknown') + const accessLogReadable = !(await commandFails([ + 'docker', + 'exec', + '--user', + '10003:10003', + recreatedId, + 'test', + '-r', + accessLogPath, + ])) + console.error( + 'Managed CrowdSec acquisition probe: ' + + JSON.stringify({ + ...acquisitionProbe, + accessLogBytes: Number(accessLogBytes), + accessLogMode, + accessLogReadable, + }), + ) + throw error + } + const bouncerKeyDigest = await command([ + 'docker', + 'exec', + recreatedId, + 'sha256sum', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]) + for (const protocol of ['--http1.1', '--http2', '--http3-only'] as const) { + const response = await requestPublishedProtocol(protocol, 200) + assert.ok(response.output.includes(trafficMarker)) + } + passed( + 'managed CrowdSec runs unprivileged, acquires Caddy logs, stays private, and allows HTTP/1.1, HTTP/2, and HTTP/3', + ) + + await command([ + 'docker', + 'exec', + recreatedId, + 'gosu', + 'crowdsec', + 'cscli', + '-c', + '/usr/share/rentnerproxy/crowdsec/config.yaml', + 'decisions', + 'add', + '--range', + '0.0.0.0/0', + '--duration', + '5m', + '--reason', + 'rentnerproxy-appliance-smoke', + ]) + await waitFor( + async () => (await internalHttpStatus()) === 403, + 'managed CrowdSec decision propagation', + ) + for (const protocol of ['--http1.1', '--http2', '--http3-only'] as const) { + await requestPublishedProtocol(protocol, 403) + } + assert.notEqual(await internalHttpStatus('/.well-known/acme-challenge/not-present'), 403) + + const forceHttpsConfig = { + ...realConfig, + proxyHosts: realConfig.proxyHosts.map((host) => ({ ...host, forceHttps: true })), + } + await applyRealConfiguration(forceHttpsConfig, 'CrowdSec with Force HTTPS configuration') + assert.equal(await internalHttpStatus(), 403) + await command([ + 'docker', + 'exec', + recreatedId, + 'gosu', + 'crowdsec', + 'cscli', + '-c', + '/usr/share/rentnerproxy/crowdsec/config.yaml', + 'decisions', + 'delete', + '--all', + ]) + await waitFor( + async () => (await internalHttpStatus()) === 308, + 'Force HTTPS after CrowdSec decision removal', + ) + await applyRealConfiguration(realConfig, 'restore non-redirecting real traffic') + await waitFor(async () => (await internalHttpStatus()) === 200, 'managed CrowdSec allow') + passed( + 'real managed decisions deny every HTTP protocol before Force HTTPS while ACME remains reachable', + ) + + await command([ + 'docker', + 'exec', + recreatedId, + 'sh', + '-c', + 'for status_file in /proc/[0-9]*/status; do if grep -q "^Name:[[:space:]]*crowdsec$" "$status_file"; then pid=${status_file#/proc/}; pid=${pid%/status}; kill -TERM "$pid"; fi; done', + ]) + await waitFor(async () => { + const status = JSON.parse( + await command([ + 'docker', + 'exec', + recreatedId, + 'cat', + '/run/rentnerproxy/crowdsec/status.json', + ]), + ) as { state?: string; restarts?: number } + return status.state !== 'ready' || (status.restarts ?? 0) > 0 + }, 'managed CrowdSec restart state') + assert.equal(await internalHttpStatus(), 200) + await waitForCrowdSec( + recreatedId, + (status) => + status.mode === 'managed' && + status.state === 'connected' && + status.managedEngine === 'ready', + 'managed CrowdSec recovery', + ) + const recoveredSupervisor = JSON.parse( + await command([ + 'docker', + 'exec', + recreatedId, + 'cat', + '/run/rentnerproxy/crowdsec/status.json', + ]), + ) as { restarts?: number } + assert.ok((recoveredSupervisor.restarts ?? 0) >= 1) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'sha256sum', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]), + bouncerKeyDigest, + ) + passed('managed LAPI failure stays fail-open and the supervised engine recovers') + + await applyCrowdSecMode(recreatedId, 'disabled') + assert.deepEqual(await crowdSecEngineUids(recreatedId), []) + await command([ + 'docker', + 'exec', + recreatedId, + 'test', + '-s', + '/var/lib/rentnerproxy/crowdsec/data/crowdsec.db', + ]) + assert.equal( + await command([ + 'docker', + 'exec', + recreatedId, + 'sha256sum', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]), + bouncerKeyDigest, + ) + await applyCrowdSecMode(recreatedId, 'managed') + const reactivatedKeyDigest = await command([ + 'docker', + 'exec', + recreatedId, + 'sha256sum', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]) + assert.notEqual(reactivatedKeyDigest, bouncerKeyDigest) + passed('mode switches retain managed state and rotate the internal bouncer credential') + await command([...compose, 'restart', 'rentnerproxy']) await waitForHealthy(recreatedId) + await waitForCrowdSec( + recreatedId, + (status) => + status.mode === 'managed' && + status.state === 'connected' && + status.managedEngine === 'ready', + 'managed CrowdSec after appliance restart', + ) + assert.notEqual( + await command([ + 'docker', + 'exec', + recreatedId, + 'sha256sum', + '/var/lib/rentnerproxy/crowdsec/bouncer/caddy-bouncer-key', + ]), + reactivatedKeyDigest, + ) await assertRealTraffic( recreatedId, 'real HTTP and HTTPS traffic survives appliance restart', ) await assertPublishedQuic() passed('verified HTTP/3 survives production appliance restart') + await applyCrowdSecMode(recreatedId, 'disabled') + await assertRealTraffic( + recreatedId, + 'disabling CrowdSec after restart leaves the proxy configuration untouched', + ) const proxyBackupMarker = '/var/lib/rentnerproxy/proxy/appliance-backup-marker' await command([ @@ -1302,7 +1872,11 @@ try { : undefined if (locations) { for (const location of [...locations].slice(0, 6)) { - console.error('at scripts/' + location[1] + '.ts:' + location[2] + ':' + location[3]) + const locationRoot = + location[1] === 'appliance-compose-smoke' ? 'tests/production' : 'scripts' + console.error( + 'at ' + locationRoot + '/' + location[1] + '.ts:' + location[2] + ':' + location[3], + ) } } process.exitCode = 1 diff --git a/scripts/proxy-smoke.ts b/tests/production/proxy-smoke.ts similarity index 76% rename from scripts/proxy-smoke.ts rename to tests/production/proxy-smoke.ts index c8017703..0863e131 100644 --- a/scripts/proxy-smoke.ts +++ b/tests/production/proxy-smoke.ts @@ -2,15 +2,15 @@ import assert from 'node:assert/strict' import { randomBytes, randomUUID } from 'node:crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createConnection } from 'node:net' +import { createConnection, isIP } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { SQL } from 'bun' -import { startTestUpstream } from './proxy-test-upstream' -import { smokeCompose, smokeDockerArguments } from './smoke-resources' +import { startTestUpstream } from '../../scripts/proxy-test-upstream' +import { smokeCompose, smokeDockerArguments } from '../../scripts/smoke-resources' function basicHeader(username: string, password: string): string { return 'Basic ' + Buffer.from(username + ':' + password).toString('base64') @@ -18,12 +18,14 @@ function basicHeader(username: string, password: string): string { const POSTGRES_IMAGE = 'postgres:18.6@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280' -const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)) const runId = randomUUID().replaceAll('-', '').slice(0, 12) const project = 'rentnerproxy-smoke-' + runId const databaseContainer = project + '-postgres' const token = randomBytes(32).toString('hex') const databasePassword = randomBytes(24).toString('hex') +const appEncryptionKey = randomBytes(32).toString('base64') +const crowdSecBouncerKey = randomBytes(32).toString('hex') const temporaryComposeDirectory = await mkdtemp(join(tmpdir(), 'rentnerproxy-proxy-smoke-')) const temporaryComposeFile = join(temporaryComposeDirectory, 'docker-compose.yml') const environment: NodeJS.ProcessEnv = { @@ -52,6 +54,8 @@ await writeFile( '${RENTNERPROXY_CONTROLLER_TOKEN:?Set a random server-only controller token}', RENTNERPROXY_PROXY_PUBLIC_HTTPS_PORT: '${RENTNERPROXY_PROXY_PUBLIC_HTTPS_PORT:-443}', + RENTNERPROXY_PROXY_TRUSTED_PROXY_CIDRS: + '${RENTNERPROXY_PROXY_TRUSTED_PROXY_CIDRS:-}', RUST_LOG: '${RUST_LOG:-info}', }, ports: [ @@ -144,11 +148,77 @@ async function runSmoke(): Promise { console.log('Starting isolated PostgreSQL and the real Caddy runtime.') let closeDatabase: (() => Promise) | undefined let stopReconciliation: (() => Promise) | undefined + let stopCrowdSecReconciliation: (() => Promise) | undefined const upstreamHost = process.env.RENTNERPROXY_TEST_UPSTREAM_HOST ?? (process.platform === 'linux' ? '0.0.0.0' : '127.0.0.1') const first = startTestUpstream({ hostname: upstreamHost, port: 0, message: 'upstream-one' }) const second = startTestUpstream({ hostname: upstreamHost, port: 0, message: 'upstream-two' }) + let crowdSecLapiAvailable = true + let crowdSecDecisionStreamProvider: string | undefined + const crowdSecLapi = Bun.serve({ + hostname: upstreamHost, + port: 0, + fetch(request) { + if (request.headers.get('x-api-key') !== crowdSecBouncerKey) { + return Response.json({ message: 'forbidden' }, { status: 403 }) + } + if (!crowdSecLapiAvailable) { + return Response.json({ message: 'unavailable' }, { status: 503 }) + } + const url = new URL(request.url) + const provider = url.pathname.split('/').find(Boolean) + if (provider === 'rejected') { + return Response.json({ message: 'rejected' }, { status: 403 }) + } + const decisions = + provider === 'block' + ? [ + { + duration: '1h', + id: 1, + origin: 'rentnerproxy-smoke', + scenario: 'rentnerproxy/smoke-block-all', + scope: 'Range', + type: 'ban', + value: '0.0.0.0/0', + }, + ] + : provider === 'trusted' + ? [ + { + duration: '1h', + id: 2, + origin: 'rentnerproxy-smoke', + scenario: 'rentnerproxy/smoke-trusted-ipv4', + scope: 'Ip', + type: 'ban', + value: '198.51.100.45', + }, + { + duration: '1h', + id: 3, + origin: 'rentnerproxy-smoke', + scenario: 'rentnerproxy/smoke-trusted-ipv6', + scope: 'Ip', + type: 'ban', + value: '2001:db8::123', + }, + ] + : [] + if (url.pathname.endsWith('/v1/decisions/stream')) { + crowdSecDecisionStreamProvider = provider + return Response.json({ + deleted: null, + new: decisions.length > 0 ? decisions : null, + }) + } + if (url.pathname.endsWith('/v1/decisions')) { + return Response.json(decisions.length > 0 ? decisions : null) + } + return new Response(null, { status: 404 }) + }, + }) try { await command(['docker', 'version', '--format', '{{.Server.Version}}']) @@ -184,6 +254,7 @@ async function runSmoke(): Promise { process.env.NODE_ENV = 'test' process.env.RENTNERPROXY_PUBLIC_ORIGIN = 'http://localhost:5173' process.env.RENTNERPROXY_CONTROLLER_TOKEN = token + process.env.APP_ENCRYPTION_KEY = appEncryptionKey environment.DATABASE_URL = databaseUrl const probeDatabase = new SQL(databaseUrl) @@ -228,26 +299,32 @@ async function runSmoke(): Promise { redirectServices, policyServices, basicAuthServices, + crowdSecServices, runtime, controller, + authorizationRegistry, ] = await Promise.all([ import('drizzle-orm'), import('@tanstack/react-start/server'), - import('../web/src/config/auth.config'), - import('../web/src/config/permissions.config'), - import('../web/src/db/schema'), - import('../web/src/server/Auth/Core/database.server'), - import('../web/src/server/Auth/Access/sessions.service'), - import('../web/src/server/Admin/ProxyHostManagement/proxy-hosts.service'), - import('../web/src/server/Admin/RedirectHostManagement/redirect-hosts.service'), - import('../web/src/server/Admin/AccessPolicyManagement/access-policies.service'), - import('../web/src/server/Admin/AccessPolicyManagement/basic-auth.service'), - import('../web/src/server/ProxyRuntime/proxy-runtime.service'), - import('../web/src/server/Foundation/controller.server'), + import('../../web/src/config/auth.config'), + import('../../web/src/config/permissions.config'), + import('../../web/src/db/schema'), + import('../../web/src/server/Auth/Core/database.server'), + import('../../web/src/server/Auth/Access/sessions.service'), + import('../../web/src/server/Admin/ProxyHostManagement/proxy-hosts.service'), + import('../../web/src/server/Admin/RedirectHostManagement/redirect-hosts.service'), + import('../../web/src/server/Admin/AccessPolicyManagement/access-policies.service'), + import('../../web/src/server/Admin/AccessPolicyManagement/basic-auth.service'), + import('../../web/src/server/Admin/CrowdSec/crowdsec.service'), + import('../../web/src/server/ProxyRuntime/proxy-runtime.service'), + import('../../web/src/server/Foundation/controller.server'), + import('../../web/src/server/Auth/Access/registry.service'), ]) const database = getAuthDatabase() closeDatabase = () => database.$client.close() stopReconciliation = runtime.stopProxyRuntimeReconciliation + stopCrowdSecReconciliation = crowdSecServices.stopCrowdSecReconciliation + await database.transaction(authorizationRegistry.ensureAuthorizationRegistryInTransaction) await waitFor( async () => (await controller.getProxyRuntimeStatus())?.running === true, @@ -272,7 +349,11 @@ async function runSmoke(): Promise { }) } - async function expectProxyStatus(host: string, expected: number): Promise { + async function expectProxyStatus( + host: string, + expected: number, + timeoutMs = 5_000, + ): Promise { await waitFor( async () => { const response = await proxyRequest(host) @@ -280,7 +361,7 @@ async function runSmoke(): Promise { return response.status === expected }, 'HTTP ' + expected + ' for ' + host, - 5_000, + timeoutMs, ) } @@ -299,7 +380,11 @@ async function runSmoke(): Promise { ) } - async function expectWebSocketUpgrade(host: string, authorization?: string): Promise { + async function expectWebSocketUpgrade( + host: string, + authorization?: string, + expectedStatus = 101, + ): Promise { const proxyPort = Number(new URL(proxyUrl).port) await new Promise((resolve, reject) => { const socket = createConnection({ host: '127.0.0.1', port: proxyPort }) @@ -318,9 +403,14 @@ async function runSmoke(): Promise { response += chunk.toString('latin1') if (!response.includes('\r\n\r\n')) return try { - assert.match(response, /^HTTP\/1\.1 101 Switching Protocols\r\n/iu) - assert.match(response, /^upgrade: websocket\r\n/imu) - assert.match(response, /^connection: Upgrade\r\n/imu) + assert.match( + response, + new RegExp(`^HTTP\\/1\\.1 ${expectedStatus}\\b`, 'iu'), + ) + if (expectedStatus === 101) { + assert.match(response, /^upgrade: websocket\r\n/imu) + assert.match(response, /^connection: Upgrade\r\n/imu) + } finish() } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) @@ -746,6 +836,194 @@ async function runSmoke(): Promise { 'Basic Auth enforces shared credentials, WebSockets, rotation and removal without exposing hashes', ) + const crowdSecApiUrl = (provider: 'allow' | 'block' | 'rejected' | 'trusted') => + `http://host.docker.internal:${crowdSecLapi.port}/${provider}/` + const initialCrowdSec = await authorized(() => + crowdSecServices.getCrowdSecConfigurationService(), + ) + assert.equal(initialCrowdSec.mode, 'disabled') + assert.equal(initialCrowdSec.runtime?.enforcementActive, false) + const initialCaddySource = await controller.getActiveProxyConfiguration() + assert.ok(initialCaddySource) + const initialCaddyConfiguration = JSON.parse(initialCaddySource.config) as { + apps?: { crowdsec?: unknown } + } + assert.equal(initialCaddyConfiguration.apps?.crowdsec, undefined) + passed('CrowdSec defaults to disabled without changing the existing request path') + + const externalAllow = await authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ + mode: 'external', + apiUrl: crowdSecApiUrl('allow'), + apiKey: crowdSecBouncerKey, + }), + ) + assert.equal(externalAllow.runtimeStatus, 'applied') + const externalConfiguration = await authorized(() => + crowdSecServices.getCrowdSecConfigurationService(), + ) + assert.equal(externalConfiguration.mode, 'external') + assert.equal(externalConfiguration.hasApiKey, true) + assert.equal(JSON.stringify(externalConfiguration).includes(crowdSecBouncerKey), false) + assert.equal(externalConfiguration.runtime?.state, 'connected') + const crowdSecCaddySource = await controller.getActiveProxyConfiguration() + assert.ok(crowdSecCaddySource) + assert.equal(crowdSecCaddySource.config.includes(crowdSecBouncerKey), false) + assert.equal(crowdSecCaddySource.config.includes('RENTNERPROXY_CROWDSEC_BOUNCER_KEY'), true) + await expectProxyMessage('demo.test', 'upstream-two') + await expectBasicAccess('policy.test', policyUsername, rotatedPassword, 200) + await expectWebSocketUpgrade('policy.test', basicHeader(policyUsername, rotatedPassword)) + passed( + 'external CrowdSec allows HTTP, Basic Auth, and WebSocket traffic without leaking keys', + ) + + for (const rejected of [ + { apiUrl: crowdSecApiUrl('allow'), apiKey: 'invalid-credential-value' }, + { apiUrl: crowdSecApiUrl('rejected'), apiKey: crowdSecBouncerKey }, + ]) { + await assert.rejects( + authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ + mode: 'external', + ...rejected, + }), + ), + { code: 'connection_failed' }, + ) + } + assert.equal( + (await authorized(() => crowdSecServices.getCrowdSecConfigurationService())) + .externalApiUrl, + crowdSecApiUrl('allow'), + ) + await expectProxyMessage('demo.test', 'upstream-two') + passed('invalid external endpoint and credentials preserve the last working provider') + + crowdSecLapiAvailable = false + assert.equal((await controller.getCrowdSecRuntimeStatus())?.state, 'degraded') + await expectProxyMessage('demo.test', 'upstream-two') + crowdSecLapiAvailable = true + assert.equal((await controller.getCrowdSecRuntimeStatus())?.state, 'connected') + passed('temporary LAPI failure reports degraded and fails open without proxy downtime') + + assert.equal( + ( + await authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ + mode: 'external', + apiUrl: crowdSecApiUrl('block'), + apiKey: crowdSecBouncerKey, + }), + ) + ).runtimeStatus, + 'applied', + ) + await expectProxyStatus('demo.test', 403, 20_000) + await expectProxyStatus('policy.test', 403, 20_000) + await expectWebSocketUpgrade('demo.test', undefined, 403) + const acmeWhileBlocked = await proxyRequest( + 'demo.test', + '/.well-known/acme-challenge/not-present', + ) + assert.notEqual(acmeWhileBlocked.status, 403) + await acmeWhileBlocked.body?.cancel() + passed( + 'real Caddy denies HTTP, Basic Auth, and WebSocket requests while ACME HTTP-01 remains outside enforcement', + ) + + assert.equal( + ( + await authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ mode: 'disabled' }), + ) + ).runtimeStatus, + 'applied', + ) + await expectBasicAccess('policy.test', policyUsername, rotatedPassword, 200) + passed('disabling CrowdSec removes enforcement and leaves existing Access Policies intact') + + crowdSecDecisionStreamProvider = undefined + await authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ + mode: 'external', + apiUrl: crowdSecApiUrl('trusted'), + apiKey: crowdSecBouncerKey, + }), + ) + await waitFor( + async () => crowdSecDecisionStreamProvider === 'trusted', + 'trusted CrowdSec decisions', + ) + const untrustedSpoof = await fetch(proxyUrl + '/trusted-client-ip', { + headers: { + host: 'demo.test', + connection: 'close', + 'x-forwarded-for': '198.51.100.45', + 'x-real-ip': '198.51.100.45', + }, + signal: AbortSignal.timeout(5_000), + }) + assert.equal(untrustedSpoof.status, 200) + await untrustedSpoof.body?.cancel() + passed('untrusted X-Forwarded-For and X-Real-IP cannot forge a CrowdSec client identity') + + const runtimeContainerId = await command([...compose, 'ps', '-q', 'proxy-runtime']) + const dockerGateway = await command([ + 'docker', + 'inspect', + '--format', + '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}', + runtimeContainerId, + ]) + assert.equal(isIP(dockerGateway), 4) + environment.RENTNERPROXY_PROXY_TRUSTED_PROXY_CIDRS = dockerGateway + '/32' + await command([...compose, 'up', '--detach', '--force-recreate'], { + inherit: true, + timeoutMs: 180_000, + }) + await refreshRuntimeAddresses() + await waitFor( + async () => (await controller.getProxyRuntimeStatus())?.running === true, + 'trusted-proxy controller restart', + ) + assert.equal(await crowdSecServices.reconcileCrowdSecConfiguration(), 'applied') + for (const clientIp of ['198.51.100.45', '2001:db8::123']) { + await waitFor( + async () => { + const blocked = await fetch(proxyUrl + '/trusted-client-ip', { + headers: { + host: 'demo.test', + connection: 'close', + 'x-forwarded-for': clientIp, + }, + signal: AbortSignal.timeout(5_000), + }) + await blocked.body?.cancel() + return blocked.status === 403 + }, + `trusted CrowdSec client IP ${clientIp}`, + 20_000, + ) + } + const ignoredRealIp = await fetch(proxyUrl + '/trusted-client-ip', { + headers: { + host: 'demo.test', + connection: 'close', + 'x-forwarded-for': '203.0.113.10', + 'x-real-ip': '198.51.100.45', + }, + signal: AbortSignal.timeout(5_000), + }) + assert.equal(ignoredRealIp.status, 200) + await ignoredRealIp.body?.cancel() + passed('trusted Caddy client-IP resolution enforces IPv4/IPv6 while ignoring X-Real-IP') + + await authorized(() => + crowdSecServices.updateCrowdSecConfigurationService({ mode: 'disabled' }), + ) + await expectProxyMessage('demo.test', 'upstream-two') + passed('controller restart rehydrates the encrypted external provider before clean disable') + const initialSnapshot = await runtime.getProxyRuntimeSnapshotService() assert.equal(initialSnapshot.version, 7) assert.equal( @@ -1097,13 +1375,19 @@ try { ]).catch(() => '') if (logs) console.error( - logs.replaceAll(token, '[redacted]').replaceAll(databasePassword, '[redacted]'), + logs + .replaceAll(token, '[redacted]') + .replaceAll(databasePassword, '[redacted]') + .replaceAll(appEncryptionKey, '[redacted]') + .replaceAll(crowdSecBouncerKey, '[redacted]'), ) throw error } finally { + if (stopCrowdSecReconciliation) await stopCrowdSecReconciliation().catch(() => undefined) if (stopReconciliation) await stopReconciliation().catch(() => undefined) await first.stop(true) await second.stop(true) + await crowdSecLapi.stop(true) if (closeDatabase) await closeDatabase().catch(() => undefined) // These names are generated above for this run; no existing dev volume/database is touched. await command([...compose, 'down', '--volumes', '--remove-orphans']).catch(() => undefined) diff --git a/web/drizzle/0020_chief_warpath.sql b/web/drizzle/0020_chief_warpath.sql new file mode 100644 index 00000000..8934d8c4 --- /dev/null +++ b/web/drizzle/0020_chief_warpath.sql @@ -0,0 +1 @@ +ALTER TYPE "rentnerproxy"."audit_resource" ADD VALUE 'crowdsec'; \ No newline at end of file diff --git a/web/drizzle/meta/0020_snapshot.json b/web/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..641ae4a9 --- /dev/null +++ b/web/drizzle/meta/0020_snapshot.json @@ -0,0 +1,2946 @@ +{ + "id": "a1b99050-3326-427c-b736-d5625260d1d5", + "prevId": "3212a539-891d-4387-8055-bf656f7864b2", + "version": "7", + "dialect": "postgresql", + "tables": { + "rentnerproxy.user_settings": { + "name": "user_settings", + "schema": "rentnerproxy", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "theme_mode": { + "name": "theme_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'light'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_settings_theme_mode_check": { + "name": "user_settings_theme_mode_check", + "value": "\"rentnerproxy\".\"user_settings\".\"theme_mode\" in ('light', 'dark')" + }, + "user_settings_language_check": { + "name": "user_settings_language_check", + "value": "\"rentnerproxy\".\"user_settings\".\"language\" in ('en', 'de', 'es', 'fr')" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.users": { + "name": "users", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "display_name": { + "name": "display_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_version": { + "name": "profile_image_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "profile_image_webp": { + "name": "profile_image_webp", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_lower_unique_idx": { + "name": "users_email_lower_unique_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_status_idx": { + "name": "users_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.permissions": { + "name": "permissions", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_key_unique": { + "name": "permissions_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.role_permissions": { + "name": "role_permissions", + "schema": "rentnerproxy", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_permissions_permission_id_idx": { + "name": "role_permissions_permission_id_idx", + "columns": [ + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "schemaTo": "rentnerproxy", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_permissions_permission_id_permissions_id_fk": { + "name": "role_permissions_permission_id_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "schemaTo": "rentnerproxy", + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_id_pk": { + "name": "role_permissions_role_id_permission_id_pk", + "columns": ["role_id", "permission_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.roles": { + "name": "roles", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_is_system_idx": { + "name": "roles_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_key_unique": { + "name": "roles_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.user_roles": { + "name": "user_roles", + "schema": "rentnerproxy", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_roles_role_id_idx": { + "name": "user_roles_role_id_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "schemaTo": "rentnerproxy", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.access_policies": { + "name": "access_policies", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "access_policy_mode", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true + }, + "combination": { + "name": "combination", + "type": "access_policy_combination", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": false + }, + "ip_rules": { + "name": "ip_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "access_policies_mode_idx": { + "name": "access_policies_mode_idx", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "access_policies_combination_check": { + "name": "access_policies_combination_check", + "value": "(\"rentnerproxy\".\"access_policies\".\"mode\" = 'combined' and \"rentnerproxy\".\"access_policies\".\"combination\" is not null) or (\"rentnerproxy\".\"access_policies\".\"mode\" <> 'combined' and \"rentnerproxy\".\"access_policies\".\"combination\" is null)" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.access_policy_basic_auth_accounts": { + "name": "access_policy_basic_auth_accounts", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "access_policy_basic_auth_accounts_policy_id_idx": { + "name": "access_policy_basic_auth_accounts_policy_id_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_policy_basic_auth_accounts_policy_username_unique": { + "name": "access_policy_basic_auth_accounts_policy_username_unique", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_policy_basic_auth_accounts_policy_id_access_policies_id_fk": { + "name": "access_policy_basic_auth_accounts_policy_id_access_policies_id_fk", + "tableFrom": "access_policy_basic_auth_accounts", + "tableTo": "access_policies", + "schemaTo": "rentnerproxy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.system_settings": { + "name": "system_settings", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_key_unique": { + "name": "system_settings_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.passkeys": { + "name": "passkeys", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "device_type": { + "name": "device_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkeys_credential_id_unique_idx": { + "name": "passkeys_credential_id_unique_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "passkeys_counter_check": { + "name": "passkeys_counter_check", + "value": "\"rentnerproxy\".\"passkeys\".\"counter\" >= 0" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_reset_tokens_token_hash_unique_idx": { + "name": "password_reset_tokens_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_reset_tokens_expires_at_idx": { + "name": "password_reset_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.sessions": { + "name": "sessions", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reauthenticated_at": { + "name": "reauthenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_token_hash_unique_idx": { + "name": "sessions_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.user_invites": { + "name": "user_invites", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invites_user_id_unique_idx": { + "name": "user_invites_user_id_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invites_token_hash_unique_idx": { + "name": "user_invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invites_invited_by_user_id_idx": { + "name": "user_invites_invited_by_user_id_idx", + "columns": [ + { + "expression": "invited_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invites_expires_at_idx": { + "name": "user_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invites_user_id_users_id_fk": { + "name": "user_invites_user_id_users_id_fk", + "tableFrom": "user_invites", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invites_invited_by_user_id_users_id_fk": { + "name": "user_invites_invited_by_user_id_users_id_fk", + "tableFrom": "user_invites", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.user_recovery_codes": { + "name": "user_recovery_codes", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_recovery_codes_user_id_code_hash_unique_idx": { + "name": "user_recovery_codes_user_id_code_hash_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_recovery_codes_user_id_idx": { + "name": "user_recovery_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_recovery_codes_user_id_users_id_fk": { + "name": "user_recovery_codes_user_id_users_id_fk", + "tableFrom": "user_recovery_codes", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.user_totp_factors": { + "name": "user_totp_factors", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "last_used_counter": { + "name": "last_used_counter", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": -1 + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_totp_factors_user_id_unique_idx": { + "name": "user_totp_factors_user_id_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_totp_factors_user_id_users_id_fk": { + "name": "user_totp_factors_user_id_users_id_fk", + "tableFrom": "user_totp_factors", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_totp_factors_secret_iv_length_check": { + "name": "user_totp_factors_secret_iv_length_check", + "value": "octet_length(\"rentnerproxy\".\"user_totp_factors\".\"secret_iv\") = 12" + }, + "user_totp_factors_last_used_counter_check": { + "name": "user_totp_factors_last_used_counter_check", + "value": "\"rentnerproxy\".\"user_totp_factors\".\"last_used_counter\" >= -1" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.host_domains": { + "name": "host_domains", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "proxy_host_id": { + "name": "proxy_host_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "redirect_host_id": { + "name": "redirect_host_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "host_domains_proxy_host_id_idx": { + "name": "host_domains_proxy_host_id_idx", + "columns": [ + { + "expression": "proxy_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "host_domains_redirect_host_id_idx": { + "name": "host_domains_redirect_host_id_idx", + "columns": [ + { + "expression": "redirect_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_domains_proxy_host_id_proxy_hosts_id_fk": { + "name": "host_domains_proxy_host_id_proxy_hosts_id_fk", + "tableFrom": "host_domains", + "tableTo": "proxy_hosts", + "schemaTo": "rentnerproxy", + "columnsFrom": ["proxy_host_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_domains_redirect_host_id_redirect_hosts_id_fk": { + "name": "host_domains_redirect_host_id_redirect_hosts_id_fk", + "tableFrom": "host_domains", + "tableTo": "redirect_hosts", + "schemaTo": "rentnerproxy", + "columnsFrom": ["redirect_host_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "host_domains_domain_unique": { + "name": "host_domains_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "host_domains_domain_canonical_check": { + "name": "host_domains_domain_canonical_check", + "value": "\"rentnerproxy\".\"host_domains\".\"domain\" ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' and \"rentnerproxy\".\"host_domains\".\"domain\" !~ '^[0-9.]+$'" + }, + "host_domains_exactly_one_owner_check": { + "name": "host_domains_exactly_one_owner_check", + "value": "num_nonnulls(\"rentnerproxy\".\"host_domains\".\"proxy_host_id\", \"rentnerproxy\".\"host_domains\".\"redirect_host_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.proxy_hosts": { + "name": "proxy_hosts", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "forward_scheme": { + "name": "forward_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "forward_host": { + "name": "forward_host", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true + }, + "forward_port": { + "name": "forward_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "certificate_id": { + "name": "certificate_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "force_https": { + "name": "force_https", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verify_upstream_tls": { + "name": "verify_upstream_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "upstream_tls_server_name": { + "name": "upstream_tls_server_name", + "type": "varchar(253)", + "primaryKey": false, + "notNull": false + }, + "trusted_ca_id": { + "name": "trusted_ca_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_policy_id": { + "name": "access_policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proxy_hosts_enabled_idx": { + "name": "proxy_hosts_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proxy_hosts_certificate_id_idx": { + "name": "proxy_hosts_certificate_id_idx", + "columns": [ + { + "expression": "certificate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proxy_hosts_trusted_ca_id_idx": { + "name": "proxy_hosts_trusted_ca_id_idx", + "columns": [ + { + "expression": "trusted_ca_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proxy_hosts_access_policy_id_idx": { + "name": "proxy_hosts_access_policy_id_idx", + "columns": [ + { + "expression": "access_policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proxy_hosts_certificate_id_certificates_id_fk": { + "name": "proxy_hosts_certificate_id_certificates_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "certificates", + "schemaTo": "rentnerproxy", + "columnsFrom": ["certificate_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "proxy_hosts_trusted_ca_id_trusted_cas_id_fk": { + "name": "proxy_hosts_trusted_ca_id_trusted_cas_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "trusted_cas", + "schemaTo": "rentnerproxy", + "columnsFrom": ["trusted_ca_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "proxy_hosts_access_policy_id_access_policies_id_fk": { + "name": "proxy_hosts_access_policy_id_access_policies_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "access_policies", + "schemaTo": "rentnerproxy", + "columnsFrom": ["access_policy_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proxy_hosts_forward_scheme_check": { + "name": "proxy_hosts_forward_scheme_check", + "value": "\"rentnerproxy\".\"proxy_hosts\".\"forward_scheme\" in ('http', 'https')" + }, + "proxy_hosts_forward_port_check": { + "name": "proxy_hosts_forward_port_check", + "value": "\"rentnerproxy\".\"proxy_hosts\".\"forward_port\" between 1 and 65535" + }, + "proxy_hosts_forward_host_check": { + "name": "proxy_hosts_forward_host_check", + "value": "length(btrim(\"rentnerproxy\".\"proxy_hosts\".\"forward_host\")) > 0" + }, + "proxy_hosts_trusted_ca_verification_check": { + "name": "proxy_hosts_trusted_ca_verification_check", + "value": "\"rentnerproxy\".\"proxy_hosts\".\"trusted_ca_id\" is null or (\"rentnerproxy\".\"proxy_hosts\".\"forward_scheme\" = 'https' and \"rentnerproxy\".\"proxy_hosts\".\"verify_upstream_tls\" = true)" + }, + "proxy_hosts_force_https_check": { + "name": "proxy_hosts_force_https_check", + "value": "\"rentnerproxy\".\"proxy_hosts\".\"force_https\" = false or \"rentnerproxy\".\"proxy_hosts\".\"certificate_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.redirect_hosts": { + "name": "redirect_hosts", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "destination": { + "name": "destination", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 302 + }, + "preserve_request_uri": { + "name": "preserve_request_uri", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "certificate_id": { + "name": "certificate_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "redirect_hosts_enabled_idx": { + "name": "redirect_hosts_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redirect_hosts_certificate_id_idx": { + "name": "redirect_hosts_certificate_id_idx", + "columns": [ + { + "expression": "certificate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "redirect_hosts_certificate_id_certificates_id_fk": { + "name": "redirect_hosts_certificate_id_certificates_id_fk", + "tableFrom": "redirect_hosts", + "tableTo": "certificates", + "schemaTo": "rentnerproxy", + "columnsFrom": ["certificate_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "redirect_hosts_status_code_check": { + "name": "redirect_hosts_status_code_check", + "value": "\"rentnerproxy\".\"redirect_hosts\".\"status_code\" in (301, 302, 307, 308)" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.certificate_domains": { + "name": "certificate_domains", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "certificate_id": { + "name": "certificate_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "certificate_domains_certificate_id_idx": { + "name": "certificate_domains_certificate_id_idx", + "columns": [ + { + "expression": "certificate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "certificate_domains_certificate_id_certificates_id_fk": { + "name": "certificate_domains_certificate_id_certificates_id_fk", + "tableFrom": "certificate_domains", + "tableTo": "certificates", + "schemaTo": "rentnerproxy", + "columnsFrom": ["certificate_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "certificate_domains_certificate_domain_unique": { + "name": "certificate_domains_certificate_domain_unique", + "nullsNotDistinct": false, + "columns": ["certificate_id", "domain"] + } + }, + "policies": {}, + "checkConstraints": { + "certificate_domains_canonical_check": { + "name": "certificate_domains_canonical_check", + "value": "\"rentnerproxy\".\"certificate_domains\".\"domain\" ~ '^([*][.])?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' and \"rentnerproxy\".\"certificate_domains\".\"domain\" !~ '^[0-9.]+$'" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.certificate_event_cursor": { + "name": "certificate_event_cursor", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "cursor": { + "name": "cursor", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "certificate_event_cursor_singleton_check": { + "name": "certificate_event_cursor_singleton_check", + "value": "\"rentnerproxy\".\"certificate_event_cursor\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.certificate_event_receipts": { + "name": "certificate_event_receipts", + "schema": "rentnerproxy", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "certificate_id": { + "name": "certificate_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "certificate_event_receipts_received_at_idx": { + "name": "certificate_event_receipts_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "certificate_event_receipts_certificate_id_idx": { + "name": "certificate_event_receipts_certificate_id_idx", + "columns": [ + { + "expression": "certificate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "certificate_event_receipts_kind_check": { + "name": "certificate_event_receipts_kind_check", + "value": "\"rentnerproxy\".\"certificate_event_receipts\".\"kind\" in ('accepted', 'started', 'issued', 'activated', 'renewed', 'retry_scheduled', 'failed')" + }, + "certificate_event_receipts_stage_check": { + "name": "certificate_event_receipts_stage_check", + "value": "\"rentnerproxy\".\"certificate_event_receipts\".\"stage\" in ('queued', 'creating_order', 'preparing_challenge', 'waiting_for_validation', 'finalizing', 'certificate_ready', 'applying', 'applied', 'retry_scheduled', 'failed', 'needs_attention')" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.certificates": { + "name": "certificates", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "operation": { + "name": "operation", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "current_operation": { + "name": "current_operation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "challenge_type": { + "name": "challenge_type", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(71)", + "primaryKey": false, + "notNull": false + }, + "candidate": { + "name": "candidate", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dns_cleanup_pending": { + "name": "dns_cleanup_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_activated_at": { + "name": "last_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_renewal_at": { + "name": "next_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "controller_updated_at": { + "name": "controller_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "certificates_expires_at_idx": { + "name": "certificates_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "certificates_name_check": { + "name": "certificates_name_check", + "value": "length(btrim(\"rentnerproxy\".\"certificates\".\"name\")) > 0" + }, + "certificates_source_check": { + "name": "certificates_source_check", + "value": "\"rentnerproxy\".\"certificates\".\"source\" in ('manual', 'acme')" + }, + "certificates_environment_check": { + "name": "certificates_environment_check", + "value": "(\"rentnerproxy\".\"certificates\".\"source\" = 'manual' and \"rentnerproxy\".\"certificates\".\"environment\" is null) or (\"rentnerproxy\".\"certificates\".\"source\" = 'acme' and \"rentnerproxy\".\"certificates\".\"environment\" is not null and \"rentnerproxy\".\"certificates\".\"environment\" in ('staging', 'production'))" + }, + "certificates_status_check": { + "name": "certificates_status_check", + "value": "\"rentnerproxy\".\"certificates\".\"status\" in ('pending', 'valid', 'failed')" + }, + "certificates_operation_check": { + "name": "certificates_operation_check", + "value": "\"rentnerproxy\".\"certificates\".\"operation\" in ('idle', 'issuing', 'renewing')" + }, + "certificates_challenge_type_check": { + "name": "certificates_challenge_type_check", + "value": "\"rentnerproxy\".\"certificates\".\"challenge_type\" is null or \"rentnerproxy\".\"certificates\".\"challenge_type\" in ('http-01', 'dns-01')" + }, + "certificates_attempt_count_check": { + "name": "certificates_attempt_count_check", + "value": "\"rentnerproxy\".\"certificates\".\"attempt_count\" >= 0" + }, + "certificates_validity_check": { + "name": "certificates_validity_check", + "value": "\"rentnerproxy\".\"certificates\".\"status\" != 'valid' or (\"rentnerproxy\".\"certificates\".\"issued_at\" is not null and \"rentnerproxy\".\"certificates\".\"expires_at\" is not null and \"rentnerproxy\".\"certificates\".\"issued_at\" < \"rentnerproxy\".\"certificates\".\"expires_at\" and \"rentnerproxy\".\"certificates\".\"fingerprint\" is not null)" + }, + "certificates_fingerprint_check": { + "name": "certificates_fingerprint_check", + "value": "\"rentnerproxy\".\"certificates\".\"fingerprint\" is null or \"rentnerproxy\".\"certificates\".\"fingerprint\" ~ '^sha256:[a-f0-9]{64}$'" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.trusted_cas": { + "name": "trusted_cas", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "pem": { + "name": "pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_sha256": { + "name": "fingerprint_sha256", + "type": "varchar(71)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "not_before": { + "name": "not_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "not_after": { + "name": "not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trusted_cas_created_at_idx": { + "name": "trusted_cas_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trusted_cas_fingerprint_sha256_unique": { + "name": "trusted_cas_fingerprint_sha256_unique", + "nullsNotDistinct": false, + "columns": ["fingerprint_sha256"] + } + }, + "policies": {}, + "checkConstraints": { + "trusted_cas_name_check": { + "name": "trusted_cas_name_check", + "value": "length(btrim(\"rentnerproxy\".\"trusted_cas\".\"name\")) > 0" + }, + "trusted_cas_fingerprint_sha256_check": { + "name": "trusted_cas_fingerprint_sha256_check", + "value": "\"rentnerproxy\".\"trusted_cas\".\"fingerprint_sha256\" ~ '^sha256:[a-f0-9]{64}$'" + }, + "trusted_cas_validity_check": { + "name": "trusted_cas_validity_check", + "value": "\"rentnerproxy\".\"trusted_cas\".\"not_before\" < \"rentnerproxy\".\"trusted_cas\".\"not_after\"" + }, + "trusted_cas_pem_limit_check": { + "name": "trusted_cas_pem_limit_check", + "value": "octet_length(\"rentnerproxy\".\"trusted_cas\".\"pem\") between 1 and 262144" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.proxy_host_legacy_settings": { + "name": "proxy_host_legacy_settings", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "proxy_host_id": { + "name": "proxy_host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "advanced_config": { + "name": "advanced_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unsupported_settings": { + "name": "unsupported_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "rentnerproxy.audit_events": { + "name": "audit_events", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "audit_actor_kind", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "audit_resource", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "audit_result", + "typeSchema": "rentnerproxy", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_events_created_at_id_idx": { + "name": "audit_events_created_at_id_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_user_id_idx": { + "name": "audit_events_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_action_resource_idx": { + "name": "audit_events_action_resource_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_result_idx": { + "name": "audit_events_result_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_events_metadata_size_check": { + "name": "audit_events_metadata_size_check", + "value": "pg_column_size(\"rentnerproxy\".\"audit_events\".\"metadata\") <= 4096" + } + }, + "isRLSEnabled": false + }, + "rentnerproxy.certificate_binding_jobs": { + "name": "certificate_binding_jobs", + "schema": "rentnerproxy", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "proxy_host_id": { + "name": "proxy_host_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "certificate_id": { + "name": "certificate_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "domains": { + "name": "domains", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "required_permissions": { + "name": "required_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "host_revision": { + "name": "host_revision", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "assigned_revision": { + "name": "assigned_revision", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "desired_enabled": { + "name": "desired_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "desired_force_https": { + "name": "desired_force_https", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "request_ciphertext": { + "name": "request_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "request_iv": { + "name": "request_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "controller_stage": { + "name": "controller_stage", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "controller_operation_id": { + "name": "controller_operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_requested": { + "name": "retry_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "certificate_jobs_due_idx": { + "name": "certificate_jobs_due_idx", + "columns": [ + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "certificate_jobs_host_created_idx": { + "name": "certificate_jobs_host_created_idx", + "columns": [ + { + "expression": "proxy_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "certificate_binding_jobs_actor_user_id_users_id_fk": { + "name": "certificate_binding_jobs_actor_user_id_users_id_fk", + "tableFrom": "certificate_binding_jobs", + "tableTo": "users", + "schemaTo": "rentnerproxy", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "certificate_binding_jobs_proxy_host_id_proxy_hosts_id_fk": { + "name": "certificate_binding_jobs_proxy_host_id_proxy_hosts_id_fk", + "tableFrom": "certificate_binding_jobs", + "tableTo": "proxy_hosts", + "schemaTo": "rentnerproxy", + "columnsFrom": ["proxy_host_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "certificate_binding_jobs_certificate_id_certificates_id_fk": { + "name": "certificate_binding_jobs_certificate_id_certificates_id_fk", + "tableFrom": "certificate_binding_jobs", + "tableTo": "certificates", + "schemaTo": "rentnerproxy", + "columnsFrom": ["certificate_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "certificate_jobs_actor_idempotency_unique": { + "name": "certificate_jobs_actor_idempotency_unique", + "nullsNotDistinct": false, + "columns": ["actor_user_id", "idempotency_key"] + }, + "certificate_jobs_certificate_unique": { + "name": "certificate_jobs_certificate_unique", + "nullsNotDistinct": false, + "columns": ["certificate_id"] + } + }, + "policies": {}, + "checkConstraints": { + "certificate_jobs_stage_check": { + "name": "certificate_jobs_stage_check", + "value": "\"rentnerproxy\".\"certificate_binding_jobs\".\"stage\" in ('preparing','issuing','applying','applied','failed','needs_attention')" + }, + "certificate_jobs_attempts_check": { + "name": "certificate_jobs_attempts_check", + "value": "\"rentnerproxy\".\"certificate_binding_jobs\".\"attempt_count\" >= 0" + }, + "certificate_jobs_domains_check": { + "name": "certificate_jobs_domains_check", + "value": "cardinality(\"rentnerproxy\".\"certificate_binding_jobs\".\"domains\") between 1 and 100" + }, + "certificate_jobs_permissions_check": { + "name": "certificate_jobs_permissions_check", + "value": "cardinality(\"rentnerproxy\".\"certificate_binding_jobs\".\"required_permissions\") between 1 and 10" + }, + "certificate_jobs_secret_check": { + "name": "certificate_jobs_secret_check", + "value": "(\"rentnerproxy\".\"certificate_binding_jobs\".\"request_ciphertext\" is null and \"rentnerproxy\".\"certificate_binding_jobs\".\"request_iv\" is null) or (\"rentnerproxy\".\"certificate_binding_jobs\".\"request_ciphertext\" is not null and \"rentnerproxy\".\"certificate_binding_jobs\".\"request_iv\" is not null and octet_length(\"rentnerproxy\".\"certificate_binding_jobs\".\"request_ciphertext\") between 17 and 65536 and octet_length(\"rentnerproxy\".\"certificate_binding_jobs\".\"request_iv\") = 12)" + }, + "certificate_jobs_lease_check": { + "name": "certificate_jobs_lease_check", + "value": "(\"rentnerproxy\".\"certificate_binding_jobs\".\"lease_token\" is null) = (\"rentnerproxy\".\"certificate_binding_jobs\".\"lease_expires_at\" is null)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "rentnerproxy.user_status": { + "name": "user_status", + "schema": "rentnerproxy", + "values": ["pending", "active", "disabled"] + }, + "rentnerproxy.access_policy_combination": { + "name": "access_policy_combination", + "schema": "rentnerproxy", + "values": ["all", "any"] + }, + "rentnerproxy.access_policy_mode": { + "name": "access_policy_mode", + "schema": "rentnerproxy", + "values": ["public", "authenticated", "ip-restricted", "combined"] + }, + "rentnerproxy.audit_action": { + "name": "audit_action", + "schema": "rentnerproxy", + "values": [ + "login", + "logout", + "create", + "update", + "delete", + "enable", + "disable", + "rotate", + "reset", + "accept", + "reauthenticate", + "import", + "replace", + "request", + "renew", + "save", + "apply", + "accepted", + "started", + "issued", + "activated", + "renewed", + "retry_scheduled", + "failed" + ] + }, + "rentnerproxy.audit_actor_kind": { + "name": "audit_actor_kind", + "schema": "rentnerproxy", + "values": ["user", "anonymous", "system"] + }, + "rentnerproxy.audit_resource": { + "name": "audit_resource", + "schema": "rentnerproxy", + "values": [ + "session", + "password", + "totp", + "recovery-codes", + "passkey", + "invite", + "setup", + "security-settings", + "user", + "role", + "proxy-host", + "redirect-host", + "access-policy", + "basic-auth-account", + "certificate", + "trusted-ca", + "proxy-runtime-settings", + "proxy-host-settings", + "proxy-runtime", + "crowdsec" + ] + }, + "rentnerproxy.audit_result": { + "name": "audit_result", + "schema": "rentnerproxy", + "values": ["success", "failure", "denied"] + } + }, + "schemas": { + "rentnerproxy": "rentnerproxy" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index ccc8c532..ae5752bf 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1789246463427, "tag": "0019_certificate_binding_jobs", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1790103395265, + "tag": "0020_chief_warpath", + "breakpoints": true } ] } diff --git a/web/src/config/crowdsec.config.ts b/web/src/config/crowdsec.config.ts new file mode 100644 index 00000000..cd5c82b5 --- /dev/null +++ b/web/src/config/crowdsec.config.ts @@ -0,0 +1,9 @@ +export const CROWDSEC_MODES = ['disabled', 'managed', 'external'] as const +export type CrowdSecMode = (typeof CROWDSEC_MODES)[number] + +export const CROWDSEC_API_URL_MAX_LENGTH = 2_048 +export const CROWDSEC_API_KEY_MIN_LENGTH = 16 +export const CROWDSEC_API_KEY_MAX_LENGTH = 512 + +export const CROWDSEC_SETTINGS_KEY = 'crowdsec_configuration_v1' +export const CROWDSEC_SECRET_CONTEXT = 'crowdsec_configuration_v1:external_api_key' diff --git a/web/src/config/permissions.config.ts b/web/src/config/permissions.config.ts index a1972694..69f491fc 100644 --- a/web/src/config/permissions.config.ts +++ b/web/src/config/permissions.config.ts @@ -42,6 +42,8 @@ export const PERMISSIONS = { ACCESS_POLICIES_ASSIGN: 'access_policies.assign', ACCESS_POLICIES_APPLY: 'access_policies.apply', PROXY_ACCESS_LOGS_VIEW: 'proxy-access-logs:view', + CROWDSEC_VIEW: 'crowdsec.view', + CROWDSEC_UPDATE: 'crowdsec.update', AUDIT_LOGS_VIEW: 'audit-logs:view', ACCOUNT_VIEW: 'account.view', ACCOUNT_UPDATE: 'account.update', @@ -96,6 +98,8 @@ export const PERMISSION_REGISTRY = [ { key: PERMISSIONS.ACCESS_POLICIES_ASSIGN, name: 'Assign access policies' }, { key: PERMISSIONS.ACCESS_POLICIES_APPLY, name: 'Apply access policies' }, { key: PERMISSIONS.PROXY_ACCESS_LOGS_VIEW, name: 'View proxy access logs' }, + { key: PERMISSIONS.CROWDSEC_VIEW, name: 'View CrowdSec configuration' }, + { key: PERMISSIONS.CROWDSEC_UPDATE, name: 'Update CrowdSec configuration' }, { key: PERMISSIONS.AUDIT_LOGS_VIEW, name: 'View audit log' }, { key: PERMISSIONS.ACCOUNT_VIEW, name: 'View own account' }, { key: PERMISSIONS.ACCOUNT_UPDATE, name: 'Update own account' }, @@ -166,6 +170,8 @@ export const SYSTEM_ROLE_REGISTRY = [ PERMISSIONS.ACCESS_POLICIES_ASSIGN, PERMISSIONS.ACCESS_POLICIES_APPLY, PERMISSIONS.PROXY_ACCESS_LOGS_VIEW, + PERMISSIONS.CROWDSEC_VIEW, + PERMISSIONS.CROWDSEC_UPDATE, PERMISSIONS.AUDIT_LOGS_VIEW, PERMISSIONS.ACCOUNT_VIEW, PERMISSIONS.ACCOUNT_UPDATE, @@ -181,6 +187,7 @@ export const SYSTEM_ROLE_REGISTRY = [ PERMISSIONS.REDIRECT_HOSTS_VIEW, PERMISSIONS.CERTIFICATES_VIEW, PERMISSIONS.TRUSTED_CAS_VIEW, + PERMISSIONS.CROWDSEC_VIEW, PERMISSIONS.ACCOUNT_VIEW, PERMISSIONS.ACCOUNT_UPDATE, ], diff --git a/web/src/db/Schema/auditEvents.ts b/web/src/db/Schema/auditEvents.ts index e997a0f8..26ceb862 100644 --- a/web/src/db/Schema/auditEvents.ts +++ b/web/src/db/Schema/auditEvents.ts @@ -55,6 +55,7 @@ export const auditResource = rentnerProxySchema.enum('audit_resource', [ 'proxy-runtime-settings', 'proxy-host-settings', 'proxy-runtime', + 'crowdsec', ]) export const auditResult = rentnerProxySchema.enum('audit_result', ['success', 'failure', 'denied']) diff --git a/web/src/features/Admin/CrowdSec/Components/CrowdSecPageView.tsx b/web/src/features/Admin/CrowdSec/Components/CrowdSecPageView.tsx new file mode 100644 index 00000000..8ab9bf6d --- /dev/null +++ b/web/src/features/Admin/CrowdSec/Components/CrowdSecPageView.tsx @@ -0,0 +1,309 @@ +import { + CloudCog, + KeyRound, + LockKeyhole, + Network, + RefreshCw, + ServerCog, + ShieldOff, +} from 'lucide-react' +import { PasswordInput, TextInput } from '@rentnerkev/inputs' + +import { + CROWDSEC_API_KEY_MAX_LENGTH, + CROWDSEC_API_URL_MAX_LENGTH, + type CrowdSecMode, +} from '../../../../config/crowdsec.config' +import useTranslationStore from '../../../../language/useTranslationStore' +import FieldError from '../../../../shared/Forms/FieldError' +import ContentState from '../../../../shared/Management/ContentState' +import PageHeader from '../../../../shared/Management/PageHeader' +import { uiClassNames } from '../../../../shared/Styles/uiClassNames' +import type { CrowdSecPageViewProps } from '../Types/crowdsec.types' +import CrowdSecStatusPanel from './CrowdSecStatusPanel' + +const MODE_ICONS = { + disabled: ShieldOff, + managed: ServerCog, + external: CloudCog, +} as const + +function ModeCard({ + mode, + selected, + disabled, + onSelect, +}: { + readonly mode: CrowdSecMode + readonly selected: boolean + readonly disabled: boolean + readonly onSelect: (mode: CrowdSecMode) => void +}) { + const { t } = useTranslationStore() + const Icon = MODE_ICONS[mode] + return ( +
+ onSelect(mode)} + /> + +
+ ) +} + +export default function CrowdSecPageView({ logic: { state, handler } }: CrowdSecPageViewProps) { + const { t } = useTranslationStore() + const busy = state.isSaving || state.isTesting + return ( + <> + + {state.isError ? ( + + {t('common.retry')} + + } + /> + ) : state.isLoading || !state.configuration ? ( + + ) : ( + <> + +
+
+
+

+ {t('admin.crowdSec.configuration.eyebrow')} +

+

+ {t('admin.crowdSec.configuration.title')} +

+

+ {t('admin.crowdSec.configuration.description')} +

+
+ {state.isRefreshing ? ( + + + ) : null} +
+ +
+ {(['disabled', 'managed', 'external'] as const).map((mode) => ( + + ))} +
+ + {state.mode === 'external' ? ( +
+
+ + +
+

+ {t('admin.crowdSec.external.title')} +

+

+ {t('admin.crowdSec.external.description')} +

+
+
+
+
+ + + handler.setApiUrl(event.target.value) + } + /> + +
+
+ + + handler.setApiKey(event.target.value) + } + /> + +

+ {t('admin.crowdSec.external.apiKeyHint')} +

+
+
+ +
+ ) : null} + +
+

+ {t('admin.crowdSec.configuration.retention')} +

+ +
+
+ +
+ {(['clientIp', 'failure', 'deployment'] as const).map((item) => ( +
+

+ {t(`admin.crowdSec.facts.${item}.label`)} +

+

+ {t(`admin.crowdSec.facts.${item}.value`)} +

+

+ {t(`admin.crowdSec.facts.${item}.description`)} +

+
+ ))} +
+ + )} + + ) +} diff --git a/web/src/features/Admin/CrowdSec/Components/CrowdSecStatusPanel.tsx b/web/src/features/Admin/CrowdSec/Components/CrowdSecStatusPanel.tsx new file mode 100644 index 00000000..3709660f --- /dev/null +++ b/web/src/features/Admin/CrowdSec/Components/CrowdSecStatusPanel.tsx @@ -0,0 +1,94 @@ +import { Activity, CircleDot, ShieldCheck } from 'lucide-react' + +import useTranslationStore from '../../../../language/useTranslationStore' +import type { CrowdSecConfiguration } from '../../../../shared/Types/crowdsec.types' + +const stateStyles = { + disabled: 'border-border-strong bg-surface-raised text-muted', + starting: 'border-amber-500/35 bg-amber-500/10 text-amber-300', + connected: 'border-brand-500/30 bg-success-bg text-success-text', + degraded: 'border-red-500/30 bg-danger-bg text-danger-text', + unavailable: 'border-red-500/30 bg-danger-bg text-danger-text', +} as const + +export default function CrowdSecStatusPanel({ + configuration, +}: { + readonly configuration: CrowdSecConfiguration +}) { + const { t } = useTranslationStore() + const runtimeState = configuration.runtime?.state ?? 'unavailable' + const managedEngine = configuration.runtime?.managedEngine ?? 'unavailable' + return ( +
+
+ ) +} diff --git a/web/src/features/Admin/CrowdSec/Hooks/useCrowdSecLogic.ts b/web/src/features/Admin/CrowdSec/Hooks/useCrowdSecLogic.ts new file mode 100644 index 00000000..518e9f3f --- /dev/null +++ b/web/src/features/Admin/CrowdSec/Hooks/useCrowdSecLogic.ts @@ -0,0 +1,157 @@ +import { toast } from '@rentnerkev/toasts/toast' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' + +import type { CrowdSecMode } from '../../../../config/crowdsec.config' +import { PERMISSIONS } from '../../../../config/permissions.config' +import useTranslationStore from '../../../../language/useTranslationStore' +import { crowdSecQueryKeys } from '../queryKeys' +import { + getCrowdSecConfigurationHandler, + testCrowdSecConnectionHandler, + updateCrowdSecConfigurationHandler, +} from '../server' +import type { CrowdSecPageLogic, CrowdSecPageProps } from '../Types/crowdsec.types' +import { testCrowdSecConnectionSchema, updateCrowdSecConfigurationSchema } from '../validation' + +type FieldErrors = CrowdSecPageLogic['state']['fieldErrors'] + +function validationErrors(error: { + issues: readonly { path: readonly PropertyKey[] }[] +}): FieldErrors { + return { + ...(error.issues.some((issue) => issue.path[0] === 'apiUrl') + ? { apiUrl: 'admin.crowdSec.validation.apiUrl' } + : {}), + ...(error.issues.some((issue) => issue.path[0] === 'apiKey') + ? { apiKey: 'admin.crowdSec.validation.apiKey' } + : {}), + } +} + +export default function useCrowdSecLogic({ permissions }: CrowdSecPageProps): CrowdSecPageLogic { + const { t } = useTranslationStore() + const queryClient = useQueryClient() + const canUpdate = permissions.includes(PERMISSIONS.CROWDSEC_UPDATE) + const [draftMode, setDraftMode] = useState(null) + const [draftApiUrl, setDraftApiUrl] = useState(null) + const [apiKey, setApiKey] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + const query = useQuery({ + queryKey: crowdSecQueryKeys.configuration, + queryFn: () => getCrowdSecConfigurationHandler(), + refetchInterval: 10_000, + }) + const configuration = query.data + const mode = draftMode ?? configuration?.mode ?? 'disabled' + const apiUrl = draftApiUrl ?? configuration?.externalApiUrl ?? '' + + const testMutation = useMutation({ + mutationFn: async () => { + const candidate = { + apiUrl, + ...(apiKey.length > 0 ? { apiKey } : {}), + } + const parsed = testCrowdSecConnectionSchema.safeParse(candidate) + if (!parsed.success) { + setFieldErrors(validationErrors(parsed.error)) + return null + } + setFieldErrors({}) + return testCrowdSecConnectionHandler({ data: parsed.data }) + }, + onSuccess: (result) => { + if (!result) return + toast[result.success ? 'success' : 'error'](t(result.message), { + title: t(`toast.titles.${result.success ? 'success' : 'error'}`), + }) + }, + onError: () => { + toast.error(t('admin.crowdSec.errors.testFailed'), { + title: t('toast.titles.error'), + }) + }, + }) + + const saveMutation = useMutation({ + mutationFn: async () => { + const candidate = + mode === 'external' + ? { mode, apiUrl, ...(apiKey.length > 0 ? { apiKey } : {}) } + : { mode } + const parsed = updateCrowdSecConfigurationSchema.safeParse(candidate) + if (!parsed.success) { + setFieldErrors(validationErrors(parsed.error)) + return null + } + setFieldErrors({}) + return updateCrowdSecConfigurationHandler({ data: parsed.data }) + }, + onSuccess: async (result) => { + if (!result) return + if (!result.success) { + toast.error(t(result.message), { title: t('toast.titles.error') }) + return + } + setDraftMode(null) + setDraftApiUrl(null) + setApiKey('') + await queryClient.invalidateQueries({ queryKey: crowdSecQueryKeys.configuration }) + const kind = result.runtimeStatus === 'pending' ? 'warning' : 'success' + toast[kind](t(result.message), { title: t(`toast.titles.${kind}`) }) + }, + onError: () => { + toast.error(t('admin.crowdSec.errors.saveFailed'), { + title: t('toast.titles.error'), + }) + }, + }) + + const isDirty = + configuration !== undefined && + (mode !== configuration.mode || + apiUrl !== (configuration.externalApiUrl ?? '') || + apiKey.length > 0) + + return { + state: { + canUpdate, + configuration, + mode, + apiUrl, + apiKey, + fieldErrors, + isDirty, + isError: query.isError, + isLoading: query.isPending, + isRefreshing: query.isFetching, + isSaving: saveMutation.isPending, + isTesting: testMutation.isPending, + }, + handler: { + retry: () => void query.refetch(), + setMode: (nextMode) => { + if (!canUpdate) return + setDraftMode(nextMode) + setFieldErrors({}) + }, + setApiUrl: (value) => { + if (!canUpdate) return + setDraftApiUrl(value) + setFieldErrors(({ apiUrl: _apiUrl, ...current }) => current) + }, + setApiKey: (value) => { + if (!canUpdate) return + setApiKey(value) + setFieldErrors(({ apiKey: _apiKey, ...current }) => current) + }, + testConnection: () => { + if (canUpdate && mode === 'external' && !testMutation.isPending) + testMutation.mutate() + }, + save: () => { + if (canUpdate && isDirty && !saveMutation.isPending) saveMutation.mutate() + }, + }, + } +} diff --git a/web/src/features/Admin/CrowdSec/Types/crowdsec.types.ts b/web/src/features/Admin/CrowdSec/Types/crowdsec.types.ts new file mode 100644 index 00000000..7591419d --- /dev/null +++ b/web/src/features/Admin/CrowdSec/Types/crowdsec.types.ts @@ -0,0 +1,40 @@ +import type { CrowdSecMode } from '../../../../config/crowdsec.config' +import type { CrowdSecConfiguration } from '../../../../shared/Types/crowdsec.types' + +export interface CrowdSecPageProps { + readonly permissions: readonly string[] +} + +export interface CrowdSecPageState { + readonly canUpdate: boolean + readonly configuration: CrowdSecConfiguration | undefined + readonly mode: CrowdSecMode + readonly apiUrl: string + readonly apiKey: string + readonly fieldErrors: { + readonly apiUrl?: string + readonly apiKey?: string + } + readonly isDirty: boolean + readonly isError: boolean + readonly isLoading: boolean + readonly isRefreshing: boolean + readonly isSaving: boolean + readonly isTesting: boolean +} + +export interface CrowdSecPageLogic { + readonly state: CrowdSecPageState + readonly handler: { + readonly retry: () => void + readonly setMode: (mode: CrowdSecMode) => void + readonly setApiUrl: (value: string) => void + readonly setApiKey: (value: string) => void + readonly testConnection: () => void + readonly save: () => void + } +} + +export interface CrowdSecPageViewProps { + readonly logic: CrowdSecPageLogic +} diff --git a/web/src/features/Admin/CrowdSec/index.tsx b/web/src/features/Admin/CrowdSec/index.tsx new file mode 100644 index 00000000..4fd6bf18 --- /dev/null +++ b/web/src/features/Admin/CrowdSec/index.tsx @@ -0,0 +1,7 @@ +import CrowdSecPageView from './Components/CrowdSecPageView' +import useCrowdSecLogic from './Hooks/useCrowdSecLogic' +import type { CrowdSecPageProps } from './Types/crowdsec.types' + +export default function CrowdSecPage(props: CrowdSecPageProps) { + return +} diff --git a/web/src/features/Admin/CrowdSec/queryKeys.ts b/web/src/features/Admin/CrowdSec/queryKeys.ts new file mode 100644 index 00000000..323136e3 --- /dev/null +++ b/web/src/features/Admin/CrowdSec/queryKeys.ts @@ -0,0 +1,3 @@ +export const crowdSecQueryKeys = { + configuration: ['crowdsec', 'configuration'] as const, +} diff --git a/web/src/features/Admin/CrowdSec/server.ts b/web/src/features/Admin/CrowdSec/server.ts new file mode 100644 index 00000000..075f52fb --- /dev/null +++ b/web/src/features/Admin/CrowdSec/server.ts @@ -0,0 +1,83 @@ +import { createServerFn } from '@tanstack/react-start' +import { setResponseHeader, setResponseStatus } from '@tanstack/react-start/server' + +import { CrowdSecDomainError } from '../../../server/Admin/CrowdSec/crowdsec.errors' +import { + getCrowdSecConfigurationService, + testCrowdSecConnectionService, + updateCrowdSecConfigurationService, +} from '../../../server/Admin/CrowdSec/crowdsec.service' +import { localizedActionFailure, throwLocalizedQueryError } from '../../Auth/serverHelpers' +import { testCrowdSecConnectionSchema, updateCrowdSecConfigurationSchema } from './validation' + +function noStore(): void { + setResponseHeader('Cache-Control', 'private, no-store') +} + +const errorMessages = { + invalid_input: 'admin.crowdSec.errors.invalidInput', + api_key_required: 'admin.crowdSec.errors.apiKeyRequired', + connection_failed: 'admin.crowdSec.errors.connectionFailed', + controller_unavailable: 'admin.crowdSec.errors.controllerUnavailable', + configuration_conflict: 'admin.crowdSec.errors.configurationConflict', + configuration_unavailable: 'admin.crowdSec.errors.configurationUnavailable', +} as const + +function crowdSecActionFailure(error: unknown) { + if (error instanceof CrowdSecDomainError) { + setResponseStatus( + error.code === 'configuration_conflict' + ? 409 + : error.code === 'controller_unavailable' || + error.code === 'configuration_unavailable' + ? 503 + : error.code === 'connection_failed' + ? 502 + : 422, + ) + return { success: false as const, message: errorMessages[error.code] } + } + return localizedActionFailure(error, 'admin.crowdSec.errors.saveFailed') +} + +export const getCrowdSecConfigurationHandler = createServerFn({ method: 'GET' }).handler( + async () => { + noStore() + try { + return await getCrowdSecConfigurationService() + } catch (error) { + throwLocalizedQueryError(error, 'admin.crowdSec.errors.loadFailed') + } + }, +) + +export const testCrowdSecConnectionHandler = createServerFn({ method: 'POST' }) + .validator(testCrowdSecConnectionSchema) + .handler(async ({ data }) => { + noStore() + try { + await testCrowdSecConnectionService(data) + return { success: true as const, message: 'admin.crowdSec.messages.connectionValid' } + } catch (error) { + return crowdSecActionFailure(error) + } + }) + +export const updateCrowdSecConfigurationHandler = createServerFn({ method: 'POST' }) + .validator(updateCrowdSecConfigurationSchema) + .handler(async ({ data }) => { + noStore() + try { + const result = await updateCrowdSecConfigurationService(data) + return { + success: true as const, + message: + result.runtimeStatus === 'pending' + ? 'admin.crowdSec.messages.savedPending' + : 'admin.crowdSec.messages.saved', + ...result, + } + } catch (error) { + return crowdSecActionFailure(error) + } + }) diff --git a/web/src/features/Admin/CrowdSec/validation.ts b/web/src/features/Admin/CrowdSec/validation.ts new file mode 100644 index 00000000..9a5353bc --- /dev/null +++ b/web/src/features/Admin/CrowdSec/validation.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' + +import { + CROWDSEC_API_KEY_MAX_LENGTH, + CROWDSEC_API_KEY_MIN_LENGTH, + CROWDSEC_API_URL_MAX_LENGTH, + CROWDSEC_MODES, +} from '../../../config/crowdsec.config' + +export const crowdSecModeSchema = z.enum(CROWDSEC_MODES) + +export const crowdSecApiUrlSchema = z + .string() + .min(1) + .max(CROWDSEC_API_URL_MAX_LENGTH) + .refine((value) => value === value.trim()) + .refine((value) => { + try { + const url = new URL(value) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + url.hostname.length > 0 && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ) + } catch { + return false + } + }) + +export const crowdSecApiKeySchema = z + .string() + .min(CROWDSEC_API_KEY_MIN_LENGTH) + .max(CROWDSEC_API_KEY_MAX_LENGTH) + .refine((value) => /^[\x21-\x7e]+$/u.test(value)) + +export const updateCrowdSecConfigurationSchema = z + .strictObject({ + mode: crowdSecModeSchema, + apiUrl: crowdSecApiUrlSchema.optional(), + apiKey: crowdSecApiKeySchema.optional(), + }) + .superRefine((value, context) => { + if (value.mode === 'external') { + if (value.apiUrl === undefined) { + context.addIssue({ code: 'custom', path: ['apiUrl'], message: 'required' }) + } + return + } + if (value.apiUrl !== undefined || value.apiKey !== undefined) { + context.addIssue({ code: 'custom', message: 'unexpected_external_configuration' }) + } + }) + +export const testCrowdSecConnectionSchema = z.strictObject({ + apiUrl: crowdSecApiUrlSchema, + apiKey: crowdSecApiKeySchema.optional(), +}) + +export type UpdateCrowdSecConfigurationInput = z.infer +export type TestCrowdSecConnectionInput = z.infer diff --git a/web/src/language/Locales/de.json b/web/src/language/Locales/de.json index 1c79cc02..65e73c66 100644 --- a/web/src/language/Locales/de.json +++ b/web/src/language/Locales/de.json @@ -48,6 +48,7 @@ "certificates": "Zertifikate", "accessPolicies": "Zugriffsrichtlinien", "proxyAccessLogs": "Zugriffsprotokolle", + "crowdSec": "CrowdSec", "auditLogs": "Audit-Protokoll", "systemVersion": "Systemversion {{version}}", "updateAvailable": "Update verfügbar auf Version {{version}}" @@ -1747,7 +1748,8 @@ "trusted-ca": "Vertrauenswürdige CA", "proxy-runtime-settings": "Proxy-Laufzeiteinstellungen", "proxy-host-settings": "Proxy-Host-Einstellungen", - "proxy-runtime": "Proxy-Laufzeit" + "proxy-runtime": "Proxy-Laufzeit", + "crowdsec": "CrowdSec" } }, "metadata": { @@ -1785,6 +1787,122 @@ "loadFailed": "Die Audit-Ereignisse konnten nicht geladen werden." } }, + "crowdSec": { + "page": { + "eyebrow": "Sicherheit", + "title": "CrowdSec", + "description": "Lege fest, wie RentnerProxy schädliche Clients blockiert. Der Schutz bleibt in der bestehenden Appliance und nutzt dieselben Trusted-Proxy-Regeln wie alle Proxy-Hosts." + }, + "status": { + "eyebrow": "Schutzstatus", + "description": "Ein gemeinsamer Durchsetzungspfad schützt öffentlichen HTTP- und HTTPS-Verkehr; Status und Zugangsdaten bleiben innerhalb der Appliance.", + "synchronized": "Gewünschter Modus ist aktiv", + "pending": "Der vorherige funktionierende Modus bleibt während weiterer Abgleichversuche aktiv", + "details": { + "enforcement": "Durchsetzung", + "active": "Aktiv", + "inactive": "Inaktiv", + "localApi": "Local-API-Zustand", + "managedEngine": "Verwaltete Engine" + } + }, + "states": { + "disabled": "Deaktiviert", + "starting": "Wird gestartet", + "connected": "Verbunden", + "degraded": "Beeinträchtigt", + "unavailable": "Controller nicht verfügbar" + }, + "managedStates": { + "stopped": "Gestoppt", + "starting": "Startet", + "ready": "Bereit", + "restarting": "Startet neu", + "degraded": "Beeinträchtigt", + "unavailable": "Nicht verfügbar" + }, + "modes": { + "disabled": { + "title": "Deaktiviert", + "description": "Standard nach dem Update. CrowdSec-Entscheidungen werden nicht auf den Verkehr angewendet." + }, + "managed": { + "title": "Von RentnerProxy verwaltet", + "description": "Verwendet die gebündelte lokale CrowdSec-Engine mit der kuratierten Caddy-Collection." + }, + "external": { + "title": "Externes CrowdSec", + "description": "Verwendet einen vorhandenen Local-API-Endpunkt mit einem Bouncer-Key. Der Bouncer-Key wird nach dem Speichern nicht mehr angezeigt." + } + }, + "configuration": { + "eyebrow": "Betriebsmodus", + "title": "Schutzquelle", + "description": "Das Ziel wird vor dem Umschalten von Caddy geprüft. Bei einem Fehler bleibt die vorherige verifizierte Konfiguration aktiv.", + "refreshing": "Status wird aktualisiert", + "modeLabel": "CrowdSec-Betriebsmodus", + "retention": "Beim Moduswechsel bleiben Daten der verwalteten Engine und gespeicherte externe Zugangsdaten erhalten. Nichts wird automatisch gelöscht." + }, + "external": { + "title": "Externe Local API", + "description": "Die Appliance verbindet sich direkt mit dem Endpunkt. Weiterleitungen werden abgelehnt und der Schlüssel nie an den Browser zurückgegeben.", + "apiUrl": "Local-API-URL", + "apiUrlPlaceholder": "https://crowdsec.example.test/", + "apiKey": "Bouncer-API-Key", + "apiKeyPlaceholder": "Bouncer-Key eingeben", + "apiKeyReplacePlaceholder": "Zugangsdaten gespeichert – Ersatz eingeben", + "apiKeyHint": "Nur bei unverändertem Endpunkt leer lassen, um den verschlüsselten Key beizubehalten. Ein neuer Endpunkt benötigt einen neuen Key." + }, + "actions": { + "test": "Verbindung testen", + "testing": "Verbindung wird getestet…", + "save": "Speichern und anwenden", + "saving": "Wird gespeichert und angewendet…" + }, + "facts": { + "clientIp": { + "label": "Client-IP", + "value": "Native Caddy-Auflösung", + "description": "Trusted-Proxy-CIDRs werden ausgewertet, bevor CrowdSec die effektive Client-Adresse erhält." + }, + "failure": { + "label": "Fehlerverhalten", + "value": "Fail-open", + "description": "Proxy-Verkehr läuft weiter, wenn die gewählte CrowdSec-API vorübergehend nicht erreichbar ist." + }, + "deployment": { + "label": "Bereitstellung", + "value": "Eine Appliance", + "description": "Kein zusätzlicher Dienst, Datenträger, Socket, öffentlicher Port oder Pflicht-Umgebungswert." + } + }, + "statesPage": { + "loadingTitle": "CrowdSec wird geladen", + "loadingDescription": "Gewünschter Modus und aktueller Schutzstatus werden gelesen.", + "unavailableTitle": "CrowdSec-Konfiguration nicht verfügbar", + "unavailableDescription": "Die Konfiguration konnte nicht geladen werden. Der bestehende Laufzeitstatus wurde nicht verändert." + }, + "validation": { + "apiUrl": "Gib eine absolute HTTP- oder HTTPS-URL ohne Zugangsdaten, Parameter oder Fragment ein.", + "apiKey": "Gib einen druckbaren ASCII-Bouncer-Key mit 16–512 Zeichen ohne Leerzeichen ein." + }, + "errors": { + "invalidInput": "Prüfe den CrowdSec-Endpunkt und die Zugangsdaten.", + "apiKeyRequired": "Gib vor dem Aktivieren von externem CrowdSec oder dem Ändern des Endpunkts einen Bouncer-Key ein.", + "connectionFailed": "CrowdSec hat Endpunkt oder Zugangsdaten abgelehnt. Der aktive Modus wurde nicht geändert.", + "controllerUnavailable": "Der Proxy-Controller ist vorübergehend nicht verfügbar. Der aktive Modus wurde nicht geändert.", + "configurationConflict": "Die Konfiguration wurde in einer anderen Sitzung geändert. Lade neu und versuche es erneut.", + "configurationUnavailable": "Die verschlüsselte CrowdSec-Konfiguration konnte nicht gelesen werden.", + "saveFailed": "Die CrowdSec-Konfiguration konnte nicht gespeichert werden.", + "loadFailed": "Die CrowdSec-Konfiguration konnte nicht geladen werden.", + "testFailed": "Der Verbindungstest konnte nicht abgeschlossen werden." + }, + "messages": { + "connectionValid": "CrowdSec-Verbindung erfolgreich geprüft.", + "saved": "CrowdSec-Konfiguration gespeichert und aktiviert.", + "savedPending": "CrowdSec-Konfiguration gespeichert. Der vorherige funktionierende Modus bleibt während weiterer Abgleichversuche aktiv." + } + }, "trustedCas": { "page": { "eyebrow": "Zertifikate", @@ -1877,6 +1995,7 @@ "trusted_cas": "Vertrauenswürdige CAs", "access_policies": "Zugriffsrichtlinien", "proxy-access-logs": "Proxy-Zugriffsprotokolle", + "crowdsec": "CrowdSec", "audit-logs": "Audit-Protokoll" }, "app": { @@ -1936,6 +2055,10 @@ "apply": "Zugriffsrichtlinien-Konfiguration anwenden" }, "proxy-access-logs:view": "Proxy-Zugriffsprotokolle anzeigen", + "crowdsec": { + "view": "CrowdSec-Konfiguration anzeigen", + "update": "CrowdSec-Konfiguration aktualisieren" + }, "audit-logs:view": "Audit-Protokoll anzeigen", "trusted_cas": { "view": "Vertrauenswürdige CAs anzeigen", diff --git a/web/src/language/Locales/en.json b/web/src/language/Locales/en.json index 851596d1..f555c551 100644 --- a/web/src/language/Locales/en.json +++ b/web/src/language/Locales/en.json @@ -48,6 +48,7 @@ "certificates": "Certificates", "accessPolicies": "Access policies", "proxyAccessLogs": "Access logs", + "crowdSec": "CrowdSec", "auditLogs": "Audit log", "systemVersion": "System version {{version}}", "updateAvailable": "Update available to version {{version}}" @@ -1747,7 +1748,8 @@ "trusted-ca": "Trusted CA", "proxy-runtime-settings": "Proxy runtime settings", "proxy-host-settings": "Proxy host settings", - "proxy-runtime": "Proxy runtime" + "proxy-runtime": "Proxy runtime", + "crowdsec": "CrowdSec" } }, "metadata": { @@ -1785,6 +1787,122 @@ "loadFailed": "Audit events could not be loaded." } }, + "crowdSec": { + "page": { + "eyebrow": "Security", + "title": "CrowdSec", + "description": "Choose how RentnerProxy blocks malicious clients. Protection stays inside the existing appliance and uses the same trusted-proxy client IP rules as every proxy host." + }, + "status": { + "eyebrow": "Protection status", + "description": "One enforcement path protects public HTTP and HTTPS traffic while health and credentials remain private to the appliance.", + "synchronized": "Desired mode is active", + "pending": "Previous working mode remains active while synchronization retries", + "details": { + "enforcement": "Enforcement", + "active": "Active", + "inactive": "Inactive", + "localApi": "Local API health", + "managedEngine": "Managed engine" + } + }, + "states": { + "disabled": "Disabled", + "starting": "Starting", + "connected": "Connected", + "degraded": "Degraded", + "unavailable": "Controller unavailable" + }, + "managedStates": { + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "restarting": "Restarting", + "degraded": "Degraded", + "unavailable": "Unavailable" + }, + "modes": { + "disabled": { + "title": "Disabled", + "description": "Default after upgrading. No CrowdSec decision is applied to traffic." + }, + "managed": { + "title": "Managed by RentnerProxy", + "description": "Run the bundled CrowdSec engine locally with the curated Caddy collection." + }, + "external": { + "title": "External CrowdSec", + "description": "Use an existing Local API endpoint with a write-only bouncer key." + } + }, + "configuration": { + "eyebrow": "Operating mode", + "title": "Protection source", + "description": "The target is verified before Caddy switches. A failed change leaves the previous verified configuration active.", + "refreshing": "Refreshing health", + "modeLabel": "CrowdSec operating mode", + "retention": "Switching modes preserves managed engine data and saved external credentials. Nothing is automatically deleted." + }, + "external": { + "title": "External Local API", + "description": "The endpoint is contacted directly from the appliance. Redirects are rejected and the credential is never returned to the browser.", + "apiUrl": "Local API URL", + "apiUrlPlaceholder": "https://crowdsec.example.test/", + "apiKey": "Bouncer API key", + "apiKeyPlaceholder": "Enter the bouncer key", + "apiKeyReplacePlaceholder": "Credential stored — enter a replacement", + "apiKeyHint": "Leave blank to keep the encrypted key only if the endpoint is unchanged. A new endpoint requires a new key." + }, + "actions": { + "test": "Test connection", + "testing": "Testing connection…", + "save": "Save and apply", + "saving": "Saving and applying…" + }, + "facts": { + "clientIp": { + "label": "Client IP", + "value": "Caddy-native resolution", + "description": "Trusted proxy CIDRs are evaluated before CrowdSec sees the effective client address." + }, + "failure": { + "label": "Failure behavior", + "value": "Fail open", + "description": "Proxy traffic continues if the selected CrowdSec API is temporarily unavailable." + }, + "deployment": { + "label": "Deployment", + "value": "Single appliance", + "description": "No extra service, volume, socket, public port, or mandatory environment variable." + } + }, + "statesPage": { + "loadingTitle": "Loading CrowdSec", + "loadingDescription": "Reading the desired mode and current enforcement health.", + "unavailableTitle": "CrowdSec configuration unavailable", + "unavailableDescription": "The configuration could not be loaded. The existing runtime state has not been changed." + }, + "validation": { + "apiUrl": "Enter an absolute HTTP or HTTPS URL without credentials, query parameters, or a fragment.", + "apiKey": "Enter a 16–512 character printable ASCII bouncer key without spaces." + }, + "errors": { + "invalidInput": "Review the CrowdSec endpoint and credential.", + "apiKeyRequired": "Enter a bouncer key before enabling external CrowdSec or changing its endpoint.", + "connectionFailed": "CrowdSec rejected the endpoint or credential. The active mode was not changed.", + "controllerUnavailable": "The proxy controller is temporarily unavailable. The active mode was not changed.", + "configurationConflict": "The configuration changed in another session. Reload and try again.", + "configurationUnavailable": "The encrypted CrowdSec configuration could not be read.", + "saveFailed": "The CrowdSec configuration could not be saved.", + "loadFailed": "The CrowdSec configuration could not be loaded.", + "testFailed": "The connection test could not be completed." + }, + "messages": { + "connectionValid": "CrowdSec connection verified.", + "saved": "CrowdSec configuration saved and activated.", + "savedPending": "CrowdSec configuration saved. The previous working mode remains active while synchronization retries." + } + }, "trustedCas": { "page": { "eyebrow": "Certificates", @@ -1877,6 +1995,7 @@ "trusted_cas": "Trusted CAs", "access_policies": "Access policies", "proxy-access-logs": "Proxy access logs", + "crowdsec": "CrowdSec", "audit-logs": "Audit log" }, "app": { @@ -1936,6 +2055,10 @@ "apply": "Apply access policy configuration" }, "proxy-access-logs:view": "View proxy access logs", + "crowdsec": { + "view": "View CrowdSec configuration", + "update": "Update CrowdSec configuration" + }, "audit-logs:view": "View audit log", "trusted_cas": { "view": "View trusted CAs", diff --git a/web/src/language/Locales/es.json b/web/src/language/Locales/es.json index 1d431872..31ecb57c 100644 --- a/web/src/language/Locales/es.json +++ b/web/src/language/Locales/es.json @@ -48,6 +48,7 @@ "certificates": "Certificados", "accessPolicies": "Políticas de acceso", "proxyAccessLogs": "Registros de acceso", + "crowdSec": "CrowdSec", "auditLogs": "Registro de auditoría", "systemVersion": "Versión del sistema {{version}}", "updateAvailable": "Actualización disponible a la versión {{version}}" @@ -1747,7 +1748,8 @@ "trusted-ca": "CA de confianza", "proxy-runtime-settings": "Configuración de ejecución del proxy", "proxy-host-settings": "Configuración del host proxy", - "proxy-runtime": "Ejecución del proxy" + "proxy-runtime": "Ejecución del proxy", + "crowdsec": "CrowdSec" } }, "metadata": { @@ -1785,6 +1787,122 @@ "loadFailed": "No se pudieron cargar los eventos de auditoría." } }, + "crowdSec": { + "page": { + "eyebrow": "Seguridad", + "title": "CrowdSec", + "description": "Elige cómo RentnerProxy bloquea clientes maliciosos. La protección permanece en el dispositivo y usa las mismas reglas de proxy de confianza que todos los hosts." + }, + "status": { + "eyebrow": "Estado de protección", + "description": "Una única ruta protege el tráfico HTTP y HTTPS público; el estado y las credenciales permanecen privados.", + "synchronized": "El modo deseado está activo", + "pending": "El modo anterior sigue activo mientras se reintenta la sincronización", + "details": { + "enforcement": "Aplicación", + "active": "Activa", + "inactive": "Inactiva", + "localApi": "Estado de Local API", + "managedEngine": "Motor gestionado" + } + }, + "states": { + "disabled": "Desactivado", + "starting": "Iniciando", + "connected": "Conectado", + "degraded": "Degradado", + "unavailable": "Controlador no disponible" + }, + "managedStates": { + "stopped": "Detenido", + "starting": "Iniciando", + "ready": "Listo", + "restarting": "Reiniciando", + "degraded": "Degradado", + "unavailable": "No disponible" + }, + "modes": { + "disabled": { + "title": "Desactivado", + "description": "Valor predeterminado tras actualizar. No se aplican decisiones de CrowdSec al tráfico." + }, + "managed": { + "title": "Gestionado por RentnerProxy", + "description": "Ejecuta localmente el motor CrowdSec incluido con la colección de Caddy seleccionada." + }, + "external": { + "title": "CrowdSec externo", + "description": "Usa una Local API existente con una clave de bouncer de solo escritura." + } + }, + "configuration": { + "eyebrow": "Modo operativo", + "title": "Origen de protección", + "description": "El destino se verifica antes de cambiar Caddy. Si falla, permanece activa la configuración verificada anterior.", + "refreshing": "Actualizando estado", + "modeLabel": "Modo operativo de CrowdSec", + "retention": "Cambiar de modo conserva los datos gestionados y las credenciales externas guardadas. Nada se elimina automáticamente." + }, + "external": { + "title": "Local API externa", + "description": "El dispositivo conecta directamente con el endpoint. Se rechazan redirecciones y la credencial nunca vuelve al navegador.", + "apiUrl": "URL de Local API", + "apiUrlPlaceholder": "https://crowdsec.example.test/", + "apiKey": "Clave API del bouncer", + "apiKeyPlaceholder": "Introduce la clave del bouncer", + "apiKeyReplacePlaceholder": "Credencial guardada — introduce un reemplazo", + "apiKeyHint": "Déjalo vacío para conservar la clave cifrada solo si no cambias el endpoint. Un endpoint nuevo requiere una clave nueva." + }, + "actions": { + "test": "Probar conexión", + "testing": "Probando conexión…", + "save": "Guardar y aplicar", + "saving": "Guardando y aplicando…" + }, + "facts": { + "clientIp": { + "label": "IP del cliente", + "value": "Resolución nativa de Caddy", + "description": "Los CIDR de proxies de confianza se evalúan antes de que CrowdSec reciba la dirección efectiva." + }, + "failure": { + "label": "Comportamiento ante fallos", + "value": "Fail-open", + "description": "El tráfico continúa si la API de CrowdSec no está disponible temporalmente." + }, + "deployment": { + "label": "Despliegue", + "value": "Un solo dispositivo", + "description": "Sin servicio, volumen, socket, puerto público ni variable obligatoria adicionales." + } + }, + "statesPage": { + "loadingTitle": "Cargando CrowdSec", + "loadingDescription": "Leyendo el modo deseado y el estado de protección actual.", + "unavailableTitle": "Configuración de CrowdSec no disponible", + "unavailableDescription": "No se pudo cargar la configuración. El estado actual no se ha modificado." + }, + "validation": { + "apiUrl": "Introduce una URL HTTP o HTTPS absoluta sin credenciales, parámetros ni fragmento.", + "apiKey": "Introduce una clave ASCII imprimible de 16–512 caracteres sin espacios." + }, + "errors": { + "invalidInput": "Revisa el endpoint y la credencial de CrowdSec.", + "apiKeyRequired": "Introduce una clave antes de activar CrowdSec externo o cambiar su endpoint.", + "connectionFailed": "CrowdSec rechazó el endpoint o la credencial. El modo activo no cambió.", + "controllerUnavailable": "El controlador no está disponible temporalmente. El modo activo no cambió.", + "configurationConflict": "La configuración cambió en otra sesión. Recarga e inténtalo de nuevo.", + "configurationUnavailable": "No se pudo leer la configuración cifrada de CrowdSec.", + "saveFailed": "No se pudo guardar la configuración de CrowdSec.", + "loadFailed": "No se pudo cargar la configuración de CrowdSec.", + "testFailed": "No se pudo completar la prueba de conexión." + }, + "messages": { + "connectionValid": "Conexión con CrowdSec verificada.", + "saved": "Configuración de CrowdSec guardada y activada.", + "savedPending": "Configuración guardada. El modo anterior sigue activo mientras se reintenta la sincronización." + } + }, "trustedCas": { "page": { "eyebrow": "Certificados", @@ -1877,6 +1995,7 @@ "trusted_cas": "CA de confianza", "access_policies": "Políticas de acceso", "proxy-access-logs": "Registros de acceso del proxy", + "crowdsec": "CrowdSec", "audit-logs": "Registro de auditoría" }, "app": { @@ -1936,6 +2055,10 @@ "apply": "Aplicar la configuración de políticas de acceso" }, "proxy-access-logs:view": "Ver registros de acceso del proxy", + "crowdsec": { + "view": "Ver la configuración de CrowdSec", + "update": "Actualizar la configuración de CrowdSec" + }, "audit-logs:view": "Ver el registro de auditoría", "trusted_cas": { "view": "Ver CA de confianza", diff --git a/web/src/language/Locales/fr.json b/web/src/language/Locales/fr.json index f763fe55..cb20d51f 100644 --- a/web/src/language/Locales/fr.json +++ b/web/src/language/Locales/fr.json @@ -48,6 +48,7 @@ "certificates": "Certificats", "accessPolicies": "Politiques d’accès", "proxyAccessLogs": "Journaux d’accès", + "crowdSec": "CrowdSec", "auditLogs": "Journal d’audit", "systemVersion": "Version du système {{version}}", "updateAvailable": "Mise à jour disponible vers la version {{version}}" @@ -1747,7 +1748,8 @@ "trusted-ca": "AC de confiance", "proxy-runtime-settings": "Paramètres d’exécution du proxy", "proxy-host-settings": "Paramètres de l’hôte proxy", - "proxy-runtime": "Exécution du proxy" + "proxy-runtime": "Exécution du proxy", + "crowdsec": "CrowdSec" } }, "metadata": { @@ -1785,6 +1787,122 @@ "loadFailed": "Les événements d’audit n’ont pas pu être chargés." } }, + "crowdSec": { + "page": { + "eyebrow": "Sécurité", + "title": "CrowdSec", + "description": "Choisissez comment RentnerProxy bloque les clients malveillants. La protection reste dans l’appliance et utilise les mêmes règles de proxy approuvé que tous les hôtes." + }, + "status": { + "eyebrow": "État de la protection", + "description": "Un chemin d’application unique protège le trafic HTTP et HTTPS public, tandis que l’état et les identifiants restent privés.", + "synchronized": "Le mode souhaité est actif", + "pending": "Le mode fonctionnel précédent reste actif pendant les nouvelles tentatives", + "details": { + "enforcement": "Application", + "active": "Active", + "inactive": "Inactive", + "localApi": "État de la Local API", + "managedEngine": "Moteur géré" + } + }, + "states": { + "disabled": "Désactivé", + "starting": "Démarrage", + "connected": "Connecté", + "degraded": "Dégradé", + "unavailable": "Contrôleur indisponible" + }, + "managedStates": { + "stopped": "Arrêté", + "starting": "Démarrage", + "ready": "Prêt", + "restarting": "Redémarrage", + "degraded": "Dégradé", + "unavailable": "Indisponible" + }, + "modes": { + "disabled": { + "title": "Désactivé", + "description": "Mode par défaut après une mise à niveau. Aucune décision CrowdSec n’est appliquée." + }, + "managed": { + "title": "Géré par RentnerProxy", + "description": "Exécute localement le moteur CrowdSec intégré avec la collection Caddy sélectionnée." + }, + "external": { + "title": "CrowdSec externe", + "description": "Utilise une Local API existante avec une clé bouncer en écriture seule." + } + }, + "configuration": { + "eyebrow": "Mode de fonctionnement", + "title": "Source de protection", + "description": "La cible est vérifiée avant le basculement de Caddy. En cas d’échec, la configuration vérifiée précédente reste active.", + "refreshing": "Actualisation de l’état", + "modeLabel": "Mode de fonctionnement CrowdSec", + "retention": "Le changement de mode conserve les données gérées et les identifiants externes enregistrés. Rien n’est supprimé automatiquement." + }, + "external": { + "title": "Local API externe", + "description": "L’appliance contacte directement le point de terminaison. Les redirections sont refusées et l’identifiant n’est jamais renvoyé au navigateur.", + "apiUrl": "URL de la Local API", + "apiUrlPlaceholder": "https://crowdsec.example.test/", + "apiKey": "Clé API du bouncer", + "apiKeyPlaceholder": "Saisissez la clé du bouncer", + "apiKeyReplacePlaceholder": "Identifiant enregistré — saisissez un remplacement", + "apiKeyHint": "Laissez vide pour conserver la clé chiffrée uniquement si le point de terminaison ne change pas. Un nouveau point de terminaison exige une nouvelle clé." + }, + "actions": { + "test": "Tester la connexion", + "testing": "Test de la connexion…", + "save": "Enregistrer et appliquer", + "saving": "Enregistrement et application…" + }, + "facts": { + "clientIp": { + "label": "IP du client", + "value": "Résolution native de Caddy", + "description": "Les CIDR des proxies approuvés sont évalués avant que CrowdSec reçoive l’adresse effective." + }, + "failure": { + "label": "Comportement en cas d’échec", + "value": "Fail-open", + "description": "Le trafic continue si l’API CrowdSec choisie est temporairement indisponible." + }, + "deployment": { + "label": "Déploiement", + "value": "Appliance unique", + "description": "Aucun service, volume, socket, port public ou variable obligatoire supplémentaire." + } + }, + "statesPage": { + "loadingTitle": "Chargement de CrowdSec", + "loadingDescription": "Lecture du mode souhaité et de l’état actuel de la protection.", + "unavailableTitle": "Configuration CrowdSec indisponible", + "unavailableDescription": "La configuration n’a pas pu être chargée. L’état d’exécution actuel n’a pas été modifié." + }, + "validation": { + "apiUrl": "Saisissez une URL HTTP ou HTTPS absolue sans identifiants, paramètres ni fragment.", + "apiKey": "Saisissez une clé bouncer ASCII imprimable de 16 à 512 caractères sans espaces." + }, + "errors": { + "invalidInput": "Vérifiez le point de terminaison et l’identifiant CrowdSec.", + "apiKeyRequired": "Saisissez une clé bouncer avant d’activer CrowdSec externe ou de changer son point de terminaison.", + "connectionFailed": "CrowdSec a refusé le point de terminaison ou l’identifiant. Le mode actif n’a pas changé.", + "controllerUnavailable": "Le contrôleur proxy est temporairement indisponible. Le mode actif n’a pas changé.", + "configurationConflict": "La configuration a changé dans une autre session. Rechargez puis réessayez.", + "configurationUnavailable": "La configuration CrowdSec chiffrée n’a pas pu être lue.", + "saveFailed": "La configuration CrowdSec n’a pas pu être enregistrée.", + "loadFailed": "La configuration CrowdSec n’a pas pu être chargée.", + "testFailed": "Le test de connexion n’a pas pu être terminé." + }, + "messages": { + "connectionValid": "Connexion CrowdSec vérifiée.", + "saved": "Configuration CrowdSec enregistrée et activée.", + "savedPending": "Configuration enregistrée. Le mode fonctionnel précédent reste actif pendant les nouvelles tentatives." + } + }, "trustedCas": { "page": { "eyebrow": "Certificats", @@ -1877,6 +1995,7 @@ "trusted_cas": "AC de confiance", "access_policies": "Politiques d’accès", "proxy-access-logs": "Journaux d’accès du proxy", + "crowdsec": "CrowdSec", "audit-logs": "Journal d’audit" }, "app": { @@ -1936,6 +2055,10 @@ "apply": "Appliquer la configuration des règles d’accès" }, "proxy-access-logs:view": "Afficher les journaux d’accès du proxy", + "crowdsec": { + "view": "Afficher la configuration CrowdSec", + "update": "Modifier la configuration CrowdSec" + }, "audit-logs:view": "Afficher le journal d’audit", "trusted_cas": { "view": "Afficher les AC de confiance", diff --git a/web/src/layout/Components/ApplicationShell/Components/ApplicationNavigation.tsx b/web/src/layout/Components/ApplicationShell/Components/ApplicationNavigation.tsx index 05213037..216565be 100644 --- a/web/src/layout/Components/ApplicationShell/Components/ApplicationNavigation.tsx +++ b/web/src/layout/Components/ApplicationShell/Components/ApplicationNavigation.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, Network, ScrollText, + Shield, ShieldCheck, UsersRound, } from 'lucide-react' @@ -74,6 +75,8 @@ export default function ApplicationNavigation({ items }: ApplicationNavigationPr className="size-4 shrink-0" strokeWidth={1.8} /> + ) : item.to === '/crowdsec' ? ( +