Skip to main content

UNDERSTANDING TAILSCALE AUTOMATION IN SWAMP

Tailscale has two kinds of state. One is the tailnet's own configuration: who can reach what, how names resolve, which keys can add devices, and where events go. The other is the set of machines that run the Tailscale client. Swamp works with both, through different extensions. This page covers the first, which the @swamp/tailscale extension handles through the Tailscale API, and how it relates to the second.

One behavior type per API resource

@swamp/tailscale does not provide a single type for the whole tailnet. It provides a behavior type for each resource the Tailscale API exposes: policy-file, dns-configuration, tailnet-key, oauth-client, device, user, webhook, log-stream, tailnet-settings and others. Each behavior writes resource state, and that state can be queried later as data. Behaviors, Types, and Methods describes how types relate to behaviors, and The Data Layer describes what happens to the state they write.

Splitting the tailnet this finely has a cost: a full tailnet configuration is spread across many behaviors. The benefit is that each concern can change, be reviewed and be run on its own. A change to split DNS cannot accidentally rewrite the access policy, because the two live in different behaviors. The extension also has overlapping types. dns-configuration sets resolvers, search paths and split DNS in one request, and dns-nameservers, dns-search-paths and dns-split-nameservers handle each part separately. Teams pick the granularity that matches how they review changes.

Three shapes of behavior type

The types fall into three shapes, and the shape determines what a definition must contain.

Tailnet-wide types, such as tailnet-settings, dns-preferences, contacts and policy-file, need no required arguments. A tailnet has only one of each of these, so the behavior manages that one.

Device-scoped types, such as device-tags, device-subnet-routes, device-key, device-authorization and device-posture-attribute, require a deviceId. Each behavior manages one aspect of one device.

Factory types, such as tailnet-key, oauth-client, webhook, user-invite and posture-integration, require an instance name. A tailnet can hold many auth keys or webhooks, and the instance name tells Swamp which one a behavior's state belongs to. The pattern is the same one described in Reuse a Behavior with the Factory Pattern.

What the methods mean

The method names are shared across types. create and update apply the global arguments to Tailscale. get and sync read live values into state. list writes every matching resource to state, and adopt brings a resource that already exists under a behavior's management.

delete is the method whose meaning varies most between types. For contacts, tailnet-settings and device-authorization, it only stops Swamp from managing the resource, and Tailscale keeps the current values. A tailnet must always have contacts and settings, so there is nothing to remove. For device-tags, device-key and the DNS types, delete clears the values. For factory types and device, it removes the resource from the tailnet. policy-file is the most careful: delete resets the policy to Tailscale's default only when resetOnDelete is set. It is worth checking which kind of delete a type has before running it.

device and user are less about declared configuration than about operations. They provide methods such as expire, set_name, set_role, approve, suspend and restore. These are actions an administrator takes in response to events, not settings that stay applied.

Credentials and tailnet selection

Every type accepts the same credential arguments. There are two options: an API access token in apiKey, or an OAuth client in oauthClientId and oauthClientSecret, with optional oauthScopes that otherwise default to all of the client's scopes. Each argument overrides a matching TAILSCALE_* environment variable. tailnet defaults to TAILSCALE_TAILNET, and then to the tailnet of the credential in use.

Environment variables suit one person working from a terminal. Behaviors run by a team, a schedule or a server should wire apiKey and oauthClientSecret to a vault with a ${{ vault.get('<vault-name>', '<key>') }} expression. The credential then lives in one place, and Swamp redacts it from output. Storing Secrets walks through the pattern, and the Vaults reference describes the expression. OAuth clients are generally the better long-term choice. Their scopes can be narrowed to what a set of behaviors needs, while an API access token acts with the full rights of the user who created it.

Secrets Tailscale returns only once

Tailscale returns some values once, at creation, and never again: auth keys, OAuth client secrets, OAuth app secrets, and webhook signing secrets. The tailnet-key, oauth-client, oauth-app and webhook types write these values as a secret resource to a vault, not into plain state. The behavior's state records that the key exists and what it can do. The vault holds the value that grants access.

This has two consequences. Rotating a credential, for example with rotate_secret on a webhook, replaces the vaulted value, and anything that reads it gets the new one. Who can use the secret also becomes a question of vault access, not access to the repository's data. Route Sensitive Fields to Specific Vaults covers sending these values to a vault other than the default. Teams that want an explicit, journaled process for replacing an OAuth client, with a preview and confirmation before revoking the old one, can look at @mgreten/tailscale-oauth-client.

Policy as code

The access policy carries the most risk, and policy-file is the most defensive type in the extension. create refuses to overwrite a policy that has been edited since the tailnet was created, unless overwriteExistingContent is set. update refuses if the policy has changed since the last get or sync. This is optimistic concurrency. Someone may have edited the policy in the admin console since Swamp last read it, and Swamp will not silently undo that edit. validate and preview check a policy, or which rules match a user or IP and port, without saving anything.

Teams that move the policy into Swamp also have to decide what happens to the admin console. The aclsExternallyManagedOn setting on tailnet-settings stops users from editing the policy there. That removes one source of conflicting edits, but every change then has to go through the repository. Some teams instead let both paths exist and add drift detection. @bixu/tailscale-community and @sntxrr/tailscale-acl-drift compare the live policy with a policy file kept in version control and propose changes as pull requests, without writing to the tailnet themselves.

The API and the hosts

@swamp/tailscale configures the tailnet but installs nothing on machines. Community extensions cover that side. @keeb/tailscale and @bitgnome/tailscale install Tailscale over SSH and join hosts with an auth key. They also turn tailscale status JSON into one resource per machine. @hivemq/tailscale does the same for bare-metal fleets, with a canary group, a fleet stage and sshd hardening, run one host at a time and stopping at the first failure.

The two sides meet in workflows. A workflow can create an auth key with tailnet-key and then bring hosts up with that key through one of the SSH-based extensions. It can also pause for a person to review a canary group before the fleet stage. The Workflow Execution Model describes how those steps are ordered, and Gate a Workflow with Manual Approval shows how to add the pause. Keeping the API work and the host work in separate behaviors lets each side be rerun on its own. An auth key can be rotated without touching hosts, and a host can be rebuilt without creating a new key.