# Rota mobile nativa por sessão Este guia descreve o egress TCP fail-closed incorporado ao ZuckZapGo com `tailscale.com/tsnet` v1.98.8. Ele substitui o sidecar SOCKS manual no piloto; não cria outro serviço e não altera o protocolo WhatsApp. ## Garantias e limites - Cada usuário com rota desejada possui um `tsnet.Server`, hostname opaco e diretório de estado próprio em modo `0700`. - O exit node é selecionado por `StableNodeID`; hostname e IP mutáveis não são aceitos como identidade da rota. - `ExitNodeAllowLANAccess=false` é aplicado explicitamente. - O SOCKS5 escuta somente em `127.0.0.1`, exige usuário `tsnet` e senha aleatória criada pelo próprio tsnet. - Antes de a rota ficar `ready`, o servidor realiza conexão TCP e handshake TLS com `web.whatsapp.com:443` através desse SOCKS. Como `EditPrefs` converge de forma assíncrona, o preflight tem até 8 tentativas canceláveis (5 s por tentativa, backoff de 250 ms até 2 s) e exige 2 sucessos consecutivos. Isso absorve convergência transitória sem aceitar uma rota que oscila. - A auth key JIT existe somente durante o apply. O decoder guarda a cópia da aplicação em bytes mutáveis e os sobrescreve em todos os retornos; ela não entra no banco, estado de resposta ou logs. A API do tsnet recebe `string`, então o campo `tsnet.Server.AuthKey` é esvaziado imediatamente após `Start`; o backing imutável transitório da biblioteca fica sem referência e depende do GC, razão adicional para a chave ser JIT e realmente single-use. - A senha SOCKS e a URL interna do proxy ficam somente no manager em memória. - Uma rota `mobile` é obrigatoriamente `failClosed=true` e `tcpOnly=true`. `direct` não é um modo válido. - `tcpOnly` descreve o egress WhatsApp exposto pelo SOCKS. O próprio Tailscale pode usar UDP/WireGuard ou DERP para transportar esse TCP; portanto não trate a flag como promessa de ausência de pacotes UDP no host. - Uma rota desejada que não esteja pronta bloqueia `startClient`; falha ao aplicar o proxy também aborta o bootstrap. Não há fallback para `proxy_url` nem para discagem direta. - A rota mobile é aplicada ao transporte WhatsApp (WebSocket e mídia) e não ao cliente HTTP auxiliar que entrega webhooks/control plane. Isso impede que o celular vire um proxy genérico e evita dependência circular com o Chatwoot. O comportamento legado de proxy para esse cliente auxiliar permanece apenas para sessões que usam `proxy_url`, fora do modo mobile. - Calls nativo depende de UDP e fica bloqueado em rotas mobile TCP-only. Os recursos de Calls continuam disponíveis para sessões sem essa rota. Limite honesto: o processo consegue verificar que o exit node configurado produziu um caminho SOCKS/TLS funcional, mas a prova física de operadora móvel, ASN e geolocalização do IP de saída deve ser feita no tailnet/piloto. O backend que emite a auth key também deve configurá-la como single-use; a aplicação não consegue transformar uma chave reutilizável em chave de uso único. ## Configuração do estado ```bash NETWORK_ROUTE_STATE_DIR=/var/lib/zuckzapgo/network-routes ``` Quando vazio, o default é `/network-routes`. Em container, monte esse caminho em volume persistente. O estado do nó Tailscale permite restaurar a mesma identidade após restart sem persistir a auth key. Se o volume for perdido, a restauração falha e a sessão permanece bloqueada; envie uma versão maior com uma nova auth key JIT. Os Compose de desenvolvimento e Swarm incluídos no repositório já montam o volume nomeado `network_route_state_zuckzapgo` em `/app/network-routes`. Nunca coloque `NETWORK_ROUTE_STATE_DIR` em volume compartilhado entre processos que possam iniciar a mesma sessão simultaneamente. ## Provisionar ou atualizar Endpoint autenticado com o token real da sessão: ```http POST /session/network-route token: Content-Type: application/json ``` ```json { "mode": "mobile", "authKey": "", "exitNodeId": "nStableMobileExit01", "routeVersion": 1, "idempotencyKey": "route-2026-08-15-0001", "failClosed": true, "tcpOnly": true } ``` O backend deve obter `authKey` imediatamente antes da chamada. Não envie essa chave ao navegador, não a registre em telemetria e não reutilize o valor em uma nova versão. Resposta segura: ```json { "code": 200, "data": { "mode": "mobile", "exitNodeId": "nStableMobileExit01", "desiredVersion": 1, "appliedVersion": 1, "failClosed": true, "tcpOnly": true, "status": "ready", "ready": true }, "details": "network route ready", "reconnectScheduled": false, "success": true } ``` Nem a auth key, nem a senha SOCKS, nem a URL do proxy aparecem na resposta. ## Ler o estado ```http GET /session/network-route token: ``` O GET retorna somente metadados não secretos. `ready=true` exige ao mesmo tempo: 1. estado persistido `status=ready`; 2. `desiredVersion == appliedVersion`; 3. runtime tsnet ativo para a mesma versão. ## Fencing e idempotência - `routeVersion` deve ser positivo e crescer monotonicamente. - Versão menor que a desejada retorna `409 network_route_stale_version`. - Repetir exatamente versão, digest de configuração e `idempotencyKey` retorna o resultado atual sem desconectar e sem criar outro nó. - Reutilizar a mesma versão com exit node/configuração diferente, ou com outra idempotency key, retorna `409 network_route_version_conflict`. - Depois de uma falha, corrija a causa e envie uma versão maior com nova auth key. Uma repetição exata da versão bloqueada devolve o mesmo estado e código 503 sem desconectar novamente, iniciar outro nó ou consumir outra vez a credencial; não é uma segunda tentativa de provisionamento. ## Ordem atômica do apply O handler usa o mesmo mutex por usuário de connect/disconnect/refresh/logout: 1. valida versão e idempotência; 2. bloqueia e desconecta o cliente existente; 3. persiste a nova versão como `preparing`; 4. inicia/restaura o tsnet, aplica o exit node e executa o preflight SOCKS/TLS; 5. marca a versão como `ready` e `applied`; 6. somente então agenda reconexão de uma sessão que estava conectada. Falha entre os passos 3 e 5 grava um código seguro e deixa o estado `blocked`. ## Provisionamento junto com AddUser `POST /admin/users` aceita `networkRoute` opcional com o mesmo objeto: ```json { "name": "pilot-mobile-01", "token": "", "networkRoute": { "mode": "mobile", "authKey": "", "exitNodeId": "nStableMobileExit01", "routeVersion": 1, "idempotencyKey": "provision-pilot-0001", "failClosed": true, "tcpOnly": true } } ``` Não combine `networkRoute` com `proxyConfig`; a API retorna 400. O usuário é criado antes do pareamento, a rota é verificada e só depois a sessão poderá conectar. Contrato de sucesso consumido pelo Chatwoot (campos não relacionados omitidos): ```json { "code": 201, "data": { "id": "stable-internal-user-id", "token": "", "network_route": { "mode": "mobile", "exitNodeId": "nStableMobileExit01", "desiredVersion": 1, "appliedVersion": 1, "failClosed": true, "tcpOnly": true, "status": "ready", "ready": true } }, "success": true } ``` O Chatwoot só deve considerar o provisionamento concluído quando `ready=true` e `desiredVersion == appliedVersion`. No 2xx, `data.id`, `data.token` e `data.network_route` estão presentes; a auth key nunca está. Se o provisionamento falhar depois do INSERT, o usuário permanece deliberadamente persistido e bloqueado. A resposta 503 usa `data.userId` seguro e estável — deliberadamente sem `token` — para rollback: ```json { "code": 503, "data": { "userId": "stable-internal-user-id", "network_route": { "mode": "mobile", "exitNodeId": "nStableMobileExit01", "desiredVersion": 1, "appliedVersion": 0, "failClosed": true, "tcpOnly": true, "status": "blocked", "ready": false, "errorCode": "network_route_prepare_failed" } }, "error": "network_route_prepare_failed", "details": "user persisted in blocked state; submit a higher routeVersion with a new one-use auth key or delete this user", "success": false } ``` O rollback recomendado é `DELETE /admin/users/{data.userId}` com o admin token. Alternativamente, preserve o usuário bloqueado e aplique uma versão maior com uma nova auth key por um backend que já possua o token da sessão. Nunca repita AddUser cegamente: o token já existe e a auth key anterior pode ter sido consumida. ## Restart, revogação e shutdown - Startup lê os metadados desejados e restaura cada nó do diretório persistente, sem auth key. Rotas que não restauram ficam `blocked` antes do auto-connect. - Exclusão de usuário executa logout/revogação do nó, fecha o runtime, apaga os metadados e remove apenas o subdiretório derivado daquele usuário. - Graceful shutdown fecha todos os runtimes sem logout, preservando a identidade necessária ao próximo startup. ## Códigos principais | HTTP | Código | Significado | |---|---|---| | 400 | `network_route_mode_invalid` | modo diferente de `mobile` | | 400 | `network_route_mobile_requires_fail_closed_tcp_only` | flags inseguras | | 400 | `network_route_auth_key_invalid` | auth key ausente/malformada | | 409 | `network_route_stale_version` | fencing rejeitou versão antiga | | 409 | `network_route_version_conflict` | mesma versão, outro digest/idempotency | | 503 | `network_route_prepare_failed` | tsnet, exit node ou preflight falhou | | 503 | `network_route_store_unavailable` | metadados não puderam ser lidos/gravados | Erros externos nunca incluem resposta do control plane, credencial ou proxy. ## Capacidade Cada rota pronta contém um netstack e control client Tailscale independentes. Isso custa mais memória e goroutines que um proxy compartilhado. Dimensione o piloto medindo RSS, goroutines, conexões DERP e tempo de restore com a quantidade real de sessões; não extrapole custo de uma única sessão. O binário também cresce porque incorpora tsnet/gVisor. Não existe worker ou processo sidecar adicional. ## Validação da implementação Gates executados no worktree desta implementação: ```bash go test -count=1 ./... go test -race -count=1 . -run 'Test(NetworkRoute|AddUserNetworkRoute|ApplySessionProxy)' go build ./... CGO_ENABLED=0 go build ./... git diff --check ``` Também foram validados o parse de `static/api/spec.yml`, `swagger.yaml`, `swagger.json` e a geração da collection Postman com GET/POST, fencing e o shape de rollback Chatwoot. Como referência — não como estimativa isolada de custo do tsnet — o binário Darwin/arm64 deste worktree mediu 108.219.714 bytes. O `.zzg-dev` local anterior mede 84.579.874 bytes; a diferença indicativa é 23.639.840 bytes (27,9%) e também inclui os demais diffs de Calls/buffer preservados no worktree. Ainda faltam duas provas que não podem ser fabricadas em teste unitário: executar com auth key real de uso único/exit node móvel e confirmar ASN/geolocalização do IP físico; e medir RSS/goroutines/DERP por sessão no volume real do piloto. Até essas medições, não extrapole memória por nó nem declare prova de operadora.