diff --git a/control/controlclient/map.go b/control/controlclient/map.go index b7ba87957..02308b364 100644 --- a/control/controlclient/map.go +++ b/control/controlclient/map.go @@ -39,6 +39,7 @@ type mapSession struct { lastDERPMap *tailcfg.DERPMap lastUserProfile map[tailcfg.UserID]tailcfg.UserProfile lastParsedPacketFilter []filter.Match + lastSSHPolicy *tailcfg.SSHPolicy collectServices bool previousPeers []*tailcfg.Node // for delta-purposes lastDomain string @@ -97,6 +98,9 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo if c := resp.DNSConfig; c != nil { ms.lastDNSConfig = c } + if p := resp.SSHPolicy; p != nil { + ms.lastSSHPolicy = p + } if v, ok := resp.CollectServices.Get(); ok { ms.collectServices = v @@ -117,6 +121,7 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo Domain: ms.lastDomain, DNS: *ms.lastDNSConfig, PacketFilter: ms.lastParsedPacketFilter, + SSHPolicy: ms.lastSSHPolicy, CollectServices: ms.collectServices, DERPMap: ms.lastDERPMap, Debug: resp.Debug, diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 07a1a1e38..da24d94c3 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1157,6 +1157,10 @@ type MapResponse struct { // sees. Health []string `json:",omitempty"` + // SSHPolicy, if non-nil, updates the SSH policy for how incoming + // SSH connections should be handled. + SSHPolicy *SSHPolicy `json:",omitempty"` + // Debug is normally nil, except for when the control server // is setting debug settings on a node. Debug *Debug `json:",omitempty"` @@ -1369,3 +1373,81 @@ type SetDNSRequest struct { // Value is the value to add. Value string } + +// SSHPolicy is the policy for how to handle incoming SSH connections +// over Tailscale. +type SSHPolicy struct { + // Rules are the rules to process for an incoming SSH + // connection. The first matching rule takes its action and + // stops processing further rules. + Rules []*SSHRule `json:"rules"` +} + +// An SSH rule is a match predicate and associated action for an incoming SSH connection. +type SSHRule struct { + // RuleExpires, if non-nil, is when this rule expires. + // + // For example, a (principal,sshuser) tuple might be granted + // prompt-free SSH access for N minutes, so this rule would be + // before a expiration-free rule for the same principal that + // required an auth prompt. This permits the control plane to + // be out of the path for already-authorized SSH pairs. + // + // Once a rule matches, the lifetime of any accepting connection + // is subject to the SSHAction.SessionExpires time, if any. + RuleExpires *time.Time `json:"ruleExpires,omitempty"` + + // Principals matches an incoming connection. If the connection + // matches anything in this list and also matches SSHUsers, + // then Action is applied. + Principals []*SSHPrincipal `json:"principals"` + + // SSHUsers are the SSH users that this rule matches. It is a + // map from either ssh-user|"*" => local-user. The map must + // contain a key for either ssh-user or, as a fallback, "*" to + // match anything. If it does, the map entry's value is the + // actual user that's logged in. + SSHUsers map[string]string `json:"sshUsers"` + + // Action is the outcome to task. + // A nil or invalid action means to deny. + Action *SSHAction `json:"action"` +} + +// SSHPrincipal is either a particular node or a user on any node. +// At most one field should be non-zero specified. +type SSHPrincipal struct { + Node StableNodeID `json:"node,omitempty"` + NodeIP string `json:"nodeIP,omitempty"` + UserLogin string `json:"userLogin,omitempty"` // email-ish: foo@example.com, bar@github + + // TODO(bradfitz): add StableUserID, once that exists +} + +// SSHAction is how to handle an incoming connection. +// At most one field should be non-zero. +type SSHAction struct { + // Message, if non-empty, is shown to the user before the + // action occurs. + Message string `json:"message,omitempty"` + + // Reject, if true, terminates the connection. This action + // has higher priority that Accept, if given. + // The reason this is exists is primarily so a response + // from HoldAndDelegate has a way to stop the poll. + Reject bool `json:"reject,omitempty"` + + // Accept, if true, accepts the connection immediately + // without further prompts. + Accept bool `json:"accept,omitempty"` + + // SesssionExpires, if non-nil, is the time at which this + // session should forcefully terminate. + SesssionExpires *time.Time `json:"sessionExpires,omitempty"` + + // HoldAndDelegate, if non-empty, is a URL that serves an outcome verdict. + // The connection will be accepted and will block until the + // provided long-polling URL serves a new SSHAction JSON + // value. + HoldAndDelegate string `json:"holdAndDelegate,omitempty"` +} diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index 0507e3eb9..8871eeebb 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -39,6 +39,7 @@ type NetworkMap struct { DNS tailcfg.DNSConfig Hostinfo tailcfg.Hostinfo PacketFilter []filter.Match + SSHPolicy *tailcfg.SSHPolicy // or nil, if not enabled/allowed // CollectServices reports whether this node's Tailnet has // requested that info about services be included in HostInfo.