Skip to content
Merged

Dev #38

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ LOG_ROLE=telemetry
LOG_HOST=telemetry-01
LOG_COLLECTOR_ID=telemetry-01
LOKI_ENDPOINT=https://telemetry.ops.example.com:8444
LOG_AUTH_TOKEN=replace-with-a-unique-log-ingest-token
LOG_BUFFER_BYTES=2147483648
LOG_METRICS_BIND=127.0.0.1:9599
# Loki defaults to 14 days in production; query range is independently bounded.
Expand Down
1 change: 1 addition & 0 deletions compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ services:
LOG_BUFFER_BYTES: ${LOG_BUFFER_BYTES}
LOG_METRICS_ADDRESS: 0.0.0.0:9599
LOKI_ENDPOINT: ${LOKI_ENDPOINT:?LOKI_ENDPOINT is required for the logs profile}
LOG_AUTH_TOKEN: ${LOG_AUTH_TOKEN:?LOG_AUTH_TOKEN is required for the logs profile}
volumes:
- ./docker/vector/operational.yaml:/etc/vector/operational.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
Expand Down
4 changes: 3 additions & 1 deletion core/app/Support/NetworkAddress.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ final class NetworkAddress
{
private const UNSAFE_NETWORKS = [
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
'192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4',
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4',
'::/128', '::1/128', '64:ff9b::/96', '64:ff9b:1::/48', 'fc00::/7', 'fe80::/10', 'fec0::/10', 'ff00::/8',
'2001:db8::/32',
];

private const PRIVATE_NETWORKS = ['10.0.0.0/8', '100.64.0.0/10', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'];
Expand Down
4 changes: 2 additions & 2 deletions core/tests/Feature/SystemIdentityApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ private function validPayload(): array
'platform_domain' => 'cdnf.test',
'proxy_hostname' => 'proxy.cdnf.test',
'nameservers' => [
['hostname' => 'ns1.cdnf.test', 'ipv4' => '192.0.2.10', 'ipv6' => '2001:db8::10'],
['hostname' => 'ns2.cdnf.test', 'ipv4' => '192.0.2.11', 'ipv6' => '2001:db8::11'],
['hostname' => 'ns1.cdnf.test', 'ipv4' => '8.8.8.8', 'ipv6' => '2001:4860:4860::8888'],
['hostname' => 'ns2.cdnf.test', 'ipv4' => '1.1.1.1', 'ipv6' => '2606:4700:4700::1111'],
],
'soa_primary' => 'ns1.cdnf.test',
'soa_mailbox' => 'hostmaster.cdnf.test',
Expand Down
2 changes: 1 addition & 1 deletion core/tests/Unit/NetworkAddressTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public function test_destination_safety_distinguishes_allowlistable_private_spac
$this->assertTrue(NetworkAddress::isPrivate('10.20.30.40'));
$this->assertTrue(NetworkAddress::inCidr('10.20.30.40', '10.0.0.0/8'));

foreach (['127.0.0.1', '169.254.169.254', '::1', '::ffff:127.0.0.1', '64:ff9b::7f00:1'] as $address) {
foreach (['0.1.2.3', '127.0.0.1', '169.254.169.254', '192.0.2.1', '192.88.99.1', '198.51.100.1', '203.0.113.1', '239.1.2.3', '240.0.0.1', '::1', '::ffff:127.0.0.1', '64:ff9b::7f00:1', '2001:db8::1'] as $address) {
$this->assertTrue(NetworkAddress::isUnsafe($address), "$address must be blocked");
$this->assertFalse(NetworkAddress::isPrivate($address), "$address must never be private-allowlist eligible");
}
Expand Down
21 changes: 12 additions & 9 deletions deploy/production/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@
{$CONTROL_HOSTNAME} {
encode zstd gzip
reverse_proxy web:8080 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-Host {$CONTROL_HOSTNAME}
header_down Location "http://{$CONTROL_HOSTNAME}" "https://{$CONTROL_HOSTNAME}"
}

header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-Host {$CONTROL_HOSTNAME}
header_down Location "http://{$CONTROL_HOSTNAME}" "https://{$CONTROL_HOSTNAME}"
}

header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "upgrade-insecure-requests"
-Server
Content-Security-Policy "upgrade-insecure-requests"
-Server
}
}

Expand All @@ -44,7 +43,11 @@ https://{$TELEMETRY_HOSTNAME}:8444 {

{$GRAFANA_HOSTNAME} {
reverse_proxy grafana:3000
header { Strict-Transport-Security "max-age=31536000"; X-Content-Type-Options "nosniff"; -Server }
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
-Server
}
}

http://127.0.0.1:2019 {
Expand Down
6 changes: 5 additions & 1 deletion deploy/production/Caddyfile.telemetry
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ https://{$TELEMETRY_HOSTNAME}:8444 {

{$GRAFANA_HOSTNAME} {
reverse_proxy grafana:3000
header { Strict-Transport-Security "max-age=31536000"; X-Content-Type-Options "nosniff"; -Server }
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
-Server
}
}

http://127.0.0.1:2019 {
Expand Down
12 changes: 7 additions & 5 deletions docker/openresty/runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,18 @@ local function blocked(ip, networks, blocked_networks, denied)
end
if allowed(ip, blocked_networks) then return true end
if ip:lower():match("^::ffff:") then return true end
if ip == "0.0.0.0" or ip:match("^127%.") or ip:match("^169%.254%.") or ip:match("^224%.") then return true end
local a, b = ip:match("^(%d+)%.(%d+)%.")
a, b = tonumber(a), tonumber(b)
local a, b, c = ip:match("^(%d+)%.(%d+)%.(%d+)%.")
a, b, c = tonumber(a), tonumber(b), tonumber(c)
if a == 0 or a == 127 or a == 169 and b == 254 or (a and a >= 224) then return true end
if (a == 10 or a == 192 and b == 168 or a == 172 and b and b >= 16 and b <= 31) and not allowed(ip, networks) then return true end
if a == 100 and b and b >= 64 and b <= 127 then return true end
if a == 192 and b == 0 or a == 198 and b and (b == 18 or b == 19) then return true end
if a == 192 and (b == 0 or b == 2 or b == 88 and c == 99)
or a == 198 and (b == 18 or b == 19 or b == 51 and c == 100)
or a == 203 and b == 0 and c == 113 then return true end
local lower = ip:lower()
local hard_v6 = lower == "::" or lower == "::1" or lower:match("^fe[89ab]") ~= nil
or lower:match("^fe[c-f]") ~= nil or lower:match("^ff") ~= nil
or lower:match("^64:ff9b:") ~= nil
or lower:match("^64:ff9b:") ~= nil or lower:match("^2001:db8:") ~= nil
if hard_v6 then return true end
local private_v6 = lower:match("^f[cd]") ~= nil
if private_v6 and allowed(ip, networks) then return false end
Expand Down
8 changes: 8 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ export default defineConfig({
items: [
{ text: 'Production deployment', link: '/deployment/' },
{ text: 'Production quick start', link: '/deployment/production-quick-start' },
{ text: 'Multi-region quick start', link: '/deployment/production-quick-start-multi-region' },
{ text: 'Fleet operator guide', link: '/deployment/production-fleet-operator-guide' },
{ text: 'Fleet configuration', link: '/deployment/production-fleet-config-reference' },
{ text: 'Fleet architecture', link: '/deployment/production-fleet' },
{ text: 'Manual Docker Compose deployment', link: '/deployment/manual-compose' },
{ text: 'Topology', link: '/deployment/topology' },
{ text: 'Certificates', link: '/deployment/certificates' },
{ text: 'Upgrade', link: '/deployment/upgrade' }
Expand All @@ -264,13 +269,16 @@ export default defineConfig({
{ text: 'Monitoring', link: '/operations/monitoring' },
{ text: 'Grafana command centers', link: '/operations/grafana' },
{ text: 'Operational logging', link: '/operations/operational-logging' },
{ text: 'Laravel operations dashboard', link: '/operations/laravel-operations-dashboard' },
{ text: 'Edge gateway ingress', link: '/operations/gateway-ingress' },
{ text: 'Bounded cell inventory', link: '/operations/cell-inventory' },
{ text: 'Multi-cell pools', link: '/operations/multi-cell-pools' },
{ text: 'Pool service endpoints', link: '/operations/pool-service-endpoints' },
{ text: 'Simple Anycast', link: '/operations/simple-anycast' },
{ text: 'Anycast qualification', link: '/operations/simple-anycast-qualification' },
{ text: 'Fleet rollouts', link: '/operations/fleet-rollouts' },
{ text: 'Runtime generations', link: '/operations/runtime-generations' },
{ text: 'Software supply chain', link: '/operations/software-supply-chain' },
{ text: 'Backup and recovery', link: '/operations/backup-and-recovery' },
{ text: 'Incident runbooks', link: '/operations/runbooks' },
{ text: 'Scaling', link: '/operations/scaling' },
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ startup creates writable directories but deliberately does not migrate.
| `pdns-auth` | Private PowerDNS authoritative service |
| `pdns-db` | Rebuildable PowerDNS runtime schema |
| `pdns-migrate` | Explicit PowerDNS runtime migration tool |
| `dns-api` overlay | Source-restricted TLS proxy for the private PowerDNS API |
| `dns-api` | Source-restricted TLS proxy for the private PowerDNS API in the `dns` profile |

PowerAdmin exists only in the development-tools profile and is diagnostic.
Direct edits are drift.
Expand Down
29 changes: 26 additions & 3 deletions docs/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ or rebuildable.

```mermaid
flowchart TB
ExternalDNS["Independent external DNS<br/>management hostnames"] --> UI
ExternalDNS --> EdgeControl["edge-control<br/>mTLS ingress"]
ExternalDNS --> DNSAPI["dns-api-N<br/>restricted TLS"]
ExternalDNS --> TelemetryIngress["telemetry ingress"]
subgraph Management["Management plane"]
UI["Filament panels and Sanctum API"] --> Laravel["Laravel monolith"]
Laravel --> Horizon["Horizon workers"]
Expand All @@ -24,6 +28,7 @@ flowchart TB
subgraph DNS["Authoritative DNS plane"]
DNSdist["DNSdist public ingress"] --> PowerDNS["Private PowerDNS"]
PowerDNS --> PDNSDB[("Derived PowerDNS DB")]
DNSAPI --> PowerDNS
end
subgraph Edge["HTTP edge plane"]
Agent["Edge agent"] --> Gateway["Destination + Host/SNI gateway"]
Expand All @@ -43,13 +48,31 @@ flowchart TB
Laravel --> Valkey
Horizon --> Valkey
PG -->|"sanitized read-only metadata"| Grafana
Horizon -->|"versioned reconciliation"| PowerDNS
Agent -->|"outbound mTLS: pull artifacts/tasks, acknowledge"| Laravel
Horizon -->|"versioned reconciliation"| DNSAPI
Agent -->|"outbound mTLS: pull artifacts/tasks, acknowledge"| EdgeControl
EdgeControl --> Laravel
DNSdist -.-> Vector
Cell1 -.-> Vector
Cell2 -.-> Vector
```

## DNS namespaces and addresses

| Namespace or address | Owner and purpose | CDNFoundry PowerDNS? |
| --- | --- | --- |
| `control.<operator-zone>`, `edge-control.<operator-zone>`, `telemetry.<operator-zone>`, `grafana.<operator-zone>`, `dns-api-N.<operator-zone>` | Independent external DNS provider; management and recovery reachability | Never |
| `ns1.<platform-zone>`, `ns2.<platform-zone>` and glue | Parent/registrar delegation to public DNSdist addresses | Served by DNSdist from derived PowerDNS state after bootstrap |
| Enrolled customer zones | PostgreSQL desired state reconciled into private PowerDNS databases | Yes |
| Edge pool service addresses | Public HTTP/HTTPS addresses selected through platform/customer DNS | Stored as platform desired state, not management addresses |
| Private PowerDNS, PostgreSQL, Valkey, ClickHouse, agent/status addresses | Host or private service networks | No public DNS required |

::: danger Avoid a DNS bootstrap loop
Management records must remain resolvable while CDNFoundry DNS is empty,
degraded, or being restored. Hosting them in the platform's own PowerDNS can
leave the DNS API and control plane unreachable precisely when operators need
them for repair.
:::

| Plane | Components | Responsibility |
| --- | --- | --- |
| Management | Laravel, Filament, Horizon, scheduler | Authorization, validation, desired state, operations, reconciliation |
Expand All @@ -60,7 +83,7 @@ flowchart TB

Only DNSdist, mapped edge-gateway service listeners, and the browser/API reverse proxy
belong on public ingress. Edge control uses mutual TLS. Telemetry and PowerDNS
API gateways are source restricted in the production overlays. Internal
API gateways are source restricted by the production Caddy configuration. Internal
databases, Valkey, ClickHouse, raw metrics, Grafana port 3000, and PowerDNS
itself remain private. Remote Grafana access uses a deployment-owned
authenticated HTTPS proxy or trusted tunnel.
Expand Down
17 changes: 11 additions & 6 deletions docs/architecture/production-reference-architectures.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,23 @@ domains.

```mermaid
flowchart LR
ExternalDNS["Independent external DNS<br/>management records"] --> CONTROL
ExternalDNS --> DNSAPI["dns-api-N"]
ExternalDNS --> EC["edge-control"]
Operators["Operators"] --> CONTROL["CONTROL<br>control plane"]
CONTROL --> State[("PostgreSQL + Valkey")]
Resolver["Resolvers"] --> EDGE1DNS["EDGE_1<br>DNSdist + PowerDNS"]
Resolver --> EDGE2DNS["EDGE_2<br>DNSdist + PowerDNS"]
Clients["HTTP clients"] --> EDGE1GW["EDGE_1<br>gateway + cells"]
Clients --> EDGE2GW["EDGE_2<br>gateway + cells"]
CONTROL -. "restricted DNS API" .-> EDGE1DNS
CONTROL -. "restricted DNS API" .-> EDGE2DNS
CONTROL -. "revisioned reconciliation" .-> DNSAPI
DNSAPI --> EDGE1DNS
DNSAPI --> EDGE2DNS
EDGE1GW --> Origins["Validated origins"]
EDGE2GW --> Origins
EDGE1GW -->|"outbound mTLS"| CONTROL
EDGE2GW -->|"outbound mTLS"| CONTROL
EDGE1GW -->|"edge agent: outbound mTLS"| EC
EDGE2GW -->|"edge agent: outbound mTLS"| EC
EC --> CONTROL
```

Best for:
Expand Down Expand Up @@ -116,7 +121,7 @@ Best for:
- edge bandwidth or DNS load that should not compete on one host;
- a dedicated database or observability team;
- independent maintenance windows;
- deployments using the external control-data or telemetry-data overlays.
- deployments using owner-operated external control or telemetry data services.

Tradeoffs:

Expand All @@ -125,7 +130,7 @@ Tradeoffs:
- separating roles without separate failure domains mainly improves resource
isolation, not site resilience.

CDNFoundry supplies role overlays and external endpoints. It does not supply a
CDNFoundry supplies role profiles, generated node bundles, and external endpoint settings. It does not supply a
PostgreSQL, Valkey, or ClickHouse clustering product. The operator owns those
systems' quorum, fencing, failover, consistency, and restore qualification.

Expand Down
17 changes: 10 additions & 7 deletions docs/contributing/documentation-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ current automated and owner-run qualification evidence.
:::

This documentation system was reconstructed on 2026-07-26 and re-audited on
2026-08-01 against release `v0.9.1` plus the current working tree. The previous
corpus is preserved verbatim under `docs/legacy/` and in Git history. It is
excluded from current navigation, search, lint, build, and link guarantees.
2026-08-08 against the current working tree. The superseded corpus remains
under `docs/legacy/` for historical context, with prohibited vendor comparisons
removed. It is excluded from current navigation, search, lint, build, and link
guarantees and is not an operational instruction set.

## Audited implementation surfaces

Expand All @@ -32,16 +33,18 @@ The audit covered every tracked project area:
| TLS/cache/security | controllers, support validators, jobs, runtime, feature and E2E tests |
| Telemetry | Vector transforms/sinks, ClickHouse DDL, analytics queries, metrics and alerts |
| Configuration | Laravel configs, both example environments, Compose interpolation, scripts |
| Infrastructure | development/production Compose, every production overlay, Dockerfiles, Caddy |
| Infrastructure | development/production Compose, generated role bundles, Dockerfiles, Caddy |
| Development and CI | Make targets, shell scripts, PHP/Go/Python tests, GitHub workflow/forms |
| Existing documentation | all legacy Markdown, generated OpenAPI, root guides, pull-request template |

The audit used file inventories, route registry output, environment-key
extraction, class/function indexes, migration constraints, Compose-rendered
services, and targeted full-file review. The 2026-08-01 pass additionally
services, and targeted full-file review. The 2026-08-08 pass additionally
checked bounded cells, gateway ingress, Geo-Unicast and Simple Anycast pools,
cache/compression/origin failover, managed WAF, fleet rollout, Grafana, Loki,
production overlays, the environment generator, and every quick-start command.
the single production Compose file, generated role bundles, the Fleet
environment generator, management-DNS bootstrap independence, every current
Mermaid diagram, and every quick-start command.
Generated dependency lockfiles were treated as dependency evidence, not prose
to paraphrase.

Expand All @@ -66,7 +69,7 @@ does not expand the implemented product boundary.
| Historical phase test counts were current qualification status. | Counts such as 118 or 140 tests apply only to their recorded commits; the current suite contains more tests and must be rerun. |
| Roadmap future stages, current operation, agent rules, and qualification evidence belonged in one public guide. | Governance and owner qualification are repository concerns, not public product documentation; legacy keeps the original proposal history. |
| Telemetry retention settings automatically define runtime TTLs. | Masking/finalization are active application policy, while shipped ClickHouse TTLs are static in `docker/clickhouse/init.sql` and require an operator migration to change. |
| A public address must exist on each host so Docker can bind it. | Public/NAT addresses are advertised identities. Shared production overlays bind `HOST_BIND_IPV4`/`HOST_BIND_IPV6`; the edge gateway requires a complete advertised-to-private `EDGE_GATEWAY_ADDRESS_MAP` behind NAT or a layer-4 load balancer. |
| A public address must exist on each host so Docker can bind it. | Public/NAT addresses are advertised identities. Generated bundles set `HOST_BIND_IPV4`/`HOST_BIND_IPV6`; the edge gateway requires a complete advertised-to-private `EDGE_GATEWAY_ADDRESS_MAP` behind NAT or a layer-4 load balancer. |
| Restic is required to render or start the control profile. | Built-in Restic backup is optional. Empty settings skip the daily job and fail backup requests explicitly while leaving serving available and backup health degraded. |
| One-shot migrations can bootstrap their own databases during first install. | The supported first-install sequence starts and health-checks PostgreSQL/Valkey or PowerDNS PostgreSQL before running its explicit migration container. |

Expand Down
Loading
Loading