policy/v2: name ssh-behavior-v1 a deliberate difference

SaaS stamps it on every node. Sending it would change existing users' login
environment, so it stays a nodeAttr. Document that and pin it.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 39a42e9d01
commit 1bce65a236
3 files changed
+39 -3

No files matched your search

+3
View File
@@ -214,6 +214,9 @@ configuration and attributes. At least the following node attributes are current
port 41641.
- `disable-captive-portal-detection`: [Disable automatic captive portal
detection](https://tailscale.com/docs/integrations/captive-portals#disable-captive-portal-detection).
- `ssh-behavior-v1`: Run Tailscale SSH sessions on a Linux node without `su`, PAM or a login environment, so `ssh -tt`
keeps job control but `~/.profile` is not read. Tailscale's own control plane sets it on every node; Headscale leaves
it off.
```json title="policy.json"
{
+6 -3
View File
@@ -128,9 +128,12 @@ var unmodelledTailnetStateCaps = []nodecap.Cap{
// [tailscale.com/control/controlknobs.Knobs.UpdateFromNodeAttributes].
nodecap.ProbeUDPLifetime,
// [tailcfg.NodeAttrSSHBehaviorV1]: configures the embedded SSH
// server (no su, in-process SFTP). Internal tuning; the embedded
// server picks Tailscale-vendored defaults without the cap.
// [tailcfg.NodeAttrSSHBehaviorV1]: picks the embedded SSH server's
// older path on a root-run Linux daemon: no su, in-process SFTP, no
// PAM session or login environment. SaaS stamps it on every node;
// headscale leaves it to a nodeAttr on purpose, as switching changes
// existing users' login environment. It is more than tuning, so
// TestSSHBehaviorIsAnAdminChoice pins it apart from this strip.
nodecap.SSHBehaviorV1,
}
+30
View File
@@ -226,6 +226,36 @@ func TestTaildropDisabledWithholdsFileSharingCap(t *testing.T) {
})
}
// TestSSHBehaviorIsAnAdminChoice pins a deliberate difference from SaaS, which
// stamps ssh-behavior-v1 on every node: headscale sends it only through a
// nodeAttr, so sessions keep their login environment and PAM by default.
func TestSSHBehaviorIsAnAdminChoice(t *testing.T) {
t.Parallel()
srv := servertest.NewServer(t)
user := srv.CreateUser(t, "ssh-behavior-user")
c := servertest.NewClient(t, srv, "ssh-behavior-node", servertest.WithUser(user))
c.WaitForCondition(t, "baseline without ssh-behavior-v1", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
if nm == nil || !nm.SelfNode.Valid() {
return false
}
return hasCap(nm, nodecap.SSH) && !hasCap(nm, nodecap.SSHBehaviorV1)
})
reloadPolicy(t, srv, `{
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}],
"nodeAttrs": [{"target": ["*"], "attr": ["ssh-behavior-v1"]}]
}`)
c.WaitForCondition(t, "ssh-behavior-v1 through a nodeAttr", 10*time.Second,
func(nm *netmap.NetworkMap) bool {
return hasCap(nm, nodecap.SSHBehaviorV1)
})
}
// TestNodeAttrsAddsToBaseline verifies that policy nodeAttrs caps land on
// nodes alongside the always-on baseline. The baseline caps remain
// regardless of policy contents.