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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/production-smokes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
32 changes: 30 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
61 changes: 41 additions & 20 deletions ASSURANCE_CASE.md

Large diffs are not rendered by default.

63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions controller/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ async fn serve() -> Result<(), Box<dyn Error + Send + Sync>> {
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<dyn ProxyEngine>
});
Expand Down
72 changes: 72 additions & 0 deletions controller/src/models.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(default)]
pub(crate) api_key: Option<SecretString>,
}

#[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<String>,
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 {
Expand Down
7 changes: 6 additions & 1 deletion controller/src/runtime/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 18 additions & 3 deletions controller/src/runtime/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -129,6 +130,18 @@ impl ProxyRuntime {
configuration: &ValidatedProxyConfig,
staged: Option<&StagedCertificate>,
purpose: RenderPurpose,
) -> Result<String, RuntimeError> {
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<String, RuntimeError> {
let mut materials = BTreeMap::new();
let certificate_hosts = configuration
Expand Down Expand Up @@ -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 {
Expand All @@ -188,6 +202,7 @@ impl ProxyRuntime {
},
&materials,
&upstream_tls,
crowdsec.as_ref(),
)
.map_err(|error| match error {
RenderError::ConfigTooLarge => RuntimeError::ConfigTooLarge,
Expand Down
Loading
Loading