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.