Install a Tailscale PAM connector with Ansible
You can use Ansible to create and install Tailscale PAM connectors on Linux hosts. The playbook creates the connector resource and its credentials through the Tailscale PAM API, installs the binary, and starts it as a systemd service.
You provide a Tailscale PAM service account token. The playbook automatically creates and reuses a connector token for each connector, so you don't need to get a connector token yourself.
Prerequisites
Before you begin, you need the following:
- Access to Tailscale PAM.
- Ansible on your control node, with HTTPS access to
api.border0.com. - SSH access to Linux hosts with the following available:
- Python 3
systemd- Outbound internet access
- Privilege escalation (
become: true)
As part of this guide, you will create a Tailscale PAM service account with the Member role or higher, and create a token for that service account.
Create a service account
Ansible authenticates to the API using a Tailscale PAM service account.
To create a service account:
- Open the PAM page of the Tailscale admin console.
- In the Service accounts section, select Add service account.
- Enter a Name for the service account, such as
ansible. - (Optional) Enter a description.
- Select the Member role. This gives the service account the permissions needed to manage connectors and services.
- Select Add service account.
For more information, refer to Tailscale PAM service accounts.
Create a service account token
After creating the service account, create a token for ansible:
- Open the PAM page.
- In the Service accounts section, find the service account, select the
menu, and then select Edit.
- Select Create token.
- Enter a name for the token, such as
ansible-token. - Select the token lifetime. When possible, use an expiring token and choose the shortest lifetime that works for your automation.
- Select Save.
- Copy the token and store it securely. You won't be able to get the token value again after you leave the page.
Treat service account tokens as secrets. Each token inherits the permissions of its service account.
Create the inventory
Save the hosts in a file named inventory:
[pam_connectors]
<your-connector-1> ansible_host=<your-connector-1-address>
<your-connector-2> ansible_host=<your-connector-2-address>
Replace the placeholders with the inventory aliases and addresses for your hosts. Each inventory alias becomes the connector name, so use lowercase letters, numbers, and dashes. For example:
[pam_connectors]
connector-1 ansible_host=10.0.1.10
connector-2 ansible_host=10.0.1.11
Configure the SSH user and privilege escalation credentials for your environment.
Run the playbook
Save the complete playbook as playbook-api.yaml next to the inventory.
In Bash, read the service account token without displaying it or saving it in shell history, then run the playbook:
read -rsp "Service account token: " BORDER0_AUTH_TOKEN; printf '\n'
export BORDER0_AUTH_TOKEN
ansible-playbook -i inventory playbook-api.yaml --limit pam_connectors
Requests to the Tailscale PAM API run on the control node. Each target host receives only its connector credential, which is stored in /etc/border0/tailzero.env with mode 0600. The service account token is not written to the target host.
Verify a connector is running
On a target host, confirm that the tailzero service is active:
systemctl is-active tailzero
The connector should also appear online on the Connectors page of the Tailscale admin console.
Rerun the playbook
Rerunning the playbook reuses the existing connector and the credential already on the host. It also checks for the latest stable binary. An unchanged, running installation does not restart. A new binary or changed configuration can trigger a restart.
Complete playbook
---
- name: Install Tailscale PAM connector
hosts: all
become: true
vars:
tailzero_hostname: "{{ inventory_hostname }}"
border0_service_account_token: "{{ lookup('env', 'BORDER0_AUTH_TOKEN') }}"
border0_api_base: "https://api.border0.com/api/v1"
tailzero_connector_name: >-
{{ inventory_hostname | regex_replace('[^a-z0-9-]', '-') }}
handlers:
- name: Restart connector
systemd:
name: tailzero
state: restarted
daemon_reload: true
tasks:
- name: Read existing connector token from host
slurp:
src: /etc/border0/tailzero.env
register: tailzero_env
failed_when: false
changed_when: false
no_log: true
- name: List connectors in the organization
uri:
url: "{{ border0_api_base }}/connectors"
headers:
Authorization: "Bearer {{ border0_service_account_token }}"
return_content: true
register: border0_connectors
become: false
delegate_to: localhost
- name: Find existing connector and token
set_fact:
tailzero_existing_token: >-
{{ ((tailzero_env.content | default('') | b64decode)
| regex_findall('^BORDER0_TOKEN=(\S+)') | first) | default('') }}
tailzero_connector_id: >-
{{ ((border0_connectors.json.list
| selectattr('name', 'equalto', tailzero_connector_name)
| map(attribute='connector_id') | first) | default('')) }}
- name: Create connector
uri:
url: "{{ border0_api_base }}/connector"
method: POST
body:
name: "{{ tailzero_connector_name }}"
body_format: json
status_code: [200, 201]
headers:
Authorization: "Bearer {{ border0_service_account_token }}"
register: border0_connector_create
when: tailzero_connector_id == ''
become: false
delegate_to: localhost
- name: Create connector token
uri:
url: "{{ border0_api_base }}/connector/token"
method: POST
body:
connector_id: >-
{{ border0_connector_create.json.connector_id
| default(tailzero_connector_id) }}
name: "{{ tailzero_connector_name }}-ansible"
body_format: json
status_code: [200, 201]
headers:
Authorization: "Bearer {{ border0_service_account_token }}"
register: border0_token_create
when: >-
tailzero_existing_token == ''
or border0_connector_create is not skipped
no_log: true
become: false
delegate_to: localhost
- name: Determine connector architecture
set_fact:
tailzero_arch: >-
{{ 'amd64' if ansible_facts['architecture'] == 'x86_64'
else 'arm64' if ansible_facts['architecture']
in ['aarch64', 'arm64']
else 'arm' if ansible_facts['architecture'] == 'armv7l'
else 'amd64' }}
- name: Fetch latest connector version
uri:
url: "https://tailscale.border0.com/tailzero/stable/latest_version.txt"
return_content: true
register: tailzero_version_response
- name: Set connector version
set_fact:
tailzero_version: "{{ tailzero_version_response.content | trim }}"
- name: Create connector directories
file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /etc/border0
- /var/lib/tailzero
- name: Download connector binary
get_url:
url: >-
https://tailscale.border0.com/tailzero/stable/tailzero_{{
tailzero_version }}_{{ ansible_facts['system'] | lower }}_{{
tailzero_arch }}
dest: /usr/local/bin/tailzero
mode: "0755"
force: true
notify: Restart connector
- name: Write connector credentials
copy:
dest: /etc/border0/tailzero.env
mode: "0600"
content: >-
BORDER0_TOKEN={{ border0_token_create.json.token
| default(tailzero_existing_token) }}
no_log: true
notify: Restart connector
- name: Install connector systemd unit
copy:
dest: /etc/systemd/system/tailzero.service
mode: "0644"
content: |
[Unit]
Description=Tailscale PAM connector
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/tailzero \
-hostname={{ tailzero_hostname }} \
-statedir=/var/lib/tailzero
EnvironmentFile=/etc/border0/tailzero.env
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
notify: Restart connector
- name: Enable and start connector
systemd:
name: tailzero
enabled: true
state: started
daemon_reload: true
Next steps
Create the PAM services and configure who can access them in the Tailscale admin console, or use Terraform. This playbook provisions connectors, not services or access policies.