personal_infra/ansible/services/headscale/deploy_headscale_playbook.yml

386 lines
12 KiB
YAML
Raw Normal View History

2025-10-22 23:58:38 +02:00
- name: Deploy headscale and configure Caddy reverse proxy
hosts: vpn_control
2025-10-22 23:58:38 +02:00
become: no
vars_files:
- ../../infra_vars.yml
2025-11-06 23:09:44 +01:00
- ../../services_config.yml
- ../../infra_secrets.yml
2025-10-22 23:58:38 +02:00
- ./headscale_vars.yml
vars:
2025-11-06 23:09:44 +01:00
headscale_subdomain: "{{ subdomains.headscale }}"
2025-10-22 23:58:38 +02:00
headscale_domain: "{{ headscale_subdomain }}.{{ root_domain }}"
2025-11-03 16:51:53 +01:00
headscale_base_domain: "tailnet.{{ root_domain }}"
2025-11-14 23:36:00 +01:00
headscale_namespace: "{{ service_settings.headscale.namespace }}"
2025-11-06 23:09:44 +01:00
uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}"
2025-10-22 23:58:38 +02:00
tasks:
- name: Install required packages
become: yes
apt:
name:
- wget
- gnupg
state: present
update_cache: yes
- name: Download headscale DEB package
get_url:
url: "https://github.com/juanfont/headscale/releases/download/v{{ headscale_version }}/headscale_{{ headscale_version }}_linux_amd64.deb"
dest: /tmp/headscale.deb
mode: '0644'
- name: Install headscale package
become: yes
apt:
deb: /tmp/headscale.deb
state: present
- name: Remove temporary DEB file
file:
path: /tmp/headscale.deb
state: absent
2025-11-03 16:51:53 +01:00
- name: Ensure headscale user exists
become: yes
user:
name: headscale
system: yes
shell: /usr/sbin/nologin
home: /var/lib/headscale
create_home: yes
state: present
2025-10-22 23:58:38 +02:00
- name: Create headscale data directory
become: yes
file:
path: /var/lib/headscale
state: directory
owner: headscale
group: headscale
mode: '0750'
- name: Create headscale run directory
become: yes
file:
path: /var/run/headscale
state: directory
owner: headscale
group: headscale
2025-11-03 16:51:53 +01:00
mode: '0770'
2025-10-22 23:58:38 +02:00
- name: Ensure headscale user owns data directory
become: yes
file:
path: /var/lib/headscale
owner: headscale
group: headscale
recurse: yes
2025-11-03 16:51:53 +01:00
mode: '0750'
- name: Add counterweight user to headscale group
become: yes
user:
name: counterweight
groups: headscale
append: yes
2025-10-22 23:58:38 +02:00
- name: Create ACL policies file
become: yes
copy:
dest: /etc/headscale/acl.json
content: |
2025-12-14 18:52:36 +01:00
{}
2025-10-22 23:58:38 +02:00
owner: headscale
group: headscale
2025-11-03 16:51:53 +01:00
mode: '0640'
2025-10-22 23:58:38 +02:00
notify: Restart headscale
- name: Deploy headscale configuration file
become: yes
copy:
dest: /etc/headscale/config.yaml
content: |
server_url: https://{{ headscale_domain }}
listen_addr: 0.0.0.0:{{ headscale_port }}
grpc_listen_addr: 0.0.0.0:{{ headscale_grpc_port }}
grpc_allow_insecure: false
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp:
server:
enabled: true
region_id: 999
region_code: "headscale"
region_name: "Headscale Embedded DERP"
verify_clients: true
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
urls:
- https://controlplane.tailscale.com/derpmap/default
database:
type: sqlite3
sqlite:
path: /var/lib/headscale/db.sqlite
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
log:
level: info
format: text
policy:
path: /etc/headscale/acl.json
dns:
2025-11-03 16:51:53 +01:00
base_domain: {{ headscale_base_domain | quote }}
2025-10-22 23:58:38 +02:00
magic_dns: true
search_domains:
2025-11-03 16:51:53 +01:00
- {{ headscale_base_domain | quote }}
2025-10-22 23:58:38 +02:00
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
owner: root
2025-11-03 16:51:53 +01:00
group: headscale
mode: '0640'
2025-10-22 23:58:38 +02:00
notify: Restart headscale
- name: Test headscale configuration
become: yes
command: headscale configtest
register: headscale_config_test
failed_when: headscale_config_test.rc != 0
- name: Display headscale config test results
debug:
msg: "{{ headscale_config_test.stdout }}"
2025-11-06 23:09:44 +01:00
- name: Ensure headscale data directory has correct ownership before starting service
become: yes
file:
path: /var/lib/headscale
state: directory
owner: headscale
group: headscale
mode: '0750'
recurse: yes
- name: Ensure headscale run directory has correct ownership
become: yes
file:
path: /var/run/headscale
state: directory
owner: headscale
group: headscale
mode: '0770'
2025-10-22 23:58:38 +02:00
- name: Enable and start headscale service
become: yes
systemd:
name: headscale
enabled: yes
state: started
2025-11-06 23:09:44 +01:00
daemon_reload: yes
2025-10-22 23:58:38 +02:00
2025-11-03 16:51:53 +01:00
- name: Wait for headscale unix socket to be ready
become: yes
wait_for:
path: /var/run/headscale/headscale.sock
state: present
timeout: 60
delay: 2
- name: Create headscale namespace if it doesn't exist
become: yes
command: headscale users create {{ headscale_namespace }}
register: create_namespace_result
failed_when: create_namespace_result.rc != 0 and 'already exists' not in create_namespace_result.stderr and 'UNIQUE constraint' not in create_namespace_result.stderr
changed_when: create_namespace_result.rc == 0
2025-10-22 23:58:38 +02:00
- name: Allow HTTPS through UFW
become: yes
ufw:
rule: allow
port: '443'
proto: tcp
- name: Allow HTTP through UFW (for Let's Encrypt)
become: yes
ufw:
rule: allow
port: '80'
proto: tcp
- name: Allow STUN through UFW (for DERP server)
become: yes
ufw:
rule: allow
port: '3478'
proto: udp
ntfy, datum-gateway, headscale: use the caddy_site role Completes Stage 3. No hand-rolled Caddy plumbing remains anywhere: `grep sites-enabled` outside roles/ returns nothing, and so does `grep "systemctl reload caddy"`. ntfy uses caddy_site_body for its plain-HTTP listener and @httpget redirect. Verified ok/unchanged against watchtower; the one other changed task is a pre-existing "Update APT cache". datum-gateway keeps a whole-Caddyfile validate after the role call. The role validates its own fragment, but only a whole-file validate catches a conflict between two sites, and this playbook was the only one that ever had it. Its two debug tasks that echoed command output are gone with the commands. headscale is the one that mattered. Its playbook wrote `reverse_proxy localhost:8080`, but spacey is actually running a /admin* route in front of Headplane behind Caddy basic auth. Running that playbook would have deleted the admin route and its auth - a hazard that predates this work. It now renders the config that is really there, verified ok/unchanged via --start-at-task (the play cannot reach Caddy in check mode: "Install headscale package" fails because the .deb is not really downloaded, before and after this edit alike). Supporting changes for headscale: - headscale_ui_password_hash added to infra_secrets.yml and the identical group_vars/all/vault.yml, read from the live config on spacey. The vault already had headscale_ui_username (= counterweight, confirmed) and headscale_ui_password; I did not verify the password is the plaintext of this hash. - headplane_port added to headscale_vars.yml. - The role's handler now sets become: true. Handlers do not inherit become from the task that notified them, and this play runs become: no. - The include uses `apply: become: yes`; `become:` on an include_role is rejected outright. All 14 site files on all 3 hosts still byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:37:38 +02:00
- name: Publish headscale through Caddy
ansible.builtin.include_role:
name: caddy_site
# This play is become: no and elevates per task. `apply` is how an
# include_role passes become down to the role's tasks - `become:` on
# the include itself is rejected. The role's handler sets its own.
apply:
become: yes
vars:
caddy_site_name: headscale
caddy_site_domain: "{{ headscale_domain }}"
# Raw body, and it must stay raw: the /admin* route in front of
# Headplane is not expressible as a plain reverse_proxy. The previous
# version of this task wrote only `reverse_proxy localhost:8080`, which
# would have deleted the admin route and its auth on the next run.
caddy_site_body: |
@headplane {
path /admin*
2025-10-22 23:58:38 +02:00
}
ntfy, datum-gateway, headscale: use the caddy_site role Completes Stage 3. No hand-rolled Caddy plumbing remains anywhere: `grep sites-enabled` outside roles/ returns nothing, and so does `grep "systemctl reload caddy"`. ntfy uses caddy_site_body for its plain-HTTP listener and @httpget redirect. Verified ok/unchanged against watchtower; the one other changed task is a pre-existing "Update APT cache". datum-gateway keeps a whole-Caddyfile validate after the role call. The role validates its own fragment, but only a whole-file validate catches a conflict between two sites, and this playbook was the only one that ever had it. Its two debug tasks that echoed command output are gone with the commands. headscale is the one that mattered. Its playbook wrote `reverse_proxy localhost:8080`, but spacey is actually running a /admin* route in front of Headplane behind Caddy basic auth. Running that playbook would have deleted the admin route and its auth - a hazard that predates this work. It now renders the config that is really there, verified ok/unchanged via --start-at-task (the play cannot reach Caddy in check mode: "Install headscale package" fails because the .deb is not really downloaded, before and after this edit alike). Supporting changes for headscale: - headscale_ui_password_hash added to infra_secrets.yml and the identical group_vars/all/vault.yml, read from the live config on spacey. The vault already had headscale_ui_username (= counterweight, confirmed) and headscale_ui_password; I did not verify the password is the plaintext of this hash. - headplane_port added to headscale_vars.yml. - The role's handler now sets become: true. Handlers do not inherit become from the task that notified them, and this play runs become: no. - The include uses `apply: become: yes`; `become:` on an include_role is rejected outright. All 14 site files on all 3 hosts still byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:37:38 +02:00
handle @headplane {
basicauth {
{{ headscale_ui_username }} {{ headscale_ui_password_hash }}
}
reverse_proxy http://localhost:{{ headplane_port }}
}
# Headscale API is protected by its own API key authentication
# All API operations require a valid Bearer token in the Authorization header
reverse_proxy * http://localhost:{{ headscale_port }}
2025-10-22 23:58:38 +02:00
# ═════════════════════════════════════════════════════════════════════════
# DEPRECATED — Uptime Kuma was decommissioned on 2026-09-11.
#
# Every task below is inert: uptime_kuma_enabled is false in
# group_vars/all/main.yml, so they all skip and the deployment above still
# runs normally. Kept because the health-check logic is the durable part —
# when a replacement exists, rewire the push transport and flip the flag.
#
# What was being monitored: archive/uptime_kuma/MONITORS.md
# ═════════════════════════════════════════════════════════════════════════
2025-11-06 23:09:44 +01:00
- name: Create Uptime Kuma monitor setup script for Headscale
when: uptime_kuma_enabled | default(false)
2025-11-06 23:09:44 +01:00
delegate_to: localhost
become: no
copy:
dest: /tmp/setup_headscale_monitor.py
content: |
#!/usr/bin/env python3
import sys
import yaml
from uptime_kuma_api import UptimeKumaApi, MonitorType
try:
with open('/tmp/ansible_config.yml', 'r') as f:
config = yaml.safe_load(f)
url = config['uptime_kuma_url']
username = config['username']
password = config['password']
monitor_url = config['monitor_url']
monitor_name = config['monitor_name']
api = UptimeKumaApi(url, timeout=30)
api.login(username, password)
# Get all monitors
monitors = api.get_monitors()
# Find or create "services" group
group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None)
if not group:
group_result = api.add_monitor(type='group', name='services')
# Refresh to get the group with id
monitors = api.get_monitors()
group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None)
# Check if monitor already exists
existing_monitor = None
for monitor in monitors:
if monitor.get('name') == monitor_name:
existing_monitor = monitor
break
# Get ntfy notification ID
notifications = api.get_notifications()
ntfy_notification_id = None
for notif in notifications:
if notif.get('type') == 'ntfy':
ntfy_notification_id = notif.get('id')
break
if existing_monitor:
print(f"Monitor '{monitor_name}' already exists (ID: {existing_monitor['id']})")
print("Skipping - monitor already configured")
else:
print(f"Creating monitor '{monitor_name}'...")
api.add_monitor(
type=MonitorType.HTTP,
name=monitor_name,
url=monitor_url,
parent=group['id'],
interval=60,
maxretries=3,
retryInterval=60,
notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {}
)
api.disconnect()
print("SUCCESS")
except Exception as e:
print(f"ERROR: {str(e)}", file=sys.stderr)
sys.exit(1)
mode: '0755'
- name: Create temporary config for monitor setup
when: uptime_kuma_enabled | default(false)
2025-11-06 23:09:44 +01:00
delegate_to: localhost
become: no
copy:
dest: /tmp/ansible_config.yml
content: |
uptime_kuma_url: "{{ uptime_kuma_api_url }}"
username: "{{ uptime_kuma_username }}"
password: "{{ uptime_kuma_password }}"
monitor_url: "https://{{ headscale_domain }}/health"
monitor_name: "Headscale"
mode: '0644'
- name: Run Uptime Kuma monitor setup
when: uptime_kuma_enabled | default(false)
2025-11-06 23:09:44 +01:00
command: python3 /tmp/setup_headscale_monitor.py
delegate_to: localhost
become: no
register: monitor_setup
changed_when: "'SUCCESS' in monitor_setup.stdout"
ignore_errors: yes
- name: Clean up temporary files
when: uptime_kuma_enabled | default(false)
2025-11-06 23:09:44 +01:00
delegate_to: localhost
become: no
file:
path: "{{ item }}"
state: absent
loop:
- /tmp/setup_headscale_monitor.py
- /tmp/ansible_config.yml
2025-10-22 23:58:38 +02:00
handlers:
- name: Restart headscale
become: yes
systemd:
name: headscale
state: restarted