From 1bce65a2363241eba11d29acc292fe866c21380c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:20:10 +0000 Subject: [PATCH] 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. --- docs/ref/policy.md | 3 +++ hscontrol/policy/v2/tailnet_state_caps.go | 9 ++++--- hscontrol/servertest/nodeattrs_test.go | 30 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/ref/policy.md b/docs/ref/policy.md index 342e5b6ff..55d336f62 100644 --- a/docs/ref/policy.md +++ b/docs/ref/policy.md @@ -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" { diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index bf5406780..852c6b893 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -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, } diff --git a/hscontrol/servertest/nodeattrs_test.go b/hscontrol/servertest/nodeattrs_test.go index 2df18dcf2..00f526d53 100644 --- a/hscontrol/servertest/nodeattrs_test.go +++ b/hscontrol/servertest/nodeattrs_test.go @@ -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.