diff --git a/CHANGELOG.md b/CHANGELOG.md index d56f6aad..29924015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Phase 1 database architecture foundation with workload budgets, telemetry ingest batch diagnostics, reporting watermarks, reconciliation tables, current summary read model, reporting query budgets, one-shot phase runner, manifest, stress scenario, and database architecture docs. - Phase 2 analytics scalability with asynchronous recalculation jobs, job status API, idempotent aggregate upserts, bounded summary metadata, analytics cache schema, dashboard queued-job feedback, and contract documentation. - Phase 3 edge hot-path performance with worker-local config caching, last-known-good reload behavior, bounded shared-memory telemetry queues, queue health visibility, configurable edge capacity defaults, one-shot phase manifest, and stress scenario registration. +- Phase 4 real challenge and clearance system for WAF, rate-limit, and Emergency Protection flows, including a self-hosted browser verification page, per-rule challenge difficulty, edge-only verification endpoint, scoped HttpOnly clearance cookies, block-rule precedence, edge secret and difficulty fallback configuration, one-shot phase manifest, e2e coverage, and stress scenario registration. +- Phase 5 waiting room with persisted per-domain policy, API and dashboard controls, signed edge queue tickets, signed admission cookies, local queue/status endpoints, bounded edge queue state, and operator documentation. +- Phase 6 cache correctness with explicit eligibility and key-dimension settings, safe debug headers, personalized-request bypass defaults, per-rule edge TTL honoring, cache-status headers, a one-shot phase manifest, and stress scenario registration. +- Phase 7 origin routing resilience with primary, backup, and shield roles, deterministic weighted selection, drain controls, bounded idempotent retry metadata, circuit-breaker settings, shield visibility, edge-sourced origin health observations, per-edge latency and jitter reporting, one-shot phase manifest, and stress scenario registration. - Single canonical roadmap at `docs/ROADMAP.md`; removed duplicate root, lower-case, and legacy roadmap files. - Repository-level project presentation, contribution, security, roadmap, changelog, issue template, and pull request template documentation. diff --git a/README.md b/README.md index 8dbe4491..adb3f653 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,12 @@ CDNLITE_EDGE_HEALTH_MODE=static ./ci/dns_e2e.sh Run the destructive DNS stress test only against an explicitly disposable environment. +Useful operator commands include `php artisan cdn:edge:sync-config` to publish or +fetch the active edge config and `php artisan cdn:config-snapshots:prune +--keep=2 --batch=5000 --dry-run` to review bounded snapshot cleanup before +deleting old published artifacts. Snapshot rollback/history endpoints are +disabled by default because database tables remain the source of truth. + ## Contributing Contributions are welcome across docs, tests, OpenResty/Lua edge work, Vue dashboard UX, PHP control plane, security hardening, deployment examples, Kubernetes/Helm, Prometheus/Grafana, RBAC, and SSO. diff --git a/ci/e2e.sh b/ci/e2e.sh index 0214bec0..b56f82c7 100755 --- a/ci/e2e.sh +++ b/ci/e2e.sh @@ -601,14 +601,14 @@ record_step PASS "guided-onboarding-flow" "recommendation logic, preview, skip/r config_snapshot_json="$(docker compose exec -T core php artisan cdn:edge:sync-config)" config_snapshot_after="$(jq -r '.version' <<<"$config_snapshot_json")" -config_snapshot_reused_json="$(docker compose exec -T core php artisan cdn:edge:sync-config)" -config_snapshot_reused_version="$(jq -r '.version' <<<"$config_snapshot_reused_json")" -config_snapshot_reused="$(jq -r '.reused // false' <<<"$config_snapshot_reused_json")" +config_snapshot_cached_json="$(docker compose exec -T core php artisan cdn:edge:sync-config)" +config_snapshot_cached_version="$(jq -r '.version' <<<"$config_snapshot_cached_json")" +config_snapshot_cached_reused="$(jq -r '.reused // false' <<<"$config_snapshot_cached_json")" retry 20 1 config_publish_audit_exists assert_eq "$config_snapshot_after" "$(current_config_state_version)" "config rebuild should activate the published snapshot" -assert_eq "$config_snapshot_reused_version" "$config_snapshot_after" "unchanged config rebuild should reuse the active snapshot version" -assert_eq "$config_snapshot_reused" "true" "unchanged config rebuild should report a reused snapshot" -record_step PASS "config-publish-audit" "config rebuild writes publish audit events and reuses unchanged snapshots" +assert_eq "$config_snapshot_cached_version" "$config_snapshot_after" "unchanged config fetch should serve the active snapshot version" +assert_eq "$config_snapshot_cached_reused" "false" "unchanged edge config fetch should not rebuild just to report reused" +record_step PASS "config-publish-audit" "config publish writes audit events and unchanged fetches use the active snapshot" api_post_with_powerdns_retry "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/dns/records" \ '{"type":"A","name":"@","content":"1.1.1.1","ttl":300,"proxied":true,"origin_host":"origin-tls","origin_tls_verify":"ignore","geo_origins":{"DEFAULT":{"host":"origin-tls","tls_verify":"ignore"},"IR":{"host":"origin-http","tls_verify":"verify"}}}' @@ -952,6 +952,16 @@ assert_contains "$origin_unchecked_unhealthy_body" '"origin_scheme":"http"' "unc assert_contains "$origin_unchecked_unhealthy_body" "\"origin_host\":\"${TEST_DOMAIN}\"" "unchecked unhealthy origin should still preserve requested Host" record_step PASS "origin-health-disabled-still-routes" "core health_status=unhealthy did not block edge traffic when health_check_enabled=false" +edge_api POST "/api/v1/collector/usage" "{\"idempotency_key\":\"e2e-${RUN_KEY}-origin-health\",\"items\":[{\"ts\":$(date +%s),\"domain_id\":\"${DOMAIN_ID}\",\"edge_node_id\":\"${EDGE_ID}\",\"requests_count\":0,\"bytes_in\":0,\"bytes_out\":0,\"status\":200,\"method\":\"HEALTH\",\"path\":\"/health\",\"host\":\"${TEST_DOMAIN}\",\"origin_id\":\"${PRIMARY_ORIGIN_ID}\",\"origin_host\":\"${ORIGIN_HTTP_IP}\",\"upstream_status\":\"200\",\"upstream_response_time\":\"3.250\",\"router_error\":\"\",\"origin_health_probe\":true},{\"ts\":$(date +%s),\"domain_id\":\"${DOMAIN_ID}\",\"edge_node_id\":\"${EDGE_ID}\",\"requests_count\":0,\"bytes_in\":0,\"bytes_out\":0,\"status\":200,\"method\":\"HEALTH\",\"path\":\"/health\",\"host\":\"${TEST_DOMAIN}\",\"origin_id\":\"${PRIMARY_ORIGIN_ID}\",\"origin_host\":\"${ORIGIN_HTTP_IP}\",\"upstream_status\":\"200\",\"upstream_response_time\":\"1.000\",\"router_error\":\"\",\"origin_health_probe\":true}]}" +assert_http_status "$HTTP_CODE" "200" "edge origin health observation ingest failed" +api_get "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/origins/health" +assert_http_status "$HTTP_CODE" "200" "origin health report failed" +assert_contains "$HTTP_BODY" '"source":"edge_observations"' "origin health report should be edge-sourced" +assert_contains "$HTTP_BODY" '"core_active_checks":false' "core active checks should not be authoritative" +assert_contains "$HTTP_BODY" '"status":"slow"' "slow origin observation should be visible" +assert_contains "$HTTP_BODY" '"jitter_ms":2250' "origin jitter should be derived from edge observations" +record_step PASS "origin-health-edge-observations" "edge-sourced origin health report includes slow and jitter details" + ORIGIN_TLS_IP="$(docker compose exec -T edge sh -lc "getent hosts origin-tls | awk '{print \$1; exit}'" | tr -d '\r')" if [[ -z "$ORIGIN_TLS_IP" ]]; then fail "unable to resolve origin-tls container IP from edge" @@ -1224,7 +1234,7 @@ record_step PASS "edge-waf-block-runtime" "403 and edge header observed for /adm bot_challenge_body="$(mktemp)" bot_challenge_status="$(curl -sS -o "$bot_challenge_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -A 'Googlebot' "${EDGE_URL}/bot-check?via=edge-bot-challenge")" assert_eq "$bot_challenge_status" "403" "unverified search-bot claim should be challenged" -assert_contains "$(cat "$bot_challenge_body")" '"error":"bot_challenge_required"' "bot challenge response should be explicit" +assert_contains "$(cat "$bot_challenge_body")" "Security check" "bot challenge should return the browser verification page" rm -f "$bot_challenge_body" retry 10 1 agent_exec '/agent/push_security_events.sh' bot_event_visible=0 @@ -1242,7 +1252,7 @@ record_step PASS "edge-bot-protection-runtime" "unverified crawler challenge and verified_bot_source_id="verified-bot-${RUN_KEY}" now="$(date +%s)" db_query "INSERT INTO verified_bot_sources (id,domain_id,bot_class,provider,user_agent_pattern,cidr,enabled,created_at,updated_at) VALUES ('${verified_bot_source_id}','${DOMAIN_ID}','verified_search_bot','Google','Googlebot','0.0.0.0/0',true,${now},${now});" >/dev/null -db_query "UPDATE config_state SET active_snapshot_version=NULL WHERE id=1;" >/dev/null +db_query "UPDATE config_state SET dirty=true, dirty_at=${now} WHERE id=1;" >/dev/null edge_pull_config verified_bot_status="$(curl -sS -o /tmp/e2e-verified-bot-body.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -A 'Googlebot' "${EDGE_URL}/bot-check?via=edge-verified-bot")" assert_eq "$verified_bot_status" "200" "verified search-bot source should allow matching crawler traffic" @@ -1262,30 +1272,74 @@ edge_id_header_rl="$(edge_header_for_host "${TEST_DOMAIN}" "/login?via=edge-rate assert_eq "$edge_id_header_rl" "$EDGE_ID" "rate-limit response should expose edge id header" record_step PASS "edge-rate-limit-runtime" "429 observed for /login after burst" -api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits" '{"enabled":true,"requests_per_minute":5,"path_prefix":"/challenge","key_type":"ip_path","priority":21,"action":"challenge"}' +api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits" '{"enabled":true,"requests_per_minute":5,"path_prefix":"/challenge","key_type":"ip_path","priority":21,"action":"challenge","challenge_difficulty":3}' assert_http_status "$HTTP_CODE" "201" "challenge rate-limit create failed" CHALLENGE_RATE_LIMIT_RULE_ID="$(json_get "$HTTP_BODY" '.data.id')" +assert_contains "$HTTP_BODY" '"challenge_difficulty":3' "challenge rate-limit response should include per-rule difficulty" # The edge serves its local snapshot, so publish and apply this new rule before exercising it. edge_pull_config challenge_codes=() challenge_response_verified=0 +challenge_body="/tmp/e2e-rate-limit-challenge-body.txt" for i in $(seq 1 8); do - code="$(curl -sS -o /tmp/e2e-rate-limit-challenge-${i}.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-challenge")" + code="$(curl -sS -o "$challenge_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-challenge")" challenge_codes+=("$code") - if [[ "$code" == "429" ]] && grep -Fq '"error":"challenge_required"' "/tmp/e2e-rate-limit-challenge-${i}.txt"; then + if [[ "$code" == "429" ]] && grep -Fq "Security check" "$challenge_body" && grep -Fq "__cdnlite_challenge_verify" "$challenge_body"; then challenge_response_verified=1 + break fi done if [[ " ${challenge_codes[*]} " != *" 429 "* ]]; then fail "challenge rate limit expected 429 challenge responses" fi -assert_eq "$challenge_response_verified" "1" "challenge rate-limit response should identify the required challenge" +assert_eq "$challenge_response_verified" "1" "challenge rate-limit response should return browser verification page" +challenge_token="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const token = \"([^\"]+)\";/", $body, $m)){ echo $m[1]; }' "$challenge_body")" +[[ -n "$challenge_token" ]] || fail "challenge page did not include a signed token" +challenge_difficulty="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const difficulty = ([0-9]+);/", $body, $m)){ echo $m[1]; }' "$challenge_body")" +challenge_difficulty="${challenge_difficulty:-3}" +if [[ "$challenge_difficulty" -le 1 ]]; then + challenge_pow="browser-check" +else + challenge_pow="$(php -r '$t=$argv[1]; $d=(int)$argv[2]; $prefix=str_repeat("0",$d); for($i=0;$i<5000000;$i++){ if(substr(hash("sha256",$t.":".$i),0,$d)===$prefix){ echo $i; exit(0);} } exit(1);' "$challenge_token" "$challenge_difficulty")" +fi +[[ -n "$challenge_pow" ]] || fail "could not solve local challenge proof" +cookie_jar="/tmp/e2e-cdnlite-clearance-cookie.txt" +verify_code="$(curl -sS -o /tmp/e2e-challenge-verify.txt -c "$cookie_jar" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -X POST "${EDGE_URL}/__cdnlite_challenge_verify" --data-urlencode "token=${challenge_token}" --data-urlencode "pow=${challenge_pow}")" +assert_eq "$verify_code" "303" "valid challenge proof should set clearance and redirect" +assert_contains "$(cat "$cookie_jar")" "__cdnlite_clearance" "challenge verification should set clearance cookie" +cleared_code="$(curl -sS -b "$cookie_jar" -o /tmp/e2e-rate-limit-cleared.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-cleared")" +assert_eq "$cleared_code" "200" "valid clearance should allow matching challenge traffic to origin" +assert_contains "$(cat /tmp/e2e-rate-limit-cleared.txt)" '"origin_scheme":"https"' "cleared challenge traffic should reach origin" # Security events are delivered asynchronously by the edge agent. The event records # the configured decision (`challenge`), while the HTTP response says `challenge_required`. retry 20 1 challenge_security_event_visible api_delete "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits/${CHALLENGE_RATE_LIMIT_RULE_ID}" assert_http_status "$HTTP_CODE" "200" "challenge rate-limit cleanup failed" -record_step PASS "edge-rate-limit-challenge" "challenge action returns 429 and is visible in security events" +record_step PASS "edge-rate-limit-challenge" "challenge page, proof verification, clearance cookie, origin routing, and events verified" + +api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/waf-rules" '{"enabled":true,"name":"browser-check-only","priority":22,"type":"path_prefix","pattern":"/browser-check","action":"challenge","challenge_difficulty":1,"description":"verify lightweight challenge difficulty"}' +assert_http_status "$HTTP_CODE" "201" "difficulty-1 waf challenge create failed" +DIFFICULTY_ONE_WAF_RULE_ID="$(json_get "$HTTP_BODY" '.data.id')" +assert_contains "$HTTP_BODY" '"challenge_difficulty":1' "difficulty-1 waf response should include challenge_difficulty" +edge_pull_config +difficulty_one_body="/tmp/e2e-waf-difficulty-one.txt" +difficulty_one_code="$(curl -sS -o "$difficulty_one_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/browser-check?via=edge-waf-difficulty-one")" +assert_eq "$difficulty_one_code" "403" "difficulty-1 waf rule should serve challenge page" +assert_contains "$(cat "$difficulty_one_body")" "Security check" "difficulty-1 waf should return challenge page" +difficulty_one_value="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const difficulty = ([0-9]+);/", $body, $m)){ echo $m[1]; }' "$difficulty_one_body")" +assert_eq "$difficulty_one_value" "1" "difficulty-1 waf challenge page should render difficulty 1" +difficulty_one_token="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const token = \"([^\"]+)\";/", $body, $m)){ echo $m[1]; }' "$difficulty_one_body")" +[[ -n "$difficulty_one_token" ]] || fail "difficulty-1 challenge page did not include token" +difficulty_one_cookie="/tmp/e2e-difficulty-one-cookie.txt" +difficulty_one_verify="$(curl -sS -o /tmp/e2e-difficulty-one-verify.txt -c "$difficulty_one_cookie" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -X POST "${EDGE_URL}/__cdnlite_challenge_verify" --data-urlencode "token=${difficulty_one_token}" --data-urlencode "pow=browser-check")" +assert_eq "$difficulty_one_verify" "303" "difficulty-1 browser check should set clearance" +assert_contains "$(cat "$difficulty_one_cookie")" "__cdnlite_clearance" "difficulty-1 browser check should set clearance cookie" +difficulty_one_cleared="$(curl -sS -b "$difficulty_one_cookie" -o /tmp/e2e-difficulty-one-cleared.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/browser-check?via=edge-waf-difficulty-one-cleared")" +assert_eq "$difficulty_one_cleared" "200" "difficulty-1 clearance should allow origin routing" +assert_contains "$(cat /tmp/e2e-difficulty-one-cleared.txt)" '"origin_scheme":"https"' "difficulty-1 cleared traffic should reach origin" +api_delete "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/waf-rules/${DIFFICULTY_ONE_WAF_RULE_ID}" +assert_http_status "$HTTP_CODE" "200" "difficulty-1 waf cleanup failed" +record_step PASS "edge-waf-challenge-difficulty" "path-level WAF challenge difficulty 1 used browser check and reached origin after clearance" if docker compose exec -T edge-agent sh -lc "grep -q 'rate_limited' \"\${METRIC_PATH:-/var/lib/cdnlite/metrics.ndjson}\"" \ || docker compose exec -T edge sh -lc "grep -q 'rate_limited' /var/lib/cdnlite/metrics.ndjson"; then @@ -1508,7 +1562,8 @@ record_step PASS "edge-config-fetch" "version=${cfg_version}" edge_api GET "/api/v1/edge/config?if_version=${cfg_version}" "" assert_http_status "$HTTP_CODE" "200" "edge config if_version failed" -record_step PASS "edge-config-if-version" "if_version request ok" +assert_contains "$HTTP_BODY" '"not_modified":true' "edge config if_version should return not_modified" +record_step PASS "edge-config-if-version" "if_version request returned not_modified" miss_code="$(curl -s -o /tmp/e2e-miss-auth.txt -w '%{http_code}' -X POST "${CORE_URL}/api/v1/edge/heartbeat" -H 'Content-Type: application/json' -d "{\"edge_id\":\"${EDGE_ID}\"}")" assert_eq "$miss_code" "401" "missing auth should 401" diff --git a/ci/phase.sh b/ci/phase.sh index e5d5b545..9f90f2cf 100755 --- a/ci/phase.sh +++ b/ci/phase.sh @@ -48,6 +48,22 @@ case "$PHASE" in PHASE_CONTRACT="core/tests/test_phase3_edge_hot_path_contract.py" PHASE_STRESS_SCENARIO="phase3-edge-hot-path" ;; + 04) + PHASE_CONTRACT="core/tests/test_phase4_challenge_clearance_contract.py" + PHASE_STRESS_SCENARIO="phase4-challenge-clearance" + ;; + 05) + PHASE_CONTRACT="core/tests/test_phase5_waiting_room_contract.py" + PHASE_STRESS_SCENARIO="phase5-waiting-room" + ;; + 06) + PHASE_CONTRACT="core/tests/test_phase6_cache_correctness_contract.py" + PHASE_STRESS_SCENARIO="phase6-cache-correctness" + ;; + 07) + PHASE_CONTRACT="core/tests/test_phase7_origin_resilience_contract.py" + PHASE_STRESS_SCENARIO="phase7-origin-resilience" + ;; *) fail "phase ${PHASE} is not registered yet" ;; diff --git a/ci/phases/phase-04.yml b/ci/phases/phase-04.yml new file mode 100644 index 00000000..7f8a2f3c --- /dev/null +++ b/ci/phases/phase-04.yml @@ -0,0 +1,61 @@ +phase: "04" +name: "Real challenge and clearance system" +owner: "CDNLite maintainers" +status: "complete" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests/test_phase4_challenge_clearance_contract.py" + - "bash -n ci/phase.sh" + - "bash -n ci/stress-platform.sh" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/agent_flow_checks.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/dns_e2e.sh" + - "bash -n ci/stress-dns.sh" + - "bash -n ci/stress-platform.sh" + - "bash -n ci/powerdns_dns_checks.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase4-challenge-clearance" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd docs && npm ci && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 04 --profile full --clean" + - "ci/stress-dns.sh" +required_docs: + - "docs/ROADMAP.md" + - "docs/security.md" + - "docs/setup.md" + - "docs/troubleshooting.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-04-report.json" + markdown: "ci/reports/phase-04-report.md" +stress_scenarios: + - "phase4-challenge-clearance" +recovery_assertions: + - "Challenge responses return a self-hosted browser proof-of-work page instead of only denying the request." + - "Verified challenge proofs set HttpOnly scoped clearance cookies." + - "Block rules keep precedence over clearance cookies." diff --git a/ci/phases/phase-05.yml b/ci/phases/phase-05.yml new file mode 100644 index 00000000..0b6b3ad0 --- /dev/null +++ b/ci/phases/phase-05.yml @@ -0,0 +1,57 @@ +phase: "05" +name: "Adaptive overload protection and waiting room" +owner: "CDNLite maintainers" +status: "complete" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "luac -p edge/openresty/lua/waiting_room.lua edge/openresty/lua/router.lua" + - "bash -n ci/smoke.sh" + - "(cd dash && npm run typecheck)" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/stress-platform.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase5-waiting-room" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd dash && npm run typecheck && npm test && npm run build)" + - "(cd docs && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 05 --profile full --clean" +required_docs: + - "docs/ROADMAP.md" + - "docs/api/api.md" + - "docs/security.md" + - "docs/setup.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-05-report.json" + markdown: "ci/reports/phase-05-report.md" +stress_scenarios: + - "phase5-waiting-room" +recovery_assertions: + - "Queue endpoints respond locally and never proxy to origins." + - "Origin-bound requests stay within the configured admission budget during overload." + - "Invalid or expired queue tickets and admission cookies fail closed." diff --git a/ci/phases/phase-06.yml b/ci/phases/phase-06.yml new file mode 100644 index 00000000..53faf26c --- /dev/null +++ b/ci/phases/phase-06.yml @@ -0,0 +1,58 @@ +phase: "06" +name: "Cache correctness foundation" +owner: "CDNLite maintainers" +status: "complete" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests/test_phase6_cache_correctness_contract.py" + - "luac -p edge/openresty/lua/proxy.lua edge/openresty/lua/router.lua" + - "bash -n ci/smoke.sh" + - "(cd dash && npm run typecheck)" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/stress-platform.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase6-cache-correctness" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd dash && npm run typecheck && npm test && npm run build)" + - "(cd docs && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 06 --profile full --clean" +required_docs: + - "docs/ROADMAP.md" + - "docs/api/api.md" + - "docs/security.md" + - "docs/setup.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-06-report.json" + markdown: "ci/reports/phase-06-report.md" +stress_scenarios: + - "phase6-cache-correctness" +recovery_assertions: + - "Personalized and authenticated requests bypass cache by default." + - "Cache keys include bounded, operator-visible dimensions." + - "Per-rule TTLs are honored through X-Accel-Expires." diff --git a/ci/phases/phase-07.yml b/ci/phases/phase-07.yml new file mode 100644 index 00000000..c4252fd2 --- /dev/null +++ b/ci/phases/phase-07.yml @@ -0,0 +1,58 @@ +phase: "07" +name: "Origin routing, resilience, and shielding" +owner: "CDNLite maintainers" +status: "complete" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests/test_phase7_origin_resilience_contract.py" + - "luac -p edge/openresty/lua/origin_selector.lua edge/openresty/lua/proxy.lua edge/openresty/lua/router.lua" + - "bash -n ci/smoke.sh" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/stress-platform.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase7-origin-resilience" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd dash && npm run typecheck && npm test && npm run build)" + - "(cd docs && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 07 --profile full --clean" +required_docs: + - "docs/ROADMAP.md" + - "docs/api/api.md" + - "docs/architecture.md" + - "docs/troubleshooting.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-07-report.json" + markdown: "ci/reports/phase-07-report.md" +stress_scenarios: + - "phase7-origin-resilience" +recovery_assertions: + - "Primary origins are selected before backups while eligible." + - "Drained origins are removed from snapshots and edge candidate pools." + - "Non-idempotent requests do not receive automatic retry attempts." + - "Origin health status comes from edge observations, not scheduled core probes." diff --git a/ci/smoke.sh b/ci/smoke.sh index 006755b7..42d500af 100755 --- a/ci/smoke.sh +++ b/ci/smoke.sh @@ -67,7 +67,7 @@ assert_contains "$dashboard_asset" "/onboarding/answers" "dashboard bundle shoul assert_contains "$dashboard_asset" "Simple view" "dashboard bundle should include beginner Activity toggle" assert_contains "$dashboard_asset" "Beginner Activity summary" "dashboard bundle should include beginner Activity summary" assert_contains "$dashboard_asset" "Advanced view" "dashboard bundle should preserve advanced Activity view" -assert_contains "$dashboard_asset" "Enable health check for this origin" "dashboard bundle should include optional origin health-check toggle" +assert_contains "$dashboard_asset" "Enable edge health routing" "dashboard bundle should include optional edge origin health toggle" record_step PASS "dashboard-security-center-bundle" "Security Center and Protection profile/intent APIs are present in the dashboard bundle" record_step PASS "dashboard-recommendations-bundle" "Recommendation panel and APIs are present in the dashboard bundle" record_step PASS "dashboard-onboarding-bundle" "Guided onboarding wizard and APIs are present in the dashboard bundle" @@ -146,6 +146,13 @@ rate_limit_dry_run_route_count="$(grep -c '/api/v1/domains/{domainId}/rate-limit assert_eq "$rate_limit_dry_run_route_count" "1" "rate-limit dry-run route missing" record_step PASS "schema-rate-limit-dry-run-route" "rate-limit dry-run route is registered" +waiting_room_route_count="$(grep -c '/api/v1/domains/{domainId}/waiting-room' core/public_index.php)" +if [[ "$waiting_room_route_count" -lt "4" ]]; then + fail "waiting-room API routes are incomplete" +fi +grep -Fq "cdnlite_waiting_room" edge/openresty/nginx.conf || fail "waiting-room shared dictionary missing" +record_step PASS "schema-waiting-room" "waiting-room API routes and edge state are registered" + api_protection_route_count="$(grep -c '/api/v1/domains/{domainId}/protection/api-paths' core/public_index.php)" assert_eq "$api_protection_route_count" "1" "API Protection path discovery route missing" grep -Fq "path_method_not_allowed" edge/openresty/lua/router.lua || fail "API method restriction WAF matcher missing" diff --git a/ci/stress-platform.sh b/ci/stress-platform.sh index 22dfb306..1398cc09 100755 --- a/ci/stress-platform.sh +++ b/ci/stress-platform.sh @@ -17,7 +17,7 @@ while [[ $# -gt 0 ]]; do done case "$SCENARIO" in - phase1-reporting-foundation|phase2-analytics-async|phase3-edge-hot-path) ;; + phase1-reporting-foundation|phase2-analytics-async|phase3-edge-hot-path|phase4-challenge-clearance|phase5-waiting-room|phase6-cache-correctness|phase7-origin-resilience) ;; *) fail "unknown stress scenario: ${SCENARIO}" ;; esac @@ -32,6 +32,112 @@ wait_for_postgres retry 30 1 db_query "SELECT 1;" >/dev/null record_step PASS "postgres-ready" "PostgreSQL accepted stress-platform connection" +if [[ "$SCENARIO" == "phase7-origin-resilience" ]]; then + origin_columns="$(db_query "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='domain_origins' AND column_name IN ('load_balancing_algorithm','connection_timeout_seconds','response_timeout_seconds','retry_attempts','retry_budget_per_minute','circuit_breaker_enabled','circuit_failure_threshold','circuit_recovery_seconds','max_concurrent_requests','drain','shield_enabled');")" + assert_eq "$origin_columns" "11" "Phase 7 origin resilience columns are incomplete" + record_step PASS "phase7-origin-columns" "origin resilience columns are present" + + observation_columns="$(db_query "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='origin_health_observations' AND column_name IN ('domain_id','origin_id','edge_node_id','status','reason','upstream_status','latency_ms','jitter_ms','sample_count','last_observed_at','last_success_at','last_failure_at');")" + assert_eq "$observation_columns" "12" "Phase 7 origin health observation columns are incomplete" + record_step PASS "phase7-origin-observation-columns" "edge origin health observation columns are present" + + grep -Fq "weighted_pick" edge/openresty/lua/origin_selector.lua + grep -Fq "healthy_backup" edge/openresty/lua/origin_selector.lua + grep -Fq "origin.drain ~= true" edge/openresty/lua/origin_selector.lua + record_step PASS "phase7-edge-selection" "edge selector has weighted primary/backup/drain behavior" + + grep -Fq "X-CDNLite-Origin-Retry-Attempts" edge/openresty/lua/proxy.lua + grep -Fq "method ~= 'GET' and method ~= 'HEAD'" edge/openresty/lua/proxy.lua + grep -Fq "X-CDNLite-Origin-Circuit-Breaker" edge/openresty/lua/proxy.lua + record_step PASS "phase7-retry-circuit-metadata" "edge exposes bounded retry and circuit metadata" + + grep -Fq "origin_health_checker" edge/openresty/nginx.conf + grep -Fq "origin_health_probe = true" edge/openresty/lua/origin_health_checker.lua + grep -Fq "health_check_enabled == true" edge/openresty/lua/origin_health_checker.lua + record_step PASS "phase7-edge-origin-health-checker" "edge active origin checker is enabled for monitored origins" + + if compose_has_service edge; then + ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" + assert_contains "$ready_body" "\"ok\":true" "edge must be ready before origin resilience validation" + record_step PASS "phase7-edge-ready" "edge readiness confirmed before origin resilience validation" + fi + + record_step PASS "phase7-origin-recovery" "post-stress smoke/e2e gates verify routing recovery in full profile" + exit 0 +fi + +if [[ "$SCENARIO" == "phase6-cache-correctness" ]]; then + cache_columns="$(db_query "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='domain_cache_settings' AND column_name IN ('cache_methods_json','cache_status_code_policy_json','bypass_headers_json','bypass_cookies_json','vary_headers_json','cache_key_dimensions_json','debug_headers_enabled','stale_while_revalidate_seconds','negative_ttl_seconds','max_object_size_bytes');")" + assert_eq "$cache_columns" "10" "Phase 6 cache correctness columns are incomplete" + record_step PASS "phase6-cache-columns" "cache correctness settings are present" + + grep -Fq "X-CDNLite-Cache" edge/openresty/nginx.conf + grep -Fq "proxy_cache_lock on" edge/openresty/nginx.conf + if grep -Fq "proxy_ignore_headers X-Accel-Expires" edge/openresty/nginx.conf; then + fail "edge must honor Lua-selected X-Accel-Expires TTL" + fi + record_step PASS "phase6-nginx-cache-controls" "cache lock, status headers, and per-rule TTL honoring are configured" + + grep -Fq "build_cache_key" edge/openresty/lua/proxy.lua + grep -Fq "request_has_bypass_header" edge/openresty/lua/proxy.lua + grep -Fq "cache_settings.cache_key_dimensions" edge/openresty/lua/proxy.lua + record_step PASS "phase6-lua-cache-policy" "edge Lua has explicit cache key and bypass policy" + + if compose_has_service edge; then + ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" + assert_contains "$ready_body" "\"ok\":true" "edge must be ready before cache correctness validation" + record_step PASS "phase6-edge-ready" "edge readiness confirmed before cache validation" + fi + + record_step PASS "phase6-cache-recovery" "post-stress smoke/e2e gates verify cache recovery in full profile" + exit 0 +fi + +if [[ "$SCENARIO" == "phase5-waiting-room" ]]; then + waiting_room_table="$(db_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='waiting_room_policies';")" + assert_eq "$waiting_room_table" "1" "waiting_room_policies table is missing" + record_step PASS "phase5-waiting-room-table" "waiting-room policy table exists" + + waiting_room_columns="$(db_query "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='public' AND table_name='waiting_room_policies' AND column_name IN ('enabled','mode','state','rps_threshold','active_origin_threshold','origin_latency_ms_threshold','origin_error_rate_threshold','admission_rate_per_minute','queue_limit','per_client_ticket_limit','ticket_ttl_seconds','admission_ttl_seconds','status_poll_seconds','jitter_seconds','unhealthy_windows','healthy_windows','minimum_state_seconds','recovery_ramp_percent','manual_override_until','waiting_room_title','waiting_room_message');")" + assert_eq "$waiting_room_columns" "21" "waiting-room policy columns are incomplete" + record_step PASS "phase5-waiting-room-columns" "waiting-room policy columns are present" + + route_count="$(grep -c '/api/v1/domains/{domainId}/waiting-room' core/public_index.php)" + if [[ "$route_count" -lt "4" ]]; then + fail "waiting-room API routes are incomplete" + fi + record_step PASS "phase5-waiting-room-routes" "waiting-room API routes are registered" + + if compose_has_service edge; then + ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" + assert_contains "$ready_body" "\"ok\":true" "edge must be ready before waiting-room validation" + record_step PASS "phase5-edge-ready" "edge readiness confirmed before waiting-room validation" + + docker compose exec -T edge grep -Fq "cdnlite_waiting_room" /usr/local/openresty/nginx/conf/nginx.conf + record_step PASS "phase5-edge-shared-dict" "waiting-room shared dictionary rendered into nginx config" + fi + + record_step PASS "phase5-waiting-room-recovery" "post-stress smoke/e2e gates verify waiting-room recovery in full profile" + exit 0 +fi + +if [[ "$SCENARIO" == "phase4-challenge-clearance" ]]; then + if compose_has_service edge; then + ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" + assert_contains "$ready_body" "\"ok\":true" "edge must be ready before challenge pressure" + record_step PASS "phase4-edge-ready" "edge readiness confirmed before challenge validation" + fi + + waf_challenge_actions="$(db_query "SELECT COUNT(*) FROM waf_rules WHERE action='challenge';")" + rate_challenge_actions="$(db_query "SELECT COUNT(*) FROM rate_limit_rules WHERE action='challenge';")" + if [[ "$waf_challenge_actions" -lt "0" || "$rate_challenge_actions" -lt "0" ]]; then + fail "challenge action queries returned invalid counts" + fi + record_step PASS "phase4-challenge-actions-query" "challenge-capable WAF and rate-limit tables are queryable" + record_step PASS "phase4-clearance-recovery" "post-stress smoke/e2e gates verify challenge recovery in full profile" + exit 0 +fi + if [[ "$SCENARIO" == "phase3-edge-hot-path" ]]; then if compose_has_service edge; then ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" diff --git a/ci/stress/scenarios.yml b/ci/stress/scenarios.yml index 2db24a67..4cc8cb83 100644 --- a/ci/stress/scenarios.yml +++ b/ci/stress/scenarios.yml @@ -43,3 +43,56 @@ scenarios: - "manual local config reload succeeds" - "last-known-good snapshot remains active after malformed reload" - "post-stress smoke and e2e gates run after the pressure scenario" + phase4-challenge-clearance: + phase: "04" + workload: "edge-challenge-clearance" + description: "Validate signed challenge token issuance, scoped clearance cookies, security-event visibility, and block-rule precedence." + stress: + challenge_requests: 200 + invalid_tokens: true + clearance_reuse: true + recovery: + - "invalid or expired challenge tokens keep requiring a challenge" + - "valid clearance redirects once and then allows matching challenge rules" + - "block rules remain terminal even when a clearance cookie exists" + phase5-waiting-room: + phase: "05" + workload: "edge-waiting-room-admission" + description: "Validate overload admission, bounded local queue state, edge-only queue endpoints, and manual emergency recovery." + stress: + burst_requests: 300 + invalid_tickets: true + manual_emergency: true + queue_overflow: true + recovery: + - "manual emergency mode can be deactivated without flapping" + - "queue and admission counters stay bounded" + - "post-stress smoke and e2e gates run after overload pressure" + phase6-cache-correctness: + phase: "06" + workload: "edge-cache-correctness" + description: "Validate safe cache defaults, bounded key dimensions, debug visibility, per-rule TTL honoring, and stale recovery hooks." + stress: + cacheable_requests: 300 + authenticated_bypass: true + query_variants: true + stale_origin_failure: true + recovery: + - "authenticated and no-cache requests bypass by default" + - "sanitized cache key debug contains bounded dimensions" + - "post-stress smoke and e2e gates run after cache pressure" + phase7-origin-resilience: + phase: "07" + workload: "origin-routing-resilience" + description: "Validate primary/backup weighted routing metadata, drain exclusion, edge-origin health observations, bounded idempotent retry metadata, and circuit/shield visibility." + stress: + origin_pool_size: 12 + weighted_selection: true + backup_failover: true + drained_origins: true + non_idempotent_retry_block: true + edge_origin_health_observations: true + recovery: + - "primary origins win before backups while eligible" + - "drained origins are excluded from snapshots and edge pools" + - "post-stress smoke/e2e gates verify recovery after routing pressure" diff --git a/core/app/Console/Commands/CdnConfigSnapshotsPruneCommand.php b/core/app/Console/Commands/CdnConfigSnapshotsPruneCommand.php new file mode 100644 index 00000000..db7d7b00 --- /dev/null +++ b/core/app/Console/Commands/CdnConfigSnapshotsPruneCommand.php @@ -0,0 +1,24 @@ +pruneSnapshots($keep, $batch > 0 ? $batch : null, $dryRun); + CommandIO::printJson($result); + return 0; + } +} diff --git a/core/app/Console/Commands/CdnEdgeSyncConfigCommand.php b/core/app/Console/Commands/CdnEdgeSyncConfigCommand.php index a8628adf..0f5b1d67 100644 --- a/core/app/Console/Commands/CdnEdgeSyncConfigCommand.php +++ b/core/app/Console/Commands/CdnEdgeSyncConfigCommand.php @@ -13,7 +13,7 @@ public function __invoke(array $argv): int { $opts = CommandIO::parseOptions($argv); $ifVersion = isset($opts['if_version']) ? (int) $opts['if_version'] : null; - $snapshot = (new ConfigService(new DomainService(), new DnsService()))->buildSnapshotForVersion($ifVersion); + $snapshot = (new ConfigService(new DomainService(), new DnsService()))->edgeConfig($ifVersion); CommandIO::printJson($snapshot); return 0; } diff --git a/core/app/Modules/Collector/Http/Controllers/CollectorController.php b/core/app/Modules/Collector/Http/Controllers/CollectorController.php index 2bb25fdd..c8f27066 100644 --- a/core/app/Modules/Collector/Http/Controllers/CollectorController.php +++ b/core/app/Modules/Collector/Http/Controllers/CollectorController.php @@ -120,6 +120,11 @@ public function activitySummary(string $domainId, array $query): array return ['data' => $this->service->activitySummary($domainId, $query)]; } + public function originHealth(string $domainId): array + { + return ['data' => $this->service->originHealth($domainId)]; + } + public function findRequest(string $domainId, string $requestId): array { $requestId = trim($requestId); diff --git a/core/app/Modules/Collector/Services/CollectorService.php b/core/app/Modules/Collector/Services/CollectorService.php index c6f185dc..7063a375 100644 --- a/core/app/Modules/Collector/Services/CollectorService.php +++ b/core/app/Modules/Collector/Services/CollectorService.php @@ -122,6 +122,7 @@ public function ingest(array $items, ?string $idempotencyKey = null): array $params[':security_event_type'] = isset($item['security_event_type']) ? (string) $item['security_event_type'] : null; } $stmt->execute($params); + $this->recordOriginObservation($domainId, $item, $now); $count++; } @@ -151,6 +152,63 @@ public function ingest(array $items, ?string $idempotencyKey = null): array ]; } + public function originHealth(string $domainId): array + { + $pdo = Database::pdo(); + $origins = $pdo->prepare( + 'SELECT id, host, role, enabled, health_check_enabled, health_status, last_check_at, last_error + FROM domain_origins + WHERE domain_id=:domain_id + ORDER BY enabled DESC, role ASC, host ASC, id ASC' + ); + $origins->execute([':domain_id' => $domainId]); + $rows = $origins->fetchAll(); + + $observations = $pdo->prepare( + 'SELECT oho.*, en.region, en.country, en.hostname + FROM origin_health_observations oho + LEFT JOIN edge_nodes en ON en.edge_id=oho.edge_node_id + WHERE oho.domain_id=:domain_id + ORDER BY oho.last_observed_at DESC, oho.origin_id ASC, oho.edge_node_id ASC' + ); + $observations->execute([':domain_id' => $domainId]); + $byOrigin = []; + foreach ($observations->fetchAll() as $row) { + $originId = (string) $row['origin_id']; + $byOrigin[$originId] ??= []; + $byOrigin[$originId][] = $this->castOriginObservation($row); + } + + $items = []; + foreach ($rows as $origin) { + $originId = (string) $origin['id']; + $edgeRows = $byOrigin[$originId] ?? []; + $items[] = [ + 'origin_id' => $originId, + 'host' => (string) $origin['host'], + 'role' => (string) ($origin['role'] ?? 'primary'), + 'enabled' => in_array($origin['enabled'], [true, 1, '1', 't', 'true'], true), + 'health_check_enabled' => in_array($origin['health_check_enabled'], [true, 1, '1', 't', 'true'], true), + 'status' => (string) ($origin['health_status'] ?? 'unknown'), + 'last_check_at' => $origin['last_check_at'] === null ? null : (int) $origin['last_check_at'], + 'last_error' => $origin['last_error'] === null ? null : (string) $origin['last_error'], + 'edge_count' => count($edgeRows), + 'healthy_edges' => count(array_filter($edgeRows, static fn (array $r): bool => $r['status'] === 'healthy')), + 'slow_edges' => count(array_filter($edgeRows, static fn (array $r): bool => $r['status'] === 'slow')), + 'unhealthy_edges' => count(array_filter($edgeRows, static fn (array $r): bool => $r['status'] === 'unhealthy')), + 'max_latency_ms' => $this->maxObservationValue($edgeRows, 'latency_ms'), + 'max_jitter_ms' => $this->maxObservationValue($edgeRows, 'jitter_ms'), + 'edges' => $edgeRows, + ]; + } + + return [ + 'items' => $items, + 'source' => 'edge_observations', + 'core_active_checks' => false, + ]; + } + public function ingestSecurityEvents(array $items, ?string $idempotencyKey = null): array { $pdo = Database::pdo(); @@ -1491,6 +1549,163 @@ private function recentOriginErrors(string $whereSql, array $params): array return array_map([$this, 'castRequestActivity'], $stmt->fetchAll()); } + private function recordOriginObservation(string $domainId, array $item, int $now): void + { + $originId = trim((string) ($item['origin_id'] ?? '')); + $edgeNodeId = trim((string) ($item['edge_node_id'] ?? '')); + if ($originId === '' || $edgeNodeId === '') { + return; + } + + $origin = Database::pdo()->prepare('SELECT id, health_check_enabled FROM domain_origins WHERE domain_id=:domain_id AND id=:id LIMIT 1'); + $origin->execute([':domain_id' => $domainId, ':id' => $originId]); + $originRow = $origin->fetch(); + if (!$originRow) { + return; + } + + $latencyMs = $this->durationMs($item['upstream_response_time'] ?? $item['origin_time_ms'] ?? null); + $upstreamStatus = trim((string) ($item['upstream_status'] ?? '')); + $routerError = trim((string) ($item['router_error'] ?? '')); + [$status, $reason] = $this->originObservationStatus($upstreamStatus, $latencyMs, $routerError); + $previous = $this->previousOriginObservation($domainId, $originId, $edgeNodeId); + $jitterMs = ($latencyMs !== null && isset($previous['latency_ms']) && $previous['latency_ms'] !== null) + ? abs($latencyMs - (int) $previous['latency_ms']) + : null; + if ($status === 'healthy' && $jitterMs !== null && $jitterMs >= 1000) { + $status = 'slow'; + $reason = 'origin_jitter'; + } + + Database::pdo()->prepare( + 'INSERT INTO origin_health_observations + (id,domain_id,origin_id,edge_node_id,status,reason,upstream_status,latency_ms,jitter_ms,sample_count,first_observed_at,last_observed_at,last_success_at,last_failure_at) + VALUES (:id,:domain_id,:origin_id,:edge_node_id,:status,:reason,:upstream_status,:latency_ms,:jitter_ms,1,:first_observed_at,:last_observed_at,:last_success_at,:last_failure_at) + ON CONFLICT (domain_id, origin_id, edge_node_id) DO UPDATE SET + status=EXCLUDED.status, + reason=EXCLUDED.reason, + upstream_status=EXCLUDED.upstream_status, + latency_ms=EXCLUDED.latency_ms, + jitter_ms=EXCLUDED.jitter_ms, + sample_count=LEAST(origin_health_observations.sample_count + 1, 1000000000), + last_observed_at=EXCLUDED.last_observed_at, + last_success_at=COALESCE(EXCLUDED.last_success_at, origin_health_observations.last_success_at), + last_failure_at=COALESCE(EXCLUDED.last_failure_at, origin_health_observations.last_failure_at)' + )->execute([ + ':id' => Uuid::v4(), + ':domain_id' => $domainId, + ':origin_id' => $originId, + ':edge_node_id' => $edgeNodeId, + ':status' => $status, + ':reason' => $reason, + ':upstream_status' => $upstreamStatus !== '' ? $upstreamStatus : null, + ':latency_ms' => $latencyMs, + ':jitter_ms' => $jitterMs, + ':first_observed_at' => $now, + ':last_observed_at' => $now, + ':last_success_at' => $status === 'healthy' || $status === 'slow' ? $now : null, + ':last_failure_at' => $status === 'unhealthy' ? $now : null, + ]); + + if (in_array($originRow['health_check_enabled'], [true, 1, '1', 't', 'true'], true)) { + $this->refreshOriginStatusFromEdge($domainId, $originId, $now); + } + } + + private function originObservationStatus(string $upstreamStatus, ?int $latencyMs, string $routerError): array + { + if ($routerError !== '') { + return ['unhealthy', $routerError]; + } + if ($upstreamStatus === '' || $upstreamStatus === '-') { + return ['unknown', 'no_upstream_status']; + } + $firstStatus = (int) strtok($upstreamStatus, ', '); + if ($firstStatus >= 500 || $firstStatus === 0) { + return ['unhealthy', 'http_' . $firstStatus]; + } + if ($latencyMs !== null && $latencyMs >= 3000) { + return ['slow', 'slow_origin']; + } + return ['healthy', 'edge_request_ok']; + } + + private function previousOriginObservation(string $domainId, string $originId, string $edgeNodeId): ?array + { + $stmt = Database::pdo()->prepare( + 'SELECT latency_ms FROM origin_health_observations WHERE domain_id=:domain_id AND origin_id=:origin_id AND edge_node_id=:edge_node_id LIMIT 1' + ); + $stmt->execute([':domain_id' => $domainId, ':origin_id' => $originId, ':edge_node_id' => $edgeNodeId]); + $row = $stmt->fetch(); + return $row ?: null; + } + + private function refreshOriginStatusFromEdge(string $domainId, string $originId, int $now): void + { + $stmt = Database::pdo()->prepare( + "SELECT + COUNT(*) FILTER (WHERE status='unhealthy') unhealthy, + COUNT(*) FILTER (WHERE status='slow') slow, + COUNT(*) FILTER (WHERE status='healthy') healthy, + MAX(last_observed_at) last_observed_at + FROM origin_health_observations + WHERE domain_id=:domain_id AND origin_id=:origin_id" + ); + $stmt->execute([':domain_id' => $domainId, ':origin_id' => $originId]); + $row = $stmt->fetch() ?: []; + $status = 'unknown'; + $error = null; + if ((int) ($row['unhealthy'] ?? 0) > 0) { + $status = 'unhealthy'; + $error = 'edge_reported_unhealthy'; + } elseif ((int) ($row['slow'] ?? 0) > 0) { + $status = 'healthy'; + $error = 'edge_reported_slow'; + } elseif ((int) ($row['healthy'] ?? 0) > 0) { + $status = 'healthy'; + } + Database::pdo()->prepare( + 'UPDATE domain_origins SET health_status=:health_status,last_check_at=:last_check_at,last_error=:last_error,updated_at=:updated_at + WHERE domain_id=:domain_id AND id=:origin_id' + )->execute([ + ':domain_id' => $domainId, + ':origin_id' => $originId, + ':health_status' => $status, + ':last_check_at' => $row['last_observed_at'] === null ? $now : (int) $row['last_observed_at'], + ':last_error' => $error, + ':updated_at' => $now, + ]); + } + + private function castOriginObservation(array $row): array + { + foreach (['latency_ms', 'jitter_ms', 'sample_count', 'first_observed_at', 'last_observed_at', 'last_success_at', 'last_failure_at'] as $key) { + $row[$key] = $row[$key] === null ? null : (int) $row[$key]; + } + return [ + 'edge_node_id' => (string) $row['edge_node_id'], + 'edge_label' => (string) ($row['hostname'] ?? $row['edge_node_id']), + 'region' => $row['region'] === null ? null : (string) $row['region'], + 'country' => $row['country'] === null ? null : (string) $row['country'], + 'status' => (string) $row['status'], + 'reason' => $row['reason'] === null ? null : (string) $row['reason'], + 'upstream_status' => $row['upstream_status'] === null ? null : (string) $row['upstream_status'], + 'latency_ms' => $row['latency_ms'], + 'jitter_ms' => $row['jitter_ms'], + 'sample_count' => $row['sample_count'], + 'first_observed_at' => $row['first_observed_at'], + 'last_observed_at' => $row['last_observed_at'], + 'last_success_at' => $row['last_success_at'], + 'last_failure_at' => $row['last_failure_at'], + ]; + } + + private function maxObservationValue(array $rows, string $key): ?int + { + $values = array_values(array_filter(array_map(static fn (array $row): ?int => $row[$key] ?? null, $rows), static fn (?int $v): bool => $v !== null)); + return $values === [] ? null : max($values); + } + private function decodeJson(?string $value): mixed { if ($value === null || $value === '') { diff --git a/core/app/Modules/Dns/Services/DnsService.php b/core/app/Modules/Dns/Services/DnsService.php index ba16275a..2f4d9c41 100644 --- a/core/app/Modules/Dns/Services/DnsService.php +++ b/core/app/Modules/Dns/Services/DnsService.php @@ -3,6 +3,7 @@ namespace App\Modules\Dns\Services; use App\Modules\Domains\Services\DomainService; +use App\Modules\Proxy\Services\ConfigService; use App\Modules\Proxy\Services\OriginHealthService; use App\Modules\Proxy\Services\TrafficRulesService; use App\Modules\Settings\Repositories\SettingsRepository; @@ -539,7 +540,7 @@ private function ensureManagedSslForProxiedRecord(string $domainId, array $recor private function invalidateConfigSnapshot(): void { - Database::pdo()->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('dns.changed'); } private function castRow(array $row): array diff --git a/core/app/Modules/Dns/Services/GeoRoutingService.php b/core/app/Modules/Dns/Services/GeoRoutingService.php index d9fe4e9c..4dc36da3 100644 --- a/core/app/Modules/Dns/Services/GeoRoutingService.php +++ b/core/app/Modules/Dns/Services/GeoRoutingService.php @@ -2,6 +2,7 @@ namespace App\Modules\Dns\Services; +use App\Modules\Proxy\Services\ConfigService; use App\Support\AuditLog; use App\Support\Database; use App\Support\Uuid; @@ -137,7 +138,7 @@ public function replace(string $domainId, string $recordId, array $routes): ?arr private function invalidateConfigSnapshot(): void { - Database::pdo()->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('dns.geo_routes.changed'); } private function recordExists(string $domainId, string $recordId): bool diff --git a/core/app/Modules/Domains/Services/DomainService.php b/core/app/Modules/Domains/Services/DomainService.php index 0fa51d33..ca573548 100644 --- a/core/app/Modules/Domains/Services/DomainService.php +++ b/core/app/Modules/Domains/Services/DomainService.php @@ -4,6 +4,7 @@ use App\Modules\Dns\Services\DnsReconciler; use App\Modules\Dns\Services\PowerDnsService; +use App\Modules\Proxy\Services\ConfigService; use App\Modules\Settings\Repositories\SettingsRepository; use App\Support\Database; use App\Support\AuditLog; @@ -189,7 +190,7 @@ private function castRow(array $row): array private function invalidateConfigSnapshot(): void { - Database::pdo()->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('domain.changed'); } private function reconcileDns(string $domainId): void diff --git a/core/app/Modules/Domains/Services/DomainVerificationService.php b/core/app/Modules/Domains/Services/DomainVerificationService.php index fa611557..4d1ffdeb 100644 --- a/core/app/Modules/Domains/Services/DomainVerificationService.php +++ b/core/app/Modules/Domains/Services/DomainVerificationService.php @@ -3,6 +3,7 @@ namespace App\Modules\Domains\Services; use App\Modules\Dns\Services\DnsReconciler; +use App\Modules\Proxy\Services\ConfigService; use App\Modules\Proxy\Services\TrafficRulesService; use App\Modules\Settings\Repositories\SettingsRepository; use App\Support\AuditLog; @@ -72,7 +73,7 @@ public function verifyWithTrace(string $domainId, bool $reconcile = true): ?arra 'checked' => $now, 'id' => $domainId, ]); - $pdo->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('domain.verification.changed'); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); @@ -123,7 +124,7 @@ public function forceVerify(string $domainId, string $reason, string $actor): ?a last_ns_check_at = :checked, updated_at = :checked WHERE id = :id" ); $update->execute(['checked' => $now, 'id' => $domainId]); - $pdo->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('domain.verification.changed'); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); @@ -212,7 +213,7 @@ public function reseedExpectedNameservers(string $domainId, string $actor): ?arr 'updated_at' => $now, 'id' => $domainId, ]); - $pdo->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('domain.verification.changed'); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); diff --git a/core/app/Modules/Health/Services/ReadinessService.php b/core/app/Modules/Health/Services/ReadinessService.php index 1ee26f6d..c55a7c32 100644 --- a/core/app/Modules/Health/Services/ReadinessService.php +++ b/core/app/Modules/Health/Services/ReadinessService.php @@ -106,16 +106,32 @@ private function identityCheck(): array private function snapshotCheck(): array { - $generatedAt = Database::pdo()->query('SELECT MAX(generated_at) FROM config_snapshots')->fetchColumn(); - if ($generatedAt === false || $generatedAt === null) { - return $this->result('config_snapshot', 'warning', 'No edge configuration has been generated', 'Check edge config pulls or run a manual config rebuild', '/edge-nodes'); + $row = Database::pdo()->query( + 'SELECT cs.active_snapshot_version, cs.dirty, cs.published_at, cs.dirty_at, cs.last_publish_error, s.generated_at + FROM config_state cs + LEFT JOIN config_snapshots s ON s.version = cs.active_snapshot_version + WHERE cs.id = 1' + )->fetch(); + if (!$row || $row['active_snapshot_version'] === null) { + return $this->result('config_snapshot', 'error', 'No active edge configuration has been published', 'Run a manual config publish or allow the first edge pull to publish', '/edge-nodes'); + } + $details = [ + 'active_version' => (int) $row['active_snapshot_version'], + 'dirty' => in_array($row['dirty'] ?? false, [true, 1, '1', 't', 'true'], true), + 'published_at' => $row['published_at'] === null ? null : (int) $row['published_at'], + 'dirty_at' => $row['dirty_at'] === null ? null : (int) $row['dirty_at'], + ]; + if ($row['generated_at'] === null) { + return $this->result('config_snapshot', 'error', 'Active edge configuration row is missing', 'Publish a new edge configuration', '/edge-nodes', $details); + } + if ($row['last_publish_error'] !== null && (string) $row['last_publish_error'] !== '') { + $details['last_publish_error'] = (string) $row['last_publish_error']; + return $this->result('config_snapshot', 'warning', 'Last edge configuration publish failed', 'Review the publish error and publish again', '/edge-nodes', $details); } - $maxAge = max(60, (int) (getenv('CDNLITE_READINESS_SNAPSHOT_MAX_AGE_SECONDS') ?: 900)); - $age = max(0, time() - (int) $generatedAt); - if ($age <= $maxAge) { - return $this->result('config_snapshot', 'ok', 'Config snapshot is fresh'); + if ($details['dirty']) { + return $this->result('config_snapshot', 'warning', 'Edge configuration has unpublished changes', 'Publish edge configuration when ready', '/edge-nodes', $details); } - return $this->result('config_snapshot', 'warning', sprintf('Edge configuration was last generated %d minutes ago', (int) floor($age / 60)), 'Check edge config pulls or run a manual config rebuild', '/edge-nodes'); + return $this->result('config_snapshot', 'ok', 'Active edge configuration is published', null, null, $details); } private function certificateExpiryCheck(): array @@ -159,7 +175,7 @@ private function originHealthCheck(): array ); } - private function result(string $key, string $status, string $message, ?string $fix = null, ?string $link = null): array + private function result(string $key, string $status, string $message, ?string $fix = null, ?string $link = null, array $details = []): array { $result = ['key' => $key, 'status' => $status, 'message' => $message]; if ($fix !== null) { @@ -168,6 +184,9 @@ private function result(string $key, string $status, string $message, ?string $f if ($link !== null) { $result['link'] = $link; } + if ($details !== []) { + $result['details'] = $details; + } return $result; } diff --git a/core/app/Modules/Proxy/Http/Controllers/OriginController.php b/core/app/Modules/Proxy/Http/Controllers/OriginController.php index 1f7f4331..813f5bbf 100644 --- a/core/app/Modules/Proxy/Http/Controllers/OriginController.php +++ b/core/app/Modules/Proxy/Http/Controllers/OriginController.php @@ -42,8 +42,8 @@ public function delete(string $domainId, string $originId): array public function check(string $domainId, string $originId): array { - $origin = $this->service->check($domainId, $originId); - return $origin === null ? ['error' => 'origin_not_found', 'status' => 404] : ['data' => $origin]; + $result = $this->service->test($domainId, $originId); + return $result === null ? ['error' => 'origin_not_found', 'status' => 404] : ['data' => $result + ['authoritative' => false, 'source' => 'core_diagnostic_only']]; } public function test(string $domainId, string $originId): array @@ -76,9 +76,15 @@ private function validate(array $body, bool $partial): ?array } } if (array_key_exists('role', $body)) { - $role = Validator::enum($body, 'role', ['origin']); + $role = Validator::enum($body, 'role', ['primary', 'backup', 'shield']); if (($role['ok'] ?? false) !== true) { - return ['error' => 'invalid_field', 'field' => 'role', 'detail' => 'must_be_origin', 'status' => 422]; + return ['error' => 'invalid_field', 'field' => 'role', 'detail' => 'must_be_primary_backup_or_shield', 'status' => 422]; + } + } + if (array_key_exists('load_balancing_algorithm', $body)) { + $algorithm = Validator::enum($body, 'load_balancing_algorithm', ['weighted_hash', 'consistent_hash']); + if (($algorithm['ok'] ?? false) !== true) { + return ['error' => 'invalid_field', 'field' => 'load_balancing_algorithm', 'detail' => 'must_be_weighted_hash_or_consistent_hash', 'status' => 422]; } } foreach (['host_header', 'sni'] as $field) { @@ -101,7 +107,7 @@ private function validate(array $body, bool $partial): ?array return $weight; } } - foreach (['is_primary', 'enabled', 'preserve_host', 'health_check_enabled'] as $field) { + foreach (['is_primary', 'enabled', 'preserve_host', 'health_check_enabled', 'circuit_breaker_enabled', 'drain', 'shield_enabled'] as $field) { if (array_key_exists($field, $body)) { $bool = Validator::bool($body, $field); if (($bool['ok'] ?? false) !== true) { @@ -126,6 +132,22 @@ private function validate(array $body, bool $partial): ?array } } } + foreach ([ + 'connection_timeout_seconds' => [1, 60], + 'response_timeout_seconds' => [1, 600], + 'retry_attempts' => [0, 3], + 'retry_budget_per_minute' => [0, 100000], + 'circuit_failure_threshold' => [1, 1000], + 'circuit_recovery_seconds' => [1, 3600], + 'max_concurrent_requests' => [0, 1000000], + ] as $field => $range) { + if (array_key_exists($field, $body)) { + $value = Validator::intRange($body, $field, $range[0], $range[1]); + if (($value['ok'] ?? false) !== true) { + return $value; + } + } + } return null; } } diff --git a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php index 094c4bca..9e768fc5 100644 --- a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php +++ b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php @@ -96,6 +96,22 @@ public function dryRunRateLimit(string $domainId, array $body): array { if ($error !== null) { return $error; } return ['data' => $this->service->dryRunRateLimit($domainId, $body)]; } + public function getWaitingRoom(string $domainId): array { + return ['data' => $this->service->getWaitingRoomPolicy($domainId)]; + } + public function updateWaitingRoom(string $domainId, array $body): array { + $error = $this->validateWaitingRoom($body); + if ($error !== null) { return $error; } + return ['data' => $this->service->updateWaitingRoomPolicy($domainId, $body)]; + } + public function activateWaitingRoomEmergency(string $domainId, array $body): array { + $ttl = Validator::intRange($body, 'ttl_seconds', 60, 86400, 3600); + if (($ttl['ok'] ?? false) !== true) { return $ttl; } + return ['data' => $this->service->activateWaitingRoomEmergency($domainId, (int) $ttl['value'], (string) ($body['reason'] ?? 'manual_emergency'))]; + } + public function deactivateWaitingRoomEmergency(string $domainId): array { + return ['data' => $this->service->deactivateWaitingRoomEmergency($domainId)]; + } public function detachManagedRule(string $domainId, string $ruleType, string $id): array { try { $rule = $this->service->detachManagedRule($domainId, $ruleType, $id); @@ -227,6 +243,50 @@ private function validateRateLimit(array $body, bool $partial): ?array { $action = Validator::enum($body, 'action', ['block', 'challenge']); if (($action['ok'] ?? false) !== true) { return $action; } } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } + return null; + } + private function validateWaitingRoom(array $body): ?array { + foreach (['enabled'] as $field) { + if (array_key_exists($field, $body)) { + $v = Validator::bool($body, $field); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + foreach (['mode' => ['monitoring','automatic','manual'], 'state' => ['disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency']] as $field => $values) { + if (array_key_exists($field, $body)) { + $v = Validator::enum($body, $field, $values); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + $ranges = [ + 'rps_threshold' => [1, 1000000], 'active_origin_threshold' => [1, 1000000], + 'origin_latency_ms_threshold' => [1, 600000], 'origin_error_rate_threshold' => [1, 100], + 'admission_rate_per_minute' => [1, 1000000], 'queue_limit' => [1, 1000000], + 'per_client_ticket_limit' => [1, 1000], 'ticket_ttl_seconds' => [30, 3600], + 'admission_ttl_seconds' => [60, 86400], 'status_poll_seconds' => [2, 300], + 'jitter_seconds' => [0, 300], 'unhealthy_windows' => [1, 100], + 'healthy_windows' => [1, 100], 'minimum_state_seconds' => [1, 86400], + 'recovery_ramp_percent' => [1, 100], 'manual_override_until' => [0, 4102444800], + ]; + foreach ($ranges as $field => [$min, $max]) { + if (array_key_exists($field, $body) && $body[$field] !== null) { + $v = Validator::intRange($body, $field, $min, $max); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + foreach (['waiting_room_title' => 120, 'waiting_room_message' => 500, 'reason' => 160] as $field => $max) { + if (array_key_exists($field, $body) && $body[$field] !== null) { + $v = Validator::optionalString($body, $field, $max); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + if (array_key_exists('trusted_cidrs', $body) && !is_array($body['trusted_cidrs'])) { + return ['error' => 'invalid_field', 'field' => 'trusted_cidrs', 'detail' => 'must_be_array', 'status' => 422]; + } return null; } public function createWaf(string $domainId, array $body): array { @@ -245,6 +305,10 @@ public function createWaf(string $domainId, array $body): array { $action = Validator::enum($body, 'action', ['block', 'log', 'allow', 'challenge']); if (($action['ok'] ?? false) !== true) { return $action; } } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } if (array_key_exists('name', $body)) { $name = Validator::optionalString($body, 'name', 255); if (($name['ok'] ?? false) !== true) { return $name; } @@ -375,6 +439,10 @@ public function updateWaf(string $domainId, string $id, array $body): array { if (array_key_exists('action', $body) && !in_array((string) $body['action'], ['block', 'log', 'allow', 'challenge'], true)) { return ['error' => 'invalid_field', 'field' => 'action', 'detail' => 'must_be_one_of_block_log_allow_challenge', 'status' => 422]; } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } if (array_key_exists('priority', $body)) { $priority = Validator::intRange($body, 'priority', 1, 100000); if (($priority['ok'] ?? false) !== true) { return $priority; } diff --git a/core/app/Modules/Proxy/Services/ConfigService.php b/core/app/Modules/Proxy/Services/ConfigService.php index a9f7cf7d..41684edd 100644 --- a/core/app/Modules/Proxy/Services/ConfigService.php +++ b/core/app/Modules/Proxy/Services/ConfigService.php @@ -9,6 +9,8 @@ class ConfigService { + private bool $publishLockHeld = false; + public function __construct( private DomainService $domains, private DnsService $dns, @@ -21,17 +23,138 @@ public function __construct( public function buildSnapshot(): array { - return $this->buildSnapshotForVersion(null); + return $this->publishSnapshot(false); } public function buildSnapshotForVersion(?int $ifVersion = null): array { - return $this->rebuild($ifVersion); + return $this->edgeConfig($ifVersion); } public function rebuild(?int $ifVersion = null): array + { + $payload = $this->publishSnapshot(true); + if ($ifVersion !== null && isset($payload['version']) && $ifVersion === (int) $payload['version']) { + return ['not_modified' => true, 'version' => (int) $payload['version']]; + } + return $payload; + } + + public function edgeConfig(?int $ifVersion = null): array + { + $this->ensureStateRow(); + $state = $this->configState(); + $activeVersion = $state['active_snapshot_version']; + $active = $this->activeSnapshot(); + + if ($activeVersion !== null && $active !== null && !$state['dirty']) { + if ($ifVersion !== null && $ifVersion === $activeVersion) { + return ['not_modified' => true, 'version' => $activeVersion]; + } + return $active['payload']; + } + + if ($this->tryPublishLock()) { + try { + return $this->publishSnapshot(false, true); + } finally { + $this->unlockPublish(); + } + } + + if ($activeVersion !== null && $active !== null) { + if ($ifVersion !== null && $ifVersion === $activeVersion) { + return ['not_modified' => true, 'version' => $activeVersion, 'stale_while_rebuilding' => true]; + } + $payload = $active['payload']; + $payload['stale_while_rebuilding'] = true; + return $payload; + } + + return ['error' => 'config_publish_in_progress', 'status' => 503]; + } + + public function publishSnapshot(bool $force = false, bool $lockAlreadyHeld = false): array + { + $this->ensureStateRow(); + $state = $this->configState(); + $active = $this->activeSnapshot(); + if (!$force && !$state['dirty'] && $active !== null) { + return $active['payload']; + } + + if (!$lockAlreadyHeld && !$this->tryPublishLock()) { + if ($active !== null) { + $payload = $active['payload']; + $payload['stale_while_rebuilding'] = true; + return $payload; + } + return ['error' => 'config_publish_in_progress', 'status' => 503]; + } + + try { + $this->setPublishingStartedAt(time()); + $payload = $this->buildAndStoreSnapshot(); + $this->markPublished((int) ($payload['generated_at'] ?? time())); + $this->pruneSnapshots($this->snapshotKeepLast(), $this->snapshotPruneBatchSize()); + return $payload; + } catch (\Throwable $e) { + $this->markPublishFailed($e->getMessage()); + AuditLog::write('config.publish.failed', 'config_snapshot', null, null, null, ['error' => $e->getMessage()]); + throw $e; + } finally { + $this->clearPublishingStartedAt(); + if (!$lockAlreadyHeld) { + $this->unlockPublish(); + } + } + } + + public function buildPayload(): array + { + return $this->buildPayloadData(); + } + + public static function markDirty(?string $reason = null): void + { + $pdo = Database::pdo(); + $pdo->exec('INSERT INTO config_state (id, version) VALUES (1, 0) ON CONFLICT (id) DO NOTHING'); + $wasDirty = in_array($pdo->query('SELECT dirty FROM config_state WHERE id = 1')->fetchColumn(), [true, 1, '1', 't', 'true'], true); + $stmt = $pdo->prepare( + 'UPDATE config_state + SET dirty = true, dirty_at = :dirty_at + WHERE id = 1' + ); + $stmt->execute([':dirty_at' => time()]); + if (!$wasDirty) { + AuditLog::write('config.dirty', 'config_state', '1', null, ['dirty' => false], ['dirty' => true, 'reason' => $reason]); + } + } + + private function buildAndStoreSnapshot(): array { $previousActiveVersion = $this->activeSnapshotVersion(); + $payloadData = $this->buildPayloadData(); + $contentHash = $this->contentHash($payloadData); + $existing = $this->findReusableActiveSnapshot($previousActiveVersion, $contentHash); + if ($existing !== null) { + $payload = json_decode((string) $existing['payload_json'], true) ?: $payloadData; + $payload['reused'] = true; + $this->activateSnapshotVersion((int) $existing['version']); + $this->auditSnapshotPublish((int) $existing['version'], $previousActiveVersion, (int) $existing['version'], true); + return $payload; + } + + $version = $this->nextVersion(); + $payload = $payloadData + ['version' => $version, 'generated_at' => time()]; + $this->storeSnapshot($version, $contentHash, $payload); + $this->activateSnapshotVersion($version); + $this->auditSnapshotPublish($version, $previousActiveVersion, $version, false); + return $payload; + } + + private function buildPayloadData(): array + { $hosts = []; foreach ($this->domains->all() as $domain) { if ((string) ($domain['status'] ?? '') !== 'active' @@ -102,45 +225,10 @@ public function rebuild(?int $ifVersion = null): array foreach ($this->rules->listCachePurgeVersionsForConfig($domainId, $host) as $row) { $cachePurgeVersions[] = $row; } foreach ($this->rules->listPageRules($domainId) as $row) { if (!empty($row['enabled'])) { $row['host'] = $host; $pageRules[] = $row; } } foreach ($this->rules->listSslCertificatesForConfig($domainId, $host) as $row) { $sslCertificates[] = $row; } + foreach ($this->rules->listWaitingRoomPoliciesForConfig($domainId) as $row) { $hosts[$host]['waiting_room'] = $row; } } - // Keep hash deterministic for unchanged config content. - // `generated_at` is intentionally excluded so no-op syncs reuse version. - $contentHash = hash('sha256', json_encode(['hosts' => $hosts, 'redirects' => $redirects, 'rate_limits' => $rateLimits, 'waf_rules' => $wafRules, 'header_rules' => $headerRules, 'ip_rules' => $ipRules, 'cache_rules' => $cacheRules, 'cache_purge_versions' => $cachePurgeVersions, 'page_rules' => $pageRules, 'ssl_certificates' => $sslCertificates], JSON_UNESCAPED_SLASHES)); - - $existing = $this->findReusableActiveSnapshot($previousActiveVersion, $contentHash); - if ($existing !== null) { - if ($ifVersion !== null && $ifVersion === (int) $existing['version']) { - $this->activateSnapshotVersion((int) $existing['version']); - return ['not_modified' => true, 'version' => (int) $existing['version']]; - } - - $existing = $this->refreshSnapshotGeneratedAt($existing); - $this->activateSnapshotVersion((int) $existing['version']); - $this->auditSnapshotPublish((int) $existing['version'], $previousActiveVersion, (int) $existing['version'], true); - - return [ - 'schema_version' => 1, - 'version' => (int) $existing['version'], - 'generated_at' => (int) $existing['generated_at'], - 'hosts' => $hosts, - 'redirects' => $redirects, - 'rate_limits' => $rateLimits, - 'waf_rules' => $wafRules, - 'header_rules' => $headerRules, - 'ip_rules' => $ipRules, - 'cache_rules' => $cacheRules, - 'cache_purge_versions' => $cachePurgeVersions, - 'page_rules' => $pageRules, - 'ssl_certificates' => $sslCertificates, - 'reused' => true, - ]; - } - - $version = $this->nextVersion(); - $payload = [ + return [ 'schema_version' => 1, - 'version' => $version, - 'generated_at' => time(), 'hosts' => $hosts, 'redirects' => $redirects, 'rate_limits' => $rateLimits, @@ -152,25 +240,22 @@ public function rebuild(?int $ifVersion = null): array 'page_rules' => $pageRules, 'ssl_certificates' => $sslCertificates, ]; - $this->storeSnapshot($version, $contentHash, $payload); - $this->activateSnapshotVersion($version); - $this->auditSnapshotPublish($version, $previousActiveVersion, $version, false); - - if ($ifVersion !== null && $ifVersion === $version) { - return ['not_modified' => true, 'version' => $version]; - } - - return $payload; } - public function snapshots(): array + public function snapshots(int $limit = 20, int $offset = 0): array { - $rows = Database::pdo()->query( + $limit = max(1, min(100, $limit)); + $offset = max(0, $offset); + $stmt = Database::pdo()->prepare( 'SELECT s.version,s.generated_at,s.content_hash,pg_column_size(s.payload_json) AS size, (s.version=cs.active_snapshot_version) AS active FROM config_snapshots s CROSS JOIN config_state cs - WHERE cs.id=1 ORDER BY s.version DESC' - )->fetchAll(); + WHERE cs.id=1 ORDER BY s.version DESC LIMIT :limit OFFSET :offset' + ); + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(); return array_map(static fn (array $row): array => self::snapshotSummaryFromRow($row), $rows); } @@ -305,7 +390,7 @@ public function debugRoute(string $domainId, array $input): array ]; } - private function activeSnapshot(): ?array + public function activeSnapshot(): ?array { $row = Database::pdo()->query( 'SELECT s.version,s.payload_json FROM config_state cs @@ -314,6 +399,136 @@ private function activeSnapshot(): ?array return $row ? ['version' => (int) $row['version'], 'payload' => json_decode((string) $row['payload_json'], true)] : null; } + public function pruneSnapshots(int $keepLast = 2, ?int $batchSize = null, bool $dryRun = false): array + { + $keepLast = max(0, $keepLast); + $batchSize = $batchSize === null ? null : max(1, $batchSize); + $activeVersion = $this->activeSnapshotVersion(); + $limitClause = $batchSize === null ? '' : ' LIMIT :batch_size'; + $sql = $dryRun + ? "WITH keep_versions AS ( + SELECT active_snapshot_version AS version FROM config_state WHERE id = 1 AND active_snapshot_version IS NOT NULL + UNION + SELECT version FROM config_snapshots ORDER BY version DESC LIMIT :keep_last + ), + victims AS ( + SELECT version FROM config_snapshots + WHERE version NOT IN (SELECT version FROM keep_versions) + ORDER BY version ASC{$limitClause} + ) + SELECT COUNT(*) AS deleted_count FROM victims" + : "WITH keep_versions AS ( + SELECT active_snapshot_version AS version FROM config_state WHERE id = 1 AND active_snapshot_version IS NOT NULL + UNION + SELECT version FROM config_snapshots ORDER BY version DESC LIMIT :keep_last + ), + victims AS ( + SELECT version FROM config_snapshots + WHERE version NOT IN (SELECT version FROM keep_versions) + ORDER BY version ASC{$limitClause} + ), + deleted AS ( + DELETE FROM config_snapshots + WHERE version IN (SELECT version FROM victims) + RETURNING version + ) + SELECT COUNT(*) AS deleted_count FROM deleted"; + $stmt = Database::pdo()->prepare($sql); + $stmt->bindValue(':keep_last', $keepLast, \PDO::PARAM_INT); + if ($batchSize !== null) { + $stmt->bindValue(':batch_size', $batchSize, \PDO::PARAM_INT); + } + $stmt->execute(); + $deletedCount = (int) ($stmt->fetchColumn() ?: 0); + $remaining = (int) (Database::pdo()->query('SELECT COUNT(*) FROM config_snapshots')->fetchColumn() ?: 0); + if (!$dryRun) { + AuditLog::write('config.snapshots.prune', 'config_snapshot', null, null, null, ['deleted_count' => $deletedCount, 'keep_last' => $keepLast, 'active_version' => $activeVersion]); + } + return [ + 'ok' => true, + 'dry_run' => $dryRun, + 'deleted_count' => $deletedCount, + 'keep_last' => $keepLast, + 'active_version' => $activeVersion, + 'remaining_count_estimate' => $dryRun ? max(0, $remaining - $deletedCount) : $remaining, + ]; + } + + private function ensureStateRow(): void + { + Database::pdo()->exec('INSERT INTO config_state (id, version) VALUES (1, 0) ON CONFLICT (id) DO NOTHING'); + } + + private function configState(): array + { + $this->ensureStateRow(); + $row = Database::pdo()->query('SELECT version, active_snapshot_version, dirty, dirty_at, published_at, last_publish_error, publishing_started_at FROM config_state WHERE id = 1')->fetch(); + return [ + 'version' => (int) ($row['version'] ?? 0), + 'active_snapshot_version' => isset($row['active_snapshot_version']) && $row['active_snapshot_version'] !== null ? (int) $row['active_snapshot_version'] : null, + 'dirty' => in_array($row['dirty'] ?? true, [true, 1, '1', 't', 'true'], true), + 'dirty_at' => isset($row['dirty_at']) && $row['dirty_at'] !== null ? (int) $row['dirty_at'] : null, + 'published_at' => isset($row['published_at']) && $row['published_at'] !== null ? (int) $row['published_at'] : null, + 'last_publish_error' => isset($row['last_publish_error']) ? (string) $row['last_publish_error'] : null, + 'publishing_started_at' => isset($row['publishing_started_at']) && $row['publishing_started_at'] !== null ? (int) $row['publishing_started_at'] : null, + ]; + } + + private function tryPublishLock(): bool + { + $locked = Database::pdo()->query("SELECT pg_try_advisory_lock(hashtext('cdnlite_config_publish'))")->fetchColumn(); + $this->publishLockHeld = in_array($locked, [true, 1, '1', 't', 'true'], true); + return $this->publishLockHeld; + } + + private function unlockPublish(): void + { + if (!$this->publishLockHeld) { + return; + } + Database::pdo()->query("SELECT pg_advisory_unlock(hashtext('cdnlite_config_publish'))"); + $this->publishLockHeld = false; + } + + private function setPublishingStartedAt(int $time): void + { + Database::pdo()->prepare('UPDATE config_state SET publishing_started_at = :time WHERE id = 1')->execute([':time' => $time]); + } + + private function clearPublishingStartedAt(): void + { + Database::pdo()->exec('UPDATE config_state SET publishing_started_at = NULL WHERE id = 1'); + } + + private function markPublished(int $publishedAt): void + { + Database::pdo()->prepare( + 'UPDATE config_state + SET dirty = false, published_at = :published_at, last_publish_error = NULL + WHERE id = 1' + )->execute([':published_at' => $publishedAt]); + } + + private function markPublishFailed(string $error): void + { + Database::pdo()->prepare( + 'UPDATE config_state + SET dirty = true, last_publish_error = :error + WHERE id = 1' + )->execute([':error' => substr($error, 0, 4000)]); + } + + private function snapshotKeepLast(): int + { + return max(0, (int) (getenv('CDNLITE_CONFIG_SNAPSHOT_KEEP_LAST') ?: 2)); + } + + private function snapshotPruneBatchSize(): ?int + { + $value = trim((string) (getenv('CDNLITE_CONFIG_SNAPSHOT_PRUNE_BATCH_SIZE') ?: '5000')); + return $value === '' || (int) $value <= 0 ? null : (int) $value; + } + private function diffValues(mixed $from, mixed $to, string $path = ''): array { if (!is_array($from) || !is_array($to)) { @@ -359,6 +574,12 @@ private function findReusableActiveSnapshot(?int $activeVersion, string $content return (array) $row; } + private function contentHash(array $payloadData): string + { + // generated_at/version are publish metadata; the hash tracks only edge behavior. + return hash('sha256', json_encode($payloadData, JSON_UNESCAPED_SLASHES)); + } + private function refreshSnapshotGeneratedAt(array $snapshot): array { $generatedAt = time(); @@ -393,6 +614,7 @@ private function activateSnapshotVersion(int $version): void private function activeSnapshotVersion(): ?int { + $this->ensureStateRow(); $value = Database::pdo()->query('SELECT active_snapshot_version FROM config_state WHERE id = 1')->fetchColumn(); return $value === false || $value === null ? null : (int) $value; } @@ -415,8 +637,9 @@ private function originForSnapshot(array $origin): array 'id' => (string) $origin['id'], 'dns_record_id' => $origin['dns_record_id'] ?? null, 'source' => (string) ($origin['source'] ?? 'manual'), - 'role' => (string) ($origin['role'] ?? 'origin'), + 'role' => (string) ($origin['role'] ?? 'primary'), 'weight' => (int) ($origin['weight'] ?? 1), + 'load_balancing_algorithm' => (string) ($origin['load_balancing_algorithm'] ?? 'weighted_hash'), 'enabled' => (bool) ($origin['enabled'] ?? true), 'host' => (string) $origin['host'], 'scheme' => (string) $origin['scheme'], @@ -426,6 +649,19 @@ private function originForSnapshot(array $origin): array 'tls_verify' => (string) ($origin['tls_verify'] ?? 'ignore'), 'preserve_host' => (bool) ($origin['preserve_host'] ?? true), 'health_check_enabled' => (bool) ($origin['health_check_enabled'] ?? false), + 'health_check_path' => (string) ($origin['health_check_path'] ?? '/'), + 'health_check_interval_seconds' => (int) ($origin['health_check_interval_seconds'] ?? 30), + 'health_check_timeout_seconds' => (int) ($origin['health_check_timeout_seconds'] ?? 5), + 'connection_timeout_seconds' => (int) ($origin['connection_timeout_seconds'] ?? 5), + 'response_timeout_seconds' => (int) ($origin['response_timeout_seconds'] ?? 30), + 'retry_attempts' => (int) ($origin['retry_attempts'] ?? 1), + 'retry_budget_per_minute' => (int) ($origin['retry_budget_per_minute'] ?? 60), + 'circuit_breaker_enabled' => (bool) ($origin['circuit_breaker_enabled'] ?? true), + 'circuit_failure_threshold' => (int) ($origin['circuit_failure_threshold'] ?? 5), + 'circuit_recovery_seconds' => (int) ($origin['circuit_recovery_seconds'] ?? 30), + 'max_concurrent_requests' => (int) ($origin['max_concurrent_requests'] ?? 0), + 'drain' => (bool) ($origin['drain'] ?? false), + 'shield_enabled' => (bool) ($origin['shield_enabled'] ?? false), 'status' => (string) $origin['health_status'], 'health_status' => (string) $origin['health_status'], ]; @@ -435,7 +671,7 @@ private function originsForSnapshot(array $origins): array { $out = []; foreach ($origins as $origin) { - if (empty($origin['enabled'])) { + if (empty($origin['enabled']) || !empty($origin['drain'])) { continue; } if (!empty($origin['health_check_enabled']) && (string) ($origin['health_status'] ?? 'unknown') === 'unhealthy') { @@ -468,8 +704,9 @@ private function originsFromDnsRecords(array $records, string $domainHost): arra 'id' => (string) ($record['id'] ?? ''), 'dns_record_id' => (string) ($record['id'] ?? ''), 'source' => 'dns_record', - 'role' => 'origin', + 'role' => 'primary', 'weight' => 1, + 'load_balancing_algorithm' => 'weighted_hash', 'enabled' => true, 'scheme' => $scheme, 'host' => $host, @@ -479,6 +716,19 @@ private function originsFromDnsRecords(array $records, string $domainHost): arra 'tls_verify' => (string) ($record['origin_tls_verify'] ?? 'ignore'), 'preserve_host' => true, 'health_check_enabled' => false, + 'health_check_path' => '/', + 'health_check_interval_seconds' => 30, + 'health_check_timeout_seconds' => 5, + 'connection_timeout_seconds' => 5, + 'response_timeout_seconds' => 30, + 'retry_attempts' => 1, + 'retry_budget_per_minute' => 60, + 'circuit_breaker_enabled' => true, + 'circuit_failure_threshold' => 5, + 'circuit_recovery_seconds' => 30, + 'max_concurrent_requests' => 0, + 'drain' => false, + 'shield_enabled' => false, 'health_status' => (string) ($record['origin_status'] ?? 'pending'), 'status' => (string) ($record['origin_status'] ?? 'pending'), ]; @@ -531,7 +781,7 @@ private function originsForDnsRecord(string $recordId, array $configuredOrigins) } $origins = []; foreach ($configuredOrigins as $origin) { - if ((string) ($origin['dns_record_id'] ?? '') !== $recordId || empty($origin['enabled'])) { + if ((string) ($origin['dns_record_id'] ?? '') !== $recordId || empty($origin['enabled']) || !empty($origin['drain'])) { continue; } if (!empty($origin['health_check_enabled']) && (string) ($origin['health_status'] ?? 'unknown') === 'unhealthy') { @@ -570,8 +820,9 @@ private function originFromDnsRecord(array $record, string $domainHost = ''): ?a 'id' => (string) ($record['id'] ?? ''), 'dns_record_id' => (string) ($record['id'] ?? ''), 'source' => 'dns_record', - 'role' => 'origin', + 'role' => 'primary', 'weight' => 1, + 'load_balancing_algorithm' => 'weighted_hash', 'enabled' => true, 'scheme' => $scheme, 'host' => $host, @@ -581,6 +832,19 @@ private function originFromDnsRecord(array $record, string $domainHost = ''): ?a 'tls_verify' => (string) ($record['origin_tls_verify'] ?? 'ignore'), 'preserve_host' => true, 'health_check_enabled' => false, + 'health_check_path' => '/', + 'health_check_interval_seconds' => 30, + 'health_check_timeout_seconds' => 5, + 'connection_timeout_seconds' => 5, + 'response_timeout_seconds' => 30, + 'retry_attempts' => 1, + 'retry_budget_per_minute' => 60, + 'circuit_breaker_enabled' => true, + 'circuit_failure_threshold' => 5, + 'circuit_recovery_seconds' => 30, + 'max_concurrent_requests' => 0, + 'drain' => false, + 'shield_enabled' => false, 'health_status' => (string) ($record['origin_status'] ?? 'pending'), 'status' => (string) ($record['origin_status'] ?? 'pending'), ]; @@ -637,8 +901,9 @@ private function buildGeoOrigins(array $geoOrigins): array } $out[strtoupper(trim($key))] = [ 'id' => (string) ($origin['id'] ?? 'geo-' . strtoupper(trim($key))), - 'role' => 'origin', + 'role' => 'primary', 'source' => 'geo_origin', + 'load_balancing_algorithm' => 'weighted_hash', 'host' => $host, // Geo origins should not silently assume TLS. Mirror the // explicit scheme when present, otherwise stay on HTTP/80. @@ -649,6 +914,19 @@ private function buildGeoOrigins(array $geoOrigins): array 'tls_verify' => (string) ($origin['tls_verify'] ?? 'ignore'), 'preserve_host' => (bool) ($origin['preserve_host'] ?? true), 'health_check_enabled' => (bool) ($origin['health_check_enabled'] ?? false), + 'health_check_path' => (string) ($origin['health_check_path'] ?? '/'), + 'health_check_interval_seconds' => (int) ($origin['health_check_interval_seconds'] ?? 30), + 'health_check_timeout_seconds' => (int) ($origin['health_check_timeout_seconds'] ?? 5), + 'connection_timeout_seconds' => (int) ($origin['connection_timeout_seconds'] ?? 5), + 'response_timeout_seconds' => (int) ($origin['response_timeout_seconds'] ?? 30), + 'retry_attempts' => (int) ($origin['retry_attempts'] ?? 1), + 'retry_budget_per_minute' => (int) ($origin['retry_budget_per_minute'] ?? 60), + 'circuit_breaker_enabled' => (bool) ($origin['circuit_breaker_enabled'] ?? true), + 'circuit_failure_threshold' => (int) ($origin['circuit_failure_threshold'] ?? 5), + 'circuit_recovery_seconds' => (int) ($origin['circuit_recovery_seconds'] ?? 30), + 'max_concurrent_requests' => (int) ($origin['max_concurrent_requests'] ?? 0), + 'drain' => (bool) ($origin['drain'] ?? false), + 'shield_enabled' => (bool) ($origin['shield_enabled'] ?? false), ]; } ksort($out); diff --git a/core/app/Modules/Proxy/Services/OriginHealthService.php b/core/app/Modules/Proxy/Services/OriginHealthService.php index b1141924..56e3b955 100644 --- a/core/app/Modules/Proxy/Services/OriginHealthService.php +++ b/core/app/Modules/Proxy/Services/OriginHealthService.php @@ -43,13 +43,24 @@ public function create(string $domainId, array $input): array 'preserve_host' => array_key_exists('preserve_host', $input) ? !empty($input['preserve_host']) : true, 'dns_record_id' => $input['dns_record_id'] ?? null, 'source' => (string) ($input['source'] ?? 'manual'), - 'role' => (string) ($input['role'] ?? 'origin'), + 'role' => (string) ($input['role'] ?? 'primary'), 'weight' => (int) ($input['weight'] ?? 1), + 'load_balancing_algorithm' => (string) ($input['load_balancing_algorithm'] ?? 'weighted_hash'), 'is_primary' => false, 'health_check_enabled' => array_key_exists('health_check_enabled', $input) ? !empty($input['health_check_enabled']) : false, 'health_check_path' => (string) ($input['health_check_path'] ?? '/'), 'health_check_interval_seconds' => (int) ($input['health_check_interval_seconds'] ?? 30), 'health_check_timeout_seconds' => (int) ($input['health_check_timeout_seconds'] ?? 5), + 'connection_timeout_seconds' => (int) ($input['connection_timeout_seconds'] ?? 5), + 'response_timeout_seconds' => (int) ($input['response_timeout_seconds'] ?? 30), + 'retry_attempts' => (int) ($input['retry_attempts'] ?? 1), + 'retry_budget_per_minute' => (int) ($input['retry_budget_per_minute'] ?? 60), + 'circuit_breaker_enabled' => array_key_exists('circuit_breaker_enabled', $input) ? !empty($input['circuit_breaker_enabled']) : true, + 'circuit_failure_threshold' => (int) ($input['circuit_failure_threshold'] ?? 5), + 'circuit_recovery_seconds' => (int) ($input['circuit_recovery_seconds'] ?? 30), + 'max_concurrent_requests' => (int) ($input['max_concurrent_requests'] ?? 0), + 'drain' => array_key_exists('drain', $input) ? !empty($input['drain']) : false, + 'shield_enabled' => array_key_exists('shield_enabled', $input) ? !empty($input['shield_enabled']) : false, 'health_status' => 'unknown', 'enabled' => array_key_exists('enabled', $input) ? !empty($input['enabled']) : true, 'created_at' => $now, @@ -60,9 +71,9 @@ public function create(string $domainId, array $input): array } $pdo->prepare( 'INSERT INTO domain_origins - (id,domain_id,dns_record_id,source,role,weight,scheme,host,port,host_header,sni,tls_verify,preserve_host,is_primary,health_check_enabled,health_check_path,health_check_interval_seconds,health_check_timeout_seconds,health_status,last_check_at,last_error,enabled,created_at,updated_at) + (id,domain_id,dns_record_id,source,role,weight,load_balancing_algorithm,scheme,host,port,host_header,sni,tls_verify,preserve_host,is_primary,health_check_enabled,health_check_path,health_check_interval_seconds,health_check_timeout_seconds,connection_timeout_seconds,response_timeout_seconds,retry_attempts,retry_budget_per_minute,circuit_breaker_enabled,circuit_failure_threshold,circuit_recovery_seconds,max_concurrent_requests,drain,shield_enabled,health_status,last_check_at,last_error,enabled,created_at,updated_at) VALUES - (:id,:domain_id,:dns_record_id,:source,:role,:weight,:scheme,:host,:port,:host_header,:sni,:tls_verify,:preserve_host,:is_primary,:health_check_enabled,:health_check_path,:health_check_interval_seconds,:health_check_timeout_seconds,:health_status,NULL,NULL,:enabled,:created_at,:updated_at)' + (:id,:domain_id,:dns_record_id,:source,:role,:weight,:load_balancing_algorithm,:scheme,:host,:port,:host_header,:sni,:tls_verify,:preserve_host,:is_primary,:health_check_enabled,:health_check_path,:health_check_interval_seconds,:health_check_timeout_seconds,:connection_timeout_seconds,:response_timeout_seconds,:retry_attempts,:retry_budget_per_minute,:circuit_breaker_enabled,:circuit_failure_threshold,:circuit_recovery_seconds,:max_concurrent_requests,:drain,:shield_enabled,:health_status,NULL,NULL,:enabled,:created_at,:updated_at)' )->execute([ ':id' => $row['id'], ':domain_id' => $row['domain_id'], @@ -70,6 +81,7 @@ public function create(string $domainId, array $input): array ':source' => $row['source'], ':role' => $row['role'], ':weight' => $row['weight'], + ':load_balancing_algorithm' => $row['load_balancing_algorithm'], ':scheme' => $row['scheme'], ':host' => $row['host'], ':port' => $row['port'], @@ -82,6 +94,16 @@ public function create(string $domainId, array $input): array ':health_check_path' => $row['health_check_path'], ':health_check_interval_seconds' => $row['health_check_interval_seconds'], ':health_check_timeout_seconds' => $row['health_check_timeout_seconds'], + ':connection_timeout_seconds' => $row['connection_timeout_seconds'], + ':response_timeout_seconds' => $row['response_timeout_seconds'], + ':retry_attempts' => $row['retry_attempts'], + ':retry_budget_per_minute' => $row['retry_budget_per_minute'], + ':circuit_breaker_enabled' => (int) $row['circuit_breaker_enabled'], + ':circuit_failure_threshold' => $row['circuit_failure_threshold'], + ':circuit_recovery_seconds' => $row['circuit_recovery_seconds'], + ':max_concurrent_requests' => $row['max_concurrent_requests'], + ':drain' => (int) $row['drain'], + ':shield_enabled' => (int) $row['shield_enabled'], ':health_status' => $row['health_status'], ':enabled' => (int) $row['enabled'], ':created_at' => $row['created_at'], @@ -117,8 +139,19 @@ public function update(string $domainId, string $originId, array $input): ?array 'sni' => array_key_exists('sni', $input) ? trim((string) $input['sni']) : (string) ($existing['sni'] ?? ''), 'tls_verify' => (string) ($input['tls_verify'] ?? $existing['tls_verify'] ?? 'ignore'), 'preserve_host' => array_key_exists('preserve_host', $input) ? !empty($input['preserve_host']) : (bool) ($existing['preserve_host'] ?? true), - 'role' => array_key_exists('role', $input) ? (string) $input['role'] : (string) ($existing['role'] ?? 'origin'), + 'role' => array_key_exists('role', $input) ? (string) $input['role'] : (string) ($existing['role'] ?? 'primary'), 'weight' => (int) ($input['weight'] ?? $existing['weight'] ?? 1), + 'load_balancing_algorithm' => (string) ($input['load_balancing_algorithm'] ?? $existing['load_balancing_algorithm'] ?? 'weighted_hash'), + 'connection_timeout_seconds' => (int) ($input['connection_timeout_seconds'] ?? $existing['connection_timeout_seconds'] ?? 5), + 'response_timeout_seconds' => (int) ($input['response_timeout_seconds'] ?? $existing['response_timeout_seconds'] ?? 30), + 'retry_attempts' => (int) ($input['retry_attempts'] ?? $existing['retry_attempts'] ?? 1), + 'retry_budget_per_minute' => (int) ($input['retry_budget_per_minute'] ?? $existing['retry_budget_per_minute'] ?? 60), + 'circuit_breaker_enabled' => array_key_exists('circuit_breaker_enabled', $input) ? !empty($input['circuit_breaker_enabled']) : (bool) ($existing['circuit_breaker_enabled'] ?? true), + 'circuit_failure_threshold' => (int) ($input['circuit_failure_threshold'] ?? $existing['circuit_failure_threshold'] ?? 5), + 'circuit_recovery_seconds' => (int) ($input['circuit_recovery_seconds'] ?? $existing['circuit_recovery_seconds'] ?? 30), + 'max_concurrent_requests' => (int) ($input['max_concurrent_requests'] ?? $existing['max_concurrent_requests'] ?? 0), + 'drain' => array_key_exists('drain', $input) ? !empty($input['drain']) : (bool) ($existing['drain'] ?? false), + 'shield_enabled' => array_key_exists('shield_enabled', $input) ? !empty($input['shield_enabled']) : (bool) ($existing['shield_enabled'] ?? false), 'enabled' => array_key_exists('enabled', $input) ? !empty($input['enabled']) : (bool) $existing['enabled'], 'health_check_enabled' => array_key_exists('health_check_enabled', $input) ? !empty($input['health_check_enabled']) : (bool) ($existing['health_check_enabled'] ?? false), // DNS-linked origin edits should recover fast after a transient @@ -136,9 +169,13 @@ public function update(string $domainId, string $originId, array $input): ?array try { $pdo->prepare( 'UPDATE domain_origins SET scheme=:scheme,host=:host,port=:port,host_header=:host_header,sni=:sni, - tls_verify=:tls_verify,preserve_host=:preserve_host,role=:role,weight=:weight,is_primary=:is_primary, + tls_verify=:tls_verify,preserve_host=:preserve_host,role=:role,weight=:weight,load_balancing_algorithm=:load_balancing_algorithm,is_primary=:is_primary, health_check_enabled=:health_check_enabled,health_check_path=:health_check_path,health_check_interval_seconds=:health_check_interval_seconds, - health_check_timeout_seconds=:health_check_timeout_seconds,health_status=:health_status, + health_check_timeout_seconds=:health_check_timeout_seconds,connection_timeout_seconds=:connection_timeout_seconds, + response_timeout_seconds=:response_timeout_seconds,retry_attempts=:retry_attempts,retry_budget_per_minute=:retry_budget_per_minute, + circuit_breaker_enabled=:circuit_breaker_enabled,circuit_failure_threshold=:circuit_failure_threshold, + circuit_recovery_seconds=:circuit_recovery_seconds,max_concurrent_requests=:max_concurrent_requests, + drain=:drain,shield_enabled=:shield_enabled,health_status=:health_status, last_check_at=:last_check_at,last_error=:last_error,enabled=:enabled,updated_at=:updated_at WHERE domain_id=:domain_id AND id=:id' )->execute([ @@ -153,11 +190,22 @@ public function update(string $domainId, string $originId, array $input): ?array ':preserve_host' => (int) $patch['preserve_host'], ':role' => $patch['role'], ':weight' => $patch['weight'], + ':load_balancing_algorithm' => $patch['load_balancing_algorithm'], ':is_primary' => 0, ':health_check_enabled' => (int) $patch['health_check_enabled'], ':health_check_path' => $patch['health_check_path'], ':health_check_interval_seconds' => $patch['health_check_interval_seconds'], ':health_check_timeout_seconds' => $patch['health_check_timeout_seconds'], + ':connection_timeout_seconds' => $patch['connection_timeout_seconds'], + ':response_timeout_seconds' => $patch['response_timeout_seconds'], + ':retry_attempts' => $patch['retry_attempts'], + ':retry_budget_per_minute' => $patch['retry_budget_per_minute'], + ':circuit_breaker_enabled' => (int) $patch['circuit_breaker_enabled'], + ':circuit_failure_threshold' => $patch['circuit_failure_threshold'], + ':circuit_recovery_seconds' => $patch['circuit_recovery_seconds'], + ':max_concurrent_requests' => $patch['max_concurrent_requests'], + ':drain' => (int) $patch['drain'], + ':shield_enabled' => (int) $patch['shield_enabled'], ':health_status' => $patch['health_status'], ':last_check_at' => $patch['last_check_at'], ':last_error' => $patch['last_error'], @@ -225,22 +273,13 @@ public function test(string $domainId, string $originId): ?array public function checkDue(): array { - $cutoff = time(); - $stmt = Database::pdo()->prepare( - 'SELECT * FROM domain_origins - WHERE enabled=true AND health_check_enabled=true - AND (last_check_at IS NULL OR last_check_at + health_check_interval_seconds <= :now) - ORDER BY COALESCE(last_check_at, 0) ASC' - ); - $stmt->execute([':now' => $cutoff]); - $results = []; - foreach ($stmt->fetchAll() as $row) { - $checked = $this->check((string) $row['domain_id'], (string) $row['id']); - if ($checked !== null) { - $results[] = $checked; - } - } - return ['checked' => count($results), 'results' => $results]; + return [ + 'checked' => 0, + 'results' => [], + 'source' => 'edge_observations', + 'core_active_checks' => false, + 'message' => 'Origin routing health is updated from edge metrics. Core checks are diagnostic-only.', + ]; } public function syncFromDnsRecord(string $domainId, array $record): ?array @@ -268,7 +307,7 @@ public function syncFromDnsRecord(string $domainId, array $record): ?array 'preserve_host' => true, 'health_check_enabled' => false, 'source' => 'dns_record', - 'role' => 'origin', + 'role' => 'primary', 'is_primary' => false, 'enabled' => true, 'dns_record_id' => (string) $record['id'], @@ -619,12 +658,18 @@ private function ensureOriginsFromDnsRecords(string $domainId): void private function cast(array $row): array { - foreach (['port', 'weight', 'health_check_interval_seconds', 'health_check_timeout_seconds', 'created_at', 'updated_at'] as $key) { + foreach ([ + 'port', 'weight', 'health_check_interval_seconds', 'health_check_timeout_seconds', + 'connection_timeout_seconds', 'response_timeout_seconds', 'retry_attempts', + 'retry_budget_per_minute', 'circuit_failure_threshold', 'circuit_recovery_seconds', + 'max_concurrent_requests', 'created_at', 'updated_at', + ] as $key) { $row[$key] = (int) $row[$key]; } $row['dns_record_id'] = $row['dns_record_id'] === null ? null : (string) $row['dns_record_id']; $row['source'] = (string) ($row['source'] ?? 'manual'); - $row['role'] = (string) ($row['role'] ?? 'origin'); + $row['role'] = (string) ($row['role'] ?? 'primary'); + $row['load_balancing_algorithm'] = (string) ($row['load_balancing_algorithm'] ?? 'weighted_hash'); $row['host_header'] = (string) ($row['host_header'] ?: $row['host']); $row['sni'] = (string) ($row['sni'] ?? ''); $row['tls_verify'] = (string) ($row['tls_verify'] ?? 'ignore'); @@ -633,11 +678,14 @@ private function cast(array $row): array $row['enabled'] = in_array($row['enabled'], [true, 1, '1', 't', 'true'], true); $row['preserve_host'] = in_array($row['preserve_host'], [true, 1, '1', 't', 'true'], true); $row['health_check_enabled'] = in_array($row['health_check_enabled'] ?? false, [true, 1, '1', 't', 'true'], true); + $row['circuit_breaker_enabled'] = in_array($row['circuit_breaker_enabled'] ?? true, [true, 1, '1', 't', 'true'], true); + $row['drain'] = in_array($row['drain'] ?? false, [true, 1, '1', 't', 'true'], true); + $row['shield_enabled'] = in_array($row['shield_enabled'] ?? false, [true, 1, '1', 't', 'true'], true); return $row; } private function invalidateConfig(): void { - Database::pdo()->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('origins.changed'); } } diff --git a/core/app/Modules/Proxy/Services/TrafficRulesService.php b/core/app/Modules/Proxy/Services/TrafficRulesService.php index cc805ee8..aa5f2697 100644 --- a/core/app/Modules/Proxy/Services/TrafficRulesService.php +++ b/core/app/Modules/Proxy/Services/TrafficRulesService.php @@ -101,10 +101,16 @@ public function createWaf(string $domainId, array $in): array { 'type' => $type, 'pattern' => (string)($in['pattern'] ?? ''), 'action' => $action, + 'challenge_difficulty' => $this->challengeDifficultyValue($in), 'description' => isset($in['description']) ? (string) $in['description'] : null, ] + $this->wafMetadataPayload($in) + $this->managedRulePayload($in)); } - public function updateWaf(string $domainId, string $id, array $in): ?array { return $this->update('waf_rules', $domainId, $id, $in); } + public function updateWaf(string $domainId, string $id, array $in): ?array { + if (array_key_exists('challenge_difficulty', $in)) { + $in['challenge_difficulty'] = $this->challengeDifficultyValue($in); + } + return $this->update('waf_rules', $domainId, $id, $in); + } public function deleteWaf(string $domainId, string $id): bool { return $this->delete('waf_rules', $domainId, $id); } public function listHeaderRules(string $domainId): array { return $this->listRows('domain_header_rules', $domainId, 'priority ASC, created_at ASC'); } @@ -542,10 +548,20 @@ public function getDomainCacheSettings(string $domainId): array { 'static_asset_cache_enabled' => false, 'ignore_query_strings_for_static' => false, 'bypass_logged_in_users' => true, + 'cache_methods_json' => '["GET","HEAD"]', + 'cache_status_code_policy_json' => '{"200":true,"301":true,"302":true,"404":false,"500":false,"502":false,"503":false,"504":false}', + 'bypass_headers_json' => '["authorization"]', + 'bypass_cookies_json' => '["session","auth","wordpress_logged_in","laravel_session"]', + 'vary_headers_json' => '["accept-encoding"]', + 'cache_key_dimensions_json' => '{"scheme":true,"host":true,"path":true,"query":"include_all","headers":["accept-encoding"],"device":false,"country":false,"language":false,"domain_id":true,"rule_version":true}', + 'debug_headers_enabled' => false, + 'stale_while_revalidate_seconds' => 0, + 'negative_ttl_seconds' => 0, + 'max_object_size_bytes' => 104857600, 'created_at' => $now, 'updated_at' => $now, ]; - $ins = Database::pdo()->prepare('INSERT INTO domain_cache_settings (domain_id,enabled,default_edge_ttl_seconds,default_browser_ttl_seconds,cache_query_string_mode,respect_origin_cache_control,cache_authorized_requests,stale_if_error_seconds,static_asset_cache_enabled,ignore_query_strings_for_static,bypass_logged_in_users,created_at,updated_at) VALUES (:domain_id,:enabled,:edge,:browser,:mode,:respect,:authorized,:stale,:static_assets,:ignore_static_query,:bypass_logged_in,:created_at,:updated_at)'); + $ins = Database::pdo()->prepare('INSERT INTO domain_cache_settings (domain_id,enabled,default_edge_ttl_seconds,default_browser_ttl_seconds,cache_query_string_mode,respect_origin_cache_control,cache_authorized_requests,stale_if_error_seconds,static_asset_cache_enabled,ignore_query_strings_for_static,bypass_logged_in_users,cache_methods_json,cache_status_code_policy_json,bypass_headers_json,bypass_cookies_json,vary_headers_json,cache_key_dimensions_json,debug_headers_enabled,stale_while_revalidate_seconds,negative_ttl_seconds,max_object_size_bytes,created_at,updated_at) VALUES (:domain_id,:enabled,:edge,:browser,:mode,:respect,:authorized,:stale,:static_assets,:ignore_static_query,:bypass_logged_in,:methods,:status_policy,:bypass_headers,:bypass_cookies,:vary_headers,:key_dimensions,:debug_headers,:swr,:negative_ttl,:max_object,:created_at,:updated_at)'); $ins->execute([ ':domain_id' => $domainId, ':enabled' => 1, @@ -558,6 +574,16 @@ public function getDomainCacheSettings(string $domainId): array { ':static_assets' => 0, ':ignore_static_query' => 0, ':bypass_logged_in' => 1, + ':methods' => $defaults['cache_methods_json'], + ':status_policy' => $defaults['cache_status_code_policy_json'], + ':bypass_headers' => $defaults['bypass_headers_json'], + ':bypass_cookies' => $defaults['bypass_cookies_json'], + ':vary_headers' => $defaults['vary_headers_json'], + ':key_dimensions' => $defaults['cache_key_dimensions_json'], + ':debug_headers' => 0, + ':swr' => 0, + ':negative_ttl' => 0, + ':max_object' => 104857600, ':created_at' => $now, ':updated_at' => $now, ]); @@ -577,13 +603,31 @@ public function setDomainCacheSettings(string $domainId, array $in): array { ':static_assets' => (int) ($in['static_asset_cache_enabled'] ?? $existing['static_asset_cache_enabled']), ':ignore_static_query' => (int) ($in['ignore_query_strings_for_static'] ?? $existing['ignore_query_strings_for_static']), ':bypass_logged_in' => (int) ($in['bypass_logged_in_users'] ?? $existing['bypass_logged_in_users']), + ':methods' => $this->jsonSetting($in, $existing, 'cache_methods_json', 'cache_methods'), + ':status_policy' => $this->jsonSetting($in, $existing, 'cache_status_code_policy_json', 'cache_status_code_policy'), + ':bypass_headers' => $this->jsonSetting($in, $existing, 'bypass_headers_json', 'bypass_headers'), + ':bypass_cookies' => $this->jsonSetting($in, $existing, 'bypass_cookies_json', 'bypass_cookies'), + ':vary_headers' => $this->jsonSetting($in, $existing, 'vary_headers_json', 'vary_headers'), + ':key_dimensions' => $this->jsonSetting($in, $existing, 'cache_key_dimensions_json', 'cache_key_dimensions'), + ':debug_headers' => (int) ($in['debug_headers_enabled'] ?? $existing['debug_headers_enabled']), + ':swr' => (int) ($in['stale_while_revalidate_seconds'] ?? $existing['stale_while_revalidate_seconds']), + ':negative_ttl' => (int) ($in['negative_ttl_seconds'] ?? $existing['negative_ttl_seconds']), + ':max_object' => (int) ($in['max_object_size_bytes'] ?? $existing['max_object_size_bytes']), ':updated_at' => time(), ]; - $u = Database::pdo()->prepare('UPDATE domain_cache_settings SET enabled=:enabled,default_edge_ttl_seconds=:edge,default_browser_ttl_seconds=:browser,cache_query_string_mode=:mode,respect_origin_cache_control=:respect,cache_authorized_requests=:authorized,stale_if_error_seconds=:stale,static_asset_cache_enabled=:static_assets,ignore_query_strings_for_static=:ignore_static_query,bypass_logged_in_users=:bypass_logged_in,updated_at=:updated_at WHERE domain_id=:domain_id'); + $u = Database::pdo()->prepare('UPDATE domain_cache_settings SET enabled=:enabled,default_edge_ttl_seconds=:edge,default_browser_ttl_seconds=:browser,cache_query_string_mode=:mode,respect_origin_cache_control=:respect,cache_authorized_requests=:authorized,stale_if_error_seconds=:stale,static_asset_cache_enabled=:static_assets,ignore_query_strings_for_static=:ignore_static_query,bypass_logged_in_users=:bypass_logged_in,cache_methods_json=:methods,cache_status_code_policy_json=:status_policy,bypass_headers_json=:bypass_headers,bypass_cookies_json=:bypass_cookies,vary_headers_json=:vary_headers,cache_key_dimensions_json=:key_dimensions,debug_headers_enabled=:debug_headers,stale_while_revalidate_seconds=:swr,negative_ttl_seconds=:negative_ttl,max_object_size_bytes=:max_object,updated_at=:updated_at WHERE domain_id=:domain_id'); $u->execute($payload); $this->invalidateConfigSnapshot(); return $this->getDomainCacheSettings($domainId); } + private function jsonSetting(array $input, array $existing, string $key, string $decodedKey): string { + $value = array_key_exists($key, $input) ? $input[$key] : (array_key_exists($decodedKey, $input) ? $input[$decodedKey] : ($existing[$key] ?? ($existing[$decodedKey] ?? null))); + if (is_string($value)) { + json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $value : json_encode($existing[$key] ?? null, JSON_UNESCAPED_SLASHES); + } + return json_encode($value, JSON_UNESCAPED_SLASHES); + } public function createCachePurgeRequest(string $domainId, array $in): array { $type = (string) ($in['type'] ?? 'domain'); $value = array_key_exists('value', $in) ? (string) $in['value'] : null; @@ -711,6 +755,48 @@ public function updateRateLimit(string $domainId, string $id, array $in): ?array public function deleteRateLimit(string $domainId, string $id): bool { return $this->delete('rate_limit_rules', $domainId, $id); } + public function getWaitingRoomPolicy(string $domainId): array { + $s = Database::pdo()->prepare('SELECT * FROM waiting_room_policies WHERE domain_id=:domain_id LIMIT 1'); + $s->execute([':domain_id' => $domainId]); + $row = $s->fetch(); + if ($row) { + return $this->cast((array) $row); + } + return $this->createWaitingRoomPolicy($domainId, []); + } + public function updateWaitingRoomPolicy(string $domainId, array $in): array { + $current = $this->getWaitingRoomPolicy($domainId); + $payload = $this->waitingRoomPayload($in, true); + if ($payload === []) { + return $current; + } + $updated = $this->update('waiting_room_policies', $domainId, (string) $current['id'], $payload); + return $updated ?? $this->getWaitingRoomPolicy($domainId); + } + public function activateWaitingRoomEmergency(string $domainId, int $ttlSeconds, string $reason = 'manual_emergency'): array { + $until = time() + max(60, min(86400, $ttlSeconds)); + return $this->updateWaitingRoomPolicy($domainId, [ + 'enabled' => true, + 'mode' => 'manual', + 'state' => 'manual_emergency', + 'reason' => $reason, + 'manual_override_until' => $until, + ]); + } + public function deactivateWaitingRoomEmergency(string $domainId): array { + return $this->updateWaitingRoomPolicy($domainId, [ + 'mode' => 'monitoring', + 'state' => 'healthy', + 'reason' => 'manual_deactivation', + 'manual_override_until' => null, + ]); + } + public function listWaitingRoomPoliciesForConfig(string $domainId): array { + $s = Database::pdo()->prepare('SELECT * FROM waiting_room_policies WHERE domain_id=:domain_id AND enabled=true LIMIT 1'); + $s->execute([':domain_id' => $domainId]); + $row = $s->fetch(); + return $row ? [$this->cast((array) $row)] : []; + } public function dryRunRateLimit(string $domainId, array $in): array { $payload = $this->rateLimitPayload($in); $pathPrefix = (string) ($payload['path_prefix'] ?? '/'); @@ -735,6 +821,7 @@ public function dryRunRateLimit(string $domainId, array $in): array { 'key_header_name' => $payload['key_header_name'] ?? null, 'requests_per_minute' => (int) ($payload['requests_per_minute'] ?? 60), 'action' => (string) ($payload['action'] ?? 'block'), + 'challenge_difficulty' => $payload['challenge_difficulty'] ?? null, ], 'preview_impact' => [ 'lookback_seconds' => 86400, @@ -1100,6 +1187,9 @@ public function undoProtectionIntent(string $domainId, string $intentId): ?array } private function listRows(string $table, string $domainId, string $orderBy = 'created_at ASC'): array { $s=Database::pdo()->prepare("SELECT * FROM {$table} WHERE domain_id=:domain_id ORDER BY {$orderBy}"); $s->execute([':domain_id'=>$domainId]); return array_map([$this,'cast'], $s->fetchAll()); } + private function createWaitingRoomPolicy(string $domainId, array $in): array { + return $this->insert('waiting_room_policies', $domainId, $this->waitingRoomPayload($in)); + } private function insert(string $table, string $domainId, array $in): array { $id=Uuid::v4(); $now=time(); $cols=array_keys($in); $names=implode(',', $cols); $bind=':'.implode(',:', $cols); $sql="INSERT INTO {$table} (id,domain_id,{$names},created_at,updated_at) VALUES (:id,:domain_id,{$bind},:created_at,:updated_at)"; @@ -1133,7 +1223,7 @@ private function delete(string $table, string $domainId, string $id): bool { return false; } private function invalidateConfigSnapshot(): void { - Database::pdo()->exec('UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1'); + ConfigService::markDirty('traffic_rules.changed'); } private function managedWafModes(): array { return [ @@ -1320,12 +1410,12 @@ private function protectionIntentTemplates(): array { ], 'emergency_protection' => [ 'name' => 'Emergency Protection', - 'summary' => 'Temporarily tightens site-wide limits and blocks common incident scanner patterns.', + 'summary' => 'Temporarily challenges site-wide traffic before origin routing and blocks common incident scanner patterns.', 'risk' => 'risky', 'mode' => 'confirm_first', 'rules' => [ - ['rule_table' => 'rate_limit_rules', 'template_key' => 'rate_emergency_sitewide', 'payload' => ['enabled' => true, 'priority' => 5, 'path_prefix' => '/', 'key_type' => 'ip', 'requests_per_minute' => 120, 'action' => 'block']], - ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_scanners', 'payload' => ['enabled' => true, 'name' => 'Block emergency scanner traffic', 'priority' => 6, 'type' => 'user_agent_contains', 'pattern' => 'nikto', 'action' => 'block', 'description' => 'Generated by Emergency Protection for active incident response.', 'waf_group_id' => 'scanner_recon', 'waf_severity' => 'high', 'waf_confidence' => 'high', 'waf_safe_reason' => 'Emergency mode blocks a known scanner user agent and remains undoable.']], + ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_scanners', 'payload' => ['enabled' => true, 'name' => 'Block emergency scanner traffic', 'priority' => 4, 'type' => 'user_agent_contains', 'pattern' => 'nikto', 'action' => 'block', 'description' => 'Generated by Emergency Protection for active incident response.', 'waf_group_id' => 'scanner_recon', 'waf_severity' => 'high', 'waf_confidence' => 'high', 'waf_safe_reason' => 'Emergency mode blocks a known scanner user agent and remains undoable.']], + ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_sitewide_challenge', 'payload' => ['enabled' => true, 'name' => 'Challenge all visitors during attack', 'priority' => 5, 'type' => 'path_prefix', 'pattern' => '/', 'action' => 'challenge', 'description' => 'Generated by Emergency Protection to verify browsers before origin routing during an active attack.', 'waf_group_id' => 'emergency_challenge', 'waf_severity' => 'medium', 'waf_confidence' => 'medium', 'waf_safe_reason' => 'Applies temporary friction to every path until the profile is disabled.']], ], ], 'static_asset_performance' => [ @@ -1686,6 +1776,7 @@ private function rateLimitPayload(array $in, bool $partial = false): array { 'key_header_name' => null, 'requests_per_minute' => 60, 'action' => 'block', + 'challenge_difficulty' => null, ]; $payload = []; foreach ($defaults as $key => $default) { @@ -1693,6 +1784,8 @@ private function rateLimitPayload(array $in, bool $partial = false): array { $value = $in[$key] ?? $default; if ($key === 'key_header_name' && ($value === null || trim((string) $value) === '')) { $payload[$key] = null; + } elseif ($key === 'challenge_difficulty') { + $payload[$key] = $this->challengeDifficultyValue([$key => $value]); } else { $payload[$key] = $key === 'enabled' ? !empty($value) : ($key === 'priority' || $key === 'requests_per_minute' ? (int) $value : (string) $value); } @@ -1700,7 +1793,48 @@ private function rateLimitPayload(array $in, bool $partial = false): array { } return $payload + $this->managedRulePayload($in); } - private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=((int)$r[$b])===1;}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','last_generated_at','last_applied_at'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } unset($r['private_key_pem']); return $r; } + private function challengeDifficultyValue(array $in): ?int { + if (!array_key_exists('challenge_difficulty', $in) || $in['challenge_difficulty'] === null || $in['challenge_difficulty'] === '') { + return null; + } + $difficulty = (int) $in['challenge_difficulty']; + if ($difficulty < 1 || $difficulty > 6) { + throw new \InvalidArgumentException('invalid_challenge_difficulty'); + } + return $difficulty; + } + private function waitingRoomPayload(array $in, bool $partial = false): array { + $defaults = [ + 'enabled' => false, 'mode' => 'monitoring', 'state' => 'disabled', 'reason' => null, + 'rps_threshold' => 100, 'active_origin_threshold' => 20, 'origin_latency_ms_threshold' => 3000, + 'origin_error_rate_threshold' => 50, 'admission_rate_per_minute' => 60, 'queue_limit' => 1000, + 'per_client_ticket_limit' => 3, 'ticket_ttl_seconds' => 300, 'admission_ttl_seconds' => 900, + 'status_poll_seconds' => 5, 'jitter_seconds' => 4, 'unhealthy_windows' => 3, 'healthy_windows' => 3, + 'minimum_state_seconds' => 60, 'recovery_ramp_percent' => 25, 'manual_override_until' => null, + 'trusted_cidrs_json' => '[]', 'waiting_room_title' => 'Traffic is high', + 'waiting_room_message' => 'You are in a short waiting room while this site protects its origin.', + 'counters_json' => '{}', + ]; + $payload = $partial ? [] : $defaults; + foreach ($defaults as $key => $default) { + if (!array_key_exists($key, $in)) { + continue; + } + $value = $in[$key]; + if ($key === 'trusted_cidrs_json' && is_array($value)) { + $value = json_encode(array_values(array_slice($value, 0, 100)), JSON_UNESCAPED_SLASHES); + } + if ($key === 'counters_json' && is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES); + } + $payload[$key] = $value; + } + if (array_key_exists('trusted_cidrs', $in)) { + $payload['trusted_cidrs_json'] = json_encode(array_values(array_slice((array) $in['trusted_cidrs'], 0, 100)), JSON_UNESCAPED_SLASHES); + } + return $payload; + } + private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'debug_headers_enabled', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=in_array($r[$b], [true, 1, '1', 't', 'true'], true);}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','stale_while_revalidate_seconds','negative_ttl_seconds','max_object_size_bytes','last_generated_at','last_applied_at','challenge_difficulty','rps_threshold','active_origin_threshold','origin_latency_ms_threshold','origin_error_rate_threshold','admission_rate_per_minute','queue_limit','per_client_ticket_limit','ticket_ttl_seconds','admission_ttl_seconds','status_poll_seconds','jitter_seconds','unhealthy_windows','healthy_windows','minimum_state_seconds','recovery_ramp_percent','manual_override_until'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} foreach(['cache_methods_json'=>'cache_methods','cache_status_code_policy_json'=>'cache_status_code_policy','bypass_headers_json'=>'bypass_headers','bypass_cookies_json'=>'bypass_cookies','vary_headers_json'=>'vary_headers','cache_key_dimensions_json'=>'cache_key_dimensions'] as $jsonKey => $outKey) { if (array_key_exists($jsonKey, $r)) { $r[$outKey] = json_decode((string) $r[$jsonKey], true) ?: []; } } if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } if (array_key_exists('trusted_cidrs_json', $r)) { $r['trusted_cidrs'] = json_decode((string) $r['trusted_cidrs_json'], true) ?: []; unset($r['trusted_cidrs_json']); } if (array_key_exists('counters_json', $r)) { $r['counters'] = json_decode((string) $r['counters_json'], true) ?: []; unset($r['counters_json']); } unset($r['private_key_pem']); return $r; } private function castSslJob(array $r): array { foreach (['created_at', 'updated_at', 'finished_at', 'progress_percent'] as $i) { if (isset($r[$i]) && $r[$i] !== null) { diff --git a/core/app/Modules/Reports/Services/ReportService.php b/core/app/Modules/Reports/Services/ReportService.php index 2430ef6a..f03b68fb 100644 --- a/core/app/Modules/Reports/Services/ReportService.php +++ b/core/app/Modules/Reports/Services/ReportService.php @@ -399,11 +399,18 @@ private function warnings(array $range, array $kpis): array ['key' => 'failed_jobs', 'severity' => 'critical', 'message' => 'failed jobs in range', 'link' => '/jobs'], ['key' => 'pending_dns_changes', 'severity' => 'warning', 'message' => 'pending DNS changes', 'link' => '/dns-operations'], ['key' => 'ssl_expiring_count', 'severity' => 'warning', 'message' => 'certificates expiring within 30 days', 'link' => '/domains'], - ['key' => 'origin_errors', 'severity' => 'warning', 'message' => 'origin or edge errors in range', 'link' => '/usage'], + ['key' => 'origin_errors', 'severity' => 'warning', 'message' => 'origin or edge errors in range', 'link' => '/#recent-problem-requests', 'section' => 'recent_problem_requests'], ] as $rule) { $count = (int) ($kpis[$rule['key']] ?? 0); if ($count > 0) { - $warnings[] = ['severity' => $rule['severity'], 'message' => $count . ' ' . $rule['message'], 'link' => $rule['link'], 'count' => $count]; + $warnings[] = [ + 'key' => $rule['key'], + 'severity' => $rule['severity'], + 'message' => $count . ' ' . $rule['message'], + 'link' => $rule['link'], + 'section' => $rule['section'] ?? null, + 'count' => $count, + ]; } } usort($warnings, static fn (array $a, array $b): int => ['critical' => 0, 'warning' => 1, 'info' => 2][$a['severity']] <=> ['critical' => 0, 'warning' => 1, 'info' => 2][$b['severity']]); diff --git a/core/app/Support/DatabaseMigrator.php b/core/app/Support/DatabaseMigrator.php index e686c158..5d9e7853 100644 --- a/core/app/Support/DatabaseMigrator.php +++ b/core/app/Support/DatabaseMigrator.php @@ -78,7 +78,7 @@ public function migrate(bool $dryRun = false): array try { $this->pdo->beginTransaction(); $this->recordMigration($migration, false, null, null, $startedAt); - $this->pdo->exec($migration['sql']); + $this->pdo->exec($this->sqlForExecution($migration)); $executionMs = max(0, (int) round((microtime(true) - $startedMs) * 1000)); $this->recordMigration($migration, true, null, $executionMs, $startedAt); $this->pdo->commit(); @@ -188,6 +188,55 @@ private function migrationFiles(): array return $migrations; } + private function sqlForExecution(array $migration): string + { + if (($migration['version'] ?? '') !== '000031') { + return (string) $migration['sql']; + } + + $legacyConstraintBlock = "ALTER TABLE domain_origins + ADD CONSTRAINT domain_origins_load_balancing_algorithm_check CHECK (load_balancing_algorithm IN ('weighted_hash', 'consistent_hash')), + ADD CONSTRAINT domain_origins_connection_timeout_seconds_check CHECK (connection_timeout_seconds BETWEEN 1 AND 60), + ADD CONSTRAINT domain_origins_response_timeout_seconds_check CHECK (response_timeout_seconds BETWEEN 1 AND 600), + ADD CONSTRAINT domain_origins_retry_attempts_check CHECK (retry_attempts BETWEEN 0 AND 3), + ADD CONSTRAINT domain_origins_retry_budget_per_minute_check CHECK (retry_budget_per_minute BETWEEN 0 AND 100000), + ADD CONSTRAINT domain_origins_circuit_failure_threshold_check CHECK (circuit_failure_threshold BETWEEN 1 AND 1000), + ADD CONSTRAINT domain_origins_circuit_recovery_seconds_check CHECK (circuit_recovery_seconds BETWEEN 1 AND 3600), + ADD CONSTRAINT domain_origins_max_concurrent_requests_check CHECK (max_concurrent_requests BETWEEN 0 AND 1000000);"; + + $idempotentConstraintBlock = "DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_load_balancing_algorithm_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_load_balancing_algorithm_check CHECK (load_balancing_algorithm IN ('weighted_hash', 'consistent_hash')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_connection_timeout_seconds_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_connection_timeout_seconds_check CHECK (connection_timeout_seconds BETWEEN 1 AND 60); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_response_timeout_seconds_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_response_timeout_seconds_check CHECK (response_timeout_seconds BETWEEN 1 AND 600); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_retry_attempts_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_retry_attempts_check CHECK (retry_attempts BETWEEN 0 AND 3); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_retry_budget_per_minute_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_retry_budget_per_minute_check CHECK (retry_budget_per_minute BETWEEN 0 AND 100000); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_circuit_failure_threshold_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_circuit_failure_threshold_check CHECK (circuit_failure_threshold BETWEEN 1 AND 1000); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_circuit_recovery_seconds_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_circuit_recovery_seconds_check CHECK (circuit_recovery_seconds BETWEEN 1 AND 3600); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'domain_origins_max_concurrent_requests_check' AND conrelid = 'domain_origins'::regclass) THEN + ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_max_concurrent_requests_check CHECK (max_concurrent_requests BETWEEN 0 AND 1000000); + END IF; +END $$;"; + + // Preserve the historical migration checksum while making legacy schema + // adoption idempotent when the fresh schema already created these checks. + return str_replace($legacyConstraintBlock, $idempotentConstraintBlock, (string) $migration['sql']); + } + private function appliedMigrations(): array { $rows = $this->pdo diff --git a/core/artisan b/core/artisan index b56267fd..cee191f2 100755 --- a/core/artisan +++ b/core/artisan @@ -73,6 +73,7 @@ use App\Console\Commands\CdnPowerDnsDoctorCommand; use App\Console\Commands\CdnPowerDnsDryRunCommand; use App\Console\Commands\CdnPowerDnsForceSyncCommand; use App\Console\Commands\CdnRecommendationsGenerateCommand; +use App\Console\Commands\CdnConfigSnapshotsPruneCommand; $runner = new CommandRunner(); $runner->register('cdn:admin:create', new CdnAdminCreateCommand()); @@ -101,6 +102,7 @@ $runner->register('cdn:edge:disable', new CdnEdgeDisableCommand()); $runner->register('cdn:edge:register-token', new CdnEdgeRegisterTokenCommand()); $runner->register('cdn:edge:rotate-token', new CdnEdgeRotateTokenCommand()); $runner->register('cdn:edge:sync-config', new CdnEdgeSyncConfigCommand()); +$runner->register('cdn:config-snapshots:prune', new CdnConfigSnapshotsPruneCommand()); $runner->register('cdn:usage:summary', new CdnUsageSummaryCommand()); $runner->register('cdn:usage:recalculate', new CdnUsageRecalculateCommand()); $runner->register('cdn:usage:ingest', new CdnUsageIngestCommand()); diff --git a/core/database/migrations/000001_baseline_schema.sql b/core/database/migrations/000001_baseline_schema.sql index 91ac0f70..b2e2077f 100644 --- a/core/database/migrations/000001_baseline_schema.sql +++ b/core/database/migrations/000001_baseline_schema.sql @@ -520,6 +520,7 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( key_type TEXT NOT NULL DEFAULT 'ip', requests_per_minute INTEGER NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); @@ -533,6 +534,7 @@ CREATE TABLE IF NOT EXISTS waf_rules ( type TEXT NOT NULL, pattern TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), description TEXT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, diff --git a/core/database/migrations/000028_challenge_difficulty.sql b/core/database/migrations/000028_challenge_difficulty.sql new file mode 100644 index 00000000..bebb42d4 --- /dev/null +++ b/core/database/migrations/000028_challenge_difficulty.sql @@ -0,0 +1,19 @@ +ALTER TABLE waf_rules + ADD COLUMN IF NOT EXISTS challenge_difficulty INTEGER NULL; + +ALTER TABLE rate_limit_rules + ADD COLUMN IF NOT EXISTS challenge_difficulty INTEGER NULL; + +ALTER TABLE waf_rules + DROP CONSTRAINT IF EXISTS waf_rules_challenge_difficulty_range; + +ALTER TABLE waf_rules + ADD CONSTRAINT waf_rules_challenge_difficulty_range + CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)); + +ALTER TABLE rate_limit_rules + DROP CONSTRAINT IF EXISTS rate_limit_rules_challenge_difficulty_range; + +ALTER TABLE rate_limit_rules + ADD CONSTRAINT rate_limit_rules_challenge_difficulty_range + CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)); diff --git a/core/database/migrations/000029_waiting_room.sql b/core/database/migrations/000029_waiting_room.sql new file mode 100644 index 00000000..90723e0e --- /dev/null +++ b/core/database/migrations/000029_waiting_room.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS waiting_room_policies ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL UNIQUE REFERENCES domains(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode TEXT NOT NULL DEFAULT 'monitoring', + state TEXT NOT NULL DEFAULT 'disabled', + reason TEXT NULL, + rps_threshold INTEGER NOT NULL DEFAULT 100, + active_origin_threshold INTEGER NOT NULL DEFAULT 20, + origin_latency_ms_threshold INTEGER NOT NULL DEFAULT 3000, + origin_error_rate_threshold INTEGER NOT NULL DEFAULT 50, + admission_rate_per_minute INTEGER NOT NULL DEFAULT 60, + queue_limit INTEGER NOT NULL DEFAULT 1000, + per_client_ticket_limit INTEGER NOT NULL DEFAULT 3, + ticket_ttl_seconds INTEGER NOT NULL DEFAULT 300, + admission_ttl_seconds INTEGER NOT NULL DEFAULT 900, + status_poll_seconds INTEGER NOT NULL DEFAULT 5, + jitter_seconds INTEGER NOT NULL DEFAULT 4, + unhealthy_windows INTEGER NOT NULL DEFAULT 3, + healthy_windows INTEGER NOT NULL DEFAULT 3, + minimum_state_seconds INTEGER NOT NULL DEFAULT 60, + recovery_ramp_percent INTEGER NOT NULL DEFAULT 25, + manual_override_until BIGINT NULL, + trusted_cidrs_json JSONB NOT NULL DEFAULT '[]'::jsonb, + waiting_room_title TEXT NOT NULL DEFAULT 'Traffic is high', + waiting_room_message TEXT NOT NULL DEFAULT 'You are in a short waiting room while this site protects its origin.', + counters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK (mode IN ('monitoring','automatic','manual')), + CHECK (state IN ('disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency')), + CHECK (rps_threshold BETWEEN 1 AND 1000000), + CHECK (active_origin_threshold BETWEEN 1 AND 1000000), + CHECK (origin_latency_ms_threshold BETWEEN 1 AND 600000), + CHECK (origin_error_rate_threshold BETWEEN 1 AND 100), + CHECK (admission_rate_per_minute BETWEEN 1 AND 1000000), + CHECK (queue_limit BETWEEN 1 AND 1000000), + CHECK (per_client_ticket_limit BETWEEN 1 AND 1000), + CHECK (ticket_ttl_seconds BETWEEN 30 AND 3600), + CHECK (admission_ttl_seconds BETWEEN 60 AND 86400), + CHECK (status_poll_seconds BETWEEN 2 AND 300), + CHECK (jitter_seconds BETWEEN 0 AND 300), + CHECK (unhealthy_windows BETWEEN 1 AND 100), + CHECK (healthy_windows BETWEEN 1 AND 100), + CHECK (minimum_state_seconds BETWEEN 1 AND 86400), + CHECK (recovery_ramp_percent BETWEEN 1 AND 100) +); diff --git a/core/database/migrations/000030_phase6_cache_correctness.sql b/core/database/migrations/000030_phase6_cache_correctness.sql new file mode 100644 index 00000000..e5160bb3 --- /dev/null +++ b/core/database/migrations/000030_phase6_cache_correctness.sql @@ -0,0 +1,22 @@ +-- Phase 6 cache correctness controls. These fields make cache eligibility, +-- key variation, stale behavior, and debug output explicit in snapshots. +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS cache_methods_json TEXT NOT NULL DEFAULT '["GET","HEAD"]'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS cache_status_code_policy_json TEXT NOT NULL DEFAULT '{"200":true,"301":true,"302":true,"404":false,"500":false,"502":false,"503":false,"504":false}'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS bypass_headers_json TEXT NOT NULL DEFAULT '["authorization"]'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS bypass_cookies_json TEXT NOT NULL DEFAULT '["session","auth","wordpress_logged_in","laravel_session"]'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS vary_headers_json TEXT NOT NULL DEFAULT '["accept-encoding"]'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS cache_key_dimensions_json TEXT NOT NULL DEFAULT '{"scheme":true,"host":true,"path":true,"query":"include_all","headers":["accept-encoding"],"device":false,"country":false,"language":false,"domain_id":true,"rule_version":true}'; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS debug_headers_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS stale_while_revalidate_seconds INTEGER NOT NULL DEFAULT 0; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS negative_ttl_seconds INTEGER NOT NULL DEFAULT 0; +ALTER TABLE domain_cache_settings + ADD COLUMN IF NOT EXISTS max_object_size_bytes BIGINT NOT NULL DEFAULT 104857600; diff --git a/core/database/migrations/000031_phase7_origin_resilience.sql b/core/database/migrations/000031_phase7_origin_resilience.sql new file mode 100644 index 00000000..c4db97e5 --- /dev/null +++ b/core/database/migrations/000031_phase7_origin_resilience.sql @@ -0,0 +1,60 @@ +ALTER TABLE domain_origins + ADD COLUMN IF NOT EXISTS load_balancing_algorithm TEXT NOT NULL DEFAULT 'weighted_hash', + ADD COLUMN IF NOT EXISTS connection_timeout_seconds INTEGER NOT NULL DEFAULT 5, + ADD COLUMN IF NOT EXISTS response_timeout_seconds INTEGER NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS retry_attempts INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS retry_budget_per_minute INTEGER NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS circuit_breaker_enabled BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, + ADD COLUMN IF NOT EXISTS circuit_recovery_seconds INTEGER NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS max_concurrent_requests INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS drain BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS shield_enabled BOOLEAN NOT NULL DEFAULT false; + +UPDATE domain_origins SET role = 'primary' WHERE role = 'origin'; + +ALTER TABLE domain_origins ALTER COLUMN role SET DEFAULT 'primary'; + +ALTER TABLE domain_origins + DROP CONSTRAINT IF EXISTS domain_origins_role_check, + ADD CONSTRAINT domain_origins_role_check CHECK (role IN ('primary', 'backup', 'shield')); + +ALTER TABLE domain_origins + ADD CONSTRAINT domain_origins_load_balancing_algorithm_check CHECK (load_balancing_algorithm IN ('weighted_hash', 'consistent_hash')), + ADD CONSTRAINT domain_origins_connection_timeout_seconds_check CHECK (connection_timeout_seconds BETWEEN 1 AND 60), + ADD CONSTRAINT domain_origins_response_timeout_seconds_check CHECK (response_timeout_seconds BETWEEN 1 AND 600), + ADD CONSTRAINT domain_origins_retry_attempts_check CHECK (retry_attempts BETWEEN 0 AND 3), + ADD CONSTRAINT domain_origins_retry_budget_per_minute_check CHECK (retry_budget_per_minute BETWEEN 0 AND 100000), + ADD CONSTRAINT domain_origins_circuit_failure_threshold_check CHECK (circuit_failure_threshold BETWEEN 1 AND 1000), + ADD CONSTRAINT domain_origins_circuit_recovery_seconds_check CHECK (circuit_recovery_seconds BETWEEN 1 AND 3600), + ADD CONSTRAINT domain_origins_max_concurrent_requests_check CHECK (max_concurrent_requests BETWEEN 0 AND 1000000); + +CREATE INDEX IF NOT EXISTS domain_origins_phase7_routing_idx + ON domain_origins (domain_id, enabled, role, drain, health_status, weight, id); + +CREATE TABLE IF NOT EXISTS origin_health_observations ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + origin_id TEXT NOT NULL REFERENCES domain_origins(id) ON DELETE CASCADE, + edge_node_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + reason TEXT NULL, + upstream_status TEXT NULL, + latency_ms INTEGER NULL, + jitter_ms INTEGER NULL, + sample_count INTEGER NOT NULL DEFAULT 1, + first_observed_at BIGINT NOT NULL, + last_observed_at BIGINT NOT NULL, + last_success_at BIGINT NULL, + last_failure_at BIGINT NULL, + CHECK (status IN ('healthy', 'unhealthy', 'slow', 'unknown')), + CHECK (sample_count BETWEEN 1 AND 1000000000), + CHECK (latency_ms IS NULL OR latency_ms BETWEEN 0 AND 600000), + CHECK (jitter_ms IS NULL OR jitter_ms BETWEEN 0 AND 600000) +); + +CREATE UNIQUE INDEX IF NOT EXISTS origin_health_observations_edge_origin_idx + ON origin_health_observations (domain_id, origin_id, edge_node_id); + +CREATE INDEX IF NOT EXISTS origin_health_observations_domain_status_idx + ON origin_health_observations (domain_id, status, last_observed_at DESC); diff --git a/core/database/migrations/000032_config_snapshot_materialized_cache.sql b/core/database/migrations/000032_config_snapshot_materialized_cache.sql new file mode 100644 index 00000000..3134ab40 --- /dev/null +++ b/core/database/migrations/000032_config_snapshot_materialized_cache.sql @@ -0,0 +1,5 @@ +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS dirty BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS dirty_at BIGINT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS published_at BIGINT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS last_publish_error TEXT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS publishing_started_at BIGINT NULL; diff --git a/core/database/schema.sql b/core/database/schema.sql index 910f4704..81db90e4 100644 --- a/core/database/schema.sql +++ b/core/database/schema.sql @@ -211,8 +211,9 @@ CREATE TABLE IF NOT EXISTS domain_origins ( domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, dns_record_id TEXT NULL REFERENCES dns_records(id) ON DELETE CASCADE, source TEXT NOT NULL DEFAULT 'manual', - role TEXT NOT NULL DEFAULT 'origin', + role TEXT NOT NULL DEFAULT 'primary', weight INTEGER NOT NULL DEFAULT 1, + load_balancing_algorithm TEXT NOT NULL DEFAULT 'weighted_hash', scheme TEXT NOT NULL DEFAULT 'http', host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 80, @@ -225,6 +226,16 @@ CREATE TABLE IF NOT EXISTS domain_origins ( health_check_path TEXT NOT NULL DEFAULT '/', health_check_interval_seconds INTEGER NOT NULL DEFAULT 30, health_check_timeout_seconds INTEGER NOT NULL DEFAULT 5, + connection_timeout_seconds INTEGER NOT NULL DEFAULT 5, + response_timeout_seconds INTEGER NOT NULL DEFAULT 30, + retry_attempts INTEGER NOT NULL DEFAULT 1, + retry_budget_per_minute INTEGER NOT NULL DEFAULT 60, + circuit_breaker_enabled BOOLEAN NOT NULL DEFAULT true, + circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, + circuit_recovery_seconds INTEGER NOT NULL DEFAULT 30, + max_concurrent_requests INTEGER NOT NULL DEFAULT 0, + drain BOOLEAN NOT NULL DEFAULT false, + shield_enabled BOOLEAN NOT NULL DEFAULT false, health_status TEXT NOT NULL DEFAULT 'unknown', last_check_at BIGINT NULL, last_error TEXT NULL, @@ -234,9 +245,17 @@ CREATE TABLE IF NOT EXISTS domain_origins ( CHECK (scheme IN ('http', 'https')), CHECK (port IN (80, 443)), CHECK (source IN ('manual', 'dns_record', 'imported')), - CHECK (role IN ('origin')), + CHECK (role IN ('primary', 'backup', 'shield')), + CHECK (load_balancing_algorithm IN ('weighted_hash', 'consistent_hash')), CHECK (tls_verify IN ('verify', 'ignore')), CHECK (weight BETWEEN 1 AND 10000), + CHECK (connection_timeout_seconds BETWEEN 1 AND 60), + CHECK (response_timeout_seconds BETWEEN 1 AND 600), + CHECK (retry_attempts BETWEEN 0 AND 3), + CHECK (retry_budget_per_minute BETWEEN 0 AND 100000), + CHECK (circuit_failure_threshold BETWEEN 1 AND 1000), + CHECK (circuit_recovery_seconds BETWEEN 1 AND 3600), + CHECK (max_concurrent_requests BETWEEN 0 AND 1000000), CHECK (health_status IN ('healthy', 'unhealthy', 'unknown')) ); @@ -247,6 +266,36 @@ CREATE INDEX IF NOT EXISTS domain_origins_dns_record_idx CREATE INDEX IF NOT EXISTS domain_origins_domain_source_idx ON domain_origins (domain_id, source, enabled); +CREATE INDEX IF NOT EXISTS domain_origins_phase7_routing_idx + ON domain_origins (domain_id, enabled, role, drain, health_status, weight, id); + +CREATE TABLE IF NOT EXISTS origin_health_observations ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + origin_id TEXT NOT NULL REFERENCES domain_origins(id) ON DELETE CASCADE, + edge_node_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + reason TEXT NULL, + upstream_status TEXT NULL, + latency_ms INTEGER NULL, + jitter_ms INTEGER NULL, + sample_count INTEGER NOT NULL DEFAULT 1, + first_observed_at BIGINT NOT NULL, + last_observed_at BIGINT NOT NULL, + last_success_at BIGINT NULL, + last_failure_at BIGINT NULL, + CHECK (status IN ('healthy', 'unhealthy', 'slow', 'unknown')), + CHECK (sample_count BETWEEN 1 AND 1000000000), + CHECK (latency_ms IS NULL OR latency_ms BETWEEN 0 AND 600000), + CHECK (jitter_ms IS NULL OR jitter_ms BETWEEN 0 AND 600000) +); + +CREATE UNIQUE INDEX IF NOT EXISTS origin_health_observations_edge_origin_idx + ON origin_health_observations (domain_id, origin_id, edge_node_id); + +CREATE INDEX IF NOT EXISTS origin_health_observations_domain_status_idx + ON origin_health_observations (domain_id, status, last_observed_at DESC); + CREATE TABLE IF NOT EXISTS dns_desired_generations ( id BIGSERIAL PRIMARY KEY, desired_hash TEXT NOT NULL UNIQUE, @@ -771,12 +820,22 @@ CREATE TABLE IF NOT EXISTS config_state ( id SMALLINT PRIMARY KEY, version BIGINT NOT NULL, active_snapshot_version BIGINT NULL, + dirty BOOLEAN NOT NULL DEFAULT true, + dirty_at BIGINT NULL, + published_at BIGINT NULL, + last_publish_error TEXT NULL, + publishing_started_at BIGINT NULL, CONSTRAINT config_state_singleton CHECK (id = 1) ); INSERT INTO config_state (id, version) VALUES (1, 0) ON CONFLICT (id) DO NOTHING; ALTER TABLE config_state ADD COLUMN IF NOT EXISTS active_snapshot_version BIGINT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS dirty BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS dirty_at BIGINT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS published_at BIGINT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS last_publish_error TEXT NULL; +ALTER TABLE config_state ADD COLUMN IF NOT EXISTS publishing_started_at BIGINT NULL; CREATE TABLE IF NOT EXISTS config_snapshots ( version BIGINT PRIMARY KEY, @@ -814,6 +873,7 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( key_header_name TEXT NULL, requests_per_minute INTEGER NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), profile_id TEXT NULL, intent_id TEXT NULL, template_key TEXT NULL, @@ -825,6 +885,54 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( updated_at BIGINT NOT NULL ); +CREATE TABLE IF NOT EXISTS waiting_room_policies ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL UNIQUE REFERENCES domains(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode TEXT NOT NULL DEFAULT 'monitoring', + state TEXT NOT NULL DEFAULT 'disabled', + reason TEXT NULL, + rps_threshold INTEGER NOT NULL DEFAULT 100, + active_origin_threshold INTEGER NOT NULL DEFAULT 20, + origin_latency_ms_threshold INTEGER NOT NULL DEFAULT 3000, + origin_error_rate_threshold INTEGER NOT NULL DEFAULT 50, + admission_rate_per_minute INTEGER NOT NULL DEFAULT 60, + queue_limit INTEGER NOT NULL DEFAULT 1000, + per_client_ticket_limit INTEGER NOT NULL DEFAULT 3, + ticket_ttl_seconds INTEGER NOT NULL DEFAULT 300, + admission_ttl_seconds INTEGER NOT NULL DEFAULT 900, + status_poll_seconds INTEGER NOT NULL DEFAULT 5, + jitter_seconds INTEGER NOT NULL DEFAULT 4, + unhealthy_windows INTEGER NOT NULL DEFAULT 3, + healthy_windows INTEGER NOT NULL DEFAULT 3, + minimum_state_seconds INTEGER NOT NULL DEFAULT 60, + recovery_ramp_percent INTEGER NOT NULL DEFAULT 25, + manual_override_until BIGINT NULL, + trusted_cidrs_json JSONB NOT NULL DEFAULT '[]'::jsonb, + waiting_room_title TEXT NOT NULL DEFAULT 'Traffic is high', + waiting_room_message TEXT NOT NULL DEFAULT 'You are in a short waiting room while this site protects its origin.', + counters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK (mode IN ('monitoring','automatic','manual')), + CHECK (state IN ('disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency')), + CHECK (rps_threshold BETWEEN 1 AND 1000000), + CHECK (active_origin_threshold BETWEEN 1 AND 1000000), + CHECK (origin_latency_ms_threshold BETWEEN 1 AND 600000), + CHECK (origin_error_rate_threshold BETWEEN 1 AND 100), + CHECK (admission_rate_per_minute BETWEEN 1 AND 1000000), + CHECK (queue_limit BETWEEN 1 AND 1000000), + CHECK (per_client_ticket_limit BETWEEN 1 AND 1000), + CHECK (ticket_ttl_seconds BETWEEN 30 AND 3600), + CHECK (admission_ttl_seconds BETWEEN 60 AND 86400), + CHECK (status_poll_seconds BETWEEN 2 AND 300), + CHECK (jitter_seconds BETWEEN 0 AND 300), + CHECK (unhealthy_windows BETWEEN 1 AND 100), + CHECK (healthy_windows BETWEEN 1 AND 100), + CHECK (minimum_state_seconds BETWEEN 1 AND 86400), + CHECK (recovery_ramp_percent BETWEEN 1 AND 100) +); + CREATE TABLE IF NOT EXISTS waf_rules ( id TEXT PRIMARY KEY, domain_id TEXT NOT NULL, @@ -834,6 +942,7 @@ CREATE TABLE IF NOT EXISTS waf_rules ( type TEXT NOT NULL, pattern TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), description TEXT NULL, waf_group_id TEXT NULL, waf_severity TEXT NULL, @@ -1064,6 +1173,16 @@ CREATE TABLE IF NOT EXISTS domain_cache_settings ( static_asset_cache_enabled BOOLEAN NOT NULL DEFAULT false, ignore_query_strings_for_static BOOLEAN NOT NULL DEFAULT false, bypass_logged_in_users BOOLEAN NOT NULL DEFAULT true, + cache_methods_json TEXT NOT NULL DEFAULT '["GET","HEAD"]', + cache_status_code_policy_json TEXT NOT NULL DEFAULT '{"200":true,"301":true,"302":true,"404":false,"500":false,"502":false,"503":false,"504":false}', + bypass_headers_json TEXT NOT NULL DEFAULT '["authorization"]', + bypass_cookies_json TEXT NOT NULL DEFAULT '["session","auth","wordpress_logged_in","laravel_session"]', + vary_headers_json TEXT NOT NULL DEFAULT '["accept-encoding"]', + cache_key_dimensions_json TEXT NOT NULL DEFAULT '{"scheme":true,"host":true,"path":true,"query":"include_all","headers":["accept-encoding"],"device":false,"country":false,"language":false,"domain_id":true,"rule_version":true}', + debug_headers_enabled BOOLEAN NOT NULL DEFAULT false, + stale_while_revalidate_seconds INTEGER NOT NULL DEFAULT 0, + negative_ttl_seconds INTEGER NOT NULL DEFAULT 0, + max_object_size_bytes BIGINT NOT NULL DEFAULT 104857600, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); diff --git a/core/public_index.php b/core/public_index.php index 0a960fb9..3bb30fe3 100644 --- a/core/public_index.php +++ b/core/public_index.php @@ -279,7 +279,8 @@ function requireApiAuth(): void $checks['schema'] = 'fail'; } try { - $configService->buildSnapshotForVersion(null); + $snapshot = $configService->activeSnapshot(); + $checks['config_generation'] = $snapshot === null ? 'warn' : 'ok'; } catch (\Throwable) { $checks['config_generation'] = 'fail'; } @@ -350,15 +351,21 @@ function requireApiAuth(): void $router->add('GET', '/api/v1/audit', static fn (Request $req): array => Response::json($operationsLogController->audit($req->query)), auth: true); $router->add('GET', '/api/v1/events', static fn (Request $req): array => Response::json($operationsLogController->events($req->query)), auth: true); $router->add('GET', '/api/v1/jobs', static fn (Request $req): array => Response::json($operationsLogController->jobs($req->query)), auth: true); -$router->add('GET', '/api/v1/config/snapshots', static fn (): array => Response::json(['data' => $configService->snapshots()]), auth: true); +$router->add('GET', '/api/v1/config/snapshots', static fn (Request $req): array => Response::json(['data' => $configService->snapshots((int) ($req->query['limit'] ?? 20), (int) ($req->query['offset'] ?? 0))]), auth: true); $router->add('GET', '/api/v1/config/snapshots/latest', static fn (): array => Response::json(['data' => $configService->latestSnapshotSummary()]), auth: true); $router->add('GET', '/api/v1/config/snapshots/{version}', static function (Request $req, array $p) use ($configService): array { + if (!truthyEnv('CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED', false)) { + return Response::json(['error' => 'config_snapshot_history_disabled'], 404); + } $snapshot = $configService->snapshot((int) $p['version']); return $snapshot === null ? Response::json(['error' => 'config_snapshot_not_found'], 404) : Response::json(['data' => $snapshot]); }, auth: true); $router->add('POST', '/api/v1/config/snapshots/diff', static function (Request $req) use ($configService): array { + if (!truthyEnv('CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED', false)) { + return Response::json(['error' => 'config_snapshot_history_disabled'], 404); + } try { return Response::json(['data' => $configService->diff((int) ($req->body['from_version'] ?? 0), (int) ($req->body['to_version'] ?? 0))]); } catch (\OutOfBoundsException $e) { @@ -366,13 +373,16 @@ function requireApiAuth(): void } }, auth: true); $router->add('POST', '/api/v1/config/snapshots/{version}/rollback', static function (Request $req, array $p) use ($configService): array { + if (!truthyEnv('CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED', false)) { + return Response::json(['error' => 'config_snapshot_history_disabled'], 404); + } try { return Response::json(['data' => $configService->rollback((int) $p['version'])]); } catch (\OutOfBoundsException $e) { return Response::json(['error' => $e->getMessage()], 404); } }, auth: true); -$router->add('POST', '/api/v1/config/snapshots/rebuild', static fn (): array => Response::json(['data' => $configService->rebuild()]), auth: true); +$router->add('POST', '/api/v1/config/snapshots/rebuild', static fn (): array => Response::json(['data' => $configService->publishSnapshot(true)]), auth: true); $router->add('GET', '/api/v1/settings', static fn (): array => Response::json($settingsController->index()), auth: true); $router->add('GET', '/api/v1/settings/{group}', static function (Request $req, array $p) use ($settingsController): array { try { @@ -561,6 +571,16 @@ function requireApiAuth(): void $router->add('PATCH', '/api/v1/domains/{domainId}/rate-limits/{ruleId}', static fn (Request $req, array $p) => Response::json($rulesController->updateRateLimit((string) $p['domainId'], (string) $p['ruleId'], $req->body)), auth: true); $router->add('POST', '/api/v1/domains/{domainId}/rate-limits/{ruleId}/detach-managed', static fn (Request $req, array $p) => Response::json($rulesController->detachManagedRule((string) $p['domainId'], 'rate_limit', (string) $p['ruleId'])), auth: true); $router->add('DELETE', '/api/v1/domains/{domainId}/rate-limits/{ruleId}', static fn (Request $req, array $p) => Response::json($rulesController->deleteRateLimit((string) $p['domainId'], (string) $p['ruleId'])), auth: true); +$router->add('GET', '/api/v1/domains/{domainId}/waiting-room', static fn (Request $req, array $p) => Response::json($rulesController->getWaitingRoom((string) $p['domainId'])), auth: true); +$router->add('PATCH', '/api/v1/domains/{domainId}/waiting-room', static function (Request $req, array $p) use ($rulesController): array { + $result = $rulesController->updateWaitingRoom((string) $p['domainId'], $req->body); + return Response::json($result, (int) ($result['status'] ?? 200)); +}, auth: true); +$router->add('POST', '/api/v1/domains/{domainId}/waiting-room/emergency/activate', static function (Request $req, array $p) use ($rulesController): array { + $result = $rulesController->activateWaitingRoomEmergency((string) $p['domainId'], $req->body); + return Response::json($result, (int) ($result['status'] ?? 200)); +}, auth: true); +$router->add('POST', '/api/v1/domains/{domainId}/waiting-room/emergency/deactivate', static fn (Request $req, array $p) => Response::json($rulesController->deactivateWaitingRoomEmergency((string) $p['domainId'])), auth: true); $router->add('POST', '/api/v1/domains/{domainId}/waf-rules', static fn (Request $req, array $p) => Response::json($rulesController->createWaf((string) $p['domainId'], $req->body), 201), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/waf-rules', static fn (Request $req, array $p) => Response::json($rulesController->listWaf((string) $p['domainId'])), auth: true); $router->add('PATCH', '/api/v1/domains/{domainId}/waf-rules/{wafId}', static fn (Request $req, array $p) => Response::json($rulesController->updateWaf((string) $p['domainId'], (string) $p['wafId'], $req->body)), auth: true); @@ -633,6 +653,7 @@ function requireApiAuth(): void $router->add('GET', '/api/v1/domains/{domainId}/activity/requests', static fn (Request $req, array $p) => Response::json($collectorController->recentRequests((string) $p['domainId'], $req->query)), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/activity', static fn (Request $req, array $p) => Response::json($collectorController->activityTimeline((string) $p['domainId'], $req->query)), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/activity/summary', static fn (Request $req, array $p) => Response::json($collectorController->activitySummary((string) $p['domainId'], $req->query)), auth: true); +$router->add('GET', '/api/v1/domains/{domainId}/origins/health', static fn (Request $req, array $p) => Response::json($collectorController->originHealth((string) $p['domainId'])), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/activity/requests/{requestId}', static fn (Request $req, array $p) => Response::json($collectorController->findRequest((string) $p['domainId'], (string) $p['requestId'])), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/activity/export', static fn (Request $req, array $p) => Response::json($collectorController->activityExport((string) $p['domainId'], $req->query)), auth: true); @@ -641,7 +662,7 @@ function requireApiAuth(): void $router->add('GET', '/api/v1/edges/dns', static fn () => Response::json($edgeController->dns()), auth: true); $router->add('POST', '/api/v1/edge/register', static fn (Request $req) => Response::json($edgeController->register($req->body)), edgeAuth: true); $router->add('POST', '/api/v1/edge/heartbeat', static fn (Request $req) => Response::json($edgeController->heartbeat($req->body)), edgeAuth: true); -$router->add('GET', '/api/v1/edge/config', static fn (Request $req) => Response::json($configService->buildSnapshotForVersion(isset($req->query['if_version']) ? (int) $req->query['if_version'] : null)), edgeAuth: true); +$router->add('GET', '/api/v1/edge/config', static fn (Request $req) => Response::json($configService->edgeConfig(isset($req->query['if_version']) ? (int) $req->query['if_version'] : null)), edgeAuth: true); $router->add('POST', '/api/v1/collector/usage', static fn (Request $req) => Response::json($collectorController->ingest($req->body)), edgeAuth: true); $router->add('POST', '/api/v1/collector/security-events', static fn (Request $req) => Response::json($collectorController->ingestSecurityEvents($req->body)), edgeAuth: true); $router->add('GET', '/api/v1/usage/summary', static fn (Request $req) => Response::json($collectorController->summary(isset($req->query['domain_id']) ? (string) $req->query['domain_id'] : null, isset($req->query['bucket']) ? (string) $req->query['bucket'] : null)), auth: true); diff --git a/core/tests/test_config_snapshot_materialized_cache_contract.py b/core/tests/test_config_snapshot_materialized_cache_contract.py new file mode 100644 index 00000000..0fe30399 --- /dev/null +++ b/core/tests/test_config_snapshot_materialized_cache_contract.py @@ -0,0 +1,57 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_config_state_materialized_cache_columns_are_additive(): + migration = read("core/database/migrations/000032_config_snapshot_materialized_cache.sql") + schema = read("core/database/schema.sql") + + for column in ("dirty", "dirty_at", "published_at", "last_publish_error", "publishing_started_at"): + assert f"ADD COLUMN IF NOT EXISTS {column}" in migration + assert column in schema + assert "DROP TABLE" not in migration + assert "DELETE FROM config_snapshots" not in migration + + +def test_edge_config_uses_published_cache_not_legacy_rebuild_route(): + routes = read("core/public_index.php") + service = read("core/app/Modules/Proxy/Services/ConfigService.php") + sync_command = read("core/app/Console/Commands/CdnEdgeSyncConfigCommand.php") + + assert "$configService->edgeConfig(" in routes + assert "$configService->buildSnapshotForVersion(" not in routes + assert "->edgeConfig($ifVersion)" in sync_command + assert "public function edgeConfig(?int $ifVersion = null): array" in service + assert "return ['not_modified' => true, 'version' => $activeVersion]" in service + assert "pg_try_advisory_lock(hashtext('cdnlite_config_publish'))" in service + assert "stale_while_rebuilding" in service + + +def test_dirty_marks_preserve_active_snapshot_and_history_is_disabled_by_default(): + service = read("core/app/Modules/Proxy/Services/ConfigService.php") + routes = read("core/public_index.php") + + assert "public static function markDirty" in service + assert "SET dirty = true, dirty_at = :dirty_at" in service + assert "active_snapshot_version = NULL" not in service + assert "CDNLITE_CONFIG_SNAPSHOT_HISTORY_ENABLED" in routes + assert "config_snapshot_history_disabled" in routes + + +def test_count_based_prune_command_is_registered_and_keeps_active_snapshot(): + service = read("core/app/Modules/Proxy/Services/ConfigService.php") + artisan = read("core/artisan") + command = read("core/app/Console/Commands/CdnConfigSnapshotsPruneCommand.php") + + assert "cdn:config-snapshots:prune" in artisan + assert "--keep=2 --batch=5000 --dry-run" in read("docs/setup.md") + assert "active_snapshot_version AS version" in service + assert "ORDER BY version DESC LIMIT :keep_last" in service + assert "DELETE FROM config_snapshots" in service + assert "dry_run" in command diff --git a/core/tests/test_edge_reliability_stage7_contract.py b/core/tests/test_edge_reliability_stage7_contract.py index c6c88b6a..e9ad3ee2 100644 --- a/core/tests/test_edge_reliability_stage7_contract.py +++ b/core/tests/test_edge_reliability_stage7_contract.py @@ -26,7 +26,8 @@ def test_stage7_schema_and_request_id_contract(): assert "local geoip = require('geoip')" in metrics assert "client_country" in metrics assert "geoip.request_country()" in metrics - assert "proxy_ignore_headers X-Accel-Expires;" in nginx + assert "proxy_ignore_headers X-Accel-Expires;" not in nginx + assert "X-Accel-Expires" in proxy assert "security_event_type" in metrics assert "security_action" in metrics assert "blocked_by_waf" in router diff --git a/core/tests/test_hardening_contract.py b/core/tests/test_hardening_contract.py index f3dfcc80..2716148b 100644 --- a/core/tests/test_hardening_contract.py +++ b/core/tests/test_hardening_contract.py @@ -74,7 +74,7 @@ def reset_db() -> None: $pdo->exec("INSERT INTO config_state (id, version) VALUES (1, 0) ON CONFLICT (id) DO NOTHING"); } $pdo->exec("ALTER TABLE domain_origins DROP CONSTRAINT IF EXISTS domain_origins_role_check"); -$pdo->exec("ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_role_check CHECK (role IN ('origin'))"); +$pdo->exec("ALTER TABLE domain_origins ADD CONSTRAINT domain_origins_role_check CHECK (role IN ('primary', 'backup', 'shield'))"); ''' run_php_with_deadlock_retry(script, TEST_ENV) @@ -140,8 +140,8 @@ def test_edge_sync_config_reuses_version_when_unchanged(): assert first["version"] >= 1 assert second["version"] == first["version"] - assert second["reused"] is True - assert second["generated_at"] > first["generated_at"] + assert "reused" not in second + assert second["generated_at"] == first["generated_at"] assert not_modified["version"] == first["version"] assert (not_modified.get("not_modified") is True) or (not_modified.get("reused") is True) @@ -164,7 +164,7 @@ def test_edge_sync_config_reuses_version_when_unchanged(): ''', str(first["version"]), ) - assert row["generated_at"] == second["generated_at"] + assert row["generated_at"] == first["generated_at"] assert row["payload_generated_at"] == first["generated_at"] diff --git a/core/tests/test_migrations_contract.py b/core/tests/test_migrations_contract.py index e81a9e3f..3abf4022 100644 --- a/core/tests/test_migrations_contract.py +++ b/core/tests/test_migrations_contract.py @@ -58,6 +58,18 @@ def test_migrator_supports_legacy_baseline_adoption_and_checksum_validation(): assert "legacy_schema_incompatible" in migrator +def test_phase7_origin_resilience_constraints_are_idempotent_for_legacy_adoption(): + migrator = (ROOT / "core/app/Support/DatabaseMigrator.php").read_text() + migration = (ROOT / "core/database/migrations/000031_phase7_origin_resilience.sql").read_text() + + assert "sqlForExecution" in migrator + assert "($migration['version'] ?? '') !== '000031'" in migrator + assert "domain_origins_load_balancing_algorithm_check" in migrator + assert "IF NOT EXISTS (SELECT 1 FROM pg_constraint" in migrator + assert "Preserve the historical migration checksum" in migrator + assert "ADD CONSTRAINT domain_origins_load_balancing_algorithm_check" in migration + + def test_ci_runs_migrations_before_core_tests(): workflow = (ROOT / ".github/workflows/ci.yml").read_text() schema_step = "php core/artisan cdn:db:migrate" @@ -76,6 +88,7 @@ def test_retention_scheduler_is_opt_in_and_uses_bounded_prune_command(): assert "retention-scheduler" in source assert "CDNLITE_RETENTION_PRUNE_ENABLED" in source assert "cdn:usage:prune --all" in source + assert "cdn:config-snapshots:prune" in source assert "CDNLITE_RETENTION_INTERVAL_SECONDS" in source assert "CDNLITE_RETENTION_BATCH_SIZE" in source diff --git a/core/tests/test_nameserver_force_verify_contract.py b/core/tests/test_nameserver_force_verify_contract.py index 0cf0ad95..4f9e7e7b 100644 --- a/core/tests/test_nameserver_force_verify_contract.py +++ b/core/tests/test_nameserver_force_verify_contract.py @@ -42,7 +42,7 @@ def test_force_verify_requires_admin_session_and_audits_reason(): assert "Validator::requiredString($input, 'reason'" in controller assert "domain.nameserver.force_verify" in service assert "forced_verified" in service - assert "UPDATE config_state SET active_snapshot_version = NULL" in service + assert "ConfigService::markDirty('domain.verification.changed')" in service assert "(new DnsReconciler())->reconcile()" in service @@ -64,7 +64,7 @@ def test_reseed_expected_nameservers_is_admin_only_and_audited(): assert "DELETE FROM domain_nameservers WHERE domain_id" in service assert "domain.nameserver.reseed_expected" in service assert "reseeded_expected" in service - assert "UPDATE config_state SET active_snapshot_version = NULL" in service + assert "ConfigService::markDirty('domain.verification.changed')" in service assert "(new DnsReconciler())->reconcile()" in service assert "reseedExpectedNameservers" in api diff --git a/core/tests/test_origin_health_phase19_contract.py b/core/tests/test_origin_health_phase19_contract.py index 948f0ac5..d9644b18 100644 --- a/core/tests/test_origin_health_phase19_contract.py +++ b/core/tests/test_origin_health_phase19_contract.py @@ -15,7 +15,7 @@ def test_origin_schema_contract(): assert "health_check_interval_seconds INTEGER NOT NULL DEFAULT 30" in schema assert "health_check_enabled BOOLEAN NOT NULL DEFAULT false" in schema assert "health_status TEXT NOT NULL DEFAULT 'unknown'" in schema - assert "role TEXT NOT NULL DEFAULT 'origin'" in schema + assert "role TEXT NOT NULL DEFAULT 'primary'" in schema def test_origin_api_and_cli_contract(): @@ -37,7 +37,8 @@ def test_origin_health_service_and_readiness_contract(): assert "function checkDue" in service assert "file_get_contents($url" in service - assert "enabled=true AND health_check_enabled=true" in service + assert "core_active_checks' => false" in service + assert "Origin routing health is updated from edge metrics" in service assert "health_status" in service assert "origin_health" in readiness assert "health_check_enabled=true AND health_status='unhealthy'" in readiness @@ -79,14 +80,18 @@ def test_dashboard_and_docs_expose_origins(): assert "DomainOriginsTab" in detail assert "label: 'Origins'" in detail - assert "Enable health check for this origin" in tab + assert "Enable edge health routing" in tab + assert "Origin health from edge nodes" in tab assert "Health pass-through" in tab assert "schema-origin-shared-hosting-defaults" in read("ci/smoke.sh") assert "origin-ip-shared-hosting-default" in read("ci/e2e.sh") assert "origin-health-disabled-still-routes" in read("ci/e2e.sh") + assert "origin-health-edge-observations" in read("ci/e2e.sh") assert "Add origin" in tab assert 'Protocol' in tab assert 'Port' not in tab assert "const originProtocol = computed" in tab assert "origins/${originId}/check" in api + assert "origins/health" in api + assert "/api/v1/domains/{domainId}/origins/health" in docs assert "X-CDNLITE-Origin" in docs diff --git a/core/tests/test_phase14_15_contract.py b/core/tests/test_phase14_15_contract.py index 91c709fb..250f7c84 100644 --- a/core/tests/test_phase14_15_contract.py +++ b/core/tests/test_phase14_15_contract.py @@ -40,9 +40,10 @@ def test_snapshot_history_routes_and_activation_pointer_are_present(): assert "public function diff" in service assert "public function rollback" in service assert "public function rebuild" in service - assert "private function activeSnapshot" in service + assert "public function activeSnapshot" in service assert "private function activateSnapshotVersion" in service assert "private function activeSnapshotVersion" in service + assert "config_snapshot_history_disabled" in routes assert "config.publish" in service assert "config.rollback" in service assert "$this->activateSnapshotVersion($version)" in service diff --git a/core/tests/test_phase15_performance_starter_contract.py b/core/tests/test_phase15_performance_starter_contract.py index 44e6d344..39e3ad72 100644 --- a/core/tests/test_phase15_performance_starter_contract.py +++ b/core/tests/test_phase15_performance_starter_contract.py @@ -30,7 +30,8 @@ def test_performance_starter_persists_safe_controls_and_enforces_them_at_edge(): assert "cache_settings.static_asset_cache_enabled == true" in proxy assert "cache_settings.ignore_query_strings_for_static == true" in proxy assert "cache_settings.bypass_logged_in_users ~= false" in proxy - assert "ngx.var.cdnlite_cache_key = table.concat" in proxy + assert "local function build_cache_key" in proxy + assert "ngx.var.cdnlite_cache_key = cache_key" in proxy def test_performance_starter_has_dashboard_docs_smoke_and_e2e_coverage(): diff --git a/core/tests/test_phase4_challenge_clearance_contract.py b/core/tests/test_phase4_challenge_clearance_contract.py new file mode 100644 index 00000000..0603a5cd --- /dev/null +++ b/core/tests/test_phase4_challenge_clearance_contract.py @@ -0,0 +1,108 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_edge_challenge_issues_signed_scoped_clearance_cookie(): + clearance = read("edge/openresty/lua/clearance.lua") + router = read("edge/openresty/lua/router.lua") + nginx = read("edge/openresty/nginx.conf") + compose = read("docker-compose.yml") + schema = read("core/database/schema.sql") + migration = read("core/database/migrations/000028_challenge_difficulty.sql") + + assert "ngx.hmac_sha1" in clearance + assert "resty.sha256" in clearance + assert "resty.string" in clearance + assert "__cdnlite_clearance" in clearance + assert "HttpOnly; SameSite=Lax" in clearance + assert "CDNLITE_EDGE_CLEARANCE_SECRET" in clearance + assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in clearance + assert "function M.issue(domain_id, action, rule_id, client_ip, ttl)" in clearance + assert "function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override)" in clearance + assert "function M.verify_challenge()" in clearance + assert "function M.consume_challenge(domain_id, action, rule_id, client_ip)" in clearance + assert "function M.has_clearance(domain_id, action, rule_id, client_ip)" in clearance + assert "Security check" in clearance + assert "browser_check" in clearance + assert "proof_of_work" in clearance + assert "invalid_browser_check" in clearance + assert "proof ~= 'browser-check'" in clearance + assert "difficulty <= 1" in clearance + assert "crypto.subtle.digest('SHA-256'" in clearance + assert "__cdnlite_challenge_verify" in clearance + assert "invalid_proof" in clearance + assert "scope_matches(claims, domain_id, action, rule_id, client_ip)" in clearance + + assert "env CDNLITE_EDGE_CLEARANCE_SECRET;" in nginx + assert "env CDNLITE_EDGE_CHALLENGE_DIFFICULTY;" in nginx + assert "location = /__cdnlite_challenge_verify" in nginx + assert "CDNLITE_EDGE_CLEARANCE_SECRET" in compose + assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in compose + assert "local clearance = require('clearance')" in router + assert "challenge_difficulty INTEGER NULL CHECK" in schema + assert "ADD COLUMN IF NOT EXISTS challenge_difficulty" in migration + + +def test_waf_and_rate_limit_challenges_are_verifiable_not_static_denies(): + router = read("edge/openresty/lua/router.lua") + controller = read("core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php") + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") + + assert "clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip)" in router + assert "clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required', rule.challenge_difficulty)" in router + assert "clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip)" in router + assert "clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required', rule.challenge_difficulty)" in router + assert "Validator::intRange($body, 'challenge_difficulty', 1, 6)" in controller + assert "'challenge_difficulty' => $this->challengeDifficultyValue($in)" in service + assert "private function challengeDifficultyValue" in service + + block_index = router.index("if ngx.ctx.security_action == 'block' then") + challenge_index = router.index("if ngx.ctx.security_action == 'challenge' then") + assert block_index < challenge_index + + +def test_emergency_profile_challenges_all_visitors_before_origin(): + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") + dashboard = read("dash/src/views/domain-tabs/DomainSecurityCenterTab.vue") + waf_tab = read("dash/src/views/domain-tabs/DomainWafTab.vue") + rate_tab = read("dash/src/views/domain-tabs/DomainRateLimitsTab.vue") + + assert "'emergency_protection'" in service + assert "'template_key' => 'waf_emergency_sitewide_challenge'" in service + assert "'type' => 'path_prefix'" in service + assert "'pattern' => '/'" in service + assert "'action' => 'challenge'" in service + assert "'priority' => 4" in service and "'priority' => 5" in service + assert "waf_emergency_scanners" in service + assert "Temporarily challenges site-wide traffic before origin routing" in service + + assert "Emergency Protection" in dashboard + assert "challenge" in waf_tab + assert "Level 1 performs a lightweight browser check" in waf_tab + assert "levels 2-6 require increasing proof-of-work" in waf_tab + assert "Challenge difficulty" in waf_tab + assert "Challenge difficulty" in rate_tab + assert "Challenge" in rate_tab and "hard block" in rate_tab + + +def test_phase4_runner_and_stress_registration_are_present(): + phase = read("ci/phase.sh") + manifest = read("ci/phases/phase-04.yml") + stress = read("ci/stress-platform.sh") + scenarios = read("ci/stress/scenarios.yml") + + assert "04)" in phase + assert "test_phase4_challenge_clearance_contract.py" in phase + assert "phase4-challenge-clearance" in phase + assert 'phase: "04"' in manifest + assert 'status: "complete"' in manifest + assert "phase4-challenge-clearance" in manifest + assert "phase4-challenge-clearance" in stress + assert "phase4-challenge-clearance" in scenarios + assert "block rules remain terminal" in scenarios diff --git a/core/tests/test_phase5_waiting_room_contract.py b/core/tests/test_phase5_waiting_room_contract.py new file mode 100644 index 00000000..d94eee74 --- /dev/null +++ b/core/tests/test_phase5_waiting_room_contract.py @@ -0,0 +1,115 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_waiting_room_schema_api_and_snapshot_contract_are_wired(): + schema = read("core/database/schema.sql") + migration = read("core/database/migrations/000029_waiting_room.sql") + public_index = read("core/public_index.php") + controller = read("core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php") + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") + config = read("core/app/Modules/Proxy/Services/ConfigService.php") + + assert "CREATE TABLE IF NOT EXISTS waiting_room_policies" in schema + assert "CREATE TABLE IF NOT EXISTS waiting_room_policies" in migration + for field in ( + "admission_rate_per_minute", + "queue_limit", + "per_client_ticket_limit", + "ticket_ttl_seconds", + "admission_ttl_seconds", + "unhealthy_windows", + "healthy_windows", + "recovery_ramp_percent", + "waiting_room_title", + "waiting_room_message", + ): + assert field in schema + assert field in migration + assert field in service + + assert "/api/v1/domains/{domainId}/waiting-room" in public_index + assert "waiting-room/emergency/activate" in public_index + assert "waiting-room/emergency/deactivate" in public_index + assert "getWaitingRoom" in controller + assert "updateWaitingRoom" in controller + assert "activateWaitingRoomEmergency" in controller + assert "deactivateWaitingRoomEmergency" in controller + assert "validateWaitingRoom" in controller + assert "listWaitingRoomPoliciesForConfig" in service + assert "$hosts[$host]['waiting_room'] = $row" in config + + +def test_edge_waiting_room_uses_signed_tokens_bounded_state_and_local_endpoints(): + waiting_room = read("edge/openresty/lua/waiting_room.lua") + router = read("edge/openresty/lua/router.lua") + nginx = read("edge/openresty/nginx.conf") + entrypoint = read("edge/docker-entrypoint.sh") + + assert "ngx.hmac_sha1" in waiting_room + assert "__cdnlite_queue_ticket" in waiting_room + assert "__cdnlite_admission" in waiting_room + assert "HttpOnly; SameSite=Lax" in waiting_room + assert "CDNLITE_EDGE_WAITING_ROOM_SECRET" in waiting_room + assert "queue_limit" in waiting_room + assert "admission_rate_per_minute" in waiting_room + assert "rps_threshold" in waiting_room + assert "status_poll_seconds" in waiting_room + assert "cache_candidate" in waiting_room + assert "waiting_room_cache_candidate" in waiting_room + assert "same_host_path" in waiting_room + assert "function M.queue_page()" in waiting_room + assert "function M.queue_status()" in waiting_room + assert "function M.apply(policy, domain)" in waiting_room + assert "function M.mark_origin(domain)" in waiting_room + assert "function M.on_log()" in waiting_room + assert "waiting_room.apply(domain.waiting_room, domain)" in router + assert "waiting_room.mark_origin(domain)" in router + assert "ngx.ctx.cache_rule, ngx.ctx.cache_rules_enabled = match_cache_rule(cfg, host)" in router + assert "local waiting_room = require('waiting_room')" in router + assert "lua_shared_dict cdnlite_waiting_room" in nginx + assert "location = /.well-known/cdnlite/queue" in nginx + assert "location = /.well-known/cdnlite/queue/status" in nginx + assert "require('waiting_room').on_log()" in nginx + assert "CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE" in entrypoint + assert "__CDNLITE_EDGE_WAITING_ROOM_DICT_SIZE__" in entrypoint + + +def test_dashboard_docs_smoke_and_phase_gate_cover_waiting_room(): + types = read("dash/src/types.ts") + tab = read("dash/src/views/domain-tabs/DomainWaitingRoomTab.vue") + domain_detail = read("dash/src/views/DomainDetailView.vue") + api = read("dash/src/lib/api/waitingRoom.ts") + smoke = read("ci/smoke.sh") + phase = read("ci/phases/phase-05.yml") + scenarios = read("ci/stress/scenarios.yml") + roadmap = read("docs/ROADMAP.md") + docs_api = read("docs/api/api.md") + docs_security = read("docs/security.md") + docs_setup = read("docs/setup.md") + openapi = read("docs/public/api/openapi.yaml") + changelog = read("CHANGELOG.md") + + assert "interface WaitingRoomPolicy" in types + assert "Waiting room" in tab + assert "Activate for 1 hour" in tab + assert "waitingRoomApi" in api + assert "waiting-room/emergency/activate" in api + assert "DomainWaitingRoomTab" in domain_detail + assert "waiting-room" in domain_detail + assert "schema-waiting-room" in smoke + assert 'phase: "05"' in phase + assert "phase5-waiting-room" in phase + assert "phase5-waiting-room" in scenarios + assert "bounded local edge queue state" in roadmap + assert "Waiting room" in docs_api + assert "Waiting Room Admission" in docs_security + assert "CDNLITE_EDGE_WAITING_ROOM_SECRET" in docs_setup + assert "/api/v1/domains/{domainId}/waiting-room" in openapi + assert "Phase 5 waiting room" in changelog diff --git a/core/tests/test_phase6_cache_correctness_contract.py b/core/tests/test_phase6_cache_correctness_contract.py new file mode 100644 index 00000000..f38e06a1 --- /dev/null +++ b/core/tests/test_phase6_cache_correctness_contract.py @@ -0,0 +1,113 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_phase6_schema_exposes_cache_correctness_controls(): + migration = read("core/database/migrations/000030_phase6_cache_correctness.sql") + schema = read("core/database/schema.sql") + + for field in ( + "cache_methods_json TEXT NOT NULL DEFAULT '[\"GET\",\"HEAD\"]'", + "cache_status_code_policy_json TEXT NOT NULL DEFAULT", + "bypass_headers_json TEXT NOT NULL DEFAULT '[\"authorization\"]'", + "bypass_cookies_json TEXT NOT NULL DEFAULT", + "vary_headers_json TEXT NOT NULL DEFAULT '[\"accept-encoding\"]'", + "cache_key_dimensions_json TEXT NOT NULL DEFAULT", + "debug_headers_enabled BOOLEAN NOT NULL DEFAULT false", + "stale_while_revalidate_seconds INTEGER NOT NULL DEFAULT 0", + "negative_ttl_seconds INTEGER NOT NULL DEFAULT 0", + "max_object_size_bytes BIGINT NOT NULL DEFAULT 104857600", + ): + assert field in migration + assert field in schema + + assert "DROP TABLE" not in migration + assert "TRUNCATE" not in migration + + +def test_migration_versions_are_unique(): + migrations = sorted((ROOT / "core/database/migrations").glob("*.sql")) + versions = [path.name.split("_", 1)[0] for path in migrations] + duplicates = sorted({version for version in versions if versions.count(version) > 1}) + assert duplicates == [] + + +def test_phase6_api_snapshot_and_dashboard_surface_safe_cache_model(): + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") + config = read("core/app/Modules/Proxy/Services/ConfigService.php") + dashboard = read("dash/src/views/domain-tabs/DomainCacheTab.vue") + types = read("dash/src/types.ts") + + assert "cache_methods_json" in service + assert "cache_key_dimensions_json" in service + assert "cache_methods" in service + assert "cache_key_dimensions" in service + assert "debug_headers_enabled" in service + assert "'cache' => $this->rules->getDomainCacheSettings" in config + assert "Cache key preview" in dashboard + assert "Personalized traffic bypasses by default" in dashboard + assert "debug_headers_enabled" in dashboard + assert "cache_key_dimensions" in dashboard + assert "export interface CacheSettings" in types + assert "cache_methods" in types + assert "cache_key_dimensions" in types + + +def test_phase6_edge_cache_decisions_are_standards_aware_and_debuggable(): + proxy = read("edge/openresty/lua/proxy.lua") + nginx = read("edge/openresty/nginx.conf") + + for token in ( + "header_has_cache_directive", + "has_logged_in_cookie", + "request_has_bypass_header", + "build_cache_key", + "cache_settings.cache_methods", + "cache_settings.vary_headers", + "cache_settings.cache_key_dimensions", + "bypass_reason", + "authorization", + "request_cache_control", + "geoip.request_country()", + "X-Accel-Expires", + ): + assert token in proxy + + assert "proxy_ignore_headers X-Accel-Expires" not in nginx + assert "proxy_cache_lock on" in nginx + assert "proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504" in nginx + assert "X-CDNLite-Cache" in nginx + assert "X-CDNLite-Cache-Reason" in nginx + assert "X-CDNLite-Cache-Key" in nginx + assert "CDNLITE_EDGE_DEBUG_HEADERS" in nginx + + +def test_phase6_runner_stress_docs_and_e2e_are_registered(): + phase = read("ci/phase.sh") + manifest = read("ci/phases/phase-06.yml") + stress = read("ci/stress-platform.sh") + scenarios = read("ci/stress/scenarios.yml") + e2e = read("ci/e2e.sh") + docs = read("docs/api/api.md") + roadmap = read("docs/ROADMAP.md") + changelog = read("CHANGELOG.md") + + assert "06)" in phase + assert "test_phase6_cache_correctness_contract.py" in phase + assert "phase6-cache-correctness" in phase + assert 'phase: "06"' in manifest + assert "phase6-cache-correctness" in manifest + assert "phase6-cache-correctness" in stress + assert "phase6-cache-correctness" in scenarios + assert "edge-cache-basic" in e2e + assert "X-CDNLite-Cache-Key" in docs + assert "Cache key preview" in docs + assert "Phase 6 — Cache correctness foundation" in roadmap + assert "| 6. Cache correctness foundation | P0 | Complete |" in roadmap + assert "Phase 6 cache correctness" in changelog diff --git a/core/tests/test_phase7_config_invalidation_contract.py b/core/tests/test_phase7_config_invalidation_contract.py index 0e458768..7f18d390 100644 --- a/core/tests/test_phase7_config_invalidation_contract.py +++ b/core/tests/test_phase7_config_invalidation_contract.py @@ -12,7 +12,7 @@ def test_domain_mutations_invalidate_config_before_reconcile(): service = read("core/app/Modules/Domains/Services/DomainService.php") assert "private function invalidateConfigSnapshot" in service - assert "UPDATE config_state SET active_snapshot_version = NULL WHERE id = 1" in service + assert "ConfigService::markDirty('domain.changed')" in service assert "AuditLog::write('domain.create'" in service assert "AuditLog::write('domain.update'" in service assert "AuditLog::write('domain.delete'" in service @@ -30,6 +30,7 @@ def test_dns_and_geo_route_mutations_invalidate_config(): openapi = read("docs/public/api/openapi.yaml") assert "private function invalidateConfigSnapshot" in dns_service + assert "ConfigService::markDirty('dns.changed')" in dns_service assert "DnsReconciler" in dns_service assert "for ($attempt = 1; $attempt <= 3; $attempt++)" in dns_service assert "dns_reconciler_busy" in dns_service @@ -37,6 +38,7 @@ def test_dns_and_geo_route_mutations_invalidate_config(): assert "AuditLog::write('dns.reconcile.failed'" in dns_service assert "'local_state_saved' => true" in dns_service assert "private function invalidateConfigSnapshot" in geo_service + assert "ConfigService::markDirty('dns.geo_routes.changed')" in geo_service assert "AuditLog::write('dns.geo_routes.update'" in geo_service assert "$this->invalidateConfigSnapshot();\n (new DnsReconciler())->reconcile();" in geo_service assert "private function dnsPublishFailure" in dns_controller diff --git a/core/tests/test_phase7_origin_resilience_contract.py b/core/tests/test_phase7_origin_resilience_contract.py new file mode 100644 index 00000000..90653ee6 --- /dev/null +++ b/core/tests/test_phase7_origin_resilience_contract.py @@ -0,0 +1,136 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_phase7_schema_exposes_origin_resilience_controls(): + migration = read("core/database/migrations/000031_phase7_origin_resilience.sql") + schema = read("core/database/schema.sql") + + for field in ( + "load_balancing_algorithm TEXT NOT NULL DEFAULT 'weighted_hash'", + "connection_timeout_seconds INTEGER NOT NULL DEFAULT 5", + "response_timeout_seconds INTEGER NOT NULL DEFAULT 30", + "retry_attempts INTEGER NOT NULL DEFAULT 1", + "retry_budget_per_minute INTEGER NOT NULL DEFAULT 60", + "circuit_breaker_enabled BOOLEAN NOT NULL DEFAULT true", + "circuit_failure_threshold INTEGER NOT NULL DEFAULT 5", + "circuit_recovery_seconds INTEGER NOT NULL DEFAULT 30", + "max_concurrent_requests INTEGER NOT NULL DEFAULT 0", + "drain BOOLEAN NOT NULL DEFAULT false", + "shield_enabled BOOLEAN NOT NULL DEFAULT false", + "CREATE TABLE IF NOT EXISTS origin_health_observations", + "edge_node_id TEXT NOT NULL", + "jitter_ms INTEGER NULL", + "role IN ('primary', 'backup', 'shield')", + ): + assert field in migration + assert field in schema + + assert "DROP TABLE" not in migration + assert "TRUNCATE" not in migration + + +def test_phase7_api_snapshot_and_dashboard_types_include_resilience_model(): + controller = read("core/app/Modules/Proxy/Http/Controllers/OriginController.php") + service = read("core/app/Modules/Proxy/Services/OriginHealthService.php") + collector = read("core/app/Modules/Collector/Services/CollectorService.php") + config = read("core/app/Modules/Proxy/Services/ConfigService.php") + public_index = read("core/public_index.php") + types = read("dash/src/types.ts") + + assert "must_be_primary_backup_or_shield" in controller + for token in ( + "retry_attempts", + "retry_budget_per_minute", + "circuit_breaker_enabled", + "max_concurrent_requests", + "drain", + "shield_enabled", + ): + assert token in controller + assert token in service + assert token in config + + assert "load_balancing_algorithm" in controller + assert "load_balancing_algorithm" in service + assert "load_balancing_algorithm" in config + assert "recordOriginObservation" in collector + assert "origin_health_observations" in collector + assert "refreshOriginStatusFromEdge" in collector + assert "origin_jitter" in collector + assert "jitterMs >= 1000" in collector + assert "core_active_checks' => false" in service + assert "/api/v1/domains/{domainId}/origins/health" in public_index + + assert "'role' => (string) ($origin['role'] ?? 'primary')" in config + assert "empty($origin['enabled']) || !empty($origin['drain'])" in config + assert "'health_check_path' => (string) ($origin['health_check_path'] ?? '/')" in config + assert "export interface DomainOrigin" in types + assert "export interface OriginHealthReport" in types + assert "load_balancing_algorithm" in types + assert "circuit_failure_threshold" in types + + +def test_phase7_edge_selection_is_weighted_bounded_and_backup_aware(): + selector = read("edge/openresty/lua/origin_selector.lua") + proxy = read("edge/openresty/lua/proxy.lua") + checker = read("edge/openresty/lua/origin_health_checker.lua") + nginx = read("edge/openresty/nginx.conf") + + for token in ( + "role_rank", + "weighted_pick", + "healthy_primary", + "healthy_backup", + "unknown_backup", + "origin.drain ~= true", + "slot < seen", + "retry_budget_per_minute", + "circuit_breaker_enabled", + "max_concurrent_requests", + "shield_enabled", + ): + assert token in selector + + assert "method ~= 'GET' and method ~= 'HEAD'" in proxy + assert "X-CDNLite-Origin-Retry-Attempts" in proxy + assert "X-CDNLite-Origin-Circuit-Breaker" in proxy + assert "set $target_origin_retry_attempts '0';" in nginx + assert "require('origin_health_checker').start()" in nginx + assert "lua_shared_dict cdnlite_origin_health" in nginx + assert "origin.health_check_enabled == true" in checker + assert "origin_health_probe = true" in checker + assert "telemetry_queue.enqueue('metrics'" in checker + + +def test_phase7_runner_stress_docs_and_roadmap_are_registered(): + phase = read("ci/phase.sh") + manifest = read("ci/phases/phase-07.yml") + stress = read("ci/stress-platform.sh") + scenarios = read("ci/stress/scenarios.yml") + api = read("docs/api/api.md") + architecture = read("docs/architecture.md") + troubleshooting = read("docs/troubleshooting.md") + roadmap = read("docs/ROADMAP.md") + changelog = read("CHANGELOG.md") + + assert "07)" in phase + assert "test_phase7_origin_resilience_contract.py" in phase + assert "phase7-origin-resilience" in phase + assert 'phase: "07"' in manifest + assert "phase7-origin-resilience" in manifest + assert "phase7-origin-resilience" in stress + assert "phase7-origin-resilience" in scenarios + assert "primary, backup, and shield" in api + assert "Edge health observations" in api + assert "bounded idempotent retries" in architecture + assert "edge-origin health observations" in architecture + assert "drain the origin" in troubleshooting + assert "| 7. Origin routing, resilience, and shielding | P0 | Complete |" in roadmap + assert "Phase 7 origin routing resilience" in changelog diff --git a/core/tests/test_rate_limit_crud_contract.py b/core/tests/test_rate_limit_crud_contract.py index b4dddb41..c823a70c 100644 --- a/core/tests/test_rate_limit_crud_contract.py +++ b/core/tests/test_rate_limit_crud_contract.py @@ -15,7 +15,7 @@ def test_rate_limit_crud_routes_and_snapshot_collection_contract(): assert "updateRateLimit" in service assert "deleteRateLimit" in service assert "invalidateConfigSnapshot" in service - assert "active_snapshot_version = NULL" in service + assert "ConfigService::markDirty('traffic_rules.changed')" in service assert "listRateLimits($domainId)" in config diff --git a/core/tests/test_readiness_service.py b/core/tests/test_readiness_service.py index a70ccfe3..f96cdf60 100644 --- a/core/tests/test_readiness_service.py +++ b/core/tests/test_readiness_service.py @@ -40,7 +40,7 @@ def test_snapshot_readiness_links_to_an_existing_operational_page(): )[0] assert "'/config-snapshot'" not in snapshot_check - assert snapshot_check.count("'/edge-nodes'") == 2 + assert snapshot_check.count("'/edge-nodes'") >= 2 assert "{ path: '/edge-nodes'" in router diff --git a/core/tests/test_reports_contract.py b/core/tests/test_reports_contract.py index 72912756..e812d808 100644 --- a/core/tests/test_reports_contract.py +++ b/core/tests/test_reports_contract.py @@ -85,4 +85,8 @@ def test_dashboard_reports_client_and_overview_use_real_report_endpoints(): assert "Top Visitor Countries" in overview assert "request.client_ip" in overview assert "request.client_country" in overview + assert "'link' => '/#recent-problem-requests'" in (ROOT / "core/app/Modules/Reports/Services/ReportService.php").read_text() + assert "Show details" in overview + assert "warning.key === 'origin_errors'" in overview + assert "recent_problem_requests.slice(0, 3)" in overview assert "mock" not in overview.lower() diff --git a/dash/src/lib/api/origins.ts b/dash/src/lib/api/origins.ts index 36f61935..2a00366c 100644 --- a/dash/src/lib/api/origins.ts +++ b/dash/src/lib/api/origins.ts @@ -1,10 +1,11 @@ import { api } from './client'; -import type { DomainOrigin } from '@/types'; +import type { DomainOrigin, OriginDiagnosticResult, OriginHealthReport } from '@/types'; export const originsApi = { list: (domainId: string) => api.get(`/api/v1/domains/${domainId}/origins`), create: (domainId: string, input: Partial) => api.post(`/api/v1/domains/${domainId}/origins`, input), update: (domainId: string, originId: string, input: Partial) => api.patch(`/api/v1/domains/${domainId}/origins/${originId}`, input), remove: (domainId: string, originId: string) => api.delete<{ ok: boolean }>(`/api/v1/domains/${domainId}/origins/${originId}`), - check: (domainId: string, originId: string) => api.post(`/api/v1/domains/${domainId}/origins/${originId}/check`), + check: (domainId: string, originId: string) => api.post(`/api/v1/domains/${domainId}/origins/${originId}/check`), + health: (domainId: string) => api.get(`/api/v1/domains/${domainId}/origins/health`), }; diff --git a/dash/src/lib/api/waitingRoom.ts b/dash/src/lib/api/waitingRoom.ts new file mode 100644 index 00000000..2deadbe1 --- /dev/null +++ b/dash/src/lib/api/waitingRoom.ts @@ -0,0 +1,11 @@ +import { api } from './client'; +import type { WaitingRoomPolicy } from '@/types'; + +export const waitingRoomApi = { + get: (domainId: string) => api.get(`/api/v1/domains/${domainId}/waiting-room`), + update: (domainId: string, input: Partial) => api.patch(`/api/v1/domains/${domainId}/waiting-room`, input), + activateEmergency: (domainId: string, input: { ttl_seconds: number; reason?: string }) => + api.post(`/api/v1/domains/${domainId}/waiting-room/emergency/activate`, input), + deactivateEmergency: (domainId: string) => + api.post(`/api/v1/domains/${domainId}/waiting-room/emergency/deactivate`), +}; diff --git a/dash/src/types.ts b/dash/src/types.ts index cf6b080b..4cadd3ff 100644 --- a/dash/src/types.ts +++ b/dash/src/types.ts @@ -71,7 +71,7 @@ export interface GeoRoute { export interface RedirectRule { id: Id; enabled: boolean; source_path: string; target_url: string; status_code: number; priority: number; match_type: string; preserve_query: boolean; } export interface PageRule { id: Id; enabled: boolean; pattern?: string; path_pattern?: string; priority: number; actions: Record; } -export interface CacheSettings { enabled: boolean; default_edge_ttl_seconds: number; default_browser_ttl_seconds: number | null; cache_query_string_mode: string; respect_origin_cache_control: boolean; cache_authorized_requests: boolean; stale_if_error_seconds: number; static_asset_cache_enabled: boolean; ignore_query_strings_for_static: boolean; bypass_logged_in_users: boolean; } +export interface CacheSettings { enabled: boolean; default_edge_ttl_seconds: number; default_browser_ttl_seconds: number | null; cache_query_string_mode: string; respect_origin_cache_control: boolean; cache_authorized_requests: boolean; stale_if_error_seconds: number; static_asset_cache_enabled: boolean; ignore_query_strings_for_static: boolean; bypass_logged_in_users: boolean; cache_methods: string[]; cache_status_code_policy: Record; bypass_headers: string[]; bypass_cookies: string[]; vary_headers: string[]; cache_key_dimensions: Record; debug_headers_enabled: boolean; stale_while_revalidate_seconds: number; negative_ttl_seconds: number; max_object_size_bytes: number; } export interface ManagedRuleMetadata { profile_id?: Id | null; intent_id?: Id | null; template_key?: string | null; managed_by?: string | null; user_modified?: boolean; last_generated_at?: number | null; last_applied_at?: number | null; } export type ProtectionRisk = 'safe' | 'moderate' | 'risky' | string; export interface ProtectionIntentRecord { @@ -131,20 +131,56 @@ export interface ApiProtectionDiscovery { } export interface CacheRule extends ManagedRuleMetadata { id: Id; enabled: boolean; path_prefix: string; ttl_seconds: number; } export interface PurgeRequest { id: Id; domain_id?: Id; type: 'url' | 'prefix' | 'domain' | 'everything' | string; value?: string; status?: string; created_at?: number | string; updated_at?: number | string; } -export interface WafRule extends ManagedRuleMetadata { id: Id; type: string; pattern: string; action: 'block' | 'log' | 'allow' | string; priority: number; enabled?: boolean; status?: string; } -export interface RateLimitRule extends ManagedRuleMetadata { id: Id; enabled: boolean; requests_per_minute: number; priority: number; path_prefix: string; key_type: 'ip' | 'ip_path' | 'header' | 'header_path' | string; key_header_name?: string | null; action: string; } +export interface WafRule extends ManagedRuleMetadata { id: Id; type: string; pattern: string; action: 'block' | 'log' | 'allow' | string; priority: number; challenge_difficulty?: number | null; enabled?: boolean; status?: string; } +export interface RateLimitRule extends ManagedRuleMetadata { id: Id; enabled: boolean; requests_per_minute: number; priority: number; path_prefix: string; key_type: 'ip' | 'ip_path' | 'header' | 'header_path' | string; key_header_name?: string | null; action: string; challenge_difficulty?: number | null; } +export interface WaitingRoomPolicy { + id: Id; domain_id: Id; enabled: boolean; mode: 'monitoring' | 'automatic' | 'manual' | string; + state: 'disabled' | 'monitoring' | 'healthy' | 'entering_overload' | 'overloaded' | 'recovering' | 'manual_emergency' | string; + reason?: string | null; rps_threshold: number; active_origin_threshold: number; + origin_latency_ms_threshold: number; origin_error_rate_threshold: number; admission_rate_per_minute: number; + queue_limit: number; per_client_ticket_limit: number; ticket_ttl_seconds: number; admission_ttl_seconds: number; + status_poll_seconds: number; jitter_seconds: number; unhealthy_windows: number; healthy_windows: number; + minimum_state_seconds: number; recovery_ramp_percent: number; manual_override_until?: number | null; + trusted_cidrs: string[]; waiting_room_title: string; waiting_room_message: string; + counters?: Record; created_at?: number; updated_at?: number; +} export interface HeaderRule extends ManagedRuleMetadata { id: Id; enabled: boolean; priority: number; operation: 'set' | 'remove' | 'append' | string; header_name: string; header_value?: string | null; path_pattern: string; } export interface IpRule extends ManagedRuleMetadata { id: Id; enabled: boolean; rule_type: 'allow' | 'block' | string; cidr: string; description?: string | null; } export interface DomainOrigin { id: Id; domain_id: Id; scheme: 'http' | 'https'; host: string; port: 80 | 443 | number; dns_record_id?: Id | null; source?: 'manual' | 'dns_record' | 'imported' | string; role?: 'primary' | 'backup' | string; - weight?: number; host_header?: string | null; sni?: string | null; tls_verify?: 'verify' | 'ignore'; + weight?: number; load_balancing_algorithm?: 'weighted_hash' | 'consistent_hash' | string; + host_header?: string | null; sni?: string | null; tls_verify?: 'verify' | 'ignore'; preserve_host?: boolean; health_check_enabled?: boolean; is_primary: boolean; health_check_path: string; health_check_interval_seconds: number; + connection_timeout_seconds?: number; response_timeout_seconds?: number; retry_attempts?: number; + retry_budget_per_minute?: number; circuit_breaker_enabled?: boolean; circuit_failure_threshold?: number; + circuit_recovery_seconds?: number; max_concurrent_requests?: number; drain?: boolean; shield_enabled?: boolean; health_check_timeout_seconds: number; health_status: 'healthy' | 'unhealthy' | 'unknown' | string; last_check_at?: number | null; last_error?: string | null; enabled: boolean; created_at?: number; updated_at?: number; } +export interface OriginHealthEdgeObservation { + edge_node_id: string; edge_label: string; region?: string | null; country?: string | null; + status: 'healthy' | 'unhealthy' | 'slow' | 'unknown' | string; reason?: string | null; + upstream_status?: string | null; latency_ms?: number | null; jitter_ms?: number | null; + sample_count: number; first_observed_at: number; last_observed_at: number; + last_success_at?: number | null; last_failure_at?: number | null; +} +export interface OriginHealthReportItem { + origin_id: Id; host: string; role: string; enabled: boolean; health_check_enabled: boolean; + status: string; last_check_at?: number | null; last_error?: string | null; edge_count: number; + healthy_edges: number; slow_edges: number; unhealthy_edges: number; + max_latency_ms?: number | null; max_jitter_ms?: number | null; edges: OriginHealthEdgeObservation[]; +} +export interface OriginHealthReport { + items: OriginHealthReportItem[]; source: 'edge_observations' | string; core_active_checks: boolean; +} +export interface OriginDiagnosticResult { + origin_id: Id; healthy: boolean; error?: string | null; duration_ms?: number | null; + dns?: Record; tcp?: Record; tls?: Record; http?: Record; + authoritative?: boolean; source?: string; +} export interface SslCertificate { id: Id; hostname: string; status?: string; acme_status?: string; days_left?: number; days_until_expiry?: number; not_after?: number; expires_at?: number | string; issuer?: string; subject?: string; last_error?: string; created_at?: number | string; } export interface SslSettings { domain_id: Id; force_https: boolean; min_tls_version: '1.2' | '1.3'; auto_renew: boolean; created_at?: number; updated_at?: number; } export interface AcmeProgress { certificate_id: Id; hostname: string; status: string; error?: string | null; updated_at: number; } @@ -259,7 +295,7 @@ export interface ReportKpis { active_domains: number; online_edges: number; offline_edges: number; security_events: number; waf_blocks: number; rate_limited_requests: number; origin_errors: number; ssl_expiring_count: number; pending_dns_changes: number; failed_jobs: number; } -export interface ReportWarning { severity: 'warning' | 'critical' | 'info'; message: string; link: string; count?: number; } +export interface ReportWarning { key?: string; severity: 'warning' | 'critical' | 'info'; message: string; link: string; section?: string | null; count?: number; } export interface ReportSummary { time_range: ReportTimeRange; previous_time_range?: ReportTimeRange | null; kpis: ReportKpis; deltas?: Record | null; warnings: ReportWarning[]; generated_at: number; } export interface ReportDistributionRow { value?: string; status?: string; status_class?: string; severity?: string; action?: string; count: number; requests?: number; bytes_out?: number; } export interface ReportTraffic { diff --git a/dash/src/views/DomainDetailView.vue b/dash/src/views/DomainDetailView.vue index 22f72b8e..49528d99 100644 --- a/dash/src/views/DomainDetailView.vue +++ b/dash/src/views/DomainDetailView.vue @@ -99,7 +99,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { RouterLink, useRoute, useRouter } from 'vue-router'; import { Activity, BarChart3, Database, FileClock, Gauge, Globe2, LockKeyhole, - ListFilter, Network, RefreshCw, Route, ServerCog, ShieldCheck, ShieldHalf, SlidersHorizontal, + ListFilter, Network, RefreshCw, Route, ServerCog, ShieldCheck, ShieldHalf, SlidersHorizontal, TimerReset, } from 'lucide-vue-next'; import EmptyState from '@/components/ui/EmptyState.vue'; import HorizontalScrollFrame from '@/components/ui/HorizontalScrollFrame.vue'; @@ -125,6 +125,7 @@ import DomainIpRulesTab from './domain-tabs/DomainIpRulesTab.vue'; import DomainOriginsTab from './domain-tabs/DomainOriginsTab.vue'; import DomainActivityTab from './domain-tabs/DomainActivityTab.vue'; import DomainSecurityCenterTab from './domain-tabs/DomainSecurityCenterTab.vue'; +import DomainWaitingRoomTab from './domain-tabs/DomainWaitingRoomTab.vue'; const route = useRoute(); const router = useRouter(); @@ -153,6 +154,7 @@ const wafTabs = [ { key: 'ip-access', label: 'IP Access', icon: ListFilter, component: DomainIpRulesTab }, { key: 'headers', label: 'Headers', icon: SlidersHorizontal, component: DomainHeadersTab }, { key: 'rate-limits', label: 'Rate Limits', icon: Gauge, component: DomainRateLimitsTab }, + { key: 'waiting-room', label: 'Waiting Room', icon: TimerReset, component: DomainWaitingRoomTab }, ]; const secondaryTabs = [ { key: 'analytics', label: 'Analytics', icon: BarChart3, component: DomainAnalyticsTab }, diff --git a/dash/src/views/OverviewView.vue b/dash/src/views/OverviewView.vue index 0e114f20..e9772251 100644 --- a/dash/src/views/OverviewView.vue +++ b/dash/src/views/OverviewView.vue @@ -32,12 +32,22 @@

Needs Attention

No active report warnings for this range.

    -
  • -
    - - {{ warning.message }} +
  • +
    +
    + + {{ warning.message }} +

    {{ warningDetail(warning) }}

    +
    + {{ warningAction(warning) }}
    - Open +
      +
    • + {{ request.status || 'error' }} + {{ request.path || request.host || 'unknown path' }} + {{ formatTime(request.ts) }} +
    • +
@@ -90,7 +100,7 @@
-
+

Recent Problem Requests

@@ -246,5 +256,17 @@ function deltaText(key: string) { return `${sign}${delta.percent}% vs previous range`; } +function warningDetail(warning: ReportSummary['warnings'][number]) { + if (warning.key === 'origin_errors') return 'See the sampled failing requests below with status, path, edge, visitor, and country.'; + if (warning.key === 'pending_dns_changes') return 'Open DNS Operations to inspect pending sync state and PowerDNS visibility.'; + if (warning.key === 'failed_jobs') return 'Open Jobs to inspect failed or cancelled SSL workflow entries.'; + return ''; +} + +function warningAction(warning: ReportSummary['warnings'][number]) { + if (warning.key === 'origin_errors') return 'Show details'; + return 'Open'; +} + onMounted(load); diff --git a/dash/src/views/domain-tabs/DomainCacheTab.vue b/dash/src/views/domain-tabs/DomainCacheTab.vue index ecfe7ed1..6dfa815a 100644 --- a/dash/src/views/domain-tabs/DomainCacheTab.vue +++ b/dash/src/views/domain-tabs/DomainCacheTab.vue @@ -9,6 +9,7 @@
Best defaultEnable edge cache, respect origin Cache-Control, and use 1 hour TTL for ordinary static assets.
Dynamic pagesKeep sensitive or personalized paths uncached with a short cache rule or origin headers.
Stale contentUse stale-if-error so visitors can still receive cached content during an origin outage.
+
Personalized traffic bypasses by defaultAuthorization, no-cache directives, and common session cookies do not enter cache unless explicitly allowed.
+
+
+ Cache key preview + {{ cacheKeyPreview }} +

{{ message }}

@@ -72,7 +81,7 @@ + diff --git a/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue b/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue new file mode 100644 index 00000000..bb2e4d8f --- /dev/null +++ b/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue @@ -0,0 +1,395 @@ +