Skip to content

F2 — Cell-0 no Hetzner: arquitetura técnica, interconexões e runbook

Escopo: documento profundo (HLD + LLD + diagramas + scripts + manuais) do OCTOPUS F2 — auto-provisionamento de clientes materializado no cell-0 (Hetzner Cloud), e de suas interconexões com AWS e Cloudflare.

Status: 🟢 LIVE (2026-06-13). Onboarding automático provado fim-a-fim. Custo incremental: ~$12-15/mo (Hetzner), zero a mais na AWS.

ADRs relacionados: 0053 (cell-based hyperscale + Wave-1 cell-0 substrate), 0056 (auto-provisioning saga), 0050/0051 (CONNECT.Art / STUN-coord), 0102 (auth). Memória: project_f2_onboarding_wiring_2026_06_13.


1. Sumário executivo

O F2 fecha o "meio" da cadeia comercial (signup → compra → provisionamento → produção). Um pagamento liquidado no customer-app (AWS App Runner) dispara, via HTTPS+HMAC, o Provisioning Orchestrator rodando no cell-0 (um VPS Hetzner com K3s), que executa o OnboardingWorkflow (Temporal) — um saga de 12 atividades com compensação LIFO — provisionando o tenant do cliente (isolamento Postgres RLS), emitindo o certificado de cliente (cert-manager), registrando quota e slots de data-plane, e gerando o JWT de onboarding.

Três nuvens, três papéis:

Nuvem Papel Componentes
AWS Control-plane SaaS sempre-online customer-app (App Runner), RDS (ledger UTXO), S3 (TBI/F1), admin-console
Cloudflare DNS + borda app.tlsstress.art (proxied), f2.tlsstress.art (DNS-only → cell)
Hetzner Cell-0 data/control de provisionamento K3s, Traefik, cert-manager, Temporal+Postgres, Provisioner, cell control DB

2. HLD — Topologia geral

graph TB
  subgraph Internet
    CUST([Cliente])
  end

  subgraph CF["Cloudflare (DNS + borda)"]
    APPDNS["app.tlsstress.art<br/>(proxied)"]
    F2DNS["f2.tlsstress.art A 204.168.178.210<br/>(DNS-only / cinza)"]
  end

  subgraph AWS["AWS us-east-1 (control plane SaaS)"]
    AR["customer-app<br/>App Runner :3200<br/>(Next 16)"]
    RDS[("RDS db.t4g.micro<br/>UTXO ledger<br/>mint @ checkout")]
    S3[("S3 bootstrap-artifacts<br/>TBI / F1 download")]
    STRIPE{{Stripe}}
  end

  subgraph HETZNER["Hetzner cx33 — cell-0 (hel1, 204.168.178.210)"]
    FW[["Cloud Firewall 11131764<br/>allow 22/80/443/ICMP · drop 6443/10250"]]
    subgraph K3S["K3s v1.35.5 (single-node)"]
      TR["Traefik ingress<br/>:80 / :443"]
      subgraph NSO["ns octopus"]
        PROV["provisioner<br/>Temporal worker + /trigger :8080"]
      end
      subgraph NST["ns temporal"]
        TMP["Temporal frontend :7233<br/>(auto-setup 1.29.6.1)"]
        PG[("Postgres 16<br/>db: temporal · cell")]
      end
      subgraph NSC["ns cert-manager"]
        CM["cert-manager 1.20.2"]
        CAISS["ClusterIssuer<br/>octopus-provisioning-ca"]
        LEISS["ClusterIssuer<br/>letsencrypt-prod"]
      end
      subgraph NSTEN["ns tenants"]
        CERTS["Certificates por tenant<br/>(client certs)"]
      end
    end
  end

  CUST -->|signup/pay| APPDNS --> AR
  STRIPE -->|checkout.session.completed| AR
  AR -->|"applyTier (mint quota)"| RDS
  AR -->|"enqueueOnboarding<br/>HMAC POST"| F2DNS -->|443 TLS| FW --> TR
  TR -->|"/trigger/onboarding"| PROV
  PROV -->|ExecuteWorkflow| TMP
  TMP <-->|sql| PG
  PROV -->|"IssueClientCert"| CM --> CAISS --> CERTS
  PROV -->|"tenant/slots/quota"| PG
  LEISS -.->|"cert público f2.*"| TR
  CUST -->|"pay→download TBI (F1)"| S3

Propriedade de segurança fundamental: o enqueueOnboarding do customer-app degrada para log se o trigger falhar (cell offline, TLS, etc.) — o checkout nunca vira 5xx. F2 é aditivo e fail-safe.


3. HLD — Fluxo de onboarding (sequência)

sequenceDiagram
  autonumber
  participant S as Stripe
  participant A as customer-app (AWS)
  participant CF as Cloudflare DNS
  participant T as Traefik (Hetzner)
  participant P as Provisioner
  participant W as Temporal
  participant DB as Cell DB
  participant CMG as cert-manager

  S->>A: checkout.session.completed (pago)
  A->>A: handleCheckoutCompleted → applyTier (mint quota no RDS)
  A->>CF: resolve f2.tlsstress.art
  A->>T: POST /trigger/onboarding (HMAC X-Provisioning-Signature)
  T->>P: rota TLS (cert LE) → :8080
  P->>P: verifica HMAC (constant-time)
  P->>W: StartWorkflow OnboardingWorkflow(input)
  Note over W,DB: saga 12 atividades (compensação LIFO em ctx desconectado)
  W->>P: KYC (antifraud) → Mint id → AllocateCell(hel1-0)
  W->>CMG: IssueClientCert → Certificate CR → assina (CA issuer)
  W->>DB: ProvisionTenant (RLS) · RecordQuota · ReserveSlot×2
  W->>P: GenerateOnboardingJWT (Ed25519) · WelcomeEmail · Audit
  W-->>P: COMPLETED {deployment_id, cert_handle, jwt, dashboard_url}
  P-->>A: 202 {workflow_id}

4. LLD — Componentes

4.1 Host (Hetzner cx33)

Item Valor
Server ID 140682072
Tipo cx33 (4 vCPU / 7.6 GB / 75 GB)
OS Ubuntu 24.04.4 LTS, x86_64
Região hel1 (Helsinki)
IPv4 / IPv6 204.168.178.210 / 2a01:4f9:c013:3c28::/64
Rede privada 172.20.20.2
SSH chave ed25519 ~/.ssh/tlsstress_f2_hetzner, root@

Hardening (/etc/ssh/sshd_config.d/99-tlsstress-hardening.conf): PasswordAuthentication no, KbdInteractiveAuthentication no, PermitRootLogin prohibit-password. + fail2ban + unattended-upgrades.

4.2 Firewall (Hetzner Cloud Firewall — Terraform)

Módulo IaC: platform/terraform/f2-hetzner-cell0/ (firewall id 11131764). Default-deny inbound; servidor referenciado read-only (data "hcloud_server") para Terraform nunca recriar.

Direção Porta Origem Motivo
in 22/tcp 0.0.0.0/0,::/0 ¹ SSH (key-only)
in 80/tcp any ACME HTTP-01 + redirect
in 443/tcp any trigger HTTPS + ingress
in ICMP any diagnóstico
in 6443, 10250, 8472, 2379-2380, resto DROP

¹ SSH aberto por uplink dinâmico 4G/CGNAT do operador; host é key-only. Restringir via -var ssh_source_ips quando houver IP fixo/VPN.

Verificado pós-apply: 6443/10250 filtradas da internet; 22/443 abertas.

4.3 Kubernetes (K3s)

v1.35.5+k3s1, single-node, instalado com --tls-san 204.168.178.210 --write-kubeconfig-mode 0644. Ingress = Traefik (bundled). API 6443 protegida pelo firewall (não pública). kubectl roda no host via SSH.

4.4 PKI (cert-manager 1.20.2) — dois issuers

graph LR
  SS["ClusterIssuer<br/>selfsigned-bootstrap"] --> CACERT["Certificate<br/>octopus-provisioning-ca<br/>(ns cert-manager, ECDSA-384, 10y)"]
  CACERT --> CAISS["ClusterIssuer (CA)<br/>octopus-provisioning-ca-issuer"]
  CAISS --> CLIENT["client certs por tenant<br/>(ns tenants)"]
  LE["ClusterIssuer (ACME)<br/>letsencrypt-prod<br/>HTTP-01 via Traefik"] --> PUBCERT["cert público<br/>f2-tlsstress-art-tls"]
  • octopus-provisioning-ca-issuer (CA interna) → emite os certs de cliente que IssueClientCert cria. Substitui o HashiCorp Vault (ADR-0053-H4).
  • letsencrypt-prod (ACME HTTP-01) → emite o cert público de f2.tlsstress.art para o trigger (issuer Let's Encrypt, auto-renova).

4.5 Temporal + Postgres (ns temporal)

Recurso Detalhe
Postgres postgres:16-alpine, Deployment + PVC 10Gi + Service postgres, user temporal (secret temporal-pg)
Databases temporal, temporal_visibility (Temporal) + cell (control plane do F2)
Temporal temporalio/auto-setup:1.29.6.1 (DB=postgres12, schema+default ns automáticos)
Frontend Service temporal-frontend.temporal.svc.cluster.local:7233

⚠️ O frontend faz bind no IP do pod, não em loopback → use o Service DNS ou o pod IP (--address localhost é recusado).

4.6 Cell control DB (database cell, ADR-0053 Wave-1)

Migração idempotente embarcada no binário (internal/cell/migrations/0001_cell_control.sql), aplicada no boot do provisioner (Manager.Migrate).

erDiagram
  tenants {
    text deployment_id PK
    text cell_id
    text customer_email
    text status "provisioning|provisioned|rollback_pending|released"
    bigint tokens_included
    timestamptz created_at
    timestamptz updated_at
  }
  slots {
    text deployment_id PK
    text kind PK "connect-art|stun-coord"
    text cell_id
    timestamptz reserved_at
    timestamptz released_at "NULL=ativo"
  }
  tenant_data {
    text deployment_id PK
    text k PK
    jsonb v
    text cell_id
  }

Isolamento = Postgres RLS shared-schema (escala a 10k tenants/cell): tenant_data tem ENABLE/FORCE ROW LEVEL SECURITY + policy USING (deployment_id = current_setting('octopus.tenant')). O control-plane (provisioner) roda privilegiado; o data-plane (CONNECT.Art/STUN-coord/runtime do tenant) conecta com o role tenant_dataplane (NOSUPERUSER NOBYPASSRLS), para o qual a RLS é enforçada. Provado live: leitura cross-tenant sob esse role = 0 linhas.

Slot = entrada de admissão (não recurso de runtime escasso): CONNECT.Art/ STUN-coord leem slots para admitir um deployment_id.

4.7 Provisioner (ns octopus)

Recurso Detalhe
Imagem tlsstress/provisioner:0.1.1 (scratch + CA bundle, imagePullPolicy: Never)
Deployment octopus/provisioner (1 réplica, runAsNonRoot 65532, probes /healthz:8080)
ServiceAccount provisioner
RBAC Role provisioner-pki (ns tenants): certificates create/get/list/delete + secrets get/delete
Service provisioner:8080
Trigger POST /trigger/onboarding (HMAC-SHA256, montado só se PROVISIONING_TRIGGER_SECRET setado)
Worker registra OnboardingWorkflow no task queue octopus-provisioning

Env (Deployment + secret provisioner-env):

PROVISIONER_MODE=real
PROVISIONER_PKI=certmanager
PROVISIONER_CERT_NAMESPACE=tenants
PROVISIONER_PKI_ISSUER=octopus-provisioning-ca-issuer
PROVISIONER_CERT_DOMAIN=satellite.tlsstress.art
OCTOPUS_CELL_ID=hel1-0
TEMPORAL_ADDR=temporal-frontend.temporal.svc.cluster.local:7233
HTTP_ADDR=:8080
CELL_DATABASE_URL=postgres://temporal:<pw>@postgres.temporal.svc.cluster.local:5432/cell?sslmode=disable   # secret
PROVISIONING_TRIGGER_SECRET=<hmac 64-hex>                                                                  # secret
PROVISIONER_JWT_ED25519_SEED=<base64 32-byte seed>                                                          # secret

Contrato de honestidade (fail-closed): cada activity é real onde a infra existe, ou retorna ErrNotProvisioned (nunca fake). RefundStripe fail-closa com escalação CRÍTICA (não há Stripe client no provisioner).

4.8 OnboardingWorkflow (Temporal, saga)

12 atividades + compensação LIFO em workflow.NewDisconnectedContext (cancelamento não aborta rollback); stack limpa após o ponto de no-return (welcome email). RetryPolicy: 5 tentativas, backoff exp.

# Activity Compensação Backend
1 KYCCheck antifraud heurístico
2 MintDeploymentID MarkDeploymentIDRolledBack crypto-rand dpl_<hex>
3 AllocateCell ReleaseCellAllocation cell (hel1-0)
4 IssueClientCert RevokeClientCert cert-manager
5 ProvisionTenant MarkTenantRollbackPending cell DB (RLS)
6 AllocateTokenQuota BurnTokenQuota cell DB (tokens_included) ²
7 ReserveConnectArtSlot ReleaseConnectArtSlot cell DB (slots)
8 ReserveStunCoordSlot ReleaseStunCoordSlot cell DB (slots)
9 GenerateOnboardingJWT Ed25519 (seed)
10 SendWelcomeEmail — (no-return) Postmark (best-effort)
11 AppendAuditChain log estruturado
12 NotifyAdminHighValue Slack webhook (best-effort)

² Quota: o mint autoritativo é no checkout (customer-app→RDS UTXO). A activity registra a entitlement no cell (tenants.tokens_included), não é 2º mint.


5. Interconexões Hetzner / AWS / Cloudflare

5.1 Matriz de rede / portas

De Para Porta/Proto Auth Notas
Internet App Runner 443 sessão / license-JWT via Cloudflare (app.* proxied)
App Runner f2.tlsstress.art (Hetzner) 443 HTTPS HMAC-SHA256 body cert Let's Encrypt; CF DNS-only
App Runner RDS 5432 IAM/secret mint de quota no checkout (privado VPC)
cert-manager (Hetzner) Let's Encrypt 443 (out) ACME HTTP-01 (porta 80 inbound p/ challenge)
Operador Hetzner 22 SSH key key-only
Operador (CF API) Cloudflare API 443 token Zone:DNS:Edit gerência do registro f2

5.2 Cadeia de confiança (PKI)

graph TB
  LE["Let's Encrypt (público)"] --> F2C["f2.tlsstress.art TLS"] --> ARFETCH["fetch do App Runner<br/>(verifica cadeia pública)"]
  ROOT["octopus-provisioning-ca (interna)"] --> CLI["client cert do deployment<br/>(satellite.*.tlsstress.art)"]
  HMAC["PROVISIONING_TRIGGER_SECRET<br/>(compartilhado App Runner ↔ provisioner)"] --> TRIG["autentica o /trigger"]
  • TLS público (App Runner→cell): Let's Encrypt → confiável pela cadeia pública padrão.
  • HMAC (App Runner↔provisioner): segredo compartilhado, assina o body do trigger.
  • Client cert (por tenant): CA interna octopus-provisioning-ca (não público).

5.3 Por que esta topologia (custo + arquitetura)

  • F2 fora da AWS (Hetzner ~$12/mo) preserva o cost-pause da AWS (pré-receita).
  • Control-plane SaaS sempre-online fica na AWS (App Runner) + Cloudflare (borda).
  • O cell phone-home reverso: o customer-app chama o cell (push), evitando abrir o RDS para o Hetzner (fronteira de segurança limpa — quota fica na AWS).

6. Scripts

Pré-requisitos: ~/.ssh/tlsstress_f2_hetzner, ~/.config/tlsstress/cloudflare.token (Zone:DNS:Edit), ~/.config/tlsstress/hcloud.token (Hetzner). Build do binário precisa cross-compile local (Mac arm64 → linux/amd64); a imagem é construída no cell (nerdctl+buildkit) por causa do uplink throttled + Docker do Mac.

6.1 Firewall (Terraform)

cd platform/terraform/f2-hetzner-cell0
export HCLOUD_TOKEN=$(tr -d '[:space:]' < ~/.config/tlsstress/hcloud.token)
terraform init && terraform plan && terraform apply

6.2 Build + load da imagem do provisioner (no cell)

# 1) cross-compile local (módulo pkg/octopus)
cd pkg/octopus
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
  -o /tmp/f2img/provisioner ./provisioning-orchestrator/cmd/provisioner

# 2) sobe (comprimido — uplink throttled) + Dockerfile.local (alpine+ca-certs+COPY)
scp -C -i ~/.ssh/tlsstress_f2_hetzner /tmp/f2img/{provisioner,Dockerfile.local} \
  root@204.168.178.210:/root/f2build/

# 3) buildkitd no cell (runc no PATH + host net) + build no namespace k8s.io
ssh -i ~/.ssh/tlsstress_f2_hetzner root@204.168.178.210 '
  export PATH=/var/lib/rancher/k3s/data/current/bin:$PATH
  pgrep buildkitd >/dev/null || nohup buildkitd --oci-worker-net=host >/var/log/buildkitd.log 2>&1 &
  sleep 2
  cd /root/f2build
  nerdctl --address /run/k3s/containerd/containerd.sock --namespace k8s.io \
    build -t tlsstress/provisioner:0.1.1 -f Dockerfile.local .'

6.3 Deploy (manifests + secret)

# manifests (SA + RBAC tenants + Deployment + Service)
ssh ... 'cat > /root/f2build/provisioner.yaml' < platform/k8s/f2-cell0/provisioner.yaml
ssh ... 'kubectl apply -f /root/f2build/provisioner.yaml'

# secret (gerado no cell — DSN reusa a senha do temporal-pg)
ssh ... '
  PGPW=$(kubectl -n temporal get secret temporal-pg -o jsonpath="{.data.password}" | base64 -d)
  kubectl -n octopus create secret generic provisioner-env \
    --from-literal=CELL_DATABASE_URL="postgres://temporal:${PGPW}@postgres.temporal.svc.cluster.local:5432/cell?sslmode=disable" \
    --from-literal=PROVISIONING_TRIGGER_SECRET="$(openssl rand -hex 32)" \
    --from-literal=PROVISIONER_JWT_ED25519_SEED="$(openssl rand 32 | base64 -w0)" \
    --dry-run=client -o yaml | kubectl apply -f -
  kubectl -n octopus rollout status deploy/provisioner'

6.4 DNS (Cloudflare API)

CF=$(tr -d '[:space:]' < ~/.config/tlsstress/cloudflare.token); API=https://api.cloudflare.com/client/v4
ZID=$(curl -s -H "Authorization: Bearer $CF" "$API/zones?name=tlsstress.art" | jq -r .result[0].id)
curl -s -X POST -H "Authorization: Bearer $CF" -H "Content-Type: application/json" \
  "$API/zones/$ZID/dns_records" \
  -d '{"type":"A","name":"f2","content":"204.168.178.210","ttl":300,"proxied":false}'

6.5 Cert público + ingress (cert-manager LE + Traefik)

kubectl apply de: ClusterIssuer letsencrypt-prod (ACME HTTP-01 via Traefik) + Ingress octopus/provisioner-trigger (host f2.tlsstress.art, path /trigger/onboarding, cert-manager.io/cluster-issuer: letsencrypt-prod, tls.secretName: f2-tlsstress-art-tls). (Manifests completos: §4.4/§4.7.)

6.6 Disparar onboarding (HMAC assinado)

SECRET=$(kubectl -n octopus get secret provisioner-env -o jsonpath='{.data.PROVISIONING_TRIGGER_SECRET}' | base64 -d)
BODY='{"stripe_session_id":"cs_x","stripe_customer_id":"cus_x","email":"a@b.c","name":"X","country_code":"US","package_slug":"pro-monthly","amount_cents":100000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -s -X POST https://f2.tlsstress.art/trigger/onboarding \
  -H "X-Provisioning-Signature: $SIG" -H "Content-Type: application/json" -d "$BODY"
# → 202 {"workflow_id":"onboarding-cs_x"}

6.7 Cutover (AWS App Runner — env vars)

⚠️ aws apprunner update-service é bloqueado pelo gate de permissão local (proteção do app de prod). Fazer no console (Configuration → Edit → Env vars): PROVISIONING_TRIGGER_URL=https://f2.tlsstress.art/trigger/onboarding + PROVISIONING_TRIGGER_SECRET=<valor do secret>. update-service substitui a config inteira → preservar os 33 env vars + 12 secrets + image.


7. Runbook operacional

7.1 Redeploy de nova versão do provisioner

  1. Cross-compile (§6.2.1) → upload (§6.2.2) → build :0.1.N (§6.2.3).
  2. kubectl -n octopus set image deploy/provisioner provisioner=tlsstress/provisioner:0.1.N.
  3. kubectl -n octopus rollout status deploy/provisioner + checar logs (worker starting on task_queue).

7.2 Rotacionar o PROVISIONING_TRIGGER_SECRET

  1. NEW=$(openssl rand -hex 32); kubectl -n octopus create secret generic provisioner-env --from-literal=PROVISIONING_TRIGGER_SECRET=$NEW ... (re-aplicar com os 3 valores) | kubectl apply -f -.
  2. kubectl -n octopus rollout restart deploy/provisioner.
  3. Atualizar PROVISIONING_TRIGGER_SECRET no App Runner (console) → redeploy.
  4. (Os dois lados devem bater; durante a troca, o enqueue degrada para log.)

7.3 Verificar o cutover (AWS — leitura permitida)

aws apprunner describe-service --service-arn <ARN> --region us-east-1 \
  --query 'Service.{status:Status, vars:SourceConfiguration.ImageRepository.ImageConfiguration.RuntimeEnvironmentVariables}'

7.4 Backup do cell DB

ssh ... 'kubectl -n temporal exec deploy/postgres -- pg_dump -U temporal cell' > cell-$(date +%F).sql

7.5 Troubleshooting

Sintoma Causa provável Ação
provisioner CrashLoop expected workflow.Context registrou função pura (não workflow) registrar OnboardingWorkflow (já corrigido)
buildkitd connection refused runc fora do PATH / sem host-net PATH=…/k3s/data/current/bin buildkitd --oci-worker-net=host
cert público não fica Ready HTTP-01 não alcança :80 / DNS não resolve conferir A f2 (DNS-only) + firewall 80 + kubectl get challenges,orders
workflow falha em AllocateCell/ProvisionTenant cell DB não wired (CELL_DATABASE_URL) conferir secret + Migrate no boot
trigger 401 HMAC não bate secret igual nos dois lados
temporal --address localhost recusa frontend bind no pod IP usar Service DNS / pod IP

7.6 Desligar / pausar

kubectl -n octopus scale deploy/provisioner --replicas=0 (o enqueue do customer-app degrada para log — checkout intacto).


8. Postura de segurança

  • Firewall default-deny; API K8s (6443) + kubelet (10250) nunca públicos.
  • SSH key-only + fail2ban + auto-updates.
  • Trigger autenticado por HMAC constant-time; cert público Let's Encrypt.
  • Tenant isolado por RLS (role tenant_dataplane NOBYPASSRLS).
  • Activities fail-closed — nunca fingem side-effect; money-path (RefundStripe) escala em vez de fingir.
  • Secrets em K8s Secrets (cell) + App Runner config.
  • ⚠️ Pendências de hardening: rotacionar o trigger secret (exposto em chat 2026-06-13); mover PROVISIONING_TRIGGER_SECRET/PROVISIONER_JWT_ED25519_SEED para um secret store; data-plane login role dedicado (hoje SET ROLE).

9. Custo

Item ~Custo/mo
Hetzner cx33 $9-13
Hetzner backups (opcional) +$2
AWS (incremental) $0 (App Runner mesma config; CF DNS grátis)
Total incremental ~$12-15/mo

10. Limitações conhecidas / Wave-2

  • SPOF: cell-0 é VPS único (sem HA). → 2º nó + Postgres gerenciado.
  • Data-plane runtime: admissão CONNECT.Art/STUN-coord é por leitura de slots (a tabela existe e isola); registro runtime ao vivo é follow-up Wave-1.
  • Redis namespace: lógico (prefixo octopus:{cell}:{deployment}:*); ACL por-tenant é Wave-2.
  • Multi-cell / GeoIP routing: AllocateCell retorna o cell único (Wave-1); routing por país é Wave-2 (ADR-0053).
  • Renewal/dunning: hoje pelo webhook Stripe (os workflows Temporal de renewal são scaffold).
  • arm64 TBI: só amd64 publicado (F1).

Última verificação contra produção: 2026-06-13 — F2 LIVE, onboarding e2e provado (público + in-cluster). PRs #1364–#1368.