WorkstationConfig
A machine setup you define once and attach to many workstations, by label or by explicit reference.
A WorkstationConfig is a machine setup you define once and reuse: the packages, tools, code, and settings that make a machine ready for a particular kind of work. You attach it to workstations by label, and every matching machine gets it, including machines created later.
Configs exist because setups outlive machines: a team baseline, a project toolchain, and your personal preferences can each be their own config. Several can apply to one workstation, stacked as layers by priority, so a project config refines the team’s and your personal one sits on top.
Concretely, a WorkstationConfig carries the same machine-contents and
system-settings fields a Workstation
does, plus a selector (which workstations receive it) and a priority (where
it sits in the stack). It has no status: it is pure declarative
configuration.
apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfigHow it attaches
A config applies to a workstation in one of two ways:
- By selector: its
spec.selector.matchLabelsmatch the workstation’smetadata.labels. An empty selector matches nothing, so a config with no selector applies only via an explicit reference (it can’t silently reconfigure unrelated workstations). - By explicit reference: the workstation lists it in
spec.configs.
How far a selector reaches depends on who authored the config. A config
written by an ordinary namespace member applies only to workstations that member
owns; a config written by a namespace admin applies across the whole namespace.
An admin who wants a namespace-wide config to affect only their own workstations can
set spec.selector.ownerScope: true, which additionally requires the workstation’s
owner to equal the config owner. An explicit reference (spec.configs) is never
gated this way: it’s an affirmative choice by the workstation’s owner.
The personal layer
spec.selector.ownerScope: true with no matchLabels means “every workstation
whose owner is me, in this namespace”, the layer that follows the developer rather
than the fleet.
apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfig
metadata:
name: my-setup
spec:
selector:
ownerScope: true # no matchLabels, which is what makes it personal
dotfilesRepo:
url: https://github.com/me/dotfiles
packages:
- ripgrep- It defaults to priority 75, above the ordinary selector default of 50, so your own preference beats a team default without your having to pick a number.
- Its steps are non-fatal by default. A broken personal step will not take a
workstation to
Available: False; see Step failure policy. - It applies to every workstation you own in the namespace, whatever the workstation
is for. In the
localnamespace that includes the local runtime, the workstation that runs Docker on your computer. So keep it to settings that are safe everywhere, and put the rest behind a label, as Selectors describes. - It does not apply to a teammate’s workstation that you have been granted SSH on. That workstation gets its owner’s personal layer.
- It re-converges live. Editing it reconfigures every workstation you own, not
just the next one you create. Preview with
rl diff -fbefore you apply. ownerScopewithmatchLabelsis a different thing: the fleet gesture described above, keeping the ordinary priority and the ordinary failure policy.
Omit the namespace from the manifest and the same file works in whichever namespace
you apply it to.
Merge order
When multiple configs (and the workstation’s inline spec) apply, they are merged
as config layers in ascending priority order: lower priority applies first,
and later layers win on conflict. To see the merged result for a given workstation, run
rl workstation get-resolved-configuration <name>.
Give an explicit reference a priority
The 50/75 defaults above apply to a selector match. An entry in a workstation’s
spec.configs carries the priority written on the reference, with no default, so
- name: team-base with no priority layers at 0, the lowest rung of all, and a
personal layer at 75 would override the config you hand-picked for that workstation.
Write the number you mean:
spec:
configs:
- name: team-base
priority: 100The workstation’s own spec is the base, not the top layer
The merge starts from the workstation’s inline spec and applies every layer over
it, so a config layer at any priority, including a negative one, wins on
environment, toolconfigs and every other last-wins field. Setting a value inline on
your own workstation does not override a config that reaches it; author a
config layer of your own instead.
Only image, defaultLocalBinding and tailscale are workstation-supreme: the
workstation re-applies those at the end, so its own value wins. This is what makes
an administrator’s Integration, which
compiles to a layer below every hand-written config, still override an inline
ANTHROPIC_BASE_URL.
Example
apiVersion: workstations.ringleader.dev/v1
kind: WorkstationConfig
metadata:
name: ide
namespace: dev
spec:
selector:
matchLabels:
tier: dev
priority: 100
image:
os: linux
distribution: debian
version: "13"
identity:
shell: /bin/bash
packages:
- git
- name: curl
updatePolicy: latest
devtools:
- name: nodejs
- name: vscode-web
ports:
- 8080
securityUpdates: true
defaultLocalBinding:
enabled: true
autoForward:
forwardAll: trueAttachment fields
| Field | Type | Description |
|---|---|---|
selector | object | {matchLabels: {key: value}}: which workstations auto-receive this config. Empty matches none. A member’s selector reaches only workstations they own; an admin’s reaches the namespace. |
selector.ownerScope | bool | Also require the workstation’s owner to equal the config owner (default false). Lets an admin self-scope a namespace-wide config to their own workstations. With no matchLabels it makes the config a personal layer. |
priority | int | Config-layer priority; lower applies first. Default 50, or 75 for a personal layer. |
Every other field below is a config-layer provisioning field, the same set a Workstation may carry inline. They are documented here because a config is where you usually put them. All are optional; unknown top-level keys are rejected.
providerConfig is not a config-layer field
providerConfig (VM sizing, cloud project/zone) is resolved from the
Workstation spec only and is never merged from config layers, so declaring it
here is refused at apply rather than quietly ignored. Keep it on the
Workstation, or, for a fleet-wide default, use a
CloudIdentity’s
defaultProviderConfig / overrideProviderConfig.Packages & repositories
packages installs OS packages. Each entry is either a bare name or an object:
packages:
- git # bare name → system package
- name: ripgrep
- type: system # system (default) | npm
name: htop
version: "3.2.2" # optional version pin
updatePolicy: latest # pinned (default) | latest (re-upgrade on the slow tick)
- type: npm
name: prettier
bin: prettier # optional installed-check override (npm)packageRepositories adds third-party apt repos or Launchpad PPAs before packages
install. aptSources is a legacy alias normalized into this shape.
packageRepositories:
- name: docker # basename for the keyring + source files
type: apt # apt (default) | ppa (apk/rpm reserved)
urls: ["https://download.docker.com/linux/debian"]
key:
url: https://download.docker.com/linux/debian/gpg # or: inline | fingerprint
apt:
suites: ["bookworm"]
components: ["stable"]
architectures: ["amd64", "arm64"] # optional; defaults to the machine's arch
types: ["deb"] # optional; default ["deb"]
- name: deadsnakes
type: ppa
ppa:
ownerName: deadsnakes/ppa
suite: jammy # optional; else derived from the OS codenameThe apt block also accepts suiteAliases (remap the resolved OS codename for a
repo that lags a new distro release) and options (an escape hatch for extra
deb822 keys). A private repo adds auth: {login, password} (each may be a
${secret:NAME} reference).
Devtools & tool config
Two separate channels: devtools installs software, toolconfigs
configures it. Each has its own reference section. The summary here is the
shape; the detail is one page per tool.
devtools runs curated install recipes, each {name, version?, config?}. The
built-in recipes are:
go ·
golangci-lint ·
docker ·
devcontainer-cli ·
nodejs ·
python ·
claude-code ·
codex ·
agy ·
cursor-agent ·
vscode-web ·
kind ·
kubectl ·
helm ·
fzf ·
kubectx ·
kubens ·
gcloud ·
aws ·
az ·
git ·
gh ·
playwright ·
passthrough-www-browser ·
spire-agent.
devtools:
- name: go
version: "1.22.5" # recipe-defined; omit for the recipe default
- name: docker
- name: claude-code
- name: kind
config: # opaque, recipe-interpreted (kind, playwright)
cluster: dev # cluster name (default "kind")
ingress: nginx # nginx (default) | none
loadBalancer: cloud-provider-kind # cloud-provider-kind (default) | metallb | none
- name: playwright
config:
browsers: ["chromium", "firefox"] # engines to install (default ["chromium"])kind, playwright, vscode-web and spire-agent interpret a config blob; the
other recipes take just an optional version, whose meaning is recipe-specific. An
entry may also carry fatal and retries; see Step failure
policy. Devtools install in declared order,
dedupe by name across layers (last-wins on the whole entry), and an unknown name
causes setup to fail. See Devtools for every
recipe.
toolconfigs configures already-installed tools without reinstalling them,
{id?, name, config}, where config is an opaque per-tool blob applied inside the
workstation on each configuration pass. The tools that accept a config channel are
claude-code,
codex,
vscode-web,
vscode-server,
git, and
gh:
toolconfigs:
- name: vscode-web
config:
port: 8080
auth: none # none | password
- name: git
config:
userName: Ada Lovelace
userEmail: ada@example.comTool entries dedupe by id (falling back to name), last-wins on the whole
entry: a higher-priority layer replaces the lower one rather than merging field by
field. Values may carry ${secret:NAME} references. See
Tool configuration for each tool’s
fields.
Workspace trust
trustedFolders lists absolute paths the workstation’s coding tools pre-trust, so they
never open with a “do you trust this folder?” prompt. Every sources[].path is
trusted automatically; this field extends that set.
trustedFolders:
- /home/dev/scratchCode sources
sources syncs code into the workstation, in declared order. A source is a git clone or
a local directory stream, synced to path:
sources:
- name: app
path: /home/dev/app
updatePolicy: latest # pinned (default; clone once) | latest (fast-forward, git only)
git:
url: https://github.com/acme/app.git
ref: main # branch/tag/commit; empty = remote default
credentialRef: # Secret with a deploy key (SSH) or PAT (HTTPS)
name: gh-deploy-key
key: token
- name: local-lib
path: /home/dev/lib
local:
path: /host/checkout/lib # host dir streamed in (ringleader-on-ringleader)A source whose repository carries a devcontainer.json can also run it on
the workstation: add devcontainer: {enabled: true} to the entry and rl shell
lands inside the built container. See
Devcontainers for the details and
the supported subset of the format.
Scripts
scripts are provisioning steps run in order, after packages and devtools:
scripts:
- name: setup
phase: system # system (root) | user (login user, in $HOME)
content: |
apt-get install -y build-essential
runPolicy: onChange # once (default) | onChange | always | onRecycle
watchPaths: ["/etc/foo"] # onChange: re-run when these change
failOnError: true # abort remaining scripts on non-zero exit
timeoutSeconds: 300 # 0 = no timeout
env:
TOKEN: ${secret:ci-token}runAsDefaultUser: true is the canonical equivalent of phase: user.
A script with runPolicy: onRecycle never runs in an ordinary configuration pass. It runs once
each time the workstation’s spec.recycleNonce changes, which is how a
WorkstationPool cleans a member
before the next lease. A failing onRecycle script fails the recycle, whatever its fatal
says, and the pool then deletes the member.
A script may take its body from a ConfigMap instead of inlining it:
scripts:
- name: bootstrap
phase: system
contentFrom:
configMapRef:
name: bootstrap
key: setup.shcontentFrom and content are mutually exclusive: declaring both is refused at
apply.
Managed services
services declares long-running units, either a full systemd unit or a command
Ringleader synthesizes a unit from:
services:
- name: api
command: /home/dev/app/serve --port 3000
runAs: dev
enable: true # start on boot (default true)
start: true # start now (default true)
environment:
PORT: "3000"Files
files materializes files on the workstation (after packages, before scripts):
files:
- path: /etc/foo.conf
content: "key = value\n" # or contentBase64, url, or contentFrom
owner: root:root # "user[:group]"; default root (or the login user for ~/ paths)
mode: "0644"
parents: true # create parent dirs
- path: ~/.config/app/config.yaml
contentFrom: # from a ConfigMap key
configMapRef:
name: app-defaults
key: config.yaml
removeWhenUndeclared: true # delete the file when this entry goes awayContent may contain ${secret:NAME} references. Exactly one content source per
entry (content, contentBase64, url or
contentFrom), and declaring two is refused
at apply.
Removing a file when it stops being declared
By default a declared file is write-once: delete the entry (or the whole config
that carried it) and the file stays on the workstation. Set
removeWhenUndeclared: true to hand the path to Ringleader instead. While the entry
is declared the file is materialized as usual, and once the entry leaves the resolved
configuration the next configuration pass deletes it.
- It is opt-in, per entry. A configuration can lose a layer without anyone editing anything, so removing every undeclared file by default would delete healthy files on that observation alone.
- Withdrawing the flag withdraws the claim on the very next pass.
- A partial configuration removes nothing. If a
contentFromreference cannot be resolved, the file is left exactly where it is rather than deleted on the strength of an incomplete picture. - The file’s content is not checked first. A declared file already has out-of-band edits overwritten on every pass; refusing to delete an edited one would leave a file behind in precisely the case where somebody tampered with it.
Identity & users
identity is the one place the login user is declared; the agent owns user
creation:
identity:
user: dev
uid: 1000
gid: 1000
group: dev
groups: ["docker", "sudo"]
shell: /bin/bash # default /bin/bash
sudo: false # passwordless sudo is on by default; false opts outThe login user gets passwordless sudo by default. Set sudo: false to create a
workstation whose login user cannot elevate, which suits a locked-down or shared machine.
Environment & shell
environment: # → /etc/environment + /etc/profile.d (system-wide)
EDITOR: vim
DB_URL: ${secret:db-url}
dotfiles: # path → content, written as managed blocks
"~/.bashrc": |
alias ll='ls -la'Dotfiles repository
dotfilesRepo clones your own dotfiles repository into the workstation and installs it
by running the repository’s own install script. It is the natural companion to a
personal layer.
dotfilesRepo:
url: https://github.com/me/dotfiles
ref: main # branch/tag/commit; empty = the remote's default branch
path: .dotfiles # HOME-relative checkout dir (default .dotfiles)
installScript: setup.sh # repo-relative entry point; empty = probe (see below)
updatePolicy: pinned # pinned (default; clone once) | latest (fast-forward each pass)
timeoutSeconds: 120 # bounds the clone AND the install script, separately
credentialRef: # a Secret with a deploy key (SSH url) or a PAT (HTTPS url)
name: dotfiles-deploy-key| Field | Type | Description |
|---|---|---|
url | string | Required. https://, http://, ssh://, git@host:path or git://. |
ref | string | Branch, tag or commit. Empty clones the remote’s default branch. |
path | string | HOME-relative checkout directory. Default .dotfiles. |
installScript | string | A repo-relative script to run instead of probing. It is executed as a file inside the checkout, never evaluated as a command line. |
credentialRef | object | {name, key} naming a Secret, for a private repository. |
updatePolicy | string | pinned (default) clones once; latest fast-forwards on each configuration pass. |
timeoutSeconds | int | Bounds the clone and the install script separately. Default 120. |
fatal | bool | Default false, so a broken dotfiles install never blocks Configured. |
retries | int | Extra attempts within one pass (0–5). |
How it installs
Everything runs as the login user, so the checkout and everything it writes belong to them, never root-owned files in someone’s home.
- Clone (or fast-forward, for
updatePolicy: latest) into$HOME/<path>. - Run the install script: your
installScriptwhen set, otherwise the first of these that exists and is executable, with the checkout as the working directory:install.sh,install,bootstrap.sh,bootstrap,setup.sh,setup, and then the same six again underscript/. - With no install script, fall back to a symlink sweep: every top-level entry
beginning with
.is linked into$HOME(directories included, so.config/works), skipping.git*except.gitconfig, which is linked.
An existing path is never overwritten. The sweep skips anything already at the
destination, which is what makes it safe to re-run on every configuration pass and
keeps it from displacing a file the dotfiles map, a files[] entry or a toolconfig
owns.
Host-key trust is the workstation’s own. The clone uses whatever sshKnownHosts
pinned; it never disables host-key checking.
One repository per workstation. A higher-priority layer replaces the whole block rather than merging into it, including the workstation’s own inline spec, which claims no override here. This field’s natural author is a personal layer, so a box’s own manifest does not quietly beat it.
It is not a sources[] entry. A sources[].path is pre-trusted by the
workstation’s coding tools, and a dotfiles checkout is not a working folder.
Step failure policy: fatal and retries
Most provisioning entries accept two extra keys: fatal and retries. They are
supported on scripts, sources, packages, devtools, files, services and
dotfilesRepo.
| Field | Type | Description |
|---|---|---|
fatal | bool | Does this step’s failure block the workstation reaching Configured (and therefore Available)? |
retries | int | Extra attempts within one configuration pass after a failure. Clamped to 0–5; a negative value means none. |
Defaults:
| Step | Default fatal |
|---|---|
scripts, sources, packages, devtools, files, services | true |
| the same steps contributed by a personal layer | false |
dotfiles (the map) and dotfilesRepo | false |
An unset retries is 2 for a non-fatal step and 0 for a fatal one. A fatal
step already gets re-attempted by the workstation’s own retry schedule, since it fails
the pass and the next pass comes quickly, whereas a non-fatal failure does not raise that
alarm, and the in-pass attempts are what fix a transient failure at boot.
fatal is not failOnError
scripts[].failOnError decides whether the rest of the pass is skipped. fatal
decides whether this step’s failure blocks Configured. They are independent and both
are honored.Two residues worth knowing: entries merged into one step across layers (the
dotfiles map, environment, sysctls, hostname, timezone, locale, limits)
carry no per-entry policy, and the package phase’s shared index refresh stays fatal
even when the package that triggered it is not.
Workstation capabilities
capabilities declares what a workstation’s own identity may do: the workstation acting
for itself, as opposed to you acting on it. A workstation has an identity of its own only where
whoever runs your control plane has turned workstation identity on, and it is off by default.
Where it is off, the workstation can use none of these capabilities, whatever you declare.
capabilities:
- self:read
- self:power| Capability | What it allows | Default |
|---|---|---|
self:read | The box reads its own Workstation record: phase, conditions, status. | on |
self:power | The box writes spec.stopped / spec.restartNonce on its own record, so it can stop or restart itself. | off |
sa:assume | The box may assume a ServiceAccount at all. Which one is decided by that account’s own trust rules. | off |
An unknown name is refused at apply, so a typo cannot quietly declare nothing.
Both tiers must agree, and neither can widen past the owner. What a box ends up holding is the intersection of what admin-authored layers declare and what member-authored ones do (the box’s own spec counts as member-authored), and it is then bounded by what the box’s owner is themselves permitted to do. So:
- An admin’s layer grants; declaring one extra capability does not silently revoke
self:read, because the defaults are the base rather than a competing term. - An admin’s layer declaring
capabilities: []opts the namespace out of the defaults entirely. - A member can only narrow. Naming a capability no admin granted yields nothing; naming a subset narrows to that subset.
- If no member-authored layer mentions the key at all, the member tier constrains nothing.
Layer priority is irrelevant here: an intersection is order-free, and merge order is not what decides an authorization.
System settings
hostname: dev-box
timezone: America/New_York # IANA name
locale: en_US.UTF-8
sysctls: # → /etc/sysctl.d/
vm.max_map_count: "262144"
limits: # pam_limits → /etc/security/limits.d/
- domain: "*"
type: soft # soft | hard | -
item: nofile
value: "65536"Outbound SSH trust
sshKnownHosts pins host keys into ~/.ssh/known_hosts for outbound SSH. Each entry
is a bare hostname (resolved from a built-in catalog) or {host, keys}:
sshKnownHosts:
- github.com # catalog-resolved
- host: git.internal
keys: ["ssh-ed25519 AAAA…"]Security updates & diagnostics
securityUpdates: true # unattended OS security upgrades (Debian; no-op on Alpine)
diagnostics:
ledger:
enabled: true # default true
retain: 30d # default 30d
maxOutputBytes: 65536 # per-step stdout/stderr capmaxOutputBytes caps each step’s stdout and stderr. A value above 128 KiB resolves to 128 KiB.
Tailscale
Join the workstation to a Tailscale tailnet. The join credential is always a Secret reference. An inline key is rejected.
tailscale:
enabled: true
authKeyRef:
name: tailscale-authkey
key: authkey
ssh: true # --ssh
hostname: dev-box # --hostname
tags: ["tag:dev"] # --advertise-tags (must match the key's ACL tags)
advertiseRoutes: ["10.0.0.0/24"] # subnet router
acceptRoutes: true
exitNode: "100.x.y.z" # route egress via this node
exitNodeAllowLANAccess: true # keep the local LAN reachable while using an exit node
advertiseExitNode: false # make THIS workstation an exit node
acceptDNS: true # MagicDNS (default on)
loginServer: "" # custom control plane / HeadscalePorts & forwarding
ports declares the workstation’s listening ports. defaultLocalBinding is a template the
daemon uses to seed a LocalBinding once, when the workstation first reaches Running,
so ports forward to your device automatically. See the LocalBinding reference for the
full autoForward/ports/sockets/urlForward shape.
ports: [8080, 3000]
defaultLocalBinding:
enabled: true
scope: owner # owner (default) | accessors (grantees' devices seed it disabled)
autoForward:
forwardAll: true
portOffset: 10000 # workstation 8080 → host 18080To forward the workstation’s Docker socket to each device that seeds the binding, add
dockerSocket. You do not name the path on your computer, because it differs from one device
to the next: Ringleader picks it on each device when it seeds the binding.
defaultLocalBinding:
enabled: true
dockerSocket:
enabled: true
remotePath: /var/run/docker.sock # the socket inside the workstation; this is the defaultThe seeded LocalBinding carries it as an ordinary
sockets[] entry marked
docker: true.
Restricting outbound connections
By default a workstation reaches anything your network routes. egress narrows that to
an allowlist, enforced by your cloud’s own firewall, by an
edge instance, or on your own machine, on hcs, vz and qemu, by
the workstation’s own network process or an edge VM. Each of those sits
outside the workstation, so nothing running on it (including its own root) can undo the
restriction.
egress:
enforcement: strict # strict | bestEffort | audit; required, no default
destinations:
- cidr: 10.20.0.0/16 # a network, or a bare address
- cidr: 203.0.113.7
ports: [443, 5432] # defaults to [443]
- host: github.com # a name; 80 and 443 only
- host: "*.githubusercontent.com"enforcement is required and has no default: “my egress policy is enforced”
must never be something you believe by accident.
| Value | Means |
|---|---|
strict | A workstation whose provider cannot hold this policy is refused when you apply it, so you find out while you can still change what you wrote. The workstation must name its provider. Once it is running, a strict workstation is never failed for its policy: while no Edge serves a policy that needs one, the workstation is held closed. |
bestEffort | The workstation is set up either way, and any shortfall is reported on its EgressEnforced condition. |
audit | The workstation’s connections go through an Edge, which records each one it lets through. It enforces no destination, and destinations are optional. The workstation must name its provider, and while no Edge serves it the workstation is held closed. Find out what a workstation reaches shows how to turn the records into a strict policy. |
Each destination is a cidr or a host, never both:
| Field | Notes |
|---|---|
cidr | A network in prefix form (10.20.0.0/16) or a bare address. A network with host bits set below the prefix length is refused. Any port. Behind an edge instance, a private range cannot be reached on GCP and Azure, and a range inside the VPC cannot be reached on AWS. |
host | A name, optionally with a leading *. wildcard. Only ports 80 and 443, because a name is read off the connection itself: the TLS server name on 443, and the HTTP Host header on 80. Enforcing a name needs an Edge serving the workstation’s provider and region. On hcs, vz and qemu, the workstation’s own network process enforces it with no Edge. See Where it can be enforced. A name that resolves to a private address is refused. To reach another port, or to have the cloud firewall hold it directly, declare the address range as a cidr. |
ports | TCP ports, 1–65535. Defaults to [443]. |
Omitting the block entirely means no restriction. An empty block is not the same
thing: it is a policy with no enforcement, and it is refused. So is a strict or
bestEffort policy that names no destinations, because a policy permitting nothing would
keep the workstation from reaching its own control plane. An audit policy with no
destinations is accepted, because it enforces no destination.
You never have to list the infrastructure a workstation needs to come up and stay managed. Those destinations are added to every compiled policy automatically, so a policy cannot accidentally cut the workstation off from Ringleader itself.
Ringleader also adds an attached Integration’s endpoint, a host that receives an injected
credential, and an MCP server’s host. It adds each one only on the ports it needs. An
inspect: true rule is different: it checks a host that the policy already allows, but it
does not add that host to the allowlist. rl workstation describe names the Integration beside
each destination it added.
How layers combine
When several layers declare egress, including the workstation’s own spec, they combine into
one policy by two rules, and layer priority decides neither:
- Destinations add up. The workstation may reach every destination any layer names. A destination two layers both name on the same ports counts once.
enforcementonly gets stricter. The order isstrict, thenbestEffort, thenaudit, and the strictest one any layer declares wins. One layer declaringstrictmakes the whole policystrict. A workstation isauditonly when nothing it declares or inherits saysstrictorbestEffort.
So a layer can make a policy stricter and never weaker: a higher-priority layer that says
bestEffort does not undo a team layer’s strict. Because destinations add up, a layer cannot
take one away either. To cap which destinations workstations may reach, an administrator writes
an egressDestinations rule in a Policy, which a member
cannot widen.
Where it can be enforced
Enforcement is a property of the provider. On the cloud providers (AWS, Azure and
GCP) it is a firewall object your cloud applies to the VM, compiled from the policy.
Those objects match addresses, so a cidr destination is held exactly by the cloud
itself. The local providers answer differently, and the end of this section says how.
A host is a name, and a firewall that matches addresses cannot decide on a name: the name
appears on the connection, not in the packet’s destination address. Enforcing one needs
something that reads the name off the connection as it passes. That is what an
Edge does. It is a VM that a namespace administrator
declares once per cloud and region, and the workstations in that region send their traffic
through it.
So what a host destination gets depends on whether an Edge serves your workstation’s
provider and region. The last case costs you the whole policy, not just the names, so read
it before you declare a host:
| What happens | |
|---|---|
| An Edge serves it | The name is enforced on ports 80 and 443, by the edge instance reading it off the connection. |
| An Edge is declared and not serving it yet | The workstation is held closed, whatever its enforcement, and reports EgressEnforced: False with reason GatewayNotServing. |
| No Edge is declared | The workstation reports EgressEnforced: False with reason NoEdge, and the message names the provider and region. With bestEffort, nothing in the policy is enforced, your cidr destinations included, because a rule set is built whole or not at all. A workstation that was already enforcing an earlier policy keeps that one instead, and the message says which. If that earlier policy was enforced behind an edge instance, keeping it restricts nothing. With strict, the workstation is held closed. |
A workstation that is held closed reaches only what it needs to stay managed by Ringleader, and the endpoints of its attached Integrations.
A strict policy can name a host on a cloud workstation when the same namespace declares
an Edge for that provider. This check does not need a matching region or a ready edge
instance, and an Edge that is being deleted does not count. Enforcement still depends on an
edge instance serving the workstation’s region and holding its policy. Read EgressEnforced
for the result.
An audit policy needs an Edge on a cloud whether or not it names a destination, because the
Edge is what records the connections. It is accepted when the workstation names its provider and
the namespace holds an Edge for that provider that is not being deleted. As with strict, the
check does not need a matching region or a ready edge instance. With no edge instance serving
it, the workstation is held closed. Edge
has the details.
On a local workstation the answer depends on the provider. An hcs workstation, on Windows,
needs no Edge. Its own network process enforces names and addresses, so a strict or audit
policy is accepted when you apply the workstation, with or without an Edge. A namespace administrator can
declare an Edge for hcs to run an edge VM on your PC instead.
An hcs workstation created before hcs gave each workstation its own address cannot be served by
its own network process, and it cannot sit behind an edge VM either. With no Edge, a
bestEffort one runs unrestricted and reports NoEdge, and a strict or audit one is held
closed. Delete it and create it again.
A vz workstation, on a Mac,
needs no Edge. The network process Ringleader runs for it enforces the policy, names and
addresses both, as an edge instance does on a cloud. A strict or audit policy on vz is accepted
when you apply the workstation, with or without an Edge. A namespace administrator can declare an
Edge for vz to run an edge VM on your machine instead.
A vz workstation created before vz gave each workstation its own address cannot be served by
its own network process, and it cannot sit behind an edge VM either. With no Edge, a
bestEffort one runs unrestricted and reports NoEdge, and a strict or audit one is held
closed. Delete it and create it again.
A qemu workstation, on Linux, works the same way. The network process Ringleader runs for it
enforces the policy, names and addresses both, and a strict or audit policy is accepted with
or without an Edge. A namespace administrator can declare an Edge for qemu to run an edge VM
instead. A qemu workstation created before qemu gave each workstation its own address, or
before Ringleader could set up the network cards its own Edge needs, is served neither way:
with no Edge, a bestEffort one runs unrestricted and reports NoEdge, and a strict or audit
one is held closed. Delete it and create it again.
lima and wsl2 enforce nothing outside the machine. A workstation on one accepts a
bestEffort policy, is set up, and reports ProviderCannotEnforce for everything it
declared: the restriction is recorded on the workstation and not in force. strict and audit on
such a workstation are refused at apply.
A WorkstationConfig is never refused for the workstations it reaches, and one that declares
audit needs no provider of its own. Each workstation is checked when you next change its own
egress, provider, labels or configs. Until then, its EgressEnforced condition reports what
is held.
A workstation that names a WorkstationConfig in spec.configs starts recording when that
WorkstationConfig’s author turns on audit. The workstation’s status.egress.recorded shows
it. The author reads the workstation’s records only if they own the workstation or are a
namespace administrator.
The onboarding assets grant the cloud-side permission this needs, on by default. See Cloud Onboarding.
What a policy does not reach
On a cloud workstation a policy applies to addresses inside your own network as well as to the public internet. On a local provider, read Where it can be enforced for what is held and what is not.
The destination to plan around is your cloud’s instance metadata endpoint at
169.254.169.254, which hands out the VM’s own cloud credentials. On GCP the cloud serves
that address to the VM underneath the firewall it applies, so the workstation reaches it
whatever the policy says, and a policy is not what keeps a GCP workstation away from its own
instance credentials. AWS and Azure serve the same endpoint. Whether a policy can close
it there is a property of those clouds, so read their documentation rather than assuming
a policy covers it.
Find out what a workstation reaches
If you do not know which destinations a workstation needs, start with enforcement: audit
and let Ringleader write the allowlist. An audit policy enforces no destination. It sends the
workstation’s connections through an Edge, which counts each one it lets through.
Give the workstation an audit policy, in its own
egressblock or in a WorkstationConfig it uses:egress: enforcement: auditWhere it can be enforced says which providers accept it.
Wait until the workstation says it is recording. Its
status.egress.recordedlistsallowedonce it is:rl workstation get my-box -o yamlUse the workstation the way you normally do, for as long as it takes to cover its work. A destination it does not reach in that time is missing from the next step.
List what it reached, and write the
strictpolicy those connections add up to:rl workstation egress my-box --allowed rl workstation egress my-box --suggest > egress.yamlRead
egress.yamland remove what the workstation should not reach. The policy keeps the destinations the workstation already declares and adds each destination it reached that they do not cover, with names and addresses copied as recorded. Hostnames on ports other than 80 and 443 are left out, and a note on standard error counts them. To reach one of those hosts, declare its address range as acidr.Apply
egress.yamlas the workstation’segress, in place ofaudit.
Recording stops once the policy is no longer audit, unless an administrator’s Edge records
allowed connections for every workstation it serves. rl workstation egress
lists every flag, and What an Edge records
says what each record holds and how long Ringleader keeps it.
Status
None. A WorkstationConfig has no status: it is consumed by the config-layer merge
when a workstation is resolved.