fix(ia-egress): estabiliza a rota ate o deepseek com proxy na tailnet + probe no deploy

This commit is contained in:
Francisco Solla 2026-09-25 01:16:20 -03:00
parent 4678117b08
commit 53bea7c455
5 changed files with 174 additions and 5 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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"

95
scripts/egress-proxy.mjs Normal file
View file

@ -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(',')}`,
)
})

View file

@ -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"')