An Ansible role that installs and configures Tailscale on Fedora using restrictive, deny-by-default settings. It prevents automatic DNS overrides, avoids route hijacking, enables shields-up inbound blocking, and confines firewall changes to a single verified port. All routing and network adjustments require explicit per-host configuration.
This role works with Tailscale's hosted control plane or a self-hosted Headscale coordination server (such as headscale-ansible).
- Official packaging: Installs from Tailscale's signed Fedora repository, verifying both package and repository metadata GPG signatures.
- DNS preservation: Sets
--accept-dns=false. Verifies/etc/resolv.confbefore and after execution to confirm DNS settings remain unchanged. - Routing isolation: Sets
--accept-routes=false. Verifies the host default route before and after execution to prevent unexpected route changes. - Inbound connection protection: Enables
--shields-upby default to block incoming connections from tailnet peers until explicitly permitted. - Controlled routing opt-ins: Exit node and subnet router advertising are disabled by default. Enabling either option configures required sysctl parameters, assigns
tailscale0to the firewalldtrustedzone, enables masquerade, and applies a forwarding policy. - Headscale support: Set
tailscale_login_serverto authenticate against a self-hosted Headscale instance rather thanlogin.tailscale.com. - Targeted firewall management: Opens a single UDP port in the firewalld zone assigned to the default-route interface, discovered at runtime. Never runs
firewall-cmd --reloadto avoid dropping runtime container network rules. - Encrypted credentials: Reads authentication keys from Ansible Vault and enforces
no_logon all sensitive tasks. - Unprivileged status checks: Uses
--operator=<user>to allow non-root users to runtailscale statusand diagnostic commands. - Idempotent execution: Consecutive runs report zero changes when no configuration drifted.
- Verification playbook: Includes a read-only
verify.ymlplaybook to validate security settings at any time without changing system state.
ansible-core2.15 or newer- Collections:
ansible.posix,community.general(installed viarequirements.yml)
- Fedora (tested on Fedora 43)
systemdfirewalldas the active firewall manager- Python 3 available to Ansible
- A user account with
sudopermissions (the playbook escalates privilege per-task; no play-levelbecome)
Run these steps on the control node:
ansible-galaxy collection install -r requirements.ymlumask 077
printf '%s' 'your-strong-passphrase' > .vault_pass
chmod 600 .vault_passStore the passphrase in a password manager. If lost, encrypted vault contents cannot be recovered.
cat > inventory.ini <<'EOF'
[tailscale_nodes]
node1 ansible_host=<ip-or-hostname> ansible_user=<user>
EOFFor local testing on the current machine, use ansible_connection=local.
inventory.ini, group_vars/, and host_vars/ are excluded from version control. Keep real hostnames, domains, and IP addresses in those files.
Override defaults in group_vars/all/vars.yml if needed:
mkdir -p group_vars/all
cat > group_vars/all/vars.yml <<'EOF'
---
tailscale_listen_port: 41641
# tailscale_login_server: "https://headscale.example.com"
EOFSee Role variables for available settings.
- Hosted Tailscale: Create a pre-authorized, single-use, non-ephemeral key in the admin console. Set a short expiration period.
- Headscale: Generate a pre-auth key on the coordinator (
headscale preauthkeys create --user <id> --expiration 24h).
ansible-vault create group_vars/all/vault.ymlAdd your key to the file:
tailscale_authkey: tskey-auth-XXXXXXXXXXXXXXXXXXXXXXXXXXansible-playbook site.yml --check --diff # preview changes
ansible-playbook site.yml # apply configuration
ansible-playbook verify.yml # verify settings
ansible-playbook site.yml # verify changed=0Supply through the encrypted vault (group_vars/all/vault.yml):
| Variable | Description |
|---|---|
tailscale_authkey |
Pre-authorized auth key from the Tailscale admin console or Headscale coordinator |
Configured in roles/tailscale/defaults/main.yml. Override in group_vars/all/vars.yml or host_vars/<hostname>.yml:
| Variable | Default | Description |
|---|---|---|
tailscale_listen_port |
41641 |
UDP listen port. Values below 1024 are rejected. Ports in 49152 to 65535 are recommended. |
tailscale_login_server |
"" |
Coordination server URL. Empty uses hosted Tailscale; specify an https:// URL for Headscale. |
tailscale_operator_user |
{{ ansible_user_id }} |
Local account permitted to run tailscale commands without sudo. |
tailscale_hostname |
{{ inventory_hostname_short }} |
Machine name displayed in the tailnet admin console. |
tailscale_firewalld_zone |
"" |
Firewalld zone. Empty string auto-discovers the zone assigned to the default-route interface. |
tailscale_accept_routes |
false |
Accept subnet routes advertised by peer nodes. |
tailscale_accept_dns |
false |
Allow Tailscale to modify host DNS settings. |
tailscale_advertise_exit_node |
false |
Advertise this node as an exit node. Enables forwarding and required firewall rules. |
tailscale_advertise_routes |
[] |
List of subnet CIDRs to advertise. Non-empty lists enable routing settings. |
tailscale_ssh |
false |
Enable Tailscale SSH. Kept false so OpenSSH manages host access. |
tailscale_shields_up |
true |
Block all inbound connections from tailnet peers until explicitly allowed. |
tailscale_reset_on_first_run |
false |
Pass --reset to the initial tailscale up command. Clears previous daemon configuration. |
tailscale_repo_name |
tailscale-stable |
DNF package repository name. |
tailscale_repo_baseurl |
https://pkgs.tailscale.com/stable/fedora/$basearch |
Package repository URL. |
tailscale_repo_gpgkey |
https://pkgs.tailscale.com/stable/fedora/repo.gpg |
GPG key URL for package and repository validation. |
Basic playbook:
- hosts: tailscale_nodes
gather_facts: true
become: false
roles:
- role: tailscalePlaybook with custom options:
- hosts: tailscale_nodes
roles:
- role: tailscale
vars:
tailscale_listen_port: 52187
tailscale_hostname: edge-1
tailscale_advertise_routes:
- 10.20.0.0/24Run the read-only verification playbook at any time:
ansible-playbook verify.ymlThe playbook asserts:
tailscale status --jsonreportsBackendState == RunningandSelf.Online.- The default network route matches the preflight check.
- The checksum of
/etc/resolv.confmatches the preflight check. - The configured UDP port is open in the active firewalld zone.
tailscaledis listening on the configured UDP port.
To disconnect from the tailnet without removing software:
sudo tailscale downConfirm that system network settings remain intact:
ip route show default
cat /etc/resolv.confTo remove Tailscale completely:
IFACE="$(ip -json route show default | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["dev"])')"
ZONE="$(firewall-cmd --get-zone-of-interface="$IFACE")"
PORT=<configured tailscale_listen_port>
sudo systemctl disable --now tailscaled
sudo dnf remove -y tailscale
sudo firewall-cmd --zone="$ZONE" --remove-port="$PORT/udp" --permanent
sudo firewall-cmd --zone="$ZONE" --remove-port="$PORT/udp"
sudo rm -f /etc/yum.repos.d/tailscale-stable.repo
sudo rm -rf /etc/systemd/system/tailscaled.service.dOn nodes acting as exit nodes or subnet routers, remove routing configuration:
sudo firewall-cmd --zone=trusted --remove-interface=tailscale0 --permanent
sudo firewall-cmd --zone=trusted --remove-interface=tailscale0
sudo firewall-cmd --zone="$ZONE" --remove-masquerade --permanent
sudo firewall-cmd --zone="$ZONE" --remove-masquerade
sudo firewall-cmd --permanent --delete-policy=tailscale-egress
sudo rm -f /etc/sysctl.d/99-tailscale.conf- Revoke the existing key in the admin console or with
headscale preauthkeys expire. - Generate a new pre-authorized key with matching tags.
- Run
ansible-vault edit group_vars/all/vault.ymland updatetailscale_authkey. - Run
sudo tailscale logouton the managed node to reset authentication. - Run
ansible-playbook site.yml. The role identifies the logged-out state and authenticates with the new key.
| Issue | Resolution |
|---|---|
tailscale_authkey is empty during preflight |
Verify that .vault_pass exists and decrypts group_vars/all/vault.yml. |
Resolved firewalld zone is empty |
Assign a firewalld zone to your default network interface, or set tailscale_firewalld_zone explicitly. |
| Default route changed after apply | Run sudo tailscale down immediately and check routing tables with ip route show table all. |
/etc/resolv.conf changed |
Check systemctl status systemd-resolved and inspect DNS managers for external modifications. |
Re-running site.yml reports changed > 0 |
Inspect task output to determine which task triggered changes. |
tailscaled is not listening on the configured port |
Run sudo systemctl daemon-reload && sudo systemctl restart tailscaled to reload systemd drop-ins. |
| Peers cannot establish new connections through exit node | Verify that the tailscale-egress policy is present and active in firewalld. |
Run ansible-lint and playbook syntax checks:
ansible-lint
ansible-playbook site.yml --syntax-checkContributions and issue reports are welcome. See CONTRIBUTING.md and CODE_OF_CONDUCT.md for guidelines. Report security issues according to SECURITY.md.
This project is licensed under the MIT License.