From 53bea7c4553069e2f59b227aff4efa1c1510df7e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 25 Sep 2026 01:16:20 -0300 Subject: [PATCH] fix(ia-egress): estabiliza a rota ate o deepseek com proxy na tailnet + probe no deploy --- docs/ops/teqo-1313-deploy.md | 29 ++++++ .../2026-09-24-busca-por-tema-indisponivel.md | 12 ++- scripts/deploy-homeserver.sh | 21 ++++ scripts/egress-proxy.mjs | 95 +++++++++++++++++++ tests/unit/deployScript.unit.spec.ts | 22 +++++ 5 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 scripts/egress-proxy.mjs diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 1cae3dd1..29012e10 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -498,6 +498,35 @@ anota no run (`::warning::`) qualquer chave de feature ausente — não falha o deploy (quais chaves existem é decisão humana), mas a perda de capacidade aparece no próprio run. +### Egress até o provedor — proxy na tailnet + +A rota do homeserver (Vivo) até `api.deepseek.com` (CloudFront) é +intermitente — medido em 2026-09-25: 1/3 de connect, enquanto a estação de +trabalho (que sai pelo exit node `mail-relay` da tailnet) dava 3/3. Para não +depender dessa rota, o egress do app passa por um **proxy HTTP na estação** +(`100.94.122.26:3128`), que usa o exit node: + +- **Onde roda:** container Docker `teqo-egress-proxy` na estação + (`--network host`, `--restart unless-stopped`, imagem `node:24-alpine`), + com o script `scripts/egress-proxy.mjs` deste repo (cópia operacional em + `~/teqo-egress-proxy/proxy.mjs`). Bind só na IP da tailnet e allowlist de + cliente = o homeserver (`100.119.220.31`); só túneis `CONNECT` para 443. +- **Como o app usa:** `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY=http://100.94.122.26:3128` + - `NO_PROXY` (internos) nos env files de produção e staging. O Node 24 honra + o proxy no `fetch()` — zero mudança de código. +- **Subir/atualizar:** + `docker run -d --name teqo-egress-proxy --restart unless-stopped --network host -e PROXY_HOST=100.94.122.26 -e PROXY_PORT=3128 -e PROXY_ALLOWED_CLIENTS=100.119.220.31,127.0.0.1,::1 -v ~/teqo-egress-proxy:/app:ro node:24-alpine node /app/proxy.mjs` +- **Limite conhecido:** a estação é ponto único — desligada, as capacidades + de IA degradam (fail-safe) e o probe de egress do deploy avisa. Se um dia o + `mail-relay` (sempre ligado) tiver acesso SSH, mover o proxy para lá é a + evolução natural. +- **Diagnóstico:** `docker logs teqo-egress-proxy`; do container, + `docker exec teqo-1313 node -e "fetch('https://api.deepseek.com/').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code))"`. + +Desde 2026-09-25 o `deploy-homeserver.sh` também **probe o egress de IA de +dentro do container** (após o healthcheck, antes do smoke) e emite +`::warning::` se `api.deepseek.com` não responder — não falha o deploy. + ## OPS79 — última migração da plataforma antiga → nova (vertical campanha) Operação de dados executada em 2026-08-23. Ver assistência lógica completa: diff --git a/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md b/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md index 7fb1915b..d01d7139 100644 --- a/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md +++ b/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md @@ -23,7 +23,8 @@ | Diagnóstico | 2026-09-24 | Read-only em prod: containers `teqo-1313`/`teqo-staging` **sem** `DEEPSEEK_API_KEY`/`DEEPINFRA_API_KEY`/`RESEND_API_KEY`/`VAPID_*` no env (nomes via `docker inspect`); `~/stack/.env` **tem** `DEEPSEEK_API_KEY` + `DEEPINFRA_API_KEY`; os env files do app não. Smoke real do módulo com a chave da estação: 5/10 falhas com `AI_NoObjectGeneratedError` (JSON truncado / resposta vazia) no teto 200; `usage` medido: 93–354 tokens de raciocínio contra 28–34 de texto. Com `thinking: disabled`: 6/6. | | Correção mergeada | 2026-09-25 02:09Z (`3cdd1cd8`) | PR #1333 mergeado com o required check `CI (PR) / checks` verde (auto-merge rebase; o fix é o commit `4e7b8444`). | | Deploy | 2026-09-25 02:39–03:32Z | Run `36087193017` (requeue do merge): verify full verde → `deploy-staging` success → `deploy-production` aprovado pelo humano; `teqo-1313` recriado em `3cdd1cd8` às 03:32:43Z. Staging e produção rodam o fix **com** as duas chaves no runtime (verificado por `docker inspect`, só nomes). | -| Verificado em prod | pendente | **Bloqueado por rede:** a rota do homeserver até `api.deepseek.com` está intermitente (abaixo) — o fix e a chave estão no ar, mas a feature ainda pode degradar. | +| Egress estabilizado | 2026-09-25 04:1xZ | Proxy HTTP de egress na estação de trabalho (tailnet, via exit node `mail-relay`) + `NODE_USE_ENV_PROXY`/`HTTPS_PROXY`/`NO_PROXY` nos env files do app; containers recriados (prod `3cdd1cd8`, staging `0f81e369`). Do container de produção: `GET /models` 5/5 200 e completion real 3/3 200. | +| Verificado em prod | parcial | Fix no ar (`3cdd1cd8`) com chave e egress de IA provados de dentro do container (fetch e completion reais); a confirmação da **UI** do acervo em produção segue humana. | ## O bug @@ -55,11 +56,11 @@ Nenhuma migration, nenhum Consent/access, nenhum contrato de URL/shape público. - Guard de ops: `bash -n` verde; `deployScript.unit.spec.ts` verde (22) pinando o audit e a ordem (healthcheck → audit → smoke); audit exercitado com `docker` stubado (env com buraco → `::warning::` com a lista exata; tudo presente → silêncio). - CI: o required check `CI (PR) / checks` do PR #1333 (o auto-merge nativo só mergeia com ele verde). - Prod: pendente — deploy começa no merge; produção só após approve humano; a validação do humano depende de a chave entrar no `~/stack/teqo-1313.env` (copia-e-cola do runbook) e o container ser recriado. -- Prod (2026-09-25): o deploy saiu — produção roda `3cdd1cd8` (o fix) com as duas chaves no runtime, mas a verificação funcional esbarrou numa **terceira camada, de rede** (abaixo). O aviso de indisponibilidade segue possível em parte das tentativas. +- Prod (2026-09-25): o deploy saiu — produção roda `3cdd1cd8` (o fix) com as duas chaves no runtime. A terceira camada (rede, abaixo) foi resolvida com o proxy de egress e verificada do próprio container: `GET /models` 5/5 e completion real 3/3. Falta só a confirmação da UI do acervo pelo humano. -### Rota intermitente até o provedor (2026-09-25) +### Rota até o provedor — resolvida com egress pela tailnet (2026-09-25) -A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfront.net` → `3.173.21.63:443`) está degradada: 3 `curl` do **host** deram 2 timeouts de 8s e 1 `401` em 0,55s; de dentro do **container de produção**, o `GET /models` deu 2 `UND_ERR_CONNECT_TIMEOUT` e 1 `200`. Não é bloqueio geral de rede: `example.com` responde 200 no container (3/3), `aws.amazon.com`/`d1.awsstatic.com` respondem no host, `api.deepinfra.com` responde 200 em 0,5s e a estação (egress AWS) alcança `api.deepseek.com` em 1,1s. Também não é o egress do Docker: o primeiro teste de `example.com` deu timeout numa janela ruim e foi refutado em 3/3 na repetição. A falha de rede cai no mesmo `catch` → `null` → aviso; **sem estabilizar a rota (ou trocar/rotear o provedor), a busca por tema continuará intermitente**. +A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfront.net` → `3.173.21.63:443`) estava degradada (host e container: 1/3 de connect; o resto timeout de 8s), enquanto a estação de trabalho — que sai pelo exit node `mail-relay` da tailnet — dava 3/3. Não era bloqueio geral nem egress do Docker: `example.com` responde 200 no container, `aws.amazon.com`/`d1.awsstatic.com` no host e `api.deepinfra.com` em 0,5s. A camada de rede foi corrigida com um **proxy HTTP de egress na estação** (`scripts/egress-proxy.mjs` versionado no repo; container `teqo-egress-proxy`, bind na IP da tailnet, allowlist do homeserver, só `CONNECT` 443) mais `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY` + `NO_PROXY` nos env files de produção e staging — o Node 24 do container honra o proxy no `fetch()`, sem mudança de código. Verificado do container de produção, com o env próprio: `GET /models` 5/5 200 e completion real (`/chat/completions` com `thinking: disabled`) 3/3 200. O `deploy-homeserver.sh` passou a **probar o egress de dentro do container** (`warn_ai_egress`, não-fatal, após o healthcheck). Limite aceito e documentado: a estação é ponto único — desligada, as capacidades de IA degradam (fail-safe) e o probe do deploy avisa; mover o proxy para o `mail-relay` (sempre ligado) quando houver acesso SSH é a evolução natural. ## Prevenção @@ -71,7 +72,7 @@ A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfr | Falhar o deploy quando uma feature env esperada está ausente | cara | documentada — muda contrato de ops (pode travar hotfix/rollback) e é decisão humana | | Observabilidade de degradação (contador/alerta por `catch`→`null` de cada feature, em vez de só o aviso na UI) | cara | documentada — instrumentação nova | | Passar as chaves de `~/stack/.env` ao runtime via `environment:` no compose (infra-solla), em vez de copiar linhas | cara | documentada — toca repo de infra fora deste repo | -| Estabilizar a rota até o provedor LLM (egress alternativo/proxy pela rede saudável, retry+timeout no cliente, ou trocar para Deep Infra — alcançável em 0,5s) | cara | documentada — aguarda decisão humana (produto/custo/infra) | +| Egress de IA via proxy na tailnet (estação → exit node) + probe do egress no deploy + script versionado (`scripts/egress-proxy.mjs`) | barata | implementada agora (este PR) | **Estratégia implementada:** os cinco pins de regressão travam a configuração do provedor em cada call site (o defeito não volta sem um teste vermelho), o dono único impede que um irmão fique para trás num próximo ajuste, e o audit de deploy transforma "capacidade degradada em silêncio" em anotação visível no run — exatamente o que faltou para o incidente aparecer em 2026-08-17. @@ -85,4 +86,5 @@ A rota do homeserver até `api.deepseek.com` (CloudFront `d3bbv8sr76az5s.cloudfr - **Teto de saída é orçamento compartilhado com o raciocínio.** Em modelo com thinking default, `maxOutputTokens` pequeno não limita o tamanho da resposta — trunca o pensamento; a resposta correta é desligar o thinking em tarefas de transformação determinística e/ou dar margem medida, não apertar o teto. - **Rede é parte do caminho crítico de um LLM externo.** O mesmo request de connect ao provedor teve 1/3 de sucesso em duas amostras (host e container de produção), enquanto a estação passava em 1,1s — degradação por rede é indistinguível de queda do provedor no log da aplicação e cai no mesmo fallback silencioso. - **Smoke local não valida o alvo.** O caminho do provedor funcionava da estação de trabalho e falhava no homeserver; validar "na minha máquina" não é validar o ambiente que serve a produção. +- **Correção de rede também é engenharia versionada.** O proxy que estabiliza o egress vive no repo (`scripts/egress-proxy.mjs`) e o deploy prova o caminho de dentro do container — infra que sustenta feature precisa de fonte e guard, não de um host configurado à mão. - **Tarefas iguais pedem dono.** A mesma config repetida em cinco call sites garantiu que o incidente atingisse cinco features de uma vez; a defaults compartilhada fecha o vetor de drift até o helper completo chegar. diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index c3344bbd..98bbf18e 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -310,6 +310,27 @@ warn_missing_feature_envs() { warn_missing_feature_envs +# --- AI egress probe (non-fatal) ---------------------------------------- +# The AI keys can be present and the capability still dead: the homeserver's +# route to api.deepseek.com (CloudFront) is intermittently unreachable (incident +# 2026-09-25), and the app's egress is a tailnet proxy that can be down. Probe +# the ACTUAL container path (it honors NODE_USE_ENV_PROXY/HTTPS_PROXY) and warn +# (never fail: the app degrades to its fallbacks). + +warn_ai_egress() { + docker exec "$TEQO_CONTAINER" node -e " + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 8000) + fetch('https://api.deepseek.com/', { signal: controller.signal }) + .then((response) => { clearTimeout(timer); process.exit(0) }) + .catch(() => process.exit(1)) + " >/dev/null 2>&1 && return 0 + say "WARNING: $TEQO_CONTAINER cannot reach api.deepseek.com — AI features degrade to their fallbacks" + printf '::warning::%s cannot reach api.deepseek.com (AI capabilities degrade silently)\n' "$TEQO_CONTAINER" +} + +warn_ai_egress + # --- smoke -------------------------------------------------------------- base="$TEQO_SMOKE_BASE" diff --git a/scripts/egress-proxy.mjs b/scripts/egress-proxy.mjs new file mode 100644 index 00000000..62d46205 --- /dev/null +++ b/scripts/egress-proxy.mjs @@ -0,0 +1,95 @@ +import http from 'node:http' +import net from 'node:net' + +const HOST = process.env.PROXY_HOST ?? '100.94.122.26' +const PORT = Number(process.env.PROXY_PORT ?? 3128) +const ALLOWED_CLIENTS = new Set( + (process.env.PROXY_ALLOWED_CLIENTS ?? '100.119.220.31,127.0.0.1,::1') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +) +const ALLOWED_PORTS = new Set( + (process.env.PROXY_ALLOWED_PORTS ?? '443') + .split(',') + .map((value) => Number(value.trim())) + .filter((value) => Number.isInteger(value) && value > 0), +) + +const log = (...args) => console.log(new Date().toISOString(), ...args) + +const clientAllowed = (socket) => { + const ip = (socket.remoteAddress ?? '').replace(/^::ffff:/, '') + return ALLOWED_CLIENTS.has(ip) +} + +const server = http.createServer((req, res) => { + let target + try { + target = new URL(req.url ?? '') + } catch { + res.writeHead(400) + res.end('bad request') + return + } + const port = Number(target.port || 80) + if (target.protocol !== 'http:' || !ALLOWED_PORTS.has(port)) { + res.writeHead(403) + res.end('forbidden') + return + } + const upstream = http.request( + { + host: target.hostname, + port, + path: `${target.pathname}${target.search}`, + method: req.method, + headers: req.headers, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers) + upstreamRes.pipe(res) + }, + ) + upstream.on('error', () => { + if (!res.headersSent) res.writeHead(502) + res.end('bad gateway') + }) + req.pipe(upstream) +}) + +server.on('connect', (req, clientSocket, head) => { + const [rawHost, rawPort] = (req.url ?? '').split(':') + const host = rawHost?.trim() + const port = Number(rawPort ?? 443) + if (!host || !ALLOWED_PORTS.has(port)) { + log('denied connect', req.url) + clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n') + return + } + const upstream = net.connect(port, host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length > 0) upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + upstream.setTimeout(30000, () => upstream.destroy()) + upstream.on('error', (error) => { + log('upstream error', host, error.code) + clientSocket.destroy() + }) + clientSocket.on('error', () => upstream.destroy()) +}) + +server.on('connection', (socket) => { + if (!clientAllowed(socket)) { + log('denied client', socket.remoteAddress) + socket.destroy() + } +}) + +server.listen(PORT, HOST, () => { + log( + `egress proxy on ${HOST}:${PORT} — clients: ${[...ALLOWED_CLIENTS].join(',')} — ports: ${[...ALLOWED_PORTS].join(',')}`, + ) +}) diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 76a5f197..812c6b2c 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -125,6 +125,28 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { expect(callIndex).toBeLessThan(smokeIndex) }) + it('probes the container AI egress non-fatally (route/proxy breakage incident 2026-09-25)', () => { + // The keys can be present and the capability still dead: the homeserver's + // route to api.deepseek.com is intermittent and the app egresses through a + // tailnet proxy. The probe runs INSIDE the container (honoring + // NODE_USE_ENV_PROXY/HTTPS_PROXY) and only warns — a broken provider path + // must never fail an otherwise good deploy. + for (const fragment of [ + 'warn_ai_egress', + 'docker exec "$TEQO_CONTAINER" node -e', + 'api.deepseek.com', + ]) { + expect(script, fragment).toContain(fragment) + } + const probeIndex = script.indexOf('warn_ai_egress() {') + const callIndex = script.indexOf('warn_ai_egress\n') + const healthIndex = script.indexOf('waiting for the healthcheck') + const smokeIndex = script.indexOf('# --- smoke') + expect(probeIndex).toBeGreaterThan(healthIndex) + expect(callIndex).toBeGreaterThan(probeIndex) + expect(callIndex).toBeLessThan(smokeIndex) + }) + it('never echoes secret values (no set -x, passwords only via stdin/secrets)', () => { expect(script).not.toMatch(/^set -x\b/m) expect(script).not.toContain('echo "$DATABASE_URL"')