From e1e20f6d3992274e257e0d1a71f9d0e6233c8341 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Fri, 18 Feb 2022 14:10:26 -0800 Subject: [PATCH] ssh/tailssh: evaluate tailcfg.SSHPolicy on incoming connections Updates #3802 Fixes #3960 Change-Id: Ieda2007d462ddce6c217b958167417ae9755774e Signed-off-by: Brad Fitzpatrick --- ssh/tailssh/tailssh.go | 189 ++++++++++++++++++++++++++++++++---- ssh/tailssh/tailssh_test.go | 157 ++++++++++++++++++++++++++++++ tailcfg/tailcfg.go | 17 +++- 3 files changed, 337 insertions(+), 26 deletions(-) create mode 100644 ssh/tailssh/tailssh_test.go diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 91bd0dcc6..13fd68732 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -10,12 +10,14 @@ package tailssh import ( "encoding/json" + "errors" "fmt" "io" "net" "os" "os/exec" "syscall" + "time" "unsafe" "github.com/creack/pty" @@ -24,6 +26,7 @@ import ( "tailscale.com/envknob" "tailscale.com/ipn/ipnlocal" "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" "tailscale.com/types/logger" ) @@ -66,6 +69,33 @@ type server struct { logf logger.Logf } +var debugPolicyFile = envknob.String("TS_DEBUG_SSH_POLICY_FILE") + +func (srv *server) sshPolicy() (_ *tailcfg.SSHPolicy, ok bool) { + lb := srv.lb + nm := lb.NetMap() + if nm == nil { + return nil, false + } + if pol := nm.SSHPolicy; pol != nil { + return pol, true + } + if debugPolicyFile != "" { + f, err := os.ReadFile(debugPolicyFile) + if err != nil { + srv.logf("error reading debug SSH policy file: %v", err) + return nil, false + } + p := new(tailcfg.SSHPolicy) + if err := json.Unmarshal(f, p); err != nil { + srv.logf("invalid JSON in %v: %v", debugPolicyFile, err) + return nil, false + } + return p, true + } + return nil, false +} + func (srv *server) handleSSH(s ssh.Session) { lb := srv.lb logf := srv.logf @@ -91,35 +121,54 @@ func (srv *server) handleSSH(s ssh.Session) { return } - ptyReq, winCh, isPty := s.Pty() - if !isPty { - fmt.Fprintf(s, "TODO scp etc") - s.Exit(1) - return - } - srcIPP := netaddr.IPPortFrom(tanetaddr, uint16(ta.Port)) - node, uprof, ok := lb.WhoIs(srcIPP) + pol, ok := srv.sshPolicy() if !ok { - fmt.Fprintf(s, "Hello, %v. I don't know who you are.\n", srcIPP) - s.Exit(0) - return - } - allow := envknob.String("TS_SSH_ALLOW_LOGIN") - if allow == "" || uprof.LoginName != allow { - logf("ssh: access denied for %q (only allowing %q)", uprof.LoginName, allow) - jnode, _ := json.Marshal(node) - jprof, _ := json.Marshal(uprof) - fmt.Fprintf(s, "Access denied.\n\nYou are node: %s\n\nYour profile: %s\n\nYou wanted %+v\n", jnode, jprof, ptyReq) + logf("tsshd: rejecting connection; no SSH policy") s.Exit(1) return } + ptyReq, winCh, isPty := s.Pty() + srcIPP := netaddr.IPPortFrom(tanetaddr, uint16(ta.Port)) + node, uprof, ok := lb.WhoIs(srcIPP) + if !ok { + fmt.Fprintf(s, "Hello, %v. I don't know who you are.\n", srcIPP) + s.Exit(1) + return + } + + srcIP := srcIPP.IP() + sctx := &sshContext{ + now: time.Now(), + sshUser: s.User(), + srcIP: srcIP, + node: node, + uprof: &uprof, + } + action, localUser, ok := evalSSHPolicy(pol, sctx) + if ok && action.Message != "" { + io.WriteString(s, action.Message) + } + if !ok || action.Reject { + logf("ssh: access denied for %q from %v", uprof.LoginName, srcIP) + s.Exit(1) + return + } + if !action.Accept || action.HoldAndDelegate != "" { + fmt.Fprintf(s, "TODO: other SSHAction outcomes") + s.Exit(1) + + } + if !isPty { + fmt.Fprintf(s, "TODO scp etc\n") + s.Exit(1) + return + } var cmd *exec.Cmd - sshUser := s.User() - if os.Getuid() != 0 || sshUser == "root" { + if os.Getuid() != 0 || localUser == "root" { cmd = exec.Command("/bin/bash") } else { - cmd = exec.Command("/usr/bin/env", "su", "-", sshUser) + cmd = exec.Command("/usr/bin/env", "su", "-", localUser) } cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) f, err := pty.Start(cmd) @@ -128,6 +177,16 @@ func (srv *server) handleSSH(s ssh.Session) { s.Exit(1) return } + + if action.SesssionDuration != 0 { + t := time.AfterFunc(action.SesssionDuration, func() { + logf("terminating SSH session from %v after max duration", srcIP) + cmd.Process.Kill() + f.Close() + }) + defer t.Stop() + } + defer f.Close() go func() { for win := range winCh { @@ -150,3 +209,91 @@ func setWinsize(f *os.File, w, h int) { syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0}))) } + +type sshContext struct { + // now is the time to consider the present moment for the + // purposes of rule evaluation. + now time.Time + + // sshUser is the requested local SSH username ("root", "alice", etc). + sshUser string + + // srcIP is the Tailscale IP that the connection came from. + srcIP netaddr.IP + + // node is srcIP's node. + node *tailcfg.Node + + // uprof is node's UserProfile. + uprof *tailcfg.UserProfile +} + +func evalSSHPolicy(pol *tailcfg.SSHPolicy, sctx *sshContext) (a *tailcfg.SSHAction, localUser string, ok bool) { + for _, r := range pol.Rules { + if a, localUser, err := matchRule(r, sctx); err == nil { + return a, localUser, true + } + } + return nil, "", false +} + +// internal errors for testing; they don't escape to callers or logs. +var ( + errNilRule = errors.New("nil rule") + errNilAction = errors.New("nil action") + errRuleExpired = errors.New("rule expired") + errPrincipalMatch = errors.New("principal didn't match") + errUserMatch = errors.New("user didn't match") +) + +func matchRule(r *tailcfg.SSHRule, sctx *sshContext) (a *tailcfg.SSHAction, localUser string, err error) { + if r == nil { + return nil, "", errNilRule + } + if r.Action == nil { + return nil, "", errNilAction + } + if r.RuleExpires != nil && sctx.now.After(*r.RuleExpires) { + return nil, "", errRuleExpired + } + if !matchesPrincipal(r.Principals, sctx) { + return nil, "", errPrincipalMatch + } + if !r.Action.Reject || r.SSHUsers != nil { + localUser = mapLocalUser(r.SSHUsers, sctx.sshUser) + if localUser == "" { + return nil, "", errUserMatch + } + } + return r.Action, localUser, nil +} + +func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser string) { + if v, ok := ruleSSHUsers[reqSSHUser]; ok { + return v + } + return ruleSSHUsers["*"] +} + +func matchesPrincipal(ps []*tailcfg.SSHPrincipal, sctx *sshContext) bool { + for _, p := range ps { + if p == nil { + continue + } + if p.Any { + return true + } + if !p.Node.IsZero() && sctx.node != nil && p.Node == sctx.node.StableID { + return true + } + if p.NodeIP != "" { + if ip, _ := netaddr.ParseIP(p.NodeIP); ip == sctx.srcIP { + return true + } + } + if p.UserLogin != "" && sctx.uprof != nil && sctx.uprof.LoginName == p.UserLogin { + return true + } + } + return false +} diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go new file mode 100644 index 000000000..8becf2e2c --- /dev/null +++ b/ssh/tailssh/tailssh_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build linux +// +build linux + +package tailssh + +import ( + "testing" + "time" + + "inet.af/netaddr" + "tailscale.com/tailcfg" +) + +func TestMatchRule(t *testing.T) { + someAction := new(tailcfg.SSHAction) + tests := []struct { + name string + rule *tailcfg.SSHRule + ctx *sshContext + wantErr error + wantUser string + }{ + { + name: "nil-rule", + rule: nil, + wantErr: errNilRule, + }, + { + name: "nil-action", + rule: &tailcfg.SSHRule{}, + wantErr: errNilAction, + }, + { + name: "expired", + rule: &tailcfg.SSHRule{ + Action: someAction, + RuleExpires: timePtr(time.Unix(100, 0)), + }, + ctx: &sshContext{now: time.Unix(200, 0)}, + wantErr: errRuleExpired, + }, + { + name: "no-principal", + rule: &tailcfg.SSHRule{ + Action: someAction, + }, + wantErr: errPrincipalMatch, + }, + { + name: "no-user-match", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{Any: true}}, + }, + ctx: &sshContext{sshUser: "alice"}, + wantErr: errUserMatch, + }, + { + name: "ok-wildcard", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{Any: true}}, + SSHUsers: map[string]string{ + "*": "ubuntu", + }, + }, + ctx: &sshContext{sshUser: "alice"}, + wantUser: "ubuntu", + }, + { + name: "ok-wildcard-and-nil-principal", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{ + nil, // don't crash on this + {Any: true}, + }, + SSHUsers: map[string]string{ + "*": "ubuntu", + }, + }, + ctx: &sshContext{sshUser: "alice"}, + wantUser: "ubuntu", + }, + { + name: "ok-exact", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{Any: true}}, + SSHUsers: map[string]string{ + "*": "ubuntu", + "alice": "thealice", + }, + }, + ctx: &sshContext{sshUser: "alice"}, + wantUser: "thealice", + }, + { + name: "no-users-for-reject", + rule: &tailcfg.SSHRule{ + Principals: []*tailcfg.SSHPrincipal{{Any: true}}, + Action: &tailcfg.SSHAction{Reject: true}, + }, + ctx: &sshContext{sshUser: "alice"}, + }, + { + name: "match-principal-node-ip", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{NodeIP: "1.2.3.4"}}, + SSHUsers: map[string]string{"*": "ubuntu"}, + }, + ctx: &sshContext{srcIP: netaddr.MustParseIP("1.2.3.4")}, + wantUser: "ubuntu", + }, + { + name: "match-principal-node-id", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{Node: "some-node-ID"}}, + SSHUsers: map[string]string{"*": "ubuntu"}, + }, + ctx: &sshContext{node: &tailcfg.Node{StableID: "some-node-ID"}}, + wantUser: "ubuntu", + }, + { + name: "match-principal-userlogin", + rule: &tailcfg.SSHRule{ + Action: someAction, + Principals: []*tailcfg.SSHPrincipal{{UserLogin: "foo@bar.com"}}, + SSHUsers: map[string]string{"*": "ubuntu"}, + }, + ctx: &sshContext{uprof: &tailcfg.UserProfile{LoginName: "foo@bar.com"}}, + wantUser: "ubuntu", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotUser, err := matchRule(tt.rule, tt.ctx) + if err != tt.wantErr { + t.Errorf("err = %v; want %v", err, tt.wantErr) + } + if gotUser != tt.wantUser { + t.Errorf("user = %q; want %q", gotUser, tt.wantUser) + } + if err == nil && got == nil { + t.Errorf("expected non-nil action on success") + } + }) + } +} + +func timePtr(t time.Time) *time.Time { return &t } diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 0c5c3b28e..aa4d14191 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -51,7 +51,8 @@ import ( // 24: 2021-09-18: MapResponse.Health from control to node; node shows in "tailscale status" // 25: 2021-11-01: MapResponse.Debug.Exit // 26: 2022-01-12: (nothing, just bumping for 1.20.0) -const CurrentMapRequestVersion = 26 +// 27: 2022-02-18: start of SSHPolicy being respected +const CurrentMapRequestVersion = 27 type StableID string @@ -1545,6 +1546,9 @@ type SSHRule struct { // 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. + // If the map value is the empty string (for either the + // requested SSH user or "*"), the rule doesn't match. + // It may be nil if the Action is reject. SSHUsers map[string]string `json:"sshUsers"` // Action is the outcome to task. @@ -1553,12 +1557,15 @@ type SSHRule struct { } // SSHPrincipal is either a particular node or a user on any node. -// At most one field should be non-zero specified. +// Any matching field causes a match. 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 + // Any, if true, matches any user. + Any bool `json:"any,omitempty"` + // TODO(bradfitz): add StableUserID, once that exists } @@ -1579,9 +1586,9 @@ type SSHAction struct { // 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"` + // SesssionDuration, if non-zero, is how long the session can stay open + // before being forcefully terminated. + SesssionDuration time.Duration `json:"sessionDuration,omitempty"` // HoldAndDelegate, if non-empty, is a URL that serves an outcome verdict. // The connection will be accepted and will block until the