Seção 14: VPN (Tailscale) e firewall das portas do Docker¶
O túnel da Cloudflare publica as aplicações na internet, mas recusa requisições acima de 100 MB e passa tudo pela Cloudflare. A VPN é o caminho privado: com o Tailscale ligado no PC ou no celular, os mesmos endereços https://<app>.seudominio.com vão direto ao Traefik de casa, cifrados pelo WireGuard e sem limite de tamanho. A rota de controle do hub de MCPs (seção 13) passa a existir só pela VPN.
flowchart LR
cel[Celular / PC<br/>com Tailscale] -- WireGuard --> ts[tailscaled<br/>core-services-vm<br/>subnet router]
cel -. "DNS: *.seudominio.com" .-> dns[CoreDNS<br/>IP da core:53]
dns -. "IP da core" .-> cel
ts --> traefik[Traefik :443<br/>core-services-vm]
ts --> hub[hub :4100<br/>rota de controle]
ts --> ai[ai-desktop-vm<br/>Syncthing :22000]
traefik --> apps[todas as aplicações<br/>Authelia na frente]
| Peça | Onde | O que faz |
|---|---|---|
tailscaled |
core-services-vm (pacote oficial, role ansible/roles/infra/tailscale/) |
Entra na sua rede Tailscale como homeserver e anuncia rotas só para as duas VMs (tailscale_rotas, /32) |
| CoreDNS | container coredns na core, porta 53 no IP da LAN |
Para quem está na VPN, responde qualquer <app>.seudominio.com com o IP da core (Traefik); outras zonas são recusadas |
| Firewall das portas do Docker | as duas VMs (role ansible/roles/infra/docker/) |
As portas publicadas pelo Docker não passam pelo UFW; a cadeia DOCKER-USER limita quem as alcança pela LAN |
Por que rotas /32 e por que o host Proxmox fica de fora
Rotas /32 são mais específicas que a /24 da LAN. Por isso um PC em casa com o Tailscale ligado também fala com as VMs pela VPN, e a rota de controle (que a LAN não alcança) funciona dentro de casa. O host Proxmox não está nas rotas: a VPN roda na core, e se a core cair você precisa alcançar o Proxmox pela LAN. Em casa, desligue o Tailscale nesse caso.
Firewall das portas publicadas pelo Docker¶
Portas publicadas pelo Docker (ports:) passam por fora do UFW. Para cada porta em firewall_docker_portas (página 01), quem chega pela interface da LAN só passa se a origem estiver na lista; lista vazia quer dizer "ninguém pela LAN". Tráfego da VPN (tailscale0) e entre containers não passa por essas regras.
| VM | Porta | Quem alcança pela LAN | Por quê |
|---|---|---|---|
| core | 9090 (Prometheus) | host Proxmox e ai-desktop-vm |
remote_write dos Alloys; sem isso, qualquer aparelho da LAN injetaria métricas |
| core | 3100 (Loki) | host Proxmox e ai-desktop-vm |
Logs dos Alloys; o Loki não tem autenticação |
| core | 4100 (hub, rota de controle) | ninguém (só pela VPN) | Ações no servidor só por canal cifrado e com a chave do cliente |
| core | 4200 (hub, rota dos autônomos) | ai-desktop-vm |
Agentes do posto e o assistente rodam lá (seção 16) |
| ai | 9001 (Portainer Agent) | core-services-vm |
Só o Portainer central gerencia a VM |
| ai | 3100 (Langfuse) | core-services-vm |
O Traefik e o hub ficam na core |
| ai | 3005 (Vibe Kanban) | core-services-vm |
Sem login próprio: só o Traefik, com o Authelia na frente (seção 16) |
O LiteLLM (4000), a Open WebUI (3000), o painel e a sincronização do Syncthing (8384, 22000) continuam abertos na LAN: o PC e o celular os usam em casa, cada um com o próprio login.
O script é gerado pelo role infra/docker (página 04) e rodado pela unit firewall-docker, que roda antes de cada (re)início do Docker: as regras já estão lá quando os containers sobem, sem janela aberta no boot. Para conferir: sudo iptables -L HOMESERVER -n -v.
#!/bin/bash
# ansible/roles/infra/docker/templates/firewall-docker.sh.j2
# Firewall das portas publicadas pelo Docker (o UFW não vê esse tráfego), na cadeia DOCKER-USER, que o Docker
# reserva para isso e não apaga. Para cada porta de firewall_docker_portas deste host, quem entra pela interface
# da LAN só passa se a origem estiver na lista (lista vazia: ninguém pela LAN). Tráfego da VPN (tailscale0) e
# entre containers não passa por aqui. Rodado pela unit firewall-docker antes de cada (re)início do Docker: cria a
# DOCKER-USER se ainda não existe, e o Docker a reaproveita (só acrescenta o salto na FORWARD).
# Página 14. Gerado pelo role infra/docker.
set -euo pipefail
LAN="{{ ansible_facts.default_ipv4.interface | default('eth0') }}"
CADEIA="HOMESERVER"
IPT="${IPTABLES:-iptables}"
$IPT -N DOCKER-USER 2>/dev/null || true
$IPT -N "$CADEIA" 2>/dev/null || $IPT -F "$CADEIA"
$IPT -C DOCKER-USER -j "$CADEIA" 2>/dev/null || $IPT -I DOCKER-USER -j "$CADEIA"
{% for regra in firewall_docker_portas.get(inventory_hostname, []) %}
# {{ regra.porta }}: {{ regra.motivo }}
{% for origem in regra.origens %}
$IPT -A "$CADEIA" -i "$LAN" -p tcp -s {{ origem }} -m conntrack --ctorigdstport {{ regra.porta }} --ctdir ORIGINAL -j RETURN
{% endfor %}
$IPT -A "$CADEIA" -i "$LAN" -p tcp -m conntrack --ctorigdstport {{ regra.porta }} --ctdir ORIGINAL -j DROP
{% endfor %}
$IPT -A "$CADEIA" -j RETURN
Arquivos¶
# ansible/playbooks/setup-vpn.yml
# VPN (página 14): Tailscale como subnet router e o DNS da VPN, na core-services-vm.
- name: VPN (Tailscale) e DNS da VPN
hosts: core_services
become: true
gather_facts: true
vars_files:
- ../inventories/home/group_vars/all/main.yml
- ../inventories/home/group_vars/all/vault.yml
roles:
- role: infra/tailscale
# ansible/roles/infra/tailscale/tasks/main.yml
# VPN (página 14): Tailscale na core-services-vm como subnet router das duas VMs, e o CoreDNS que responde
# *.<domínio> com o IP da core para quem está na VPN (split DNS configurado no painel do Tailscale).
- name: "Tailscale: chave do repositório"
ansible.builtin.get_url:
url: "https://pkgs.tailscale.com/stable/ubuntu/{{ ansible_facts.distribution_release }}.noarmor.gpg"
dest: /usr/share/keyrings/tailscale-archive-keyring.gpg
mode: '0644'
- name: "Tailscale: repositório"
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu {{ ansible_facts.distribution_release }} main"
filename: tailscale
state: present
- name: "Tailscale: pacote"
ansible.builtin.apt:
name: tailscale
state: present
update_cache: true
when: not ansible_check_mode # no --check o repositório só foi simulado: o apt não acharia o pacote
# Aplicado na hora (antes do tailscale up). Só IPv4: as rotas anunciadas são IPv4, e ligar o forwarding do
# IPv6 desligaria o accept_ra (o IPv6 por SLAAC da LAN).
- name: "Tailscale: encaminhamento de pacotes (subnet router)"
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: '1'
sysctl_file: /etc/sysctl.d/99-tailscale.conf
reload: true
- name: "Tailscale: serviço"
ansible.builtin.systemd_service:
name: tailscaled
enabled: true
state: started
when: not ansible_check_mode # no --check de uma VM nova o pacote só foi simulado
- name: "Tailscale: estado atual"
ansible.builtin.command: tailscale status --json
register: tailscale_status
changed_when: false
failed_when: false
check_mode: false # só lê
# Só na primeira vez (ou depois de um logout): a auth key é de uso único.
- name: "Tailscale: entrar na rede"
ansible.builtin.command: >-
tailscale up --auth-key={{ tailscale_auth_key }} --hostname={{ tailscale_hostname }}
--advertise-routes={{ tailscale_rotas | join(',') }} --accept-dns=false
when: (((tailscale_status.stdout | default('')) or '{}') | from_json).BackendState | default('') != 'Running'
changed_when: true
no_log: true # a auth key
- name: "Tailscale: preferências atuais"
ansible.builtin.command: tailscale debug prefs
register: tailscale_prefs
changed_when: false
failed_when: false
check_mode: false # só lê
# Mantém o nome e as rotas anunciadas iguais às do group_vars (aprovar no painel quando as rotas mudarem).
- name: "Tailscale: nome e rotas anunciadas"
vars:
prefs: "{{ ((tailscale_prefs.stdout | default('')) or '{}') | from_json }}"
ansible.builtin.command: >-
tailscale set --hostname={{ tailscale_hostname }} --advertise-routes={{ tailscale_rotas | join(',') }}
--accept-dns=false
when: >-
(prefs.AdvertiseRoutes | default([]) or []) | sort != tailscale_rotas | sort
or prefs.Hostname | default('') != tailscale_hostname
or prefs.CorpDNS | default(true)
changed_when: true
- name: "UFW: conexões diretas do Tailscale"
community.general.ufw:
rule: allow
port: '41641'
proto: udp
comment: "Tailscale (WireGuard direto)"
- name: "UFW: encaminhar da VPN para a LAN (rotas anunciadas)"
community.general.ufw:
rule: allow
route: true
interface_in: tailscale0
interface_out: "{{ ansible_facts.default_ipv4.interface }}"
comment: "Tailscale subnet router"
- name: "CoreDNS: diretório"
ansible.builtin.file:
path: "{{ vm_local_data_path }}/coredns"
state: directory
owner: root
group: root
mode: '0755'
- name: "CoreDNS: configuração"
ansible.builtin.template:
src: Corefile.j2
dest: "{{ vm_local_data_path }}/coredns/Corefile"
owner: root
group: root
mode: '0644'
register: corefile
- name: "CoreDNS: container"
community.docker.docker_container:
name: coredns
image: "coredns/coredns:{{ coredns_version }}"
command: ["-conf", "/Corefile"]
restart_policy: unless-stopped
restart: "{{ corefile.changed }}"
ports:
- "{{ core_services_vm_ip_var }}:53:53/udp"
- "{{ core_services_vm_ip_var }}:53:53/tcp"
volumes:
- "{{ vm_local_data_path }}/coredns/Corefile:/Corefile:ro"
# ansible/roles/infra/tailscale/templates/Corefile.j2
# DNS da VPN (página 14): para quem está no Tailscale (split DNS restrito ao domínio), todo <app>.<domínio>
# aponta para o Traefik na core-services-vm, pela VPN. Outras zonas são recusadas: o resto da internet
# continua resolvendo pelo DNS normal do aparelho.
{{ base_domain }}:53 {
template IN A {
answer "{{ '{{' }} .Name {{ '}}' }} 60 IN A {{ core_services_vm_ip_var }}"
}
template IN AAAA {
rcode NOERROR
}
# Os outros tipos (HTTPS/65, que navegadores e o iOS pedem em toda URL, TXT, MX…): resposta vazia, sem SERVFAIL.
template IN ANY {
rcode NOERROR
}
errors
}
Primeira configuração¶
- Conta Tailscale (plano gratuito) em
login.tailscale.com. Em Settings → Keys → Generate auth key, gere uma chave não reutilizável e pré-aprovada, e grave emtailscale_auth_keyno vault (página 01). Ela só serve para o primeirotailscale up. - Na máquina de controle:
ansible-playbook -i inventories/home/hosts.ini playbooks/setup-vpn.yml --ask-vault-pass --check, depois sem--check. - No painel do Tailscale → Machines →
homeserver:- Edit route settings: aprove as duas rotas (
<IP da core>/32e<IP da ai>/32). - Disable key expiry: o servidor não pode cair da VPN a cada 180 dias.
- Edit route settings: aprove as duas rotas (
- DNS (painel → DNS → Nameservers → Add nameserver → Custom): o IP da
core-services-vm, com Restrict to domain =seudominio.com. É o split DNS: só o seu domínio vai para o CoreDNS; o resto continua no DNS normal do aparelho. - ACL (painel → Access controls): limite quem alcança o quê. Com um único usuário, a regra padrão (todos os seus aparelhos falam entre si) serve; com família na rede, libere só o necessário:
Exemplo: ACL com o administrador e a família
{ "groups": {"group:admins": ["[email protected]"], "group:familia": ["[email protected]"]}, "acls": [ {"action": "accept", "src": ["group:admins"], "dst": ["*:*"]}, {"action": "accept", "src": ["group:familia"], "dst": ["192.168.15.67:443", "192.168.15.67:53"]} ] } - Aparelhos: instale o Tailscale no PC e no celular e entre com a mesma conta. No Windows, as rotas anunciadas já valem; no Linux, use
tailscale up --accept-routes.
O que muda nas aplicações¶
| Aplicação | Com a VPN |
|---|---|
Todas (https://<app>.seudominio.com) |
Vão direto ao Traefik, sem a Cloudflare e sem o limite de 100 MB. O Authelia continua na frente de quem já estava atrás dele |
| Immich e Nextcloud | Vídeos e arquivos grandes sobem de qualquer lugar. A troca de URL pelo Wi-Fi no app do Immich vira opcional (seção 11) |
| Syncthing | O celular conecta direto em tcp://<IP da ai-desktop-vm>:22000, sem relay (seção 12) |
| Hub de MCPs (rota de controle) | http://<IP da core>:4100/mcp só responde pela VPN, também em casa (seção 13) |
| Proxmox | Fica fora da VPN: acesse pela LAN |
Falhas¶
| Falha | Comportamento | O que fazer |
|---|---|---|
core-services-vm fora |
VPN fora: nada alcançável por ela | Em casa, desligue o Tailscale e use a LAN (o Proxmox não está na VPN) |
| Rota não aprovada no painel | O aparelho entra na VPN mas não alcança as VMs | Painel → Machines → homeserver → Edit route settings |
| Nome não resolve pela VPN | fotos.seudominio.com cai na Cloudflare (limite de 100 MB) |
Confira o nameserver restrito ao domínio no painel e docker logs coredns |
| Claude Code sem as ferramentas do hub | A 4100 só responde pela VPN | Ligue o Tailscale no PC |
| Uma aplicação da LAN parou de alcançar a 9090, 3100, 3005, 4100, 4200 ou 9001 | Bloqueada pelo firewall_docker_portas |
Acrescente a origem à lista (página 01) e rode o setup-base-ubuntu.yml |
Teste de ponta a ponta¶
- No celular, fora do Wi-Fi e com o Tailscale ligado:
https://fotos.seudominio.comabre. Um vídeo acima de 100 MB sobe pelo app do Immich. - No PC com o Tailscale ligado:
Resolve-DnsName fotos.seudominio.com(PowerShell) responde o IP da core. Onslookupdo Windows ignora o split DNS do Tailscale e pode mostrar o IP da Cloudflare.curl -s -o /dev/null -w '%{http_code}' http://<IP da core>:4100/mcpresponde401(o hub pede a chave). - Com o Tailscale desligado, na LAN: o mesmo
curlna 4100 não conecta (tempo esgotado).curl http://<IP da core>:9090/-/healthytambém não, a menos que venha do host Proxmox ou daai-desktop-vm. - No host Proxmox:
curl -s http://<IP da core>:9090/-/healthyrespondePrometheus Server is Healthy.