+
+
+
+
diff --git a/deploy/core/docker-compose.yml b/deploy/core/docker-compose.yml
index 6f158863..b1e1f276 100644
--- a/deploy/core/docker-compose.yml
+++ b/deploy/core/docker-compose.yml
@@ -34,6 +34,9 @@ services:
APP_DEBUG: ${APP_DEBUG}
CDNLITE_API_TOKEN: ${CDNLITE_API_TOKEN}
CDNLITE_ORIGIN_SHIELD_SECRET: ${CDNLITE_ORIGIN_SHIELD_SECRET}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE}
+ CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED: ${CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED}
CDNLITE_BOOTSTRAP_EDGE_TOKEN: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN}
CDNLITE_BOOTSTRAP_EDGE_ID: ${CDNLITE_BOOTSTRAP_EDGE_ID}
CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE}
@@ -158,12 +161,14 @@ services:
CDNLITE_RETENTION_PRUNE_ENABLED: ${CDNLITE_RETENTION_PRUNE_ENABLED}
CDNLITE_RETENTION_INTERVAL_SECONDS: ${CDNLITE_RETENTION_INTERVAL_SECONDS}
CDNLITE_RETENTION_BATCH_SIZE: ${CDNLITE_RETENTION_BATCH_SIZE}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE}
CDNLITE_ANALYTICS_RETENTION_DAYS: ${CDNLITE_ANALYTICS_RETENTION_DAYS}
CDNLITE_SECURITY_EVENT_RETENTION_DAYS: ${CDNLITE_SECURITY_EVENT_RETENTION_DAYS}
CDNLITE_DNS_EVENT_RETENTION_DAYS: ${CDNLITE_DNS_EVENT_RETENTION_DAYS}
CDNLITE_SSL_JOB_RETENTION_DAYS: ${CDNLITE_SSL_JOB_RETENTION_DAYS}
CDNLITE_INGEST_KEY_RETENTION_DAYS: ${CDNLITE_INGEST_KEY_RETENTION_DAYS}
- command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
+ command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; php artisan cdn:config-snapshots:prune --keep=\"$${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}\" --batch=\"$${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}\" || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
networks:
- internal
diff --git a/deploy/generate-deployment.sh b/deploy/generate-deployment.sh
index 68929f47..bf8016e3 100755
--- a/deploy/generate-deployment.sh
+++ b/deploy/generate-deployment.sh
@@ -310,6 +310,9 @@ APP_DEBUG=0
CDNLITE_API_TOKEN=${CDNLITE_API_TOKEN}
CDNLITE_SSL_SECRET_KEY=${CDNLITE_SSL_SECRET_KEY}
CDNLITE_ORIGIN_SHIELD_SECRET=${CDNLITE_ORIGIN_SHIELD_SECRET}
+CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST=2
+CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE=5000
+CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED=false
CDNLITE_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
CDNLITE_ACME_CONTACT_EMAIL=${ACME_EMAIL}
CDNLITE_ACME_DNS_PROPAGATION_SECONDS=30
@@ -681,7 +684,7 @@ services:
postgres:
condition: service_healthy
env_file: .env
- command: ["sh", "-lc", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
+ command: ["sh", "-lc", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; php artisan cdn:config-snapshots:prune --keep=\"$${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}\" --batch=\"$${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}\" || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
networks: [cdnlite-internal]
dashboard:
diff --git a/deploy/starter/docker-compose.yml b/deploy/starter/docker-compose.yml
index 061f745c..85b43e5e 100644
--- a/deploy/starter/docker-compose.yml
+++ b/deploy/starter/docker-compose.yml
@@ -36,6 +36,9 @@ services:
APP_DEBUG: ${APP_DEBUG}
CDNLITE_API_TOKEN: ${CDNLITE_API_TOKEN}
CDNLITE_ORIGIN_SHIELD_SECRET: ${CDNLITE_ORIGIN_SHIELD_SECRET}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE}
+ CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED: ${CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED}
CDNLITE_BOOTSTRAP_EDGE_TOKEN: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN}
CDNLITE_BOOTSTRAP_EDGE_ID: ${CDNLITE_BOOTSTRAP_EDGE_ID}
CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE}
@@ -160,12 +163,14 @@ services:
CDNLITE_RETENTION_PRUNE_ENABLED: ${CDNLITE_RETENTION_PRUNE_ENABLED}
CDNLITE_RETENTION_INTERVAL_SECONDS: ${CDNLITE_RETENTION_INTERVAL_SECONDS}
CDNLITE_RETENTION_BATCH_SIZE: ${CDNLITE_RETENTION_BATCH_SIZE}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE}
CDNLITE_ANALYTICS_RETENTION_DAYS: ${CDNLITE_ANALYTICS_RETENTION_DAYS}
CDNLITE_SECURITY_EVENT_RETENTION_DAYS: ${CDNLITE_SECURITY_EVENT_RETENTION_DAYS}
CDNLITE_DNS_EVENT_RETENTION_DAYS: ${CDNLITE_DNS_EVENT_RETENTION_DAYS}
CDNLITE_SSL_JOB_RETENTION_DAYS: ${CDNLITE_SSL_JOB_RETENTION_DAYS}
CDNLITE_INGEST_KEY_RETENTION_DAYS: ${CDNLITE_INGEST_KEY_RETENTION_DAYS}
- command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
+ command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; php artisan cdn:config-snapshots:prune --keep=\"$${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}\" --batch=\"$${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}\" || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
networks:
- cdnlite-internal
diff --git a/docker-compose.yml b/docker-compose.yml
index ec7685d8..19a64783 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,9 @@ services:
APP_DEBUG: ${APP_DEBUG:-0}
CDNLITE_API_TOKEN: ${CDNLITE_API_TOKEN:-}
CDNLITE_ORIGIN_SHIELD_SECRET: ${CDNLITE_ORIGIN_SHIELD_SECRET:-dev-origin-shield-secret-change-me}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}
+ CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED: ${CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED:-false}
CDNLITE_BOOTSTRAP_EDGE_TOKEN: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN:-1}
CDNLITE_BOOTSTRAP_EDGE_ID: ${CDNLITE_BOOTSTRAP_EDGE_ID:-}
CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE: ${CDNLITE_BOOTSTRAP_EDGE_TOKEN_VALUE:-}
@@ -167,12 +170,14 @@ services:
CDNLITE_RETENTION_PRUNE_ENABLED: ${CDNLITE_RETENTION_PRUNE_ENABLED:-false}
CDNLITE_RETENTION_INTERVAL_SECONDS: ${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}
CDNLITE_RETENTION_BATCH_SIZE: ${CDNLITE_RETENTION_BATCH_SIZE:-5000}
+ CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST: ${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}
+ CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE: ${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}
CDNLITE_ANALYTICS_RETENTION_DAYS: ${CDNLITE_ANALYTICS_RETENTION_DAYS:-30}
CDNLITE_SECURITY_EVENT_RETENTION_DAYS: ${CDNLITE_SECURITY_EVENT_RETENTION_DAYS:-90}
CDNLITE_DNS_EVENT_RETENTION_DAYS: ${CDNLITE_DNS_EVENT_RETENTION_DAYS:-30}
CDNLITE_SSL_JOB_RETENTION_DAYS: ${CDNLITE_SSL_JOB_RETENTION_DAYS:-180}
CDNLITE_INGEST_KEY_RETENTION_DAYS: ${CDNLITE_INGEST_KEY_RETENTION_DAYS:-7}
- command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
+ command: ["sh", "-c", "if [ \"$${CDNLITE_SCHEDULER_IDLE:-0}\" = \"1\" ] || [ \"$${CDNLITE_RETENTION_PRUNE_ENABLED:-false}\" != \"true\" ]; then echo 'retention scheduler idle'; tail -f /dev/null; fi; while true; do php artisan cdn:usage:prune --all || true; php artisan cdn:config-snapshots:prune --keep=\"$${CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST:-2}\" --batch=\"$${CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE:-5000}\" || true; sleep \"$${CDNLITE_RETENTION_INTERVAL_SECONDS:-86400}\"; done"]
restart: unless-stopped
dns-reconciler:
@@ -259,6 +264,8 @@ services:
CDNLITE_EDGE_LOG_REQUEST_BODY: ${CDNLITE_EDGE_LOG_REQUEST_BODY:-false}
CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false}
CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb}
+ CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret}
+ CDNLITE_EDGE_CHALLENGE_DIFFICULTY: ${CDNLITE_EDGE_CHALLENGE_DIFFICULTY:-3}
edge-agent:
build:
@@ -338,6 +345,8 @@ services:
CDNLITE_EDGE_LOG_REQUEST_BODY: ${CDNLITE_EDGE_LOG_REQUEST_BODY:-false}
CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false}
CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb}
+ CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret}
+ CDNLITE_EDGE_CHALLENGE_DIFFICULTY: ${CDNLITE_EDGE_CHALLENGE_DIFFICULTY:-3}
edge-agent-2:
build:
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 74410475..4328f615 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -346,11 +346,11 @@ Destructive and high-volume tests must run only against an explicitly disposable
| --- | --- | --- | --- |
| 1. Database architecture and real-time reporting foundation | P0 | Complete | Fast operational reads, scalable event ingestion, bounded reporting, and durable rollups |
| 2. Analytics scalability and asynchronous aggregation | P0 | Complete | Bounded analytics metadata, async recalculation jobs, and idempotent aggregate upserts |
-| 3. Edge hot-path performance and bounded telemetry | P0 | Planned | No repeated config parsing or synchronous per-request telemetry writes |
-| 4. Real challenge and clearance system | P0 | Planned | Challenge actions perform a real verifiable workflow |
-| 5. Adaptive overload protection and waiting room | P0 | Planned | Origins remain protected under attack or heavy usage |
-| 6. Cache correctness foundation | P0 | Planned | Standards-aware cache keys, eligibility, revalidation, and stale behavior |
-| 7. Origin routing, resilience, and shielding | P0 | Planned | Predictable health, load balancing, failover, retries, and circuit breaking |
+| 3. Edge hot-path performance and bounded telemetry | P0 | Complete | No repeated config parsing or synchronous per-request telemetry writes |
+| 4. Real challenge and clearance system | P0 | Complete | Challenge actions serve configurable self-hosted browser verification before origin routing |
+| 5. Adaptive overload protection and waiting room | P0 | Complete | Origins remain protected under attack or heavy usage |
+| 6. Cache correctness foundation | P0 | Complete | Standards-aware cache keys, eligibility, revalidation, and stale behavior |
+| 7. Origin routing, resilience, and shielding | P0 | Complete | Predictable weighted routing, backup failover, drain controls, bounded retry metadata, circuit settings, and shield visibility |
| 8. Purge and invalidation platform | P1 | Planned | Fast, safe, observable purge by URL, prefix, host, and tag |
| 9. Edge protocol and delivery performance | P1 | Planned | Efficient TLS, HTTP/2, optional HTTP/3, compression, and connection reuse |
| 10. DNS and GeoDNS reliability | P1 | Planned | Deterministic DNS publication and health-aware routing |
@@ -1451,6 +1451,14 @@ On documented reference hardware:
> **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete.
+**Status:** Complete
+**Owner:** CDNLite maintainers
+**Tracking issue:** Local roadmap Phase 4
+**Manifest:** `ci/phases/phase-04.yml`
+**Evidence:** `ci/reports/phase-04-report.md`, `ci/reports/phase-04-report.json`
+**Completed work:** Added configurable self-hosted browser verification pages for WAF and rate-limit challenge actions, per-rule `challenge_difficulty` for domain paths and WAF patterns, the edge-only `/__cdnlite_challenge_verify` endpoint, signed scoped challenge and clearance tokens, level-1 lightweight browser verification, level-2-through-6 proof-of-work, HttpOnly clearance cookies, same-host redirect recovery, block-before-challenge precedence, Emergency Protection site-wide challenge mode, `CDNLITE_EDGE_CLEARANCE_SECRET`, `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` fallback, Phase 4 runner registration, e2e coverage, and stress scenario registration.
+**Closure:** Phase 4 has no remaining mandatory work. The completed challenge engine is self-hosted and provider-independent; third-party CAPTCHA providers may be added later as optional integrations without reopening Phase 4.
+
### Objective
Make WAF, bot, and rate-limit `challenge` actions perform a real challenge and clearance workflow instead of acting as a renamed block.
@@ -1671,6 +1679,8 @@ Do not log:
> **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete.
+**Status:** Complete on 2026-06-28. CDNLite now has waiting-room persistence, API controls, dashboard policy controls, config snapshot delivery, signed edge queue tickets, signed admission cookies, bounded local edge queue state, edge-only queue/status endpoints, automatic RPS-triggered local overload admission, manual emergency mode, operator documentation, OpenAPI entries, smoke assertions, a Phase 5 one-shot manifest, and full-profile evidence at `ci/reports/phase-05-report.md` with 15 passing steps and 0 failures.
+
### Objective
Protect overloaded or attacked origins by controlling origin admission while continuing to serve safe cached traffic.
@@ -1874,6 +1884,8 @@ Add:
> **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete.
+**Status:** Complete on 2026-06-28. CDNLite now has explicit cache-method, status-code, bypass-header, bypass-cookie, vary-header, cache-key-dimension, stale, negative-cache, max-object, and safe debug-header controls in the authoritative schema; API and dashboard visibility for cache key previews; edge Lua cache eligibility and normalized key construction; per-rule TTLs honored through `X-Accel-Expires`; consistent `X-CDNLite-Cache` and bypass-reason headers; Phase 6 one-shot manifest and stress scenario registration.
+
### Objective
Make cache decisions standards-aware, predictable, testable, and safe before adding more advanced performance features.
@@ -2049,6 +2061,35 @@ Debug information must be configurable and must not reveal secrets.
> **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete.
+**Status:** Complete on 2026-06-28. CDNLite now persists explicit primary,
+backup, and shield origin roles, deterministic weighted routing intent, drain
+state, bounded retry and retry-budget settings, circuit-breaker settings,
+concurrency limits, shield visibility, and edge-origin health observations in
+the authoritative fresh-install schema. The Origins API validates the new
+controls, config snapshots exclude drained origins and carry resilience and
+edge health-check metadata without origin secrets, and OpenResty selects
+healthy primaries before backups using weighted deterministic hashing while
+suppressing automatic retry attempts for non-idempotent methods. Origin routing
+health is now edge-observed: monitored origins are actively checked by edge
+nodes, passive proxy observations are also ingested, core scheduled checks do
+not mutate health state, and the domain dashboard can show per-edge latency,
+jitter, slow-origin, and failure details. Phase 7 also adds the phase manifest,
+stress scenario registration, focused contract tests, operator documentation,
+troubleshooting guidance, and changelog coverage.
+
+**Tracking issue:** Local roadmap Phase 7
+
+**Completed work:** Schema and migration, API validation and casting, config
+snapshot metadata, edge weighted primary/backup/drain selection, active
+edge-origin health probes, passive edge-origin observation ingest, edge-sourced
+health aggregation, retry/circuit visibility, dashboard reports, one-shot
+runner registration, stress scenario, docs, and changelog.
+
+**Remaining work:** None for the accepted Phase 7 repository-contract slice.
+mTLS and multi-layer shield deployment automation remain future advanced
+origin-authentication and topology work; the Phase 7 delivery keeps shield
+state explicit and loop-safe without exposing secrets.
+
### Objective
Make origin selection and failure behavior predictable under healthy, degraded, and failed conditions.
diff --git a/docs/api/api.md b/docs/api/api.md
index 28321e31..6b030e99 100644
--- a/docs/api/api.md
+++ b/docs/api/api.md
@@ -187,7 +187,7 @@ Useful admin login response fields:
| --- | --- | --- |
| `GET` | `/api/v1/overview` | Aggregate operations summary. |
| `GET` | `/api/v1/overview/warnings` | Readiness and risk warnings. |
-| `GET` | `/api/v1/reports/summary` | CDN operations KPIs, compare-mode deltas, and ranked warnings. Accepts `domain_id`, `from`, `to`, `bucket`, `compare`, and `limit`. |
+| `GET` | `/api/v1/reports/summary` | CDN operations KPIs, compare-mode deltas, and ranked warnings with stable `key`, `link`, `section`, and `count` fields. Accepts `domain_id`, `from`, `to`, `bucket`, `compare`, and `limit`. |
| `GET` | `/api/v1/reports/traffic` | Request, bandwidth, cache-ratio, status, top domain/path/visitor-country/edge, and problem-request reports. |
| `GET` | `/api/v1/reports/cache` | Cache status distribution, hit-ratio trend, cache/origin bytes, uncached paths, and purge timeline. Unsupported ingest fields are returned as `null` with an `unavailable` note. |
| `GET` | `/api/v1/reports/edge` | Edge online/offline counts, geography, heartbeat age, config drift, config errors, traffic, error rates, and node table. |
@@ -383,8 +383,9 @@ DNS tips:
| `POST` | `/api/v1/domains/{domainId}/origins` | Create origin. |
| `PATCH` | `/api/v1/domains/{domainId}/origins/{originId}` | Update origin. |
| `DELETE` | `/api/v1/domains/{domainId}/origins/{originId}` | Delete origin. |
-| `POST` | `/api/v1/domains/{domainId}/origins/{originId}/check` | Run manual health check. |
+| `POST` | `/api/v1/domains/{domainId}/origins/{originId}/check` | Run a core diagnostic only. It does not update authoritative origin health. |
| `POST` | `/api/v1/domains/{domainId}/origins/{originId}/test` | Run a non-mutating origin diagnostic with DNS, TCP, TLS, and HTTP timing details. |
+| `GET` | `/api/v1/domains/{domainId}/origins/health` | Edge health observations, latency, jitter, failure reason, and per-edge detail for dashboard reporting. |
| `POST` | `/api/v1/domains/{domainId}/route-debug` | Preview the selected origin, origin pool size, cache/rule counts, and SSL state for a host/path/country using the active config snapshot. |
Example:
@@ -399,7 +400,14 @@ Example:
"tls_verify": "ignore",
"preserve_host": true,
"health_check_enabled": false,
- "role": "origin",
+ "role": "primary",
+ "weight": 10,
+ "load_balancing_algorithm": "weighted_hash",
+ "retry_attempts": 1,
+ "retry_budget_per_minute": 60,
+ "circuit_breaker_enabled": true,
+ "drain": false,
+ "shield_enabled": false,
"enabled": true
}
```
@@ -411,6 +419,25 @@ Origin tips:
`dns_record_id`; manual origins use `source: "manual"`.
- Duplicate manual origin hosts are allowed as separate rows because routing
identity is the origin id, not only host and scheme.
+- Use primary, backup, and shield roles to make failover explicit. The edge
+ selects healthy primary origins first, falls back to unknown primaries, then
+ uses backups only when primaries are unavailable. Drained origins are removed
+ from snapshots and edge candidate pools.
+- Edge health observations are authoritative for routing health. Core does not
+ probe customer origins on a schedule. When `health_check_enabled` is true,
+ edge nodes actively probe the configured path and also report passive origin
+ status, latency, jitter, and failure reasons through the collector metrics
+ path. When it is false, reports are still visible but do not remove the
+ origin from routing.
+- Weighted pools use deterministic `weighted_hash` selection. `consistent_hash`
+ is accepted as a stable operator intent and currently uses the same bounded
+ request hash path so routing remains predictable.
+- Retries are bounded by `retry_attempts` and `retry_budget_per_minute`.
+ Non-idempotent methods do not receive automatic retry attempts by default.
+- Circuit breaker, connection limit, and shield fields are included in API
+ responses and config snapshots for edge policy visibility. Shield loops are
+ prevented by explicit shield role metadata and the `shield_enabled` flag; do
+ not point a shield origin back at the same public hostname.
- When the scheme is omitted for a DNS-linked origin, CDNLite keeps the
backend on plain HTTP/80 unless you explicitly set `scheme: "https"`.
- For shared hosting or cPanel origins, point the origin `host` at the server
@@ -425,7 +452,9 @@ Origin tips:
- Health checks are off by default. With `health_check_enabled: false`,
`unknown` health does not block config snapshots or edge traffic. When
enabled, checked unhealthy origins are avoided and surfaced as warnings.
-- Use the manual health-check route when you intentionally monitor an origin.
+- Use the diagnostic route when you want to compare core reachability during
+ troubleshooting. The result is marked non-authoritative and does not mutate
+ stored origin health.
- Use the non-mutating origin diagnostic route when debugging 502s. It reports
DNS resolution, TCP connect, TLS handshake, HTTP status, timing, configured
host header, and SNI without changing the stored origin health state.
@@ -443,6 +472,7 @@ Origin tips:
| --- | --- |
| Redirects | `/api/v1/domains/{domainId}/redirects`, import/export/test variants. |
| Rate-limit rules | CRUD `/api/v1/domains/{domainId}/rate-limits`, dry-run `POST /api/v1/domains/{domainId}/rate-limits/dry-run`, plus `POST /api/v1/domains/{domainId}/rate-limits/{ruleId}/detach-managed`. |
+| Waiting room | `GET`/`PATCH /api/v1/domains/{domainId}/waiting-room`, plus emergency activate/deactivate endpoints for audited manual origin admission control. |
| WAF rules | CRUD `/api/v1/domains/{domainId}/waf-rules`, plus `POST /api/v1/domains/{domainId}/waf-rules/{ruleId}/detach-managed`. |
| Header rules | CRUD `/api/v1/domains/{domainId}/headers`. |
| IP rules | CRUD `/api/v1/domains/{domainId}/ip-rules`. |
@@ -454,6 +484,45 @@ Origin tips:
| Protection intents | `GET /api/v1/domains/{domainId}/protection/intents`, `POST /api/v1/domains/{domainId}/protection/intents/{intentKey}/preview`, `POST /api/v1/domains/{domainId}/protection/intents/{intentKey}/enable`, `POST /api/v1/domains/{domainId}/protection/intents/{intentId}/disable`, and `POST /api/v1/domains/{domainId}/protection/intents/{intentId}/undo`. |
| API protection discovery | `GET /api/v1/domains/{domainId}/protection/api-paths` returns likely API prefixes from recent Activity plus recommended methods and the default `Authorization` header key. |
+### Waiting Room Policy Fields
+
+Waiting room policies are domain-wide for proxied traffic. Every proxied apex
+or subdomain host for the same domain shares the same local-edge queue and
+admission budget. DNS-only records are not affected. The initial queue is local
+to each edge node, so it protects origins but is not globally fair across all
+edge nodes.
+
+| Field | Effect |
+| --- | --- |
+| `enabled` | Turns waiting-room enforcement on or off. Saved policy values remain available while disabled. |
+| `mode` | `monitoring` records policy state without queueing, `automatic` allows edge thresholds to enter overload, and `manual` queues only when an operator sets an overload state or activates emergency mode. |
+| `state` | Current posture. `healthy` and `monitoring` allow normal routing; `entering_overload`, `overloaded`, and `manual_emergency` gate non-cacheable traffic; `recovering` is used while easing traffic back to origin. |
+| `reason` | Short reason code for operators and audits, such as `flash_sale`, `slow_origin`, or `dashboard_emergency`. |
+| `rps_threshold` | Per-edge incoming request rate that can trigger automatic overload. Lower values queue sooner. |
+| `active_origin_threshold` | Target ceiling for simultaneous origin-bound requests. Lower values protect smaller or slower origins more aggressively. |
+| `origin_latency_ms_threshold` | Origin latency considered unhealthy for overload decisions and future health signals. |
+| `origin_error_rate_threshold` | Origin timeout/5xx percentage considered unhealthy. Higher values tolerate more origin errors before queueing. |
+| `admission_rate_per_minute` | Maximum non-cacheable browser sessions admitted to origin per edge node each minute during overload. |
+| `queue_limit` | Maximum local waiting population. When full, new visitors receive a bounded `503` instead of growing memory without limit. |
+| `per_client_ticket_limit` | Maximum outstanding queue tickets per client identity. Prevents one client from filling local queue state. |
+| `ticket_ttl_seconds` | Lifetime of a queue ticket. Short values recycle abandoned queue entries faster; long values reduce re-ticketing. |
+| `admission_ttl_seconds` | Lifetime of the admission cookie. Longer values reduce repeat queueing for admitted visitors; shorter values enforce tighter admission. |
+| `status_poll_seconds` | Base polling interval and `Retry-After` value for queued clients. Lower values feel more responsive but create more edge polling. |
+| `jitter_seconds` | Random extra polling delay. Higher jitter spreads polling bursts and protects the edge during large queues. |
+| `unhealthy_windows` | Consecutive unhealthy windows required before automatic overload. Higher values reduce false positives. |
+| `healthy_windows` | Consecutive healthy windows required before recovery. Higher values reduce flapping. |
+| `minimum_state_seconds` | Minimum time before changing state again. Protects against rapid oscillation. |
+| `recovery_ramp_percent` | Percentage of the normal admission budget used during recovery. Lower values reopen origin traffic more gently. |
+| `manual_override_until` | Expiry timestamp for manual emergency behavior. Expired overrides stop forcing emergency mode. |
+| `trusted_cidrs` | Reserved trusted-client CIDR list for controlled exemptions. Keep narrow and auditable. |
+| `waiting_room_title` | Visitor-facing waiting page headline. |
+| `waiting_room_message` | Visitor-facing explanation shown on the edge-generated waiting page. |
+
+During overload, safe cacheable `GET` and `HEAD` traffic can continue through
+the cache path. Non-cacheable browser navigation receives a queue page and polls
+`/.well-known/cdnlite/queue/status`; unsafe methods and API-style requests
+receive JSON `429` or `503` responses with `Retry-After` and no automatic replay.
+
Rule responses follow the same `{ "data": ... }` pattern and use `422` for validation errors. Managed rules generated by simple protection flows expose `profile_id`, `intent_id`, `template_key`, `managed_by`, `user_modified`, `last_generated_at`, and `last_applied_at` so advanced users can inspect ownership. Detaching a managed WAF or rate-limit rule preserves the technical rule, clears managed ownership, writes audit history, and invalidates edge config.
WAF rules support `country_is` for country allow, log, challenge, or block
@@ -470,13 +539,13 @@ Protection intents are the beginner/simple API layer. Preview does not mutate st
API Shield (`protect_api`) generates real advanced rules for `/api/`: IP/path rate limits, `Authorization` header/path limits for token-aware APIs, and `path_method_not_allowed` WAF rules that block unsupported methods only inside the API prefix. The generated method rule uses a pattern such as `/api/:GET,POST,PUT,PATCH,DELETE,OPTIONS`, so advanced users can edit the prefix or method set without losing ownership metadata.
-Cache settings additionally support `static_asset_cache_enabled`, `ignore_query_strings_for_static`, and `bypass_logged_in_users`. Static caching covers CSS, JavaScript, common image formats, fonts, MP4, and PDF. When enabled, query-string stripping applies only to those static extensions; common session/authentication cookies always bypass cache by default.
+Cache settings additionally support `static_asset_cache_enabled`, `ignore_query_strings_for_static`, `bypass_logged_in_users`, `cache_methods`, `cache_status_code_policy`, `bypass_headers`, `bypass_cookies`, `vary_headers`, `cache_key_dimensions`, `debug_headers_enabled`, `stale_while_revalidate_seconds`, `negative_ttl_seconds`, and `max_object_size_bytes`. Static caching covers CSS, JavaScript, common image formats, fonts, MP4, and PDF. When enabled, query-string stripping applies only to those static extensions; common session/authentication cookies always bypass cache by default.
Protection profiles are one-click bundles over the same intent engine. One-click profiles compose protection intents for Basic Website, WordPress, API, SaaS App, E-commerce, and Emergency Protection presets. Preview returns the per-intent generated rules without mutating state. Apply creates or updates a profile record, enables the profile-owned intents, writes profile/audit history, stores rollback points, and invalidates edge config. Disable turns off the generated rules for intents owned by that profile while preserving the underlying advanced rules for inspection and rollback.
Managed WAF presets add inspection metadata to generated WAF rules. WAF rules created by Security Center intents can include `waf_group_id`, `waf_severity`, `waf_confidence`, and `waf_safe_reason`; edge `waf_match` security events emit matching `group_id`, `severity`, `confidence`, and `safe_reason` fields so Activity can explain why a managed rule acted. These fields are additive metadata on the advanced WAF rule, so operators can still edit, detach, or override the generated rule.
-Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF rules may use `challenge`, which returns a 403 JSON `bot_challenge_required` response and records the event.
+Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF and rate-limit rules may use `challenge`, which serves the self-hosted browser verification page, verifies through `/__cdnlite_challenge_verify`, sets `__cdnlite_clearance`, and records the event. Set `challenge_difficulty` from `1` to `6` on a WAF or rate-limit rule to tune that path or pattern: `1` uses a lightweight browser check, while `2`-`6` add increasing proof-of-work. If omitted, the edge uses `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`.
The Managed WAF preset catalog is read-only and returns available WAF modes, group definitions, and currently generated rule templates grouped by `waf_group_id`. Use it to inspect SQL injection, XSS, traversal, inclusion, command injection, PHP/WordPress, scanner, encoding, and bad user-agent coverage before enabling or tightening rules through the preview/apply protection flows.
@@ -516,6 +585,8 @@ Cache tips:
- Prefer `url` or `prefix` purges over `everything`.
- Use short TTLs while validating an origin migration.
+- Use the dashboard Cache key preview to confirm bounded key dimensions before adding country, language, or header variation.
+- Enable debug cache headers only during diagnosis. `X-CDNLite-Cache-Key` is sanitized and omits secrets, while `X-CDNLite-Cache-Reason` explains safe bypasses such as authorization, cookie, method, or request cache-control.
- Check cache analytics after rule changes; a sudden hit-ratio drop usually means a bypass condition was introduced.
- Requests with authorization or explicit no-cache headers should not be used to judge normal cache behavior.
@@ -609,19 +680,19 @@ Edge proxy responses include an origin marker such as `X-CDNLITE-Origin: origin`
Edge endpoint notes:
- Register and heartbeat requests must have the same `edge_id` in the header and JSON body.
-- `GET /api/v1/edge/config` accepts `if_version` as a query parameter to avoid unnecessary config writes.
+- `GET /api/v1/edge/config` accepts `if_version` as a query parameter. Matching clean versions return `not_modified` from the active published snapshot without rebuilding config.
- Usage and security-event ingest are queue-friendly. If ingest fails, the agent should keep local payloads for retry.
## Config Snapshots
| Method | Route | Purpose |
| --- | --- | --- |
-| `GET` | `/api/v1/config/snapshots` | List versions. |
+| `GET` | `/api/v1/config/snapshots` | List versions, paginated with `limit` default `20` and max `100`. |
| `GET` | `/api/v1/config/snapshots/latest` | Return the latest snapshot summary without the snapshot payload. |
-| `GET` | `/api/v1/config/snapshots/{version}` | Show version. |
-| `POST` | `/api/v1/config/snapshots/diff` | Diff JSON paths between versions. |
-| `POST` | `/api/v1/config/snapshots/{version}/rollback` | Activate old version. |
-| `POST` | `/api/v1/config/snapshots/rebuild` | Rebuild from database state. |
+| `GET` | `/api/v1/config/snapshots/{version}` | Show version when `CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED=true`; otherwise returns `config_snapshot_history_disabled`. |
+| `POST` | `/api/v1/config/snapshots/diff` | Diff JSON paths between versions when history is enabled. |
+| `POST` | `/api/v1/config/snapshots/{version}/rollback` | Development-only rollback when history is enabled. Disabled by default because JSON rollback does not roll back database source-of-truth tables. |
+| `POST` | `/api/v1/config/snapshots/rebuild` | Force-publish the materialized edge config from database state. |
Diff request:
@@ -634,10 +705,10 @@ Diff request:
Snapshot safety tips:
-- Diff before rollback.
+- Treat snapshots as a generated edge cache. Domains, DNS records, origins, rules, cache settings, page rules, and SSL rows remain the source of truth.
- Prefer rebuild after normal database-backed changes.
-- Use rollback for a known-bad config version, then investigate why the bad state was generated.
-- Edge nodes pull snapshots; rollback is not an instant push to every edge.
+- Use `php artisan cdn:config-snapshots:prune --keep=2 --batch=5000` to bound large historical tables without deleting the active snapshot.
+- Edge nodes pull snapshots; publishing is not an instant push to every edge.
## Settings
diff --git a/docs/architecture.md b/docs/architecture.md
index eb754236..549d6141 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -110,18 +110,33 @@ Client request
-> Lua router uses the worker-local validated config snapshot
-> host/domain lookup chooses origin and rules
-> redirects, WAF, rate limit, IP, cache, and headers are evaluated
- -> request proxies to a selected healthy origin from the origin pool
+ -> request proxies to a weighted primary, backup, or shield origin from the origin pool
-> metrics/security events enter bounded shared queues
-> edge agent later pushes queues to core
```
+Origin routing is deterministic for a given request seed. The edge filters
+disabled, drained, and actively unhealthy checked origins, selects healthy
+primaries before backups, and uses weighted hashing inside the chosen pool.
+Origin snapshots also carry bounded idempotent retries, retry budgets, circuit
+breaker settings, connection limits, and shield metadata. Non-idempotent
+methods do not receive automatic retry attempts by default.
+
+Origin health is edge-observed. Core keeps manual diagnostics for
+troubleshooting, but scheduled core probes do not drive routing health. Edge
+workers actively probe user-enabled monitored origins and also emit passive
+edge-origin health observations from proxied requests. The edge agent pushes
+those metrics to core, where latency, jitter, slow-origin, timeout, TLS,
+connect, and HTTP failure details are stored by domain, origin, and edge node.
+
## Config Flow
```text
Admin/API change
-> Core validates and writes PostgreSQL
- -> ConfigService builds a snapshot
- -> Snapshot version is stored
+ -> ConfigService marks the published edge config dirty
+ -> One publisher rebuilds the materialized snapshot under a PostgreSQL advisory lock
+ -> Active snapshot stays valid until the new version is stored
-> Edge agent signs GET /api/v1/edge/config
-> Agent writes config.json atomically
-> OpenResty workers validate and atomically activate the fresh snapshot
@@ -143,7 +158,9 @@ queue health.
| Config snapshot JSON | Core `ConfigService` | Edge agent and OpenResty runtime. |
| Metrics NDJSON | OpenResty bounded queue flusher | Edge agent, collector API, usage aggregates. |
| Security events NDJSON | OpenResty bounded queue flusher | Edge agent, collector API, dashboard. Concurrent push attempts are serialized with a queue-scoped lock. |
-| Origin health | Scheduler/CLI | Readiness service and edge backup routing config. |
+| Origin health | OpenResty and edge agent | Readiness service, edge backup routing config, and dashboard reports. |
+| Origin resilience policy | Origins API | Config snapshot builder and OpenResty selector. |
+| edge-origin health observations | OpenResty and edge agent | Collector, origin health reports, and dashboard detail. |
| Audit records | Core services | Audit log dashboard and API. |
## Deployment Topology
@@ -188,4 +205,4 @@ or `POST /api/v1/domains/{domainId}/nameservers/verify`, which returns the
expected, observed, matched, and missing nameserver sets. Admin sessions can use
`POST /api/v1/domains/{domainId}/nameservers/force-verify` with a reason when
delegation cannot be observed but the domain must be activated; the action is
-audited, invalidates the active edge snapshot, and triggers DNS reconciliation.
+audited, marks the edge config dirty, and triggers DNS reconciliation.
diff --git a/docs/cdn-in-a-minute.md b/docs/cdn-in-a-minute.md
index 0f8c1b0c..c353328c 100644
--- a/docs/cdn-in-a-minute.md
+++ b/docs/cdn-in-a-minute.md
@@ -121,7 +121,7 @@ DOMAIN_ID=replace-with-domain-id
curl -s -X POST "$API/api/v1/domains/$DOMAIN_ID/origins" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
- -d '{"host":"origin.example.com","scheme":"http","port":80,"role":"origin","enabled":true}'
+ -d '{"host":"origin.example.com","scheme":"http","port":80,"role":"primary","enabled":true}'
```
## Step 8 - Verify End-To-End
diff --git a/docs/examples/index.md b/docs/examples/index.md
index 97248156..13235d58 100644
--- a/docs/examples/index.md
+++ b/docs/examples/index.md
@@ -36,7 +36,7 @@ curl -s -X POST "$API/api/v1/domains/$DOMAIN_ID/dns/records" \
curl -s -X POST "$API/api/v1/domains/$DOMAIN_ID/origins" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
- -d '{"host":"origin.example.test","scheme":"http","port":80,"role":"origin","enabled":true}'
+ -d '{"host":"origin.example.test","scheme":"http","port":80,"role":"primary","enabled":true}'
```
### CLI Domain Workflow
@@ -383,7 +383,7 @@ curl -s "$API$PATH_ONLY" \
The edge agent scripts already do this; the example is for developers writing custom edge tooling.
-## Config Snapshot Rollback
+## Config Snapshot Publish And Prune
```bash
curl -s "$API/api/v1/config/snapshots" \
@@ -392,16 +392,15 @@ curl -s "$API/api/v1/config/snapshots" \
curl -s "$API/api/v1/config/snapshots/latest" \
-H "Authorization: Bearer $TOKEN"
-curl -s -X POST "$API/api/v1/config/snapshots/diff" \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"from_version":12,"to_version":13}'
-
-curl -s -X POST "$API/api/v1/config/snapshots/12/rollback" \
+curl -s -X POST "$API/api/v1/config/snapshots/rebuild" \
-H "Authorization: Bearer $TOKEN"
+
+docker compose exec core php artisan cdn:config-snapshots:prune --keep=2 --batch=5000 --dry-run
```
-Rollback is a control-plane action. Edge nodes still need to pull the active snapshot.
+Snapshot payload history, diff, and rollback endpoints are disabled unless
+`CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED=true`; snapshots are a generated edge
+cache, while database tables remain the source of truth.
## Recommendations
diff --git a/docs/public/api/openapi.yaml b/docs/public/api/openapi.yaml
index 8f36bf40..f1497976 100644
--- a/docs/public/api/openapi.yaml
+++ b/docs/public/api/openapi.yaml
@@ -140,7 +140,7 @@ paths:
/api/v1/reports/summary:
get:
tags: [Reports]
- summary: CDN operations KPIs, compare deltas, and ranked warnings
+ summary: CDN operations KPIs, compare deltas, and ranked warnings with stable drilldown fields
parameters:
- $ref: "#/components/parameters/DomainIdQuery"
- $ref: "#/components/parameters/ReportFrom"
@@ -843,6 +843,45 @@ paths:
$ref: "#/components/responses/GenericObject"
/api/v1/domains/{domainId}/rate-limits/{ruleId}:
$ref: "#/components/pathItems/RuleMember"
+ /api/v1/domains/{domainId}/waiting-room:
+ get:
+ tags: [Traffic Rules]
+ summary: Show the domain waiting-room policy
+ parameters:
+ - $ref: "#/components/parameters/DomainId"
+ responses:
+ "200":
+ $ref: "#/components/responses/GenericObject"
+ patch:
+ tags: [Traffic Rules]
+ summary: Update the domain waiting-room policy
+ parameters:
+ - $ref: "#/components/parameters/DomainId"
+ requestBody:
+ $ref: "#/components/requestBodies/GenericObject"
+ responses:
+ "200":
+ $ref: "#/components/responses/GenericObject"
+ /api/v1/domains/{domainId}/waiting-room/emergency/activate:
+ post:
+ tags: [Traffic Rules]
+ summary: Activate manual emergency waiting-room admission
+ parameters:
+ - $ref: "#/components/parameters/DomainId"
+ requestBody:
+ $ref: "#/components/requestBodies/GenericObject"
+ responses:
+ "200":
+ $ref: "#/components/responses/GenericObject"
+ /api/v1/domains/{domainId}/waiting-room/emergency/deactivate:
+ post:
+ tags: [Traffic Rules]
+ summary: Deactivate manual emergency waiting-room admission
+ parameters:
+ - $ref: "#/components/parameters/DomainId"
+ responses:
+ "200":
+ $ref: "#/components/responses/GenericObject"
/api/v1/domains/{domainId}/protection/profiles:
get:
tags: [Traffic Rules]
@@ -1361,7 +1400,19 @@ paths:
/api/v1/config/snapshots:
get:
tags: [Config]
- summary: List config snapshots
+ summary: List config snapshots, paginated and bounded
+ parameters:
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ default: 20
+ maximum: 100
+ - name: offset
+ in: query
+ schema:
+ type: integer
+ default: 0
responses:
"200":
$ref: "#/components/responses/GenericArray"
@@ -1375,7 +1426,8 @@ paths:
/api/v1/config/snapshots/{version}:
get:
tags: [Config]
- summary: Get config snapshot
+ summary: Get config snapshot when history is enabled
+ description: Disabled by default unless CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED=true.
parameters:
- name: version
in: path
@@ -1388,7 +1440,8 @@ paths:
/api/v1/config/snapshots/diff:
post:
tags: [Config]
- summary: Diff two config snapshots
+ summary: Diff two config snapshots when history is enabled
+ description: Disabled by default unless CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED=true.
requestBody:
required: true
content:
@@ -1407,7 +1460,8 @@ paths:
/api/v1/config/snapshots/{version}/rollback:
post:
tags: [Config]
- summary: Roll back active config snapshot
+ summary: Development-only config snapshot rollback
+ description: Disabled by default because snapshot rollback does not roll back database source-of-truth tables.
parameters:
- name: version
in: path
@@ -1420,7 +1474,7 @@ paths:
/api/v1/config/snapshots/rebuild:
post:
tags: [Config]
- summary: Rebuild config snapshot from database state
+ summary: Force-publish materialized edge config from database state
responses:
"200":
$ref: "#/components/responses/GenericObject"
diff --git a/docs/security.md b/docs/security.md
index 703a97f9..759e5e30 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -58,6 +58,56 @@ header. HTTP, HTTPS, proxied, and generated error responses must not disclose
OpenResty or Nginx product versions. Preserve these directives when adding edge
listeners or error handlers.
+## Challenge Clearance
+
+WAF and rate-limit rules with the `challenge` action serve a self-hosted browser
+proof-of-work page instead of behaving as a renamed block. The challenge token
+is scoped to the domain, action family, rule ID, client IP, return path, and
+expiry. The browser computes a bounded SHA-256 proof and submits it to the
+edge-only `/__cdnlite_challenge_verify` endpoint. Successful verification sets
+the `__cdnlite_clearance` HttpOnly cookie with `SameSite=Lax` and redirects the
+visitor back to the original same-host relative URL. Explicit block rules and
+administrative denies still take precedence over any clearance cookie.
+
+Set `CDNLITE_EDGE_CLEARANCE_SECRET` to a strong shared secret on every edge in a
+fleet. Rotating it invalidates existing clearance cookies, which is safe but can
+temporarily make visitors solve a challenge again.
+
+Set `challenge_difficulty` on individual WAF or rate-limit challenge rules to
+tune challenge cost per domain path or pattern. If a rule omits
+`challenge_difficulty`, the edge falls back to `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`.
+This is a provider-independent abuse-friction mechanism; it is not a guarantee
+that a visitor is human.
+
+## Waiting Room Admission
+
+Waiting-room admission uses signed, short-lived queue tickets and admission
+cookies. Explicit IP access rules, WAF blocks/challenges, and rate limits run
+before waiting-room admission, so a queued or admitted browser session does not
+bypass administrative security policy. Set `CDNLITE_EDGE_WAITING_ROOM_SECRET` to
+a strong value on every edge. Rotating it invalidates existing queue tickets and
+admission cookies, which is safe during incident response.
+
+Waiting-room tickets are scoped to the domain and client IP and expire according
+to `ticket_ttl_seconds`. Admission cookies are also scoped and expire according
+to `admission_ttl_seconds`. Queue state is bounded by `queue_limit` and
+`per_client_ticket_limit`; overflow fails closed with a bounded response instead
+of growing memory without limit. Safe cacheable `GET` and `HEAD` requests may
+continue through the cache path during overload, but non-cacheable requests,
+unsafe methods, and API-style clients remain gated.
+
+Use conservative emergency settings. A low `admission_rate_per_minute` protects
+the origin but makes visitors wait longer. A high value shortens the queue but
+can re-overload the origin. During recovery, `healthy_windows`,
+`minimum_state_seconds`, and `recovery_ramp_percent` should be tuned to avoid
+rapid state flapping.
+
+| Difficulty | Behavior | Use case |
+| --- | --- | --- |
+| `1` | Lightweight browser verification. The page verifies JavaScript, same-origin fetch, cookies, and redirect handling without proof-of-work. | Low-friction checks for normal sites or mild bot noise. |
+| `2`-`4` | Browser verification plus increasing SHA-256 proof-of-work before clearance. | Suspicious automation, login abuse, or short attack windows. |
+| `5`-`6` | High-friction proof-of-work. | Temporary emergency use during active attacks where user delay is acceptable. |
+
## Authorization Limits
The dashboard admin model is simple. It does not implement fine-grained RBAC, per-domain tenancy, SSO, or role-scoped permissions. Use external controls for production segmentation.
@@ -160,14 +210,17 @@ In production, put both the dashboard and the CDNLite API behind real authentica
The edge runtime bypasses cache storage and lookup when request risk is high:
- Non-`GET`/`HEAD` methods set cache bypass.
- `Authorization` header sets cache bypass.
+- Configured bypass headers and common session/authentication cookies set cache bypass.
- `Cache-Control: no-cache` or `no-store` sets cache bypass.
When domain cache is enabled, ordinary `GET`/`HEAD` responses use the domain
default edge TTL while no path-specific cache rules exist. Once one or more
cache rules are enabled for a host, those rules become an allowlist: matching
-paths use their rule TTL and non-matching paths bypass cache. Upstream
-`X-Accel-Expires` is ignored at the CDNLite edge so origin-local nginx
-directives do not accidentally disable CDN caching for an entire site.
+paths use their rule TTL and non-matching paths bypass cache. Lua sets
+`X-Accel-Expires` from the matched rule or domain default so Nginx honors the
+selected freshness lifetime. Debug cache headers are opt-in through
+`CDNLITE_EDGE_DEBUG_HEADERS` and expose only sanitized key dimensions and bypass
+reasons.
Edge access logs are JSON lines on stdout and edge diagnostics are emitted to
stderr. They include request ids and safe routing metadata, but must not include
diff --git a/docs/setup.md b/docs/setup.md
index b321dd0c..ca9359a4 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -175,6 +175,9 @@ Core settings:
| `CDNLITE_CORS_ALLOWED_ORIGINS` | Browser origins allowed to call the API. |
| `CDNLITE_SSL_SECRET_KEY` | Secret used for stored SSL material handling. |
| `CDNLITE_ORIGIN_SHIELD_SECRET` | Default origin shield secret. |
+| `CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST` | Count of latest published config snapshots to retain in addition to the active version; default `2`. |
+| `CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE` | Bounded delete batch used after publish and by `cdn:config-snapshots:prune`; default `5000`. |
+| `CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED` | Enables legacy snapshot payload, diff, and rollback endpoints for development; default `false`. |
| `CDNLITE_ANALYTICS_RETENTION_DAYS` | Detailed edge request/activity retention window for `cdn:usage:prune`; default `30`. |
| `CDNLITE_SECURITY_EVENT_RETENTION_DAYS` | High-volume WAF, rate-limit, bot, and Geo security-event retention for `cdn:usage:prune --all`; default `90`. |
| `CDNLITE_DNS_EVENT_RETENTION_DAYS` | Successful DNS sync event retention for `cdn:usage:prune --all`; default `30`. Failed DNS sync events are retained for troubleshooting. |
@@ -223,8 +226,10 @@ Edge and agent settings:
| `METRIC_PATH` | Metrics queue file for the agent. |
| `SECURITY_EVENT_PATH` | Security event queue file for the agent. |
| `CDNLITE_CACHE_DEFAULT_TTL` | Default OpenResty cache TTL. |
+| `CDNLITE_EDGE_DEBUG_HEADERS` | Set to `1` to expose sanitized cache key and bypass reason headers during diagnosis. |
| `CDNLITE_EDGE_WORKER_PROCESSES`, `CDNLITE_EDGE_WORKER_CONNECTIONS` | OpenResty worker capacity. Defaults are `auto` and `4096`. |
| `CDNLITE_EDGE_LIMITS_DICT_SIZE`, `CDNLITE_EDGE_REQUEST_CONTEXT_DICT_SIZE`, `CDNLITE_EDGE_METRIC_QUEUE_DICT_SIZE`, `CDNLITE_EDGE_SECURITY_EVENT_QUEUE_DICT_SIZE` | Shared memory budgets for rate limits, request context, metrics, and security-event queues. |
+| `CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE`, `CDNLITE_EDGE_WAITING_ROOM_SECRET` | Shared-memory budget and signing secret for local waiting-room queue tickets and admission cookies. Initial queues are local to each edge node and are not globally fair across nodes. |
| `CDNLITE_EDGE_CONFIG_MAX_BYTES`, `CDNLITE_EDGE_CONFIG_REFRESH_SECONDS` | Maximum accepted edge snapshot size and worker config refresh interval. |
| `CDNLITE_EDGE_TELEMETRY_BATCH_SIZE`, `CDNLITE_EDGE_TELEMETRY_FLUSH_INTERVAL_SECONDS`, `CDNLITE_EDGE_TELEMETRY_QUEUE_MAX_ITEMS`, `CDNLITE_EDGE_TELEMETRY_QUEUE_MAX_BYTES` | Bounded edge telemetry queue and flush controls. Drops are counted and visible on `/ready`. |
| `CDNLITE_EDGE_RESOLVER`, `CDNLITE_EDGE_CLIENT_*`, `CDNLITE_EDGE_PROXY_*` | DNS resolver, header/body buffer, request body, and upstream timeout tuning for OpenResty. |
@@ -233,6 +238,16 @@ Edge and agent settings:
| `CDNLITE_EDGE_LOG_REQUEST_BODY` | Reserved for future strict-redaction body logging; keep `false`. |
| `CDNLITE_EDGE_DEBUG_HEADERS` | Reserved for future debug header logging; keep `false` unless a runbook explicitly enables it. |
| `CDNLITE_EDGE_MMDB_FILE` | GeoIP MMDB used by the edge for country WAF/origin decisions; default `/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb`. |
+
+Waiting-room shared memory stores rolling counters, queue population, active
+origin counts, and short-lived ticket/admission state. Increase
+`CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE` only when `/ready`, edge logs, or queue
+status indicate sustained local state pressure. Use the dashboard policy fields
+to tune traffic behavior first: lower `admission_rate_per_minute` protects
+origins more, lower `queue_limit` bounds memory more tightly, and higher polling
+jitter spreads queue status checks.
+| `CDNLITE_EDGE_CLEARANCE_SECRET` | Shared edge secret for signed challenge and clearance cookies. Set the same strong value on every edge; rotation invalidates existing clearances. |
+| `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` | Default self-hosted edge challenge difficulty, from `1` to `6`; default `3`. WAF and rate-limit challenge rules can override this per path or pattern with `challenge_difficulty`. Level `1` performs a lightweight browser check without proof-of-work. Levels `2` through `6` require increasing SHA-256 proof-of-work before origin routing. |
| `EDGE_AGENT_IDLE` | CI flag to keep agent idle while scripts drive flow manually. |
Recommended starting values:
@@ -297,6 +312,16 @@ docker compose exec core php artisan cdn:usage:prune --dry-run
docker compose exec core php artisan cdn:usage:prune --days=30
```
+Config snapshots are a published edge cache, not the source of truth. The core
+keeps the active snapshot plus the latest two snapshots by default and prunes
+after successful publishes. Operators with a large historical table can prune in
+batches without deleting the active snapshot:
+
+```bash
+docker compose exec core php artisan cdn:config-snapshots:prune --keep=2 --batch=5000 --dry-run
+docker compose exec core php artisan cdn:config-snapshots:prune --keep=2 --batch=5000
+```
+
Use the wider retention pass for high-volume operational rows after reviewing
the dry run. It prunes raw request rows, high-volume security events, successful
DNS sync events, terminal SSL jobs, expired edge nonces, and old ingest
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 1aa0b079..b6b56ca0 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -40,6 +40,7 @@ Use this order during incidents:
| Dashboard cannot reach core | `VITE_CDNLITE_CORE_URL` points at an internal Compose hostname or CORS blocks origin. | Set a browser-reachable URL, rebuild dashboard, and include dashboard origin in `CDNLITE_CORS_ALLOWED_ORIGINS`. |
| Login fails with known credentials | Bootstrap disabled or admin password changed. | Create a user with `cdn:admin:create` or verify bootstrap variables before first boot. |
| Admins get logged out, unauthorized responses, or dashboard timeouts behind Nginx | The Core API reverse proxy is not forwarding `Authorization` or has short proxy timeouts. | Put `proxy_set_header Authorization $http_authorization;` and sane `proxy_*_timeout` values on the Core API vhost/location. The dashboard proxy alone does not fix API auth. |
+| Visitors repeatedly receive challenge responses | JavaScript is disabled, the proof request is blocked, edge clearance cookies cannot be verified, the client IP changed, or the edge fleet uses different `CDNLITE_EDGE_CLEARANCE_SECRET` values. | Set the same strong `CDNLITE_EDGE_CLEARANCE_SECRET` on every edge, keep clocks sane, verify `/__cdnlite_challenge_verify` is reachable, and confirm the client is returning the `__cdnlite_clearance` cookie. |
| `/ready` reports `api_token` warn | `CDNLITE_API_TOKEN` is empty in local mode. | Accept for local dev, set a strong token for production. |
| `/ready` reports schema failure | Canonical schema bootstrap failed or the database is not disposable. | Inspect Core/PostgreSQL logs, then rebuild a fresh stack with `docker compose down -v && docker compose up -d --build`. |
| Edge returns unknown host page | Host is not in `config.json` or edge has stale config. | Activate the domain, rebuild snapshot, and confirm edge agent pulled config. |
@@ -91,7 +92,21 @@ curl -s http://localhost:8081/ready
docker compose exec edge wget -qO- http://127.0.0.1:8081/__cdnlite_reload_config
```
-If `config.json` is missing or empty, focus on edge auth and `/api/v1/edge/config`. If config exists but traffic fails, focus on host matching, DNS, origin health, and edge logs. For shared hosting or cPanel, keep `preserve_host` on so the origin receives the requested site hostname in Host and SNI even when the backend address is an IP. If the edge returns `no_healthy_origin`, check that at least one enabled origin is not a health-checked unhealthy origin.
+If `config.json` is missing or empty, focus on edge auth and `/api/v1/edge/config`. If config exists but traffic fails, focus on host matching, DNS, origin health, and edge logs. For shared hosting or cPanel, keep `preserve_host` on so the origin receives the requested site hostname in Host and SNI even when the backend address is an IP. If the edge returns `no_healthy_origin`, check that at least one enabled origin is not drained, disabled, or a health-checked unhealthy origin.
+
+During an origin incident, drain the origin before disabling it when you want
+new traffic to move away while preserving the row for recovery. Backups are
+used only after eligible primaries are unavailable. For unexpected duplicate
+writes, confirm the request method is non-idempotent and check
+`X-CDNLite-Origin-Retry-Attempts`; POST, PUT, PATCH, and DELETE should report
+zero automatic retry attempts.
+
+Origin health status comes from edge nodes, not scheduled core requests. If
+the dashboard shows stale origin health, check edge `/ready`, the edge-agent
+metrics queue, `/agent/push_metrics.sh`, and
+`GET /api/v1/domains/{domainId}/origins/health`. The core diagnostic button is
+only a comparison probe from the control plane and does not update routing
+health.
The edge keeps a worker-local last-known-good snapshot. If `/ready` reports
`reload_failures` or `last_reload_error`, fix the snapshot on disk or force the
diff --git a/edge/docker-entrypoint.sh b/edge/docker-entrypoint.sh
index e6d7ff1b..6ed6cde1 100644
--- a/edge/docker-entrypoint.sh
+++ b/edge/docker-entrypoint.sh
@@ -82,7 +82,8 @@ limits_dict="${CDNLITE_EDGE_LIMITS_DICT_SIZE:-20m}"
request_context_dict="${CDNLITE_EDGE_REQUEST_CONTEXT_DICT_SIZE:-10m}"
metric_queue_dict="${CDNLITE_EDGE_METRIC_QUEUE_DICT_SIZE:-10m}"
security_event_queue_dict="${CDNLITE_EDGE_SECURITY_EVENT_QUEUE_DICT_SIZE:-10m}"
-for item in "$limits_dict" "$request_context_dict" "$metric_queue_dict" "$security_event_queue_dict"; do
+waiting_room_dict="${CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE:-20m}"
+for item in "$limits_dict" "$request_context_dict" "$metric_queue_dict" "$security_event_queue_dict" "$waiting_room_dict"; do
size_value CDNLITE_EDGE_SHARED_DICT_SIZE "$item"
done
@@ -116,6 +117,7 @@ sed \
-e "s/__CDNLITE_EDGE_REQUEST_CONTEXT_DICT_SIZE__/$request_context_dict/g" \
-e "s/__CDNLITE_EDGE_METRIC_QUEUE_DICT_SIZE__/$metric_queue_dict/g" \
-e "s/__CDNLITE_EDGE_SECURITY_EVENT_QUEUE_DICT_SIZE__/$security_event_queue_dict/g" \
+ -e "s/__CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE__/$waiting_room_dict/g" \
-e "s#__CDNLITE_EDGE_RESOLVER__#$resolver#g" \
-e "s/__CDNLITE_EDGE_CLIENT_HEADER_BUFFER_SIZE__/$client_header_buffer_size/g" \
-e "s/__CDNLITE_EDGE_LARGE_CLIENT_HEADER_BUFFERS__/$large_client_header_buffers/g" \
diff --git a/edge/openresty/lua/clearance.lua b/edge/openresty/lua/clearance.lua
new file mode 100644
index 00000000..cb829c44
--- /dev/null
+++ b/edge/openresty/lua/clearance.lua
@@ -0,0 +1,356 @@
+local cjson = require('cjson.safe')
+local resty_sha256 = require('resty.sha256')
+local resty_string = require('resty.string')
+
+local M = {}
+
+local cookie_name = '__cdnlite_clearance'
+local default_ttl = 1800
+local challenge_ttl = 300
+local default_difficulty = 3
+
+local function secret()
+ local value = os.getenv('CDNLITE_EDGE_CLEARANCE_SECRET') or ''
+ if value == '' then
+ return 'cdnlite-dev-clearance-secret'
+ end
+ return value
+end
+
+local function b64(value)
+ return (ngx.encode_base64(value or ''):gsub('+', '-'):gsub('/', '_'):gsub('=', ''))
+end
+
+local function unb64(value)
+ local padded = tostring(value or ''):gsub('-', '+'):gsub('_', '/')
+ local remainder = #padded % 4
+ if remainder > 0 then
+ padded = padded .. string.rep('=', 4 - remainder)
+ end
+ return ngx.decode_base64(padded)
+end
+
+local function sign(payload)
+ return b64(ngx.hmac_sha1(secret(), payload))
+end
+
+local function hash_hex(value)
+ local sha = resty_sha256:new()
+ sha:update(value or '')
+ return resty_string.to_hex(sha:final())
+end
+
+local function html_escape(value)
+ return tostring(value or '')
+ :gsub('&', '&')
+ :gsub('<', '<')
+ :gsub('>', '>')
+ :gsub('"', '"')
+ :gsub("'", ''')
+end
+
+local function js_string(value)
+ return cjson.encode(tostring(value or '')) or '""'
+end
+
+local function same_host_path(value)
+ local path = tostring(value or ngx.var.request_uri or '/')
+ if path == '' or string.sub(path, 1, 1) ~= '/' or string.sub(path, 1, 2) == '//' then
+ return '/'
+ end
+ if string.find(path, '[\r\n]', 1, false) then
+ return '/'
+ end
+ return path
+end
+
+local function proof_prefix(difficulty)
+ return string.rep('0', tonumber(difficulty or default_difficulty) or default_difficulty)
+end
+
+local function challenge_mode(difficulty)
+ local n = tonumber(difficulty or default_difficulty) or default_difficulty
+ if n <= 1 then
+ return 'browser_check'
+ end
+ return 'proof_of_work'
+end
+
+local function encode_token(claims)
+ local json = cjson.encode(claims)
+ if not json then
+ return nil
+ end
+ local payload = b64(json)
+ return payload .. '.' .. sign(payload)
+end
+
+local function decode_token(token)
+ local payload, signature = tostring(token or ''):match('^([A-Za-z0-9_-]+)%.([A-Za-z0-9_-]+)$')
+ if not payload or not signature then
+ return nil, 'malformed'
+ end
+ if sign(payload) ~= signature then
+ return nil, 'bad_signature'
+ end
+ local decoded = unb64(payload)
+ if not decoded then
+ return nil, 'bad_payload'
+ end
+ local claims = cjson.decode(decoded)
+ if type(claims) ~= 'table' then
+ return nil, 'bad_claims'
+ end
+ return claims, nil
+end
+
+local function cookie_value()
+ return ngx.var['cookie_' .. cookie_name]
+end
+
+local function scope_matches(claims, domain_id, action, rule_id, client_ip)
+ if tostring(claims.domain_id or '') ~= tostring(domain_id or '') then
+ return false
+ end
+ if tostring(claims.action or '') ~= tostring(action or '') then
+ return false
+ end
+ if tostring(claims.rule_id or '') ~= tostring(rule_id or '') then
+ return false
+ end
+ if tostring(claims.client_ip or '') ~= tostring(client_ip or '') then
+ return false
+ end
+ return true
+end
+
+function M.has_clearance(domain_id, action, rule_id, client_ip)
+ local claims = decode_token(cookie_value())
+ if not claims then
+ return false
+ end
+ if tonumber(claims.expires_at or 0) <= ngx.time() then
+ return false
+ end
+ return scope_matches(claims, domain_id, action, rule_id, client_ip)
+end
+
+function M.issue(domain_id, action, rule_id, client_ip, ttl)
+ local expires_at = ngx.time() + (tonumber(ttl or default_ttl) or default_ttl)
+ local nonce = b64(tostring(ngx.now()) .. ':' .. tostring(math.random()) .. ':' .. tostring(ngx.worker.pid()))
+ return encode_token({
+ domain_id = tostring(domain_id or ''),
+ action = tostring(action or ''),
+ rule_id = tostring(rule_id or ''),
+ client_ip = tostring(client_ip or ''),
+ expires_at = expires_at,
+ nonce = nonce,
+ }), expires_at
+end
+
+function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override)
+ local expires_at = ngx.time() + challenge_ttl
+ local nonce = b64(tostring(ngx.now()) .. ':' .. tostring(math.random()) .. ':' .. tostring(ngx.worker.pid()))
+ local difficulty = tonumber(difficulty_override or '') or tonumber(os.getenv('CDNLITE_EDGE_CHALLENGE_DIFFICULTY') or '') or default_difficulty
+ if difficulty < 1 then difficulty = 1 end
+ if difficulty > 6 then difficulty = 6 end
+ return encode_token({
+ token_type = 'challenge',
+ domain_id = tostring(domain_id or ''),
+ action = tostring(action or ''),
+ rule_id = tostring(rule_id or ''),
+ client_ip = tostring(client_ip or ''),
+ return_path = same_host_path(return_path),
+ issued_at = ngx.time(),
+ expires_at = expires_at,
+ difficulty = difficulty,
+ mode = challenge_mode(difficulty),
+ nonce = nonce,
+ }), expires_at, difficulty
+end
+
+function M.consume_challenge(domain_id, action, rule_id, client_ip)
+ local token = ngx.var.arg_cdnlite_challenge or ''
+ if token == '' then
+ return false
+ end
+ local claims, err = decode_token(token)
+ if not claims then
+ return false, err
+ end
+ if tostring(claims.token_type or 'clearance') ~= 'clearance' then
+ return false, 'wrong_token_type'
+ end
+ if tonumber(claims.expires_at or 0) <= ngx.time() then
+ return false, 'expired'
+ end
+ if not scope_matches(claims, domain_id, action, rule_id, client_ip) then
+ return false, 'scope_mismatch'
+ end
+ ngx.header['Set-Cookie'] = string.format(
+ '%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax',
+ cookie_name,
+ token,
+ math.max(1, tonumber(claims.expires_at or 0) - ngx.time())
+ )
+ return true
+end
+
+function M.verify_challenge()
+ ngx.req.read_body()
+ local args = ngx.req.get_post_args(20)
+ if not args or not args.token then
+ args = ngx.req.get_uri_args(20)
+ end
+ local claims, err = decode_token(args.token or '')
+ if not claims then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = err or 'invalid_challenge' }))
+ return ngx.exit(403)
+ end
+ if tostring(claims.token_type or '') ~= 'challenge' then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = 'wrong_token_type' }))
+ return ngx.exit(403)
+ end
+ if tonumber(claims.expires_at or 0) <= ngx.time() then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = 'expired' }))
+ return ngx.exit(403)
+ end
+ local client_ip = tostring(ngx.var.remote_addr or '')
+ if not scope_matches(claims, claims.domain_id, claims.action, claims.rule_id, client_ip) then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = 'scope_mismatch' }))
+ return ngx.exit(403)
+ end
+ local difficulty = tonumber(claims.difficulty or default_difficulty) or default_difficulty
+ local proof = tostring(args.pow or '')
+ if difficulty <= 1 then
+ if proof ~= 'browser-check' then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = 'invalid_browser_check' }))
+ return ngx.exit(403)
+ end
+ else
+ local digest = hash_hex(tostring(args.token or '') .. ':' .. proof)
+ if proof == '' or string.sub(digest, 1, difficulty) ~= proof_prefix(difficulty) then
+ ngx.status = 403
+ ngx.header.content_type = 'application/json'
+ ngx.say(cjson.encode({ ok = false, error = 'invalid_proof' }))
+ return ngx.exit(403)
+ end
+ end
+ local clearance_token = encode_token({
+ domain_id = tostring(claims.domain_id or ''),
+ action = tostring(claims.action or ''),
+ rule_id = tostring(claims.rule_id or ''),
+ client_ip = client_ip,
+ expires_at = ngx.time() + default_ttl,
+ nonce = b64(tostring(ngx.now()) .. ':' .. proof),
+ })
+ ngx.header['Set-Cookie'] = string.format(
+ '%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax',
+ cookie_name,
+ clearance_token,
+ default_ttl
+ )
+ ngx.header['Location'] = same_host_path(claims.return_path)
+ return ngx.exit(303)
+end
+
+function M.challenge_response(domain_id, action, rule_id, client_ip, status_code, error_code, difficulty_override)
+ local return_path = same_host_path(ngx.var.request_uri or ngx.var.uri or '/')
+ local token, expires_at, difficulty = M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override)
+ local verify_path = '/__cdnlite_challenge_verify'
+ ngx.status = status_code
+ ngx.header.content_type = 'text/html; charset=utf-8'
+ ngx.header['Cache-Control'] = 'no-store'
+ ngx.say([[
+
+
+
+
+ Security check
+
+
+
+
+
Security check
+
CDNLite is checking this browser before sending traffic to the site.
+
Preparing challenge...
+
+
+
+
+
+]])
+ return ngx.exit(status_code)
+end
+
+return M
diff --git a/edge/openresty/lua/error_page.lua b/edge/openresty/lua/error_page.lua
index 38db9ece..602ef4bb 100644
--- a/edge/openresty/lua/error_page.lua
+++ b/edge/openresty/lua/error_page.lua
@@ -166,7 +166,7 @@ local function restore_activity_context()
local origin_id = tostring(ngx.var.target_origin_id or headers["X-CDNLite-Origin-Id"] or headers["x-cdnlite-origin-id"] or shared_origin_value(shared_context, "id") or "")
local origin_host = tostring(ngx.var.target_origin_host or headers["X-CDNLite-Origin-Host"] or headers["x-cdnlite-origin-host"] or shared_origin_value(shared_context, "host") or "")
- local origin_role = tostring((ngx.ctx.origin or {}).role or headers["X-CDNLite-Origin-Role"] or headers["x-cdnlite-origin-role"] or shared_origin_value(shared_context, "role") or "origin")
+ local origin_role = tostring((ngx.ctx.origin or {}).role or headers["X-CDNLite-Origin-Role"] or headers["x-cdnlite-origin-role"] or shared_origin_value(shared_context, "role") or "primary")
local origin_tls_verify = tostring((ngx.ctx.origin or {}).tls_verify or headers["X-CDNLite-Origin-Tls-Verify"] or headers["x-cdnlite-origin-tls-verify"] or shared_origin_value(shared_context, "tls_verify") or "ignore")
if origin_id ~= "" or origin_host ~= "" then
ngx.ctx.origin = {
diff --git a/edge/openresty/lua/metrics.lua b/edge/openresty/lua/metrics.lua
index 8283c104..36e9766c 100644
--- a/edge/openresty/lua/metrics.lua
+++ b/edge/openresty/lua/metrics.lua
@@ -49,7 +49,7 @@ function M.on_log()
cache_status = cache_status(),
router_error = tostring(ngx.ctx.router_error or ''),
origin_id = tostring((ngx.ctx.origin or {}).id or ngx.var.target_origin_id or ''),
- origin_role = tostring((ngx.ctx.origin or {}).role or 'origin'),
+ origin_role = tostring((ngx.ctx.origin or {}).role or 'primary'),
origin_host = tostring((ngx.ctx.origin or {}).host or ngx.var.target_origin_host or ''),
upstream_status = tostring(ngx.var.upstream_status or ''),
upstream_response_time = tostring(ngx.var.upstream_response_time or ''),
diff --git a/edge/openresty/lua/origin_health_checker.lua b/edge/openresty/lua/origin_health_checker.lua
new file mode 100644
index 00000000..6d9300da
--- /dev/null
+++ b/edge/openresty/lua/origin_health_checker.lua
@@ -0,0 +1,128 @@
+local loader = require('config_loader')
+local identity = require('identity')
+local telemetry_queue = require('telemetry_queue')
+
+local M = {}
+
+local function origin_key(domain_id, origin)
+ return tostring(domain_id or '') .. '|' .. tostring(origin.id or origin.host or '')
+end
+
+local function due(last_seen, interval)
+ return ngx.now() - (tonumber(last_seen or 0) or 0) >= math.max(5, tonumber(interval or 30) or 30)
+end
+
+local function path_for(origin)
+ local path = tostring(origin.health_check_path or '/')
+ if path == '' or path:sub(1, 1) ~= '/' then
+ return '/'
+ end
+ return path
+end
+
+local function probe(domain, origin)
+ local started = ngx.now()
+ local timeout_ms = math.max(1, tonumber(origin.health_check_timeout_seconds or 5) or 5) * 1000
+ local port = tonumber(origin.port or 80) or 80
+ local sock = ngx.socket.tcp()
+ sock:settimeouts(timeout_ms, timeout_ms, timeout_ms)
+ local ok, err = sock:connect(tostring(origin.host or ''), port)
+ if not ok then
+ sock:close()
+ return nil, 'connect_failed:' .. tostring(err or 'unknown'), math.floor((ngx.now() - started) * 1000)
+ end
+
+ if tostring(origin.scheme or 'http') == 'https' then
+ local sni = tostring(origin.sni or origin.host_header or origin.host or '')
+ local verify = tostring(origin.tls_verify or 'ignore') == 'verify'
+ local session, tls_err = sock:sslhandshake(nil, sni ~= '' and sni or nil, verify)
+ if not session then
+ sock:close()
+ return nil, 'tls_failed:' .. tostring(tls_err or 'unknown'), math.floor((ngx.now() - started) * 1000)
+ end
+ end
+
+ local host_header = tostring(origin.host_header or origin.host or '')
+ if origin.preserve_host == true then
+ host_header = tostring(domain.host or host_header)
+ end
+ local request = 'GET ' .. path_for(origin) .. ' HTTP/1.1\r\nHost: ' .. host_header .. '\r\nUser-Agent: CDNLite-Edge-Health\r\nConnection: close\r\n\r\n'
+ local sent, send_err = sock:send(request)
+ if not sent then
+ sock:close()
+ return nil, 'send_failed:' .. tostring(send_err or 'unknown'), math.floor((ngx.now() - started) * 1000)
+ end
+ local line, read_err = sock:receive('*l')
+ sock:close()
+ local latency_ms = math.floor((ngx.now() - started) * 1000)
+ if not line then
+ return nil, 'read_failed:' .. tostring(read_err or 'unknown'), latency_ms
+ end
+ local status = tonumber(string.match(line, '^HTTP/%S+%s+(%d+)') or '')
+ if not status then
+ return nil, 'invalid_http_response', latency_ms
+ end
+ return status, nil, latency_ms
+end
+
+local function collect_origins(cfg)
+ local out = {}
+ for host, domain in pairs(cfg.hosts or {}) do
+ domain.host = host
+ for _, origin in ipairs(domain.origins or {}) do
+ if origin and origin.enabled ~= false and origin.drain ~= true and origin.health_check_enabled == true then
+ out[origin_key(domain.domain_id, origin)] = { domain = domain, origin = origin }
+ end
+ end
+ end
+ return out
+end
+
+local function tick(premature)
+ if premature then
+ return
+ end
+ local cfg = loader.load()
+ local seen = ngx.shared.cdnlite_origin_health
+ if not seen then
+ return
+ end
+ for key, item in pairs(collect_origins(cfg)) do
+ local origin = item.origin
+ if due(seen:get(key), origin.health_check_interval_seconds) then
+ seen:set(key, ngx.now(), math.max(10, tonumber(origin.health_check_interval_seconds or 30) or 30))
+ local status, err, latency_ms = probe(item.domain, origin)
+ telemetry_queue.enqueue('metrics', {
+ ts = os.time(),
+ domain_id = tostring(item.domain.domain_id or ''),
+ edge_node_id = identity.get(),
+ requests_count = 0,
+ bytes_in = 0,
+ bytes_out = 0,
+ status = status or 0,
+ method = 'HEALTH',
+ path = path_for(origin),
+ host = tostring(item.domain.host or ''),
+ cache_status = 'BYPASS',
+ origin_id = tostring(origin.id or ''),
+ origin_host = tostring(origin.host or ''),
+ upstream_status = status and tostring(status) or '',
+ upstream_response_time = string.format('%.3f', latency_ms / 1000),
+ router_error = err or '',
+ origin_health_probe = true,
+ })
+ end
+ end
+end
+
+function M.start()
+ if ngx.worker.id and ngx.worker.id() ~= 0 then
+ return
+ end
+ local ok, err = ngx.timer.every(5, tick)
+ if not ok then
+ ngx.log(ngx.ERR, 'origin_health_checker_start_failed: ', tostring(err))
+ end
+end
+
+return M
diff --git a/edge/openresty/lua/origin_selector.lua b/edge/openresty/lua/origin_selector.lua
index 3bf073f8..267c59b3 100644
--- a/edge/openresty/lua/origin_selector.lua
+++ b/edge/openresty/lua/origin_selector.lua
@@ -14,6 +14,20 @@ local function health_check_enabled(origin)
return origin.health_check_enabled == true
end
+local function role_rank(origin)
+ local role = tostring(origin.role or 'primary')
+ if role == 'primary' then
+ return 0
+ end
+ if role == 'shield' then
+ return 1
+ end
+ if role == 'backup' then
+ return 2
+ end
+ return 3
+end
+
local function is_ip_address(host)
host = tostring(host or '')
if host:match('^%d+%.%d+%.%d+%.%d+$') then
@@ -30,10 +44,16 @@ local function sort_origins(origins)
return rank_a < rank_b
end
+ local role_a = role_rank(a)
+ local role_b = role_rank(b)
+ if role_a ~= role_b then
+ return role_a < role_b
+ end
+
local weight_a = tonumber(a.weight or 1) or 1
local weight_b = tonumber(b.weight or 1) or 1
if weight_a ~= weight_b then
- return weight_a < weight_b
+ return weight_a > weight_b
end
return tostring(a.id or '') < tostring(b.id or '')
@@ -43,7 +63,7 @@ end
local function eligible_origins(domain)
local origins = {}
for _, origin in ipairs(domain.origins or {}) do
- if origin and origin.enabled ~= false and type(origin.host) == 'string' and origin.host ~= '' then
+ if origin and origin.enabled ~= false and origin.drain ~= true and type(origin.host) == 'string' and origin.host ~= '' then
if not (health_check_enabled(origin) and tostring(origin.health_status or 'unknown') == 'unhealthy') then
table.insert(origins, origin)
end
@@ -56,32 +76,62 @@ end
local function candidate_origins(domain, country)
local geo = domain.geo_origins or {}
local geo_origin = geo[country] or geo.DEFAULT
- if type(geo_origin) == 'table' and geo_origin.enabled ~= false and type(geo_origin.host) == 'string' and geo_origin.host ~= '' and not (health_check_enabled(geo_origin) and tostring(geo_origin.health_status or 'unknown') == 'unhealthy') then
+ if type(geo_origin) == 'table' and geo_origin.enabled ~= false and geo_origin.drain ~= true and type(geo_origin.host) == 'string' and geo_origin.host ~= '' and not (health_check_enabled(geo_origin) and tostring(geo_origin.health_status or 'unknown') == 'unhealthy') then
return { geo_origin }, 'geo_origins.' .. tostring(country or 'DEFAULT')
end
return eligible_origins(domain), 'origins'
end
+local function weighted_pick(pool, seed)
+ local total = 0
+ for _, origin in ipairs(pool) do
+ local weight = tonumber(origin.weight or 1) or 1
+ if weight > 0 then
+ total = total + math.floor(weight)
+ end
+ end
+ if total <= 0 then
+ return pool[1]
+ end
+
+ local hash = ngx.crc32_short(seed or '') or 0
+ local slot = (tonumber(hash) or 0) % total
+ local seen = 0
+ for _, origin in ipairs(pool) do
+ local weight = tonumber(origin.weight or 1) or 1
+ if weight > 0 then
+ seen = seen + math.floor(weight)
+ if slot < seen then
+ return origin
+ end
+ end
+ end
+ return pool[1]
+end
+
local function choose_origin(origins, seed)
if #origins == 0 then
return nil
end
- local healthy = {}
- local unknown = {}
+ local healthy_primary = {}
+ local unknown_primary = {}
+ local healthy_backup = {}
+ local unknown_backup = {}
for _, origin in ipairs(origins) do
local status = tostring(origin.health_status or '')
+ local backup = tostring(origin.role or 'primary') == 'backup'
if status == 'healthy' then
- table.insert(healthy, origin)
+ table.insert(backup and healthy_backup or healthy_primary, origin)
elseif not health_check_enabled(origin) or status ~= 'unhealthy' then
- table.insert(unknown, origin)
+ table.insert(backup and unknown_backup or unknown_primary, origin)
end
end
- local pool = healthy
- if #pool == 0 then
- pool = unknown
- end
+ local pool = healthy_primary
+ if #pool == 0 then pool = unknown_primary end
+ if #pool == 0 then pool = healthy_backup end
+ if #pool == 0 then pool = unknown_backup end
if #pool == 0 then
return nil
end
@@ -89,9 +139,7 @@ local function choose_origin(origins, seed)
return pool[1]
end
- local hash = ngx.crc32_short(seed or '') or 0
- local index = (tonumber(hash) or 0) % #pool + 1
- return pool[index]
+ return weighted_pick(pool, seed)
end
local function origin_port(origin)
@@ -129,7 +177,7 @@ local function build_metadata(origin, role)
scheme = tostring(origin.scheme or 'http'),
port = origin_port(origin),
id = tostring(origin.id or ''),
- role = role or 'origin',
+ role = role or 'primary',
source = tostring(origin.source or ''),
host = tostring(origin.host or ''),
host_header = host_header,
@@ -139,6 +187,15 @@ local function build_metadata(origin, role)
health_check_enabled = origin.health_check_enabled == true,
health_status = tostring(origin.health_status or 'unknown'),
weight = tonumber(origin.weight or 1) or 1,
+ load_balancing_algorithm = tostring(origin.load_balancing_algorithm or 'weighted_hash'),
+ retry_attempts = tonumber(origin.retry_attempts or 1) or 1,
+ retry_budget_per_minute = tonumber(origin.retry_budget_per_minute or 60) or 60,
+ circuit_breaker_enabled = origin.circuit_breaker_enabled ~= false,
+ circuit_failure_threshold = tonumber(origin.circuit_failure_threshold or 5) or 5,
+ circuit_recovery_seconds = tonumber(origin.circuit_recovery_seconds or 30) or 30,
+ max_concurrent_requests = tonumber(origin.max_concurrent_requests or 0) or 0,
+ drain = origin.drain == true,
+ shield_enabled = origin.shield_enabled == true,
}
end
diff --git a/edge/openresty/lua/proxy.lua b/edge/openresty/lua/proxy.lua
index db3bd72f..b40bfae4 100644
--- a/edge/openresty/lua/proxy.lua
+++ b/edge/openresty/lua/proxy.lua
@@ -35,6 +35,54 @@ local function has_logged_in_cookie(value)
or value:find('%f[%w]laravel_session%s*=') ~= nil
end
+local function list_contains(list, needle)
+ if type(list) ~= 'table' then return false end
+ needle = string.lower(tostring(needle or ''))
+ for _, value in ipairs(list) do
+ if string.lower(tostring(value)) == needle then return true end
+ end
+ return false
+end
+
+local function request_has_bypass_header(headers)
+ if type(headers) ~= 'table' then return nil end
+ for _, name in ipairs(headers) do
+ local value = ngx.req.get_headers()[tostring(name):lower()]
+ if value and tostring(value) ~= '' then return tostring(name):lower() end
+ end
+ return nil
+end
+
+local function cache_key_part(name, value)
+ value = tostring(value or '')
+ return name .. '=' .. ngx.escape_uri(value)
+end
+
+local function build_cache_key(domain, cache_settings, cache_rule, static_asset)
+ local dimensions = cache_settings.cache_key_dimensions or {}
+ local parts = {}
+ if dimensions.scheme ~= false then table.insert(parts, cache_key_part('scheme', ngx.var.scheme)) end
+ if dimensions.host ~= false then table.insert(parts, cache_key_part('host', string.lower(ngx.var.host or ''))) end
+ if dimensions.domain_id ~= false then table.insert(parts, cache_key_part('domain', domain.domain_id)) end
+ if dimensions.path ~= false then table.insert(parts, cache_key_part('path', ngx.var.uri or '/')) end
+
+ local query_mode = dimensions.query or cache_settings.cache_query_string_mode or 'include_all'
+ if static_asset and cache_settings.ignore_query_strings_for_static == true then query_mode = 'ignore' end
+ if query_mode == 'include_all' then table.insert(parts, cache_key_part('query', ngx.var.args or '')) end
+
+ local headers = dimensions.headers or cache_settings.vary_headers or {'accept-encoding'}
+ for _, header in ipairs(headers) do
+ local header_name = string.lower(tostring(header))
+ if header_name ~= 'authorization' and header_name ~= 'cookie' then
+ table.insert(parts, cache_key_part('h.' .. header_name, ngx.req.get_headers()[header_name] or ''))
+ end
+ end
+ if dimensions.country == true then table.insert(parts, cache_key_part('country', geoip.request_country())) end
+ if dimensions.language == true then table.insert(parts, cache_key_part('lang', ngx.var.http_accept_language or '')) end
+ if dimensions.rule_version ~= false then table.insert(parts, cache_key_part('rule', (cache_rule and cache_rule.id) or 'default')) end
+ return table.concat(parts, '|'), table.concat(parts, '; ')
+end
+
function M.forward(domain)
local upstream = ngx.ctx.upstream or domain.upstream
if not upstream then
@@ -48,6 +96,7 @@ function M.forward(domain)
local cache_rule = ngx.ctx.cache_rule
local cache_rules_enabled = ngx.ctx.cache_rules_enabled == true
local static_asset = is_static_asset(ngx.var.uri)
+ local bypass_reason = nil
local edge_ttl = nil
if cache_rule and tonumber(cache_rule.ttl_seconds) and tonumber(cache_rule.ttl_seconds) > 0 then
edge_ttl = math.floor(tonumber(cache_rule.ttl_seconds))
@@ -62,26 +111,44 @@ function M.forward(domain)
if method ~= 'GET' and method ~= 'HEAD' then
cache_bypass = true
cache_no_store = true
+ bypass_reason = 'method'
+ end
+
+ if type(cache_settings.cache_methods) == 'table' and not list_contains(cache_settings.cache_methods, method) then
+ cache_bypass = true
+ cache_no_store = true
+ bypass_reason = bypass_reason or 'method_policy'
end
if cache_settings.enabled == false or not edge_ttl then
cache_bypass = true
cache_no_store = true
+ bypass_reason = bypass_reason or 'disabled_or_no_ttl'
end
if cache_settings.cache_authorized_requests ~= true and ngx.var.http_authorization and ngx.var.http_authorization ~= '' then
cache_bypass = true
cache_no_store = true
+ bypass_reason = bypass_reason or 'authorization'
end
if cache_settings.bypass_logged_in_users ~= false and has_logged_in_cookie(ngx.var.http_cookie) then
cache_bypass = true
cache_no_store = true
+ bypass_reason = bypass_reason or 'cookie'
+ end
+
+ local bypass_header = request_has_bypass_header(cache_settings.bypass_headers)
+ if bypass_header and bypass_header ~= 'authorization' then
+ cache_bypass = true
+ cache_no_store = true
+ bypass_reason = bypass_reason or ('header:' .. bypass_header)
end
if header_has_cache_directive(ngx.var.http_cache_control) then
cache_bypass = true
cache_no_store = true
+ bypass_reason = bypass_reason or 'request_cache_control'
end
ngx.var.target_upstream = upstream
@@ -91,20 +158,28 @@ function M.forward(domain)
ngx.var.target_origin_id = tostring((ngx.ctx.origin or {}).id or '')
ngx.var.target_origin_host = tostring((ngx.ctx.origin or {}).host or '')
ngx.var.target_origin_tls_verify = tostring((ngx.ctx.origin or {}).tls_verify or 'ignore')
+ ngx.var.target_origin_retry_attempts = tostring((ngx.ctx.origin or {}).retry_attempts or 1)
+ if method ~= 'GET' and method ~= 'HEAD' then
+ ngx.var.target_origin_retry_attempts = '0'
+ end
-- Keep the routing context on the request itself so the error page can
-- recover it even when an internal redirect clears Lua state.
ngx.req.set_header('X-CDNLite-Domain-Id', tostring(domain.domain_id or ''))
ngx.req.set_header('X-CDNLite-Origin-Id', tostring((ngx.ctx.origin or {}).id or ''))
ngx.req.set_header('X-CDNLite-Origin-Host', tostring((ngx.ctx.origin or {}).host or ''))
- ngx.req.set_header('X-CDNLite-Origin-Role', tostring((ngx.ctx.origin or {}).role or 'origin'))
+ ngx.req.set_header('X-CDNLite-Origin-Role', tostring((ngx.ctx.origin or {}).role or 'primary'))
ngx.req.set_header('X-CDNLite-Origin-Tls-Verify', tostring((ngx.ctx.origin or {}).tls_verify or 'ignore'))
+ ngx.req.set_header('X-CDNLite-Origin-Retry-Attempts', tostring(ngx.var.target_origin_retry_attempts or '0'))
+ ngx.req.set_header('X-CDNLite-Origin-Circuit-Breaker', tostring((ngx.ctx.origin or {}).circuit_breaker_enabled ~= false))
local request_context = cjson.encode({
domain_id = tostring(domain.domain_id or ''),
origin = {
id = tostring((ngx.ctx.origin or {}).id or ''),
host = tostring((ngx.ctx.origin or {}).host or ''),
- role = tostring((ngx.ctx.origin or {}).role or 'origin'),
+ role = tostring((ngx.ctx.origin or {}).role or 'primary'),
tls_verify = tostring((ngx.ctx.origin or {}).tls_verify or 'ignore'),
+ retry_attempts = tonumber(ngx.var.target_origin_retry_attempts or 0) or 0,
+ circuit_breaker_enabled = (ngx.ctx.origin or {}).circuit_breaker_enabled ~= false,
},
})
if request_context then
@@ -115,11 +190,10 @@ function M.forward(domain)
end
ngx.var.cdnlite_cache_bypass = cache_bypass and '1' or '0'
ngx.var.cdnlite_cache_no_store = cache_no_store and '1' or '0'
- local cache_uri = ngx.var.request_uri or ngx.var.uri or '/'
- if static_asset and cache_settings.ignore_query_strings_for_static == true then
- cache_uri = ngx.var.uri or '/'
- end
- ngx.var.cdnlite_cache_key = table.concat({ngx.var.scheme or '', ngx.var.host or '', cache_uri, ngx.var.http_accept_encoding or '', geoip.request_country()}, '|')
+ local cache_key, cache_debug = build_cache_key(domain, cache_settings, cache_rule, static_asset)
+ ngx.var.cdnlite_cache_key = cache_key
+ ngx.var.cdnlite_cache_debug = cache_debug
+ ngx.var.cdnlite_cache_bypass_reason = bypass_reason or ''
if edge_ttl and not cache_no_store then
ngx.header['X-Accel-Expires'] = tostring(edge_ttl)
end
@@ -130,7 +204,7 @@ function M.forward(domain)
edge_log.debug('proxy_forward', {
domain_id = tostring(domain.domain_id or ''),
origin_id = tostring(ngx.var.target_origin_id or ''),
- origin_role = tostring((ngx.ctx.origin or {}).role or 'origin'),
+ origin_role = tostring((ngx.ctx.origin or {}).role or 'primary'),
upstream = upstream,
})
return true
diff --git a/edge/openresty/lua/router.lua b/edge/openresty/lua/router.lua
index a5fadbe1..b66efb40 100644
--- a/edge/openresty/lua/router.lua
+++ b/edge/openresty/lua/router.lua
@@ -7,6 +7,8 @@ local ip_rules = require('ip_rules')
local edge_log = require('edge_log')
local geoip = require('geoip')
local telemetry_queue = require('telemetry_queue')
+local clearance = require('clearance')
+local waiting_room = require('waiting_room')
local M = {}
@@ -241,12 +243,12 @@ local function apply_waf(cfg, host)
return ngx.exit(403)
end
if ngx.ctx.security_action == 'challenge' then
+ if clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip) then
+ return true
+ end
append_security_event(nil)
- ngx.status = 403
- ngx.header.content_type = 'application/json'
identity.apply()
- ngx.say('{"error":"bot_challenge_required","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}')
- return ngx.exit(403)
+ return clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required', rule.challenge_difficulty)
end
return true
end
@@ -338,14 +340,14 @@ local function apply_rate_limit(cfg, host, domain_id)
ngx.say('{"error":"rate_limited","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}')
return ngx.exit(429)
elseif ngx.ctx.security_action == 'challenge' then
+ if clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip) then
+ return true
+ end
append_security_event(domain_id)
edge_log.warn('rate_limit_challenge', { domain_id = tostring(domain_id or ''), rule_id = tostring(rule.id or '') })
- ngx.status = 429
- ngx.header.content_type = 'application/json'
identity.apply()
ngx.header['Retry-After'] = '60'
- ngx.say('{"error":"challenge_required","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}')
- return ngx.exit(429)
+ return clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required', rule.challenge_difficulty)
end
return true
end
@@ -398,7 +400,13 @@ function M.handle()
if not rate_limit_ok then
return false, 'rate_limited'
end
+ ngx.ctx.cache_rule, ngx.ctx.cache_rules_enabled = match_cache_rule(cfg, host)
+ ngx.ctx.cache_settings = domain.cache or {}
local country = request_country()
+ local waiting_ok = waiting_room.apply(domain.waiting_room, domain)
+ if not waiting_ok then
+ return false, 'waiting_room'
+ end
local upstream, origin_meta = origin_selector.select(domain, country, tostring(ngx.ctx.request_id or ngx.var.request_id or ngx.var.uri or ''))
if not upstream then
edge_log.error('router_error', { domain_id = tostring(domain.domain_id or ''), router_error = tostring(origin_meta or 'no_healthy_origin') })
@@ -408,8 +416,6 @@ function M.handle()
ngx.ctx.origin = origin_meta or {}
ngx.ctx.origin_scheme = ngx.ctx.origin.scheme
ngx.ctx.origin_pool_size = #(domain.origins or {})
- ngx.ctx.cache_rule, ngx.ctx.cache_rules_enabled = match_cache_rule(cfg, host)
- ngx.ctx.cache_settings = domain.cache or {}
edge_log.info('origin_selected', {
domain_id = tostring(domain.domain_id or ''),
origin_id = tostring(ngx.ctx.origin.id or ''),
@@ -419,6 +425,7 @@ function M.handle()
origin_port = tostring(ngx.ctx.origin.port or ''),
origin_pool_size = tostring(ngx.ctx.origin_pool_size or ''),
})
+ waiting_room.mark_origin(domain)
return proxy.forward(domain)
end
diff --git a/edge/openresty/lua/waiting_room.lua b/edge/openresty/lua/waiting_room.lua
new file mode 100644
index 00000000..6d918278
--- /dev/null
+++ b/edge/openresty/lua/waiting_room.lua
@@ -0,0 +1,291 @@
+local cjson = require('cjson.safe')
+local identity = require('identity')
+local edge_log = require('edge_log')
+
+local M = {}
+local ticket_cookie = '__cdnlite_queue_ticket'
+local admit_cookie = '__cdnlite_admission'
+
+local function secret()
+ local value = os.getenv('CDNLITE_EDGE_WAITING_ROOM_SECRET') or os.getenv('CDNLITE_EDGE_CLEARANCE_SECRET') or ''
+ return value ~= '' and value or 'cdnlite-dev-waiting-room-secret'
+end
+
+local function b64(value)
+ return (ngx.encode_base64(value or ''):gsub('+', '-'):gsub('/', '_'):gsub('=', ''))
+end
+
+local function unb64(value)
+ local padded = tostring(value or ''):gsub('-', '+'):gsub('_', '/')
+ local remainder = #padded % 4
+ if remainder > 0 then padded = padded .. string.rep('=', 4 - remainder) end
+ return ngx.decode_base64(padded)
+end
+
+local function sign(payload)
+ return b64(ngx.hmac_sha1(secret(), payload))
+end
+
+local function encode_token(claims)
+ local json = cjson.encode(claims)
+ if not json then return nil end
+ local payload = b64(json)
+ return payload .. '.' .. sign(payload)
+end
+
+local function decode_token(token)
+ local payload, signature = tostring(token or ''):match('^([A-Za-z0-9_-]+)%.([A-Za-z0-9_-]+)$')
+ if not payload or not signature or sign(payload) ~= signature then return nil end
+ local claims = cjson.decode(unb64(payload) or '')
+ if type(claims) ~= 'table' or tonumber(claims.expires_at or 0) <= ngx.time() then return nil end
+ return claims
+end
+
+local function html_escape(value)
+ return tostring(value or ''):gsub('&', '&'):gsub('<', '<'):gsub('>', '>'):gsub('"', '"'):gsub("'", ''')
+end
+
+local function same_host_path(value)
+ local path = tostring(value or '/')
+ if path == '' or path:sub(1, 1) ~= '/' or path:sub(1, 2) == '//' or path:find('[\r\n]') then return '/' end
+ if path:sub(1, 29) == '/.well-known/cdnlite/queue' then return '/' end
+ return path
+end
+
+local function key(domain_id, suffix)
+ return 'wr|' .. tostring(domain_id or '') .. '|' .. suffix
+end
+
+local function policy_for_host()
+ local loader = require('config_loader')
+ local cfg = loader.load()
+ local host = string.lower(tostring(ngx.var.host or ''):gsub(':%d+$', ''))
+ local domain = cfg.hosts and cfg.hosts[host] or nil
+ if not domain then return nil, nil end
+ return domain.waiting_room, domain
+end
+
+local function overload_active(policy)
+ if not policy or policy.enabled ~= true then return false end
+ local state = tostring(policy.state or '')
+ if state == 'manual_emergency' or state == 'overloaded' or state == 'entering_overload' then return true end
+ if tostring(policy.mode or '') == 'manual' and tonumber(policy.manual_override_until or 0) > ngx.time() then return true end
+ if tostring(policy.mode or '') == 'automatic' then
+ local dict = ngx.shared.cdnlite_waiting_room
+ local domain_id = tostring(policy.domain_id or '')
+ local second = tostring(ngx.time())
+ local rps = dict and dict:incr(key(domain_id, 'rps|' .. second), 1, 0, 2) or 0
+ if rps and rps > (tonumber(policy.rps_threshold or 100) or 100) then
+ if dict then dict:set(key(domain_id, 'state'), 'overloaded', tonumber(policy.minimum_state_seconds or 60) or 60) end
+ return true
+ end
+ if dict and dict:get(key(domain_id, 'state')) == 'overloaded' then
+ return true
+ end
+ end
+ return false
+end
+
+local function client_ip()
+ return tostring(ngx.var.remote_addr or '')
+end
+
+local function header_has_cache_directive(value)
+ if not value then
+ return false
+ end
+ value = string.lower(value)
+ return value:find('no-cache', 1, true) ~= nil or value:find('no-store', 1, true) ~= nil
+end
+
+local function has_logged_in_cookie(value)
+ if not value then return false end
+ value = string.lower(value)
+ return value:find('%f[%w]session[%w_%-]*%s*=') ~= nil
+ or value:find('%f[%w]auth[%w_%-]*%s*=') ~= nil
+ or value:find('%f[%w]wordpress_logged_in[%w_%-]*%s*=') ~= nil
+ or value:find('%f[%w]laravel_session%s*=') ~= nil
+end
+
+local static_extensions = {
+ css = true, js = true, png = true, jpg = true, jpeg = true, gif = true,
+ svg = true, webp = true, ico = true, woff = true, woff2 = true, ttf = true,
+ mp4 = true, pdf = true,
+}
+
+local function is_static_asset(path)
+ local extension = string.lower((path or ''):match('%.([%w]+)$') or '')
+ return static_extensions[extension] == true
+end
+
+local function cache_candidate()
+ local method = ngx.req.get_method()
+ if method ~= 'GET' and method ~= 'HEAD' then
+ return false
+ end
+ local cache_settings = ngx.ctx.cache_settings or {}
+ if cache_settings.enabled == false then
+ return false
+ end
+ local cache_rule = ngx.ctx.cache_rule
+ local cache_rules_enabled = ngx.ctx.cache_rules_enabled == true
+ local edge_ttl = nil
+ if cache_rule and tonumber(cache_rule.ttl_seconds) and tonumber(cache_rule.ttl_seconds) > 0 then
+ edge_ttl = math.floor(tonumber(cache_rule.ttl_seconds))
+ elseif not cache_rules_enabled and tonumber(cache_settings.default_edge_ttl_seconds) and tonumber(cache_settings.default_edge_ttl_seconds) > 0 then
+ edge_ttl = math.floor(tonumber(cache_settings.default_edge_ttl_seconds))
+ end
+ if not edge_ttl and is_static_asset(ngx.var.uri) and cache_settings.static_asset_cache_enabled == true and tonumber(cache_settings.default_edge_ttl_seconds) and tonumber(cache_settings.default_edge_ttl_seconds) > 0 then
+ edge_ttl = math.floor(tonumber(cache_settings.default_edge_ttl_seconds))
+ end
+ if not edge_ttl then
+ return false
+ end
+ if cache_settings.cache_authorized_requests ~= true and ngx.var.http_authorization and ngx.var.http_authorization ~= '' then
+ return false
+ end
+ if cache_settings.bypass_logged_in_users ~= false and has_logged_in_cookie(ngx.var.http_cookie) then
+ return false
+ end
+ if header_has_cache_directive(ngx.var.http_cache_control) then
+ return false
+ end
+ return true
+end
+
+local function has_admission(policy, domain_id)
+ local claims = decode_token(ngx.var['cookie_' .. admit_cookie] or '')
+ return claims and tostring(claims.domain_id or '') == tostring(domain_id or '') and tostring(claims.client_ip or '') == client_ip()
+end
+
+local function issue_admission(policy, domain_id)
+ local ttl = tonumber(policy.admission_ttl_seconds or 900) or 900
+ local token = encode_token({ token_type = 'admission', domain_id = tostring(domain_id), client_ip = client_ip(), expires_at = ngx.time() + ttl })
+ if token then
+ ngx.header['Set-Cookie'] = string.format('%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax', admit_cookie, token, ttl)
+ end
+end
+
+local function admit_budget(policy, domain_id)
+ local dict = ngx.shared.cdnlite_waiting_room
+ if not dict then return true end
+ local minute = tostring(math.floor(ngx.time() / 60))
+ local budget_key = key(domain_id, 'admitted|' .. minute)
+ local current = dict:incr(budget_key, 1, 0, 61)
+ return current and current <= (tonumber(policy.admission_rate_per_minute or 60) or 60)
+end
+
+local function issue_ticket(policy, domain_id, return_path)
+ local dict = ngx.shared.cdnlite_waiting_room
+ local queue_limit = tonumber(policy.queue_limit or 1000) or 1000
+ local ttl = tonumber(policy.ticket_ttl_seconds or 300) or 300
+ local count = dict and (dict:incr(key(domain_id, 'waiting'), 1, 0, ttl) or queue_limit + 1) or 1
+ if count > queue_limit then
+ if dict then dict:incr(key(domain_id, 'rejected'), 1, 0, 3600) end
+ return nil, 'queue_full'
+ end
+ local token = encode_token({
+ token_type = 'queue_ticket', domain_id = tostring(domain_id), client_ip = client_ip(),
+ issued_at = ngx.time(), expires_at = ngx.time() + ttl, return_path = same_host_path(return_path),
+ position_hint = count,
+ })
+ if token then
+ ngx.header['Set-Cookie'] = string.format('%s=%s; Max-Age=%d; Path=/.well-known/cdnlite/queue; HttpOnly; SameSite=Lax', ticket_cookie, token, ttl)
+ end
+ return token, nil, count
+end
+
+local function json_response(status, body, retry_after)
+ ngx.status = status
+ ngx.header.content_type = 'application/json'
+ ngx.header['Cache-Control'] = 'no-store'
+ ngx.header['X-Robots-Tag'] = 'noindex, nofollow'
+ ngx.header['X-CDNLITE-Request-Id'] = tostring(ngx.ctx.request_id or ngx.var.request_id or '')
+ if retry_after then ngx.header['Retry-After'] = tostring(retry_after) end
+ identity.apply()
+ ngx.say(cjson.encode(body))
+ return ngx.exit(status)
+end
+
+function M.apply(policy, domain)
+ if not overload_active(policy) then return true end
+ local domain_id = tostring(domain.domain_id or '')
+ local method = ngx.req.get_method()
+ if cache_candidate() then
+ ngx.ctx.waiting_room_cache_candidate = true
+ return true
+ end
+ if has_admission(policy, domain_id) then return true end
+ if method ~= 'GET' and method ~= 'HEAD' then
+ return json_response(429, { error = 'waiting_room_required', request_id = tostring(ngx.ctx.request_id or ''), estimated_wait_seconds = tonumber(policy.status_poll_seconds or 5) or 5 }, tonumber(policy.status_poll_seconds or 5) or 5)
+ end
+ if admit_budget(policy, domain_id) then
+ issue_admission(policy, domain_id)
+ return true
+ end
+ local ticket, err = issue_ticket(policy, domain_id, ngx.var.request_uri or '/')
+ if not ticket then
+ return json_response(503, { error = err or 'waiting_room_unavailable', request_id = tostring(ngx.ctx.request_id or '') }, tonumber(policy.status_poll_seconds or 5) or 5)
+ end
+ local url = '/.well-known/cdnlite/queue?return=' .. ngx.escape_uri(same_host_path(ngx.var.request_uri or '/'))
+ ngx.header['Location'] = url
+ ngx.header['Cache-Control'] = 'no-store'
+ edge_log.warn('waiting_room_redirect', { domain_id = domain_id })
+ return ngx.exit(302)
+end
+
+function M.mark_origin(domain)
+ local dict = ngx.shared.cdnlite_waiting_room
+ if not dict or not domain then return end
+ if ngx.ctx.waiting_room_cache_candidate == true then return end
+ local domain_id = tostring(domain.domain_id or '')
+ ngx.ctx.waiting_room_domain_id = domain_id
+ ngx.ctx.waiting_room_origin_marked = true
+ dict:incr(key(domain_id, 'active_origin'), 1, 0, 300)
+ dict:incr(key(domain_id, 'origin_bound'), 1, 0, 3600)
+end
+
+function M.on_log()
+ local dict = ngx.shared.cdnlite_waiting_room
+ if not dict or ngx.ctx.waiting_room_origin_marked ~= true then return end
+ local domain_id = tostring(ngx.ctx.waiting_room_domain_id or '')
+ if domain_id ~= '' then
+ dict:incr(key(domain_id, 'active_origin'), -1, 0, 300)
+ end
+end
+
+function M.queue_status()
+ local policy, domain = policy_for_host()
+ if not policy or not domain then
+ return json_response(404, { error = 'domain_not_configured', request_id = tostring(ngx.ctx.request_id or ngx.var.request_id or '') })
+ end
+ local domain_id = tostring(domain.domain_id or '')
+ local poll = tonumber(policy.status_poll_seconds or 5) or 5
+ local dict = ngx.shared.cdnlite_waiting_room
+ local counters = {
+ waiting = dict and (tonumber(dict:get(key(domain_id, 'waiting')) or 0) or 0) or 0,
+ active_origin = dict and (tonumber(dict:get(key(domain_id, 'active_origin')) or 0) or 0) or 0,
+ origin_bound = dict and (tonumber(dict:get(key(domain_id, 'origin_bound')) or 0) or 0) or 0,
+ rejected = dict and (tonumber(dict:get(key(domain_id, 'rejected')) or 0) or 0) or 0,
+ }
+ if admit_budget(policy, domain_id) then
+ issue_admission(policy, domain_id)
+ return json_response(200, { ok = true, admitted = true, redirect_to = same_host_path(ngx.var.arg_return or '/'), counters = counters })
+ end
+ return json_response(200, { ok = true, admitted = false, retry_after = poll, jitter_seconds = tonumber(policy.jitter_seconds or 4) or 4, request_id = tostring(ngx.ctx.request_id or ngx.var.request_id or ''), counters = counters }, poll)
+end
+
+function M.queue_page()
+ local policy, domain = policy_for_host()
+ if not policy or not domain then return json_response(404, { error = 'domain_not_configured' }) end
+ issue_ticket(policy, tostring(domain.domain_id or ''), ngx.var.arg_return or '/')
+ local poll = tonumber(policy.status_poll_seconds or 5) or 5
+ local jitter = tonumber(policy.jitter_seconds or 4) or 4
+ ngx.header['Cache-Control'] = 'no-store'
+ ngx.header['X-Robots-Tag'] = 'noindex, nofollow'
+ ngx.header['Content-Security-Policy'] = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"
+ identity.apply()
+ ngx.say('' .. html_escape(policy.waiting_room_title or 'Traffic is high') .. '
' .. html_escape(policy.waiting_room_title or 'Traffic is high') .. '
' .. html_escape(policy.waiting_room_message or '') .. '
Checking for admission...
Request ID: ' .. html_escape(ngx.ctx.request_id or ngx.var.request_id or '') .. '
')
+end
+
+return M
diff --git a/edge/openresty/nginx.conf b/edge/openresty/nginx.conf
index 3088d7f8..6c6b5847 100644
--- a/edge/openresty/nginx.conf
+++ b/edge/openresty/nginx.conf
@@ -13,6 +13,9 @@ env CDNLITE_EDGE_LOG_LEVEL;
env CDNLITE_EDGE_DEBUG_HEADERS;
env CDNLITE_EDGE_LOG_REQUEST_BODY;
env CDNLITE_EDGE_MMDB_FILE;
+env CDNLITE_EDGE_CLEARANCE_SECRET;
+env CDNLITE_EDGE_CHALLENGE_DIFFICULTY;
+env CDNLITE_EDGE_WAITING_ROOM_SECRET;
error_log /dev/stderr __CDNLITE_EDGE_ERROR_LOG_LEVEL__;
pid /tmp/nginx.pid;
@@ -26,9 +29,12 @@ http {
lua_shared_dict cdnlite_request_context __CDNLITE_EDGE_REQUEST_CONTEXT_DICT_SIZE__;
lua_shared_dict cdnlite_metric_queue __CDNLITE_EDGE_METRIC_QUEUE_DICT_SIZE__;
lua_shared_dict cdnlite_security_event_queue __CDNLITE_EDGE_SECURITY_EVENT_QUEUE_DICT_SIZE__;
+ lua_shared_dict cdnlite_waiting_room __CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE__;
+ lua_shared_dict cdnlite_origin_health 1m;
init_worker_by_lua_block {
require('config_loader').reload()
require('telemetry_queue').start()
+ require('origin_health_checker').start()
}
resolver __CDNLITE_EDGE_RESOLVER__ ipv6=off valid=30s;
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
@@ -74,9 +80,12 @@ http {
set $target_origin_host '';
set $target_domain_id '';
set $target_origin_tls_verify 'ignore';
+ set $target_origin_retry_attempts '0';
set $cdnlite_cache_bypass 0;
set $cdnlite_cache_no_store 0;
set $cdnlite_cache_key "$scheme|$host|$request_uri|$http_accept_encoding|DEFAULT";
+ set $cdnlite_cache_debug '';
+ set $cdnlite_cache_bypass_reason '';
location = /health {
default_type application/json;
@@ -113,6 +122,41 @@ http {
}
}
+ location = /__cdnlite_challenge_verify {
+ default_type application/json;
+ content_by_lua_block {
+ require('clearance').verify_challenge()
+ }
+ log_by_lua_block {
+ local metrics = require('metrics')
+ metrics.on_log()
+ require('waiting_room').on_log()
+ }
+ }
+
+ location = /.well-known/cdnlite/queue {
+ default_type text/html;
+ content_by_lua_block {
+ require('waiting_room').queue_page()
+ }
+ log_by_lua_block {
+ local metrics = require('metrics')
+ metrics.on_log()
+ require('waiting_room').on_log()
+ }
+ }
+
+ location = /.well-known/cdnlite/queue/status {
+ default_type application/json;
+ content_by_lua_block {
+ require('waiting_room').queue_status()
+ }
+ log_by_lua_block {
+ local metrics = require('metrics')
+ metrics.on_log()
+ }
+ }
+
location = /__cdnlite_error_page {
internal;
default_type text/html;
@@ -143,6 +187,9 @@ http {
header_filter_by_lua_block {
local metrics = require('metrics')
metrics.on_header()
+ ngx.header['X-CDNLite-Cache'] = ngx.var.cdnlite_cache_bypass == '1' and 'BYPASS' or (ngx.var.upstream_cache_status or 'MISS')
+ if ngx.var.cdnlite_cache_bypass_reason ~= '' then ngx.header['X-CDNLite-Cache-Reason'] = ngx.var.cdnlite_cache_bypass_reason end
+ if os.getenv('CDNLITE_EDGE_DEBUG_HEADERS') == '1' then ngx.header['X-CDNLite-Cache-Key'] = ngx.var.cdnlite_cache_debug end
local header_rules = require('header_rules')
header_rules.apply()
}
@@ -156,7 +203,6 @@ http {
proxy_cache_key $cdnlite_cache_key;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 __CDNLITE_CACHE_DEFAULT_TTL__;
- proxy_ignore_headers X-Accel-Expires;
proxy_cache_bypass $cdnlite_cache_bypass;
proxy_no_cache $cdnlite_cache_no_store;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
@@ -176,6 +222,9 @@ http {
header_filter_by_lua_block {
local metrics = require('metrics')
metrics.on_header()
+ ngx.header['X-CDNLite-Cache'] = ngx.var.cdnlite_cache_bypass == '1' and 'BYPASS' or (ngx.var.upstream_cache_status or 'MISS')
+ if ngx.var.cdnlite_cache_bypass_reason ~= '' then ngx.header['X-CDNLite-Cache-Reason'] = ngx.var.cdnlite_cache_bypass_reason end
+ if os.getenv('CDNLITE_EDGE_DEBUG_HEADERS') == '1' then ngx.header['X-CDNLite-Cache-Key'] = ngx.var.cdnlite_cache_debug end
local header_rules = require('header_rules')
header_rules.apply()
}
@@ -189,7 +238,6 @@ http {
proxy_cache_key $cdnlite_cache_key;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 __CDNLITE_CACHE_DEFAULT_TTL__;
- proxy_ignore_headers X-Accel-Expires;
proxy_cache_bypass $cdnlite_cache_bypass;
proxy_no_cache $cdnlite_cache_no_store;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
@@ -224,9 +272,12 @@ http {
set $target_origin_host '';
set $target_domain_id '';
set $target_origin_tls_verify 'ignore';
+ set $target_origin_retry_attempts '0';
set $cdnlite_cache_bypass 0;
set $cdnlite_cache_no_store 0;
set $cdnlite_cache_key "$scheme|$host|$request_uri|$http_accept_encoding|DEFAULT";
+ set $cdnlite_cache_debug '';
+ set $cdnlite_cache_bypass_reason '';
location = /__cdnlite_error_page {
internal;
@@ -241,6 +292,17 @@ http {
}
}
+ location = /__cdnlite_challenge_verify {
+ default_type application/json;
+ content_by_lua_block {
+ require('clearance').verify_challenge()
+ }
+ log_by_lua_block {
+ local metrics = require('metrics')
+ metrics.on_log()
+ }
+ }
+
location / {
access_by_lua_block {
local router = require('router')
@@ -257,6 +319,9 @@ http {
header_filter_by_lua_block {
local metrics = require('metrics')
metrics.on_header()
+ ngx.header['X-CDNLite-Cache'] = ngx.var.cdnlite_cache_bypass == '1' and 'BYPASS' or (ngx.var.upstream_cache_status or 'MISS')
+ if ngx.var.cdnlite_cache_bypass_reason ~= '' then ngx.header['X-CDNLite-Cache-Reason'] = ngx.var.cdnlite_cache_bypass_reason end
+ if os.getenv('CDNLITE_EDGE_DEBUG_HEADERS') == '1' then ngx.header['X-CDNLite-Cache-Key'] = ngx.var.cdnlite_cache_debug end
local header_rules = require('header_rules')
header_rules.apply()
}
@@ -268,7 +333,6 @@ http {
proxy_cache_key $cdnlite_cache_key;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 __CDNLITE_CACHE_DEFAULT_TTL__;
- proxy_ignore_headers X-Accel-Expires;
proxy_cache_bypass $cdnlite_cache_bypass;
proxy_no_cache $cdnlite_cache_no_store;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
@@ -288,6 +352,9 @@ http {
header_filter_by_lua_block {
local metrics = require('metrics')
metrics.on_header()
+ ngx.header['X-CDNLite-Cache'] = ngx.var.cdnlite_cache_bypass == '1' and 'BYPASS' or (ngx.var.upstream_cache_status or 'MISS')
+ if ngx.var.cdnlite_cache_bypass_reason ~= '' then ngx.header['X-CDNLite-Cache-Reason'] = ngx.var.cdnlite_cache_bypass_reason end
+ if os.getenv('CDNLITE_EDGE_DEBUG_HEADERS') == '1' then ngx.header['X-CDNLite-Cache-Key'] = ngx.var.cdnlite_cache_debug end
local header_rules = require('header_rules')
header_rules.apply()
}
@@ -299,7 +366,6 @@ http {
proxy_cache_key $cdnlite_cache_key;
proxy_cache_methods GET HEAD;
proxy_cache_valid 200 301 302 __CDNLITE_CACHE_DEFAULT_TTL__;
- proxy_ignore_headers X-Accel-Expires;
proxy_cache_bypass $cdnlite_cache_bypass;
proxy_no_cache $cdnlite_cache_no_store;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;