feat(traefik): web upstream over the tailnet, public address as fallback (#243) #244

Merged
jfraeys merged 1 commit from jfraeys/feat/webapps-upstream-over-tailnet into main 2026-10-16 17:42:45 -04:00
Owner

Closes #243.

Today Traefik (and Anubis, if enabled) forward demo.jfraeys.com and app.jfraeys.com to http://:80. Verified: the route to that address leaves via eth0, and web's nginx log shows the services node's public address (172.105.107.95) as the source for those requests, i.e. unencrypted over public addresses, and the upstream is a raw IP from the generated inventory.

Change

  • playbooks/tasks/web_apps_upstream.yml (included in services.yml pre_tasks, tag always): runs 'tailscale status --json', picks the ONLINE peer whose HostName equals the first web_hosts host, uses its tailnet IPv4; otherwise falls back to the web host's public_ipv4 (previous behavior) and says which in the play output. Resolved once, so traefik and anubis agree.
  • roles/traefik and roles/anubis templates use web_apps_upstream_host.
  • inventory/group_vars/all.yml: web_apps_use_tailnet: true (false restores the old behavior).
  • Headscale ACL: tag:services -> tag:web:80.
  • Runbook cutover step 7 notes that the upstream follows the node named web, so the name swap needs no edit.

Why the Online filter: on the real services node 'tailscale status --json' lists two peers named web, a stale offline one (100.64.0.21) and the live one (100.64.0.1).

Checked: scripts/tests/test-web-apps-upstream.sh (7 cases with a fake tailscale; two cases fail if the Online filter is removed; in lint.yml); ansible-lint on the touched playbooks and roles passes; the ACL template renders to valid JSON and contains the new rule; the status JSON fields (HostName, Online, TailscaleIPs) confirmed on the live node.

Not checked: a real deploy. Web's ufw already allows tailscale0 and nginx listens on 0.0.0.0:80; nginx has no allow/deny or real_ip rule that would reject a 100.64.x source (grep found none).

Deploy order matters: ./infra deploy --tags headscale --limit services FIRST (ACL; recreates the Headscale container, brief control-plane blip), confirm, THEN --tags traefik. Traefik before the ACL would make demo/app time out. Baseline before the change: see the PR comment. After: nginx access log on web shows source 100.64.0.3 for demo requests; demo and app answer as before.

roles/, playbooks/ and inventory/ are staged paths: staging, promotion.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ

Closes #243. Today Traefik (and Anubis, if enabled) forward demo.jfraeys.com and app.jfraeys.com to http://<web public ip>:80. Verified: the route to that address leaves via eth0, and web's nginx log shows the services node's public address (172.105.107.95) as the source for those requests, i.e. unencrypted over public addresses, and the upstream is a raw IP from the generated inventory. Change - playbooks/tasks/web_apps_upstream.yml (included in services.yml pre_tasks, tag always): runs 'tailscale status --json', picks the ONLINE peer whose HostName equals the first web_hosts host, uses its tailnet IPv4; otherwise falls back to the web host's public_ipv4 (previous behavior) and says which in the play output. Resolved once, so traefik and anubis agree. - roles/traefik and roles/anubis templates use web_apps_upstream_host. - inventory/group_vars/all.yml: web_apps_use_tailnet: true (false restores the old behavior). - Headscale ACL: tag:services -> tag:web:80. - Runbook cutover step 7 notes that the upstream follows the node named web, so the name swap needs no edit. Why the Online filter: on the real services node 'tailscale status --json' lists two peers named web, a stale offline one (100.64.0.21) and the live one (100.64.0.1). Checked: scripts/tests/test-web-apps-upstream.sh (7 cases with a fake tailscale; two cases fail if the Online filter is removed; in lint.yml); ansible-lint on the touched playbooks and roles passes; the ACL template renders to valid JSON and contains the new rule; the status JSON fields (HostName, Online, TailscaleIPs) confirmed on the live node. Not checked: a real deploy. Web's ufw already allows tailscale0 and nginx listens on 0.0.0.0:80; nginx has no allow/deny or real_ip rule that would reject a 100.64.x source (grep found none). Deploy order matters: ./infra deploy --tags headscale --limit services FIRST (ACL; recreates the Headscale container, brief control-plane blip), confirm, THEN --tags traefik. Traefik before the ACL would make demo/app time out. Baseline before the change: see the PR comment. After: nginx access log on web shows source 100.64.0.3 for demo requests; demo and app answer as before. roles/, playbooks/ and inventory/ are staged paths: staging, promotion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
feat(traefik): reach web's nginx over the tailnet (webapps upstream), public address as fallback (#243)
All checks were successful
Lint / lint (pull_request) Successful in 2m30s
Secret scan / scan (pull_request) Successful in 17s
dd3605d85c
Traefik and Anubis forwarded demo/app traffic to http://<web public ip>:80, unencrypted over public addresses (the nginx log showed the
services node's public address as the source). playbooks/tasks/web_apps_upstream.yml now resolves the address once per services play from
'tailscale status --json': the online peer named like the first web_hosts host, its tailnet IPv4; otherwise the public address as before.
The Headscale ACL gets tag:services -> tag:web:80. web_apps_use_tailnet: true in group_vars (false restores the old behavior).
Test: test-web-apps-upstream.sh (7 cases, fake tailscale; fails if the Online filter is removed). The real status JSON on services has two peers
named web (one stale and offline); the filter handles it.

Deploy order: headscale first (ACL), then traefik.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!244
No description provided.