Skip to content

Repository files navigation

tailscale-ansible

License: MIT CI ansible-core

An Ansible role that installs and configures Tailscale on Fedora with a deny-by-default security posture. No DNS takeover, no default-route changes, shields up, a single scoped firewalld change — and every loosening is an explicit per-host opt-in. Works against Tailscale's hosted control plane or a self-hosted Headscale coordinator (see the companion headscale-ansible).

The project ships with a single role (tailscale) and is structured to grow — adding further roles to the same repository follows the conventions described below.

Features

  • Upstream packaging. Installs from Tailscale's signed Fedora repository, not distribution packages; the GPG key is verified for both metadata and packages (gpgcheck=1, repo_gpgcheck=1).
  • No DNS side-effects. --accept-dns=false; /etc/resolv.conf checksum is snapshotted pre-apply and asserted unchanged post-apply.
  • No routing side-effects. --accept-routes=false; the host default route is snapshotted pre-apply and asserted unchanged post-apply. Any drift fails the play with a before/after diff.
  • Deny-by-default inbound. --shields-up; tailnet peers cannot reach the host until explicitly allowed.
  • Exit node / subnet router as an explicit opt-in. Advertising is off by default. Opting in also configures what routing actually needs, declared in the role rather than left to chance: kernel forwarding sysctls, tailscale0 in the firewalld trusted zone, masquerade on the egress zone, and a trusted→ANY ACCEPT policy (Fedora's FORWARD chain otherwise rejects NEW connections from the trusted zone).
  • Self-hosted coordinator support. Set tailscale_login_server to enrol against a Headscale instance instead of login.tailscale.com.
  • Scoped firewall changes, no reloads. Exactly one UDP port is opened, in the firewalld zone that actually holds the default-route interface — discovered at runtime, never assumed. The role never runs firewall-cmd --reload: a reload silently drops runtime-only state such as podman/netavark's container-subnet sources, and that breakage looks nothing like its cause.
  • Vaulted credentials. The auth key comes from an ansible-vault encrypted file; every task that touches it sets no_log.
  • Rootless CLI. --operator=<user> lets a local user run tailscale without sudo for status/diagnostics.
  • Idempotent. A clean second apply reports zero changes. tailscale up runs only when the backend is not already Running; otherwise tailscale set reconciles configuration drift.
  • Standalone verification. A read-only verify.yml playbook asserts the posture at any time without modifying state.

Requirements

Control node (where ansible-playbook runs)

  • ansible-core 2.15 or newer
  • Collections: ansible.posix, community.general (installed via requirements.yml)

Managed node

  • Fedora (tested on Fedora 43)
  • systemd
  • firewalld as the active firewall manager
  • Python 3 available to Ansible
  • A user account with sudo privileges (the playbook escalates per-task; no play-level become)

Quick start

Clone the repository, then perform the steps below on the control node.

1. Install dependencies

ansible-galaxy collection install -r requirements.yml

2. Create the vault password file

umask 077
printf '%s' 'your-strong-passphrase' > .vault_pass
chmod 600 .vault_pass

Store the passphrase in a password manager. If it is lost, the encrypted vault contents are unrecoverable.

3. Create the inventory

cat > inventory.ini <<'EOF'
[tailscale_nodes]
node1 ansible_host=<ip-or-hostname> ansible_user=<user>
EOF

For a local-only setup, use ansible_connection=local.

inventory.ini, group_vars/, and host_vars/ are gitignored. Real hostnames, domains, and IPs live only there — tracked files carry placeholders.

4. (Optional) Override role defaults

Only required if a default does not suit your environment. Typical overrides are the UDP listen port and, for Headscale users, the coordinator URL:

mkdir -p group_vars/all
cat > group_vars/all/vars.yml <<'EOF'
---
tailscale_listen_port: 41641   # pick any valid UDP port
# tailscale_login_server: "https://headscale.example.com"
EOF

See Role variables for the full list.

5. Generate an auth key

Hosted Tailscale: create a pre-authorized, non-reusable, non-ephemeral key in the admin console. Use the shortest practical expiration.

Headscale: mint a pre-auth key on the coordinator (headscale preauthkeys create --user <id> --expiration 24h).

6. Encrypt the auth key

ansible-vault create group_vars/all/vault.yml

File contents:

tailscale_authkey: tskey-auth-XXXXXXXXXXXXXXXXXXXXXXXXXX

7. Apply

ansible-playbook site.yml --check --diff    # dry run
ansible-playbook site.yml                   # apply
ansible-playbook verify.yml                 # read-only verification
ansible-playbook site.yml                   # must report changed=0

Role variables

Required

Supplied via the encrypted vault (group_vars/all/vault.yml by convention).

Variable Purpose
tailscale_authkey Pre-authorized auth key from the admin console or Headscale.

Defaults

Defined and documented in roles/tailscale/defaults/main.yml. Override in group_vars/all/vars.yml or per-host host_vars/<hostname>.yml.

Variable Default Description
tailscale_listen_port 41641 UDP port. Privileged ports below 1024 are rejected; IANA dynamic range 49152–65535 is recommended for defense-in-depth.
tailscale_login_server "" Coordination server. Empty uses hosted Tailscale; set an https:// Headscale URL to self-host.
tailscale_operator_user {{ ansible_user_id }} Local user permitted to run tailscale CLI without sudo.
tailscale_hostname {{ inventory_hostname_short }} Device name displayed in the admin console.
tailscale_firewalld_zone "" Empty string triggers auto-discovery based on the interface holding the default route.
tailscale_accept_routes false Refuse to install subnet routes advertised by peers.
tailscale_accept_dns false Never modify the host's DNS configuration.
tailscale_advertise_exit_node false Advertise this node as an exit node. Enables forwarding + firewalld routing pieces.
tailscale_advertise_routes [] Subnet CIDRs to advertise. Non-empty enables the same routing pieces.
tailscale_ssh false Disable Tailscale SSH; OpenSSH remains the only SSH implementation on the host.
tailscale_shields_up true Block all inbound connections from the tailnet until explicitly allowed. Overrides coordinator ACLs.
tailscale_reset_on_first_run false Pass --reset to the first tailscale up. Wipes prior daemon state — for clean reinstalls only.
tailscale_repo_name tailscale-stable DNF repository name.
tailscale_repo_baseurl https://pkgs.tailscale.com/stable/fedora/$basearch Upstream repository base URL.
tailscale_repo_gpgkey https://pkgs.tailscale.com/stable/fedora/repo.gpg GPG key URL used for both gpgcheck and repo_gpgcheck.

Usage

Minimal consumer playbook (applied automatically by this project's site.yml):

- hosts: tailscale_nodes
  gather_facts: true
  become: false
  roles:
    - role: tailscale

With overrides at the play level:

- hosts: tailscale_nodes
  roles:
    - role: tailscale
      vars:
        tailscale_listen_port: 52187
        tailscale_hostname: edge-1
        tailscale_advertise_routes:
          - 10.20.0.0/24

Verification

verify.yml is a read-only playbook that can be run at any time. It asserts:

  1. tailscale status --json reports BackendState == Running and Self.Online.
  2. The default route is unchanged since the last apply (inline check only; a standalone run asserts current validity rather than pre/post drift).
  3. /etc/resolv.conf checksum is unchanged.
  4. The configured UDP port is open in the resolved firewalld zone.
  5. tailscaled is bound to the configured UDP port.
ansible-playbook verify.yml

Rollback

Disconnect from the tailnet without uninstalling:

sudo tailscale down

Confirm the host's networking is unchanged:

ip route show default
cat /etc/resolv.conf

Optionally remove the machine from the tailnet via the admin console.

Full uninstall:

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.d

On an exit-node/router host, also remove the opt-in routing pieces:

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

Auth key rotation

  1. Expire the current key in the admin console (or headscale preauthkeys expire).
  2. Generate a replacement pre-authorized key with the same tags.
  3. ansible-vault edit group_vars/all/vault.yml and update tailscale_authkey.
  4. On the managed node: sudo tailscale logout to force re-authentication.
  5. ansible-playbook site.yml — the role detects the logged-out state and runs tailscale up with the new key exactly once.

Vault password rotation

ansible-vault rekey group_vars/all/vault.yml
# Update .vault_pass with the new passphrase.

State and backups

The tracked repo is reproducible. The gitignored files are not: group_vars/all/{vars,vault}.yml, inventory.ini, host_vars/, and .vault_pass. Back them up outside git. Keep .vault_pass only in a password manager — a backup that bundles the vault with its password defeats the encryption. The node's tailnet identity (/var/lib/tailscale/) is re-creatable: re-authenticate with a fresh pre-auth key.

Client onboarding

Install the Tailscale client on macOS/iOS/Android/Windows/Linux and sign in to the same tailnet identity used to generate the auth key. The configured device will appear under Machines in the admin console with the hostname set by tailscale_hostname.

To verify a direct (unrelayed) connection from a client:

tailscale ping <hostname>

A response of the form pong from <host> via <ip>:<port> indicates a direct UDP connection. via DERP(<region>) indicates the connection is being relayed, typically because the client-side network blocks outbound UDP to the configured port or uses symmetric NAT.

Troubleshooting

Symptom Resolution
tailscale_authkey is empty during preflight Vault is not being decrypted. Verify .vault_pass exists and matches the passphrase used for group_vars/all/vault.yml.
Resolved firewalld zone is empty during firewall The default-route interface is not assigned to a firewalld zone. Pin the zone explicitly with tailscale_firewalld_zone: <zone>.
Default route changed after Tailscale apply The route was rewritten despite --accept-routes=false. Immediately run sudo tailscale down and investigate with ip rule, ip route show table all.
/etc/resolv.conf changed Something modified DNS despite --accept-dns=false. Check systemctl status systemd-resolved and resolvectl status.
Second site.yml run reports changed > 0 A non-idempotent task fired. Most likely tailscale up ran twice; verify tailscale_already_up was evaluated correctly (see configure.yml).
tailscaled is not bound to the configured UDP port The systemd drop-in was not loaded. sudo systemctl daemon-reload && sudo systemctl restart tailscaled.
Peers cannot open NEW connections through the exit node The tailscale-egress policy is missing. Fedora's FORWARD chain rejects new trusted→other-zone connections; re-apply with exit-node advertising enabled.

Security posture

  • Secrets management. Auth keys and other sensitive values are stored only in ansible-vault encrypted files. .vault_pass is gitignored. Tasks that handle the key set no_log: true.
  • Privilege escalation. The play does not set become globally; it is enabled per-task, only on the subset of tasks requiring root.
  • Firewall changes. One UDP port, opened with ansible.posix.firewalld in a zone discovered at runtime. The exit-node routing pieces apply only when advertising is enabled. Never firewall-cmd --reload.
  • System integrity checks. The default route and /etc/resolv.conf are snapshotted before apply and verified unchanged after apply.
  • Upstream packaging. The Tailscale package is installed from the vendor's signed Fedora repository; the GPG key is verified on both metadata and package signatures.

Development

Linting

CI gates on the production profile of ansible-lint plus playbook syntax checks. Locally:

ansible-lint

If your distribution's ansible-core is older than what recent ansible-lint releases require, use a project-local virtualenv:

python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install 'ansible-core>=2.15' 'ansible-lint>=25'
.venv/bin/ansible-lint

Deploys continue to use the system ansible-playbook; the venv is for lint only.

Adding a role

  1. mkdir -p roles/<role>/{defaults,tasks,handlers,meta,vars,templates}
  2. Populate defaults/main.yml with tunables using deny-by-default values.
  3. Write tasks/main.yml; scope become: true to individual tasks.
  4. Add read-only assertions to a tasks/verify.yml file.
  5. Add the role to site.yml and document it in this README.

Conventions

  • Every role is idempotent; a second apply must report changed=0.
  • Secrets are declared in defaults/main.yml as empty strings and sourced from vault-resolved variables; preflight fails loudly on empty values.
  • State-changing tasks that touch secrets set no_log: true.
  • Check mode (--check) is supported; read-only facts that must run in check mode set check_mode: false explicitly.

Project layout

ansible.cfg                      # inventory path, roles_path, vault_password_file
requirements.yml                 # Galaxy collection dependencies
site.yml                         # apply playbook
verify.yml                       # read-only verification
roles/tailscale/
  defaults/main.yml              # all tunables, fully commented — the reference
  vars/main.yml                  # internal constants
  tasks/
    main.yml                     # orchestration
    preflight.yml                # snapshot state, validate inputs
    install.yml                  # repo, GPG key, package, service
    systemd_override.yml         # pin listen port via drop-in
    resolve_zone.yml             # read-only firewalld zone discovery
    firewall.yml                 # open UDP port; exit-node routing pieces
    forwarding.yml               # kernel forwarding sysctls (exit/router only)
    configure.yml                # tailscale up / tailscale set
    verify.yml                   # post-apply assertions
  handlers/main.yml              # restart tailscaled
  meta/main.yml                  # Galaxy metadata
inventory.ini                    # gitignored — environment-specific
group_vars/all/                  # gitignored — environment-specific
  vars.yml                       # plaintext overrides
  vault.yml                      # ansible-vault encrypted secrets
host_vars/                       # gitignored — optional per-host overrides
.vault_pass                      # gitignored — vault passphrase

Contributing

Issues and PRs welcome — see CONTRIBUTING.md and CODE_OF_CONDUCT.md. This is a personal project maintained on a best-effort basis. Security reports: SECURITY.md.

License

MIT.

About

Ansible: Tailscale client role for the Fedora homelab (headscale-coordinated)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors