diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index b249a1063..918c5466b 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -1201,6 +1201,9 @@ func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { return true } } + if p.NodeCap != "" && ci.node.Valid() && ci.node.HasCap(p.NodeCap) { + return true + } if p.UserLogin != "" && ci.uprof.LoginName == p.UserLogin { return true } diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index 3b6d3c52c..20d45c76e 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -202,6 +202,16 @@ func TestMatchRule(t *testing.T) { ci: &sshConnInfo{node: (&tailcfg.Node{StableID: "some-node-ID"}).View()}, wantUser: "ubuntu", }, + { + name: "match-principal-node-cap", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{NodeCap: "some-node-cap"}}, + SSHUsers: map[string]string{"*": "ubuntu"}, + }, + ci: &sshConnInfo{node: (&tailcfg.Node{CapMap: tailcfg.NodeCapMap{"some-node-cap": nil}}).View()}, + wantUser: "ubuntu", + }, { name: "match-principal-userlogin", rule: &tailcfg.SSHRule{ diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 88cda044f..8f16f5e27 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -2841,13 +2841,14 @@ type SSHRule struct { // SSHPrincipal is either a particular node or a user on any node. type SSHPrincipal struct { - // Matching any one of the following four field causes a match. + // Matching any one of the following five field causes a match. // It must also match Certs, if non-empty. - Node StableNodeID `json:"node,omitempty"` - NodeIP string `json:"nodeIP,omitempty"` - UserLogin string `json:"userLogin,omitempty"` // email-ish: foo@example.com, bar@github - Any bool `json:"any,omitempty"` // if true, match any connection + Node StableNodeID `json:"node,omitempty"` + NodeIP string `json:"nodeIP,omitempty"` + NodeCap NodeCapability `json:"nodeCap,omitempty"` + UserLogin string `json:"userLogin,omitempty"` // email-ish: foo@example.com, bar@github + Any bool `json:"any,omitempty"` // if true, match any connection // TODO(bradfitz): add StableUserID, once that exists // UnusedPubKeys was public key support. It never became an official product diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 95f8905b8..c073c9e09 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -569,6 +569,7 @@ func (src *SSHPrincipal) Clone() *SSHPrincipal { var _SSHPrincipalCloneNeedsRegeneration = SSHPrincipal(struct { Node StableNodeID NodeIP string + NodeCap NodeCapability UserLogin string Any bool UnusedPubKeys []string diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index e44d0bbef..46c6da7dc 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -2220,8 +2220,9 @@ func (v *SSHPrincipalView) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return nil } -func (v SSHPrincipalView) Node() StableNodeID { return v.ж.Node } -func (v SSHPrincipalView) NodeIP() string { return v.ж.NodeIP } +func (v SSHPrincipalView) Node() StableNodeID { return v.ж.Node } +func (v SSHPrincipalView) NodeIP() string { return v.ж.NodeIP } +func (v SSHPrincipalView) NodeCap() NodeCapability { return v.ж.NodeCap } // email-ish: foo@example.com, bar@github func (v SSHPrincipalView) UserLogin() string { return v.ж.UserLogin } @@ -2243,6 +2244,7 @@ func (v SSHPrincipalView) UnusedPubKeys() views.Slice[string] { var _SSHPrincipalViewNeedsRegeneration = SSHPrincipal(struct { Node StableNodeID NodeIP string + NodeCap NodeCapability UserLogin string Any bool UnusedPubKeys []string