Pular para o conteúdo

Host bridge: como o painel executa scan e hardening na VPS real

Problema original (bug arquitetural encontrado em campo): o painel roda num container Docker, mas o módulo de segurança executava os comandos com bash -c no namespace do próprio container — o scan mostrava dados do Debian da imagem (node:22-bookworm-slim) e o hardening modificaria o container descartável, não a VPS. Inútil e enganoso.

O painel executa comandos no host real através de um container helper privilegiado e descartável — o mesmo padrão usado por Coolify e Portainer:

docker run --rm --privileged --pid=host alpine:3 \
nsenter -t 1 -m -u -i -n -p -- bash -c "<comando>"

(O painel usa a imagem fixada por digest, alpine:3@sha256:…, definida em packages/core/src/images.ts; alpine:3 aparece aqui só para facilitar a leitura. Assim, um projeto hospedado que regrave a tag alpine:3 no servidor não troca o binário que roda como root.)

  • --pid=host: o helper enxerga o PID 1 do host (init);
  • nsenter -t 1 -m -u -i -n -p: o comando entra nos namespaces de mount, uts, ipc, net e pid do host — ou seja, ufw, sshd -T, apt, fail2ban-client, /etc/passwd etc. são os da VPS real;
  • --rm: o helper é destruído ao fim de cada comando — nenhum processo ou estado privilegiado permanece;
  • o upload dos scripts (scripts/hardening/*) usa só o namespace de mount (nsenter -t 1 -m), extraindo um tar via stdin em /opt/paas-hardening do host.

Implementação: NsenterHostRunner em packages/security/src/runner.ts; a montagem dos comandos e a allowlist ficam em packages/security/src/host-bridge.ts (funções puras, 100% testadas).

PropriedadeComo é garantida
Sem credencial novaUsa o /var/run/docker.sock que o painel já monta para gerenciar o Docker — nenhuma senha/chave extra é criada. Quem monta o socket já é, por definição, equivalente a root no host: o bridge não aumenta esse privilégio.
Allowlist estritaO runner rejeita qualquer comando que não seja: (a) um dos comandos fixos somente-leitura dos checks/baseline/Lynis (inclui a leitura do fuso, timedatectl show -p Timezone --value), (b) uma invocação validada de scripts/hardening/{00..07}-*.sh com flags conhecidas (--dry-run/--rollback/--confirm/--no-tunnel/--user/--pubkey), ou (c) a troca do fuso de Configurações → Fuso horário (timedatectl set-timezone '<fuso>', com o ln -sf do /etc/localtime como alternativa), aceita só se for byte a byte o comando montado para um fuso da lista fechada (isKnownTimezone), ou (d) a leitura do estado do plugin buildx do Docker e a instalação do pacote dele (docker-buildx-plugin para o Docker do repositório oficial, docker-buildx para o docker.io do Ubuntu) de Saúde da máquina → Docker e BuildKit, aceita só se for byte a byte o comando montado para uma dessas duas origens. Nada vindo da API vira shell arbitrário.
Input revalidadoA chave pública SSH e o nome de usuário da Fase 01 são validados duas vezes (rota + builder do comando) com regex que proíbe aspas/quebras de linha — desenhado para que o quoting não possa ser quebrado.
Descartável--rm + nome único por execução; em timeout o cliente é morto e o helper é removido com docker rm -f.
Timeout por comandoexec tem timeout (default 120s; 300s para Lynis); streams de fase têm teto de 30 min.
AuditadoTodo comando enviado ao host gera entrada hardening.host-exec no log de auditoria (tela Auditoria); os do fuso horário geram timezone.host-exec, além de timezone.applied/timezone.apply_failed.
Rollback preservadoAs garantias já existentes (backup de arquivos, rollback imediato em falha, rollback agendado de 5 min em SSH/firewall/fase 01) continuam valendo — agora no host de verdade.

O scanner sabe o perfil do alvo (runner.profile):

  • host (produção, PAAS_TARGET=host): todos os checks rodam na VPS real via host bridge;
  • container (dev/teste, PAAS_TARGET=container, usado pelos E2E): checks que só fazem sentido num host — ufw, sshd, fail2ban, snapd, unattended-upgrades, sysctl, auditd, AIDE, rkhunter, cron — são pulados e listados em skippedChecks do relatório, com o motivo. Rodá-los num container gerava falsos-positivos de contexto: o check avaliava o namespace do container, não a máquina real.

Checks mantidos no perfil container (se aplicam a qualquer alvo Linux): pacotes pendentes, repositórios de terceiros, UID 0, senha do root, sudo, portas de banco/Docker expostas, inventário de portas, clientes legados.

Antes da Fase 01 o wizard pede a chave pública SSH da máquina do operador (validação de formato no cliente e no servidor). O script instala a chave no novo usuário e só trava a senha do root com a chave presente (anti-lockout, já existente). Ao travar o root, o script agenda uma reversão automática (at/timer de 5 min) que destranca o root — o alerta pulsante da UI orienta: “abra outra janela SSH e teste o login com o novo usuário ANTES de confirmar”.

O container do painel é o exemplo (ver Dockerfile e docker-compose.yml):

  • usuário não-root (tws) — acesso ao socket via group_add do GID do grupo docker do host (DOCKER_GID, gravado no .env pelo install.sh);
  • no-new-privileges: nenhum processo do container escala privilégio;
  • cap_drop: ALL sem cap_add: o cliente Docker não precisa de capabilities para falar com o socket; quem confere --privileged ao helper é o daemon do host. Documentado porque é contra-intuitivo: o privilégio do helper vem do daemon, não das caps do cliente;
  • read_only: true com tmpfs em /tmp: rootfs imutável; só /data (volume de estado) e /tmp (volátil) são graváveis;
  • Docker CLI + compose plugin instalados de binários estáticos oficiais com sha256 fixado no Dockerfile (antes a imagem nem tinha o CLI — outro bug latente para o host bridge);
  • scan Trivy no CI (.github/workflows/ci.yml, job image-scan): build da imagem + relatório HIGH/CRITICAL com fix disponível. Política atual: report-only (exit-code 0) — o CI passará a falhar em CRITICAL quando o baseline estiver limpo (findings residuais do Debian slim sem fix upstream).

Nota de modelo de ameaça: com o docker.sock montado, o container do painel é equivalente a root no host por design (ele administra a VPS). As medidas acima são defesa em profundidade contra comprometimento do processo Node (RCE na API), não contra o acesso legítimo ao daemon.

Janela do terminal
# 1) helper executa no namespace do host: hostname deve ser o da VPS
docker run --rm --privileged --pid=host alpine:3 \
nsenter -t 1 -m -u -i -n -p -- hostname
# 2) allowlist: comando fora da lista é rejeitado antes de tocar o host
# (ver packages/security/tests/host-bridge.test.ts)
# 3) E2E continuam no perfil container (ContainerRunner):
pnpm test:e2e

O terminal ao vivo do wizard (visão dupla) usa o MESMO padrão do host bridge — a decisão foi Docker Engine API via /var/run/docker.sock, NÃO node-pty:

  • node-pty compila nativo (node-gyp → python3/make/g++ na imagem) e, pior, daria um shell DENTRO do container do painel — o alvo correto é o HOST;
  • o daemon Docker JÁ aloca PTYs de graça: o painel cria um container helper descartável (Tty: true, --privileged, pid: host, AutoRemove) rodando nsenter -t 1 -m -u -i -n -p -- bash -l e faz hijack do attach (HTTP 101, stream cru — sem multiplex de stdout/stderr quando Tty: true). O resize é o endpoint POST /containers/{id}/resize da API. No alvo de dev (container), o caminho é POST /containers/{alvo}/exec + /exec/{id}/start hijacked.

Implementação: apps/server/src/services/docker-socket.ts (transporte), terminal-service.ts (sessão única DESTACÁVEL — vive no servidor entre reconexões de WS —, scrollback, fila de comandos, idle timeout de 30 min, cleanup do container ao encerrar) e routes/terminal.ts (WebSocket /api/terminal/ws, autenticado pela guarda global: setup token no wizard, sessão admin depois). Posse da sessão por clientId (anti-ping-pong): o mesmo clientId reanexa; um clientId diferente é recusado com close 4009 (“terminal em uso”) sem derrubar o dono; sessão órfã (dono desconectado) pode ser assumida por qualquer cliente.

Fases dentro do terminal: o TerminalRelayRunner desvia o execStream do executor para o shell do terminal — o comando aparece digitado no xterm, a saída rola ao vivo e o exit code é lido de um marcador :::PAAS_EXIT_<nonce>:<code> filtrado do stream. Prompts interativos são respondidos pelo usuário digitando no próprio terminal.

REGRA DE OURO: o backend faz RELAY PURO do fluxo. O input do usuário (senhas inclusas) NUNCA é logado, persistido, auditado ou inspecionado — a auditoria registra apenas conexão/desconexão/ciclo de vida da sessão. Há um teste que injeta senha-secreta no stream e prova que ela não aparece em nenhum log/auditoria (apps/server/tests/terminal-service.test.ts e routes-terminal.test.ts). Se o PTY estiver indisponível (ex.: sem docker.sock), o executor cai para o caminho antigo (execStream direto) — a indisponibilidade do terminal nunca impede o hardening.

A página Logs do servidor lê o journal do host por esta mesma ponte, com um comando fixo: journalctl só das unidades docker.service e ssh.service/sshd.service, com o número de linhas (1 a 2000) e o início da janela validados. A allowlist recompõe o comando a partir desses dois números e compara byte a byte; qualquer outra unidade ou opção é negada. O comando não tem pipe (o código de saída é o do journalctl, então uma falha aparece como indisponível). Esta leitura não gera entrada *.host-exec na Auditoria, porque a tela repete o pedido a cada poucos segundos; vale a entrada server-logs.read da página, no máximo uma por pessoa e fonte por minuto.