Просмотр исходного кода

ssh/tailssh, ipnlocal, controlclient: fetch next SSHAction from network

Updates #3802

Change-Id: I08e98805ab86d6bbabb6c365ed4526f54742fd8e
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick 4 лет назад
Родитель
Сommit
efc48b0578

+ 5 - 0
control/controlclient/auto.go

@@ -7,6 +7,7 @@ package controlclient
 import (
 	"context"
 	"fmt"
+	"net/http"
 	"sync"
 	"time"
 
@@ -725,3 +726,7 @@ func (c *Auto) TestOnlyTimeNow() time.Time {
 func (c *Auto) SetDNS(ctx context.Context, req *tailcfg.SetDNSRequest) error {
 	return c.direct.SetDNS(ctx, req)
 }
+
+func (c *Auto) DoNoiseRequest(req *http.Request) (*http.Response, error) {
+	return c.direct.DoNoiseRequest(req)
+}

+ 4 - 0
control/controlclient/client.go

@@ -11,6 +11,7 @@ package controlclient
 
 import (
 	"context"
+	"net/http"
 	"time"
 
 	"tailscale.com/tailcfg"
@@ -82,6 +83,9 @@ type Client interface {
 	// SetDNS sends the SetDNSRequest request to the control plane server,
 	// requesting a DNS record be created or updated.
 	SetDNS(context.Context, *tailcfg.SetDNSRequest) error
+	// DoNoiseRequest sends an HTTP request to the control plane
+	// over the Noise transport.
+	DoNoiseRequest(*http.Request) (*http.Response, error)
 }
 
 // UserVisibleError is an error that should be shown to users.

+ 8 - 0
control/controlclient/direct.go

@@ -1428,6 +1428,14 @@ func (c *Direct) SetDNS(ctx context.Context, req *tailcfg.SetDNSRequest) (err er
 	return nil
 }
 
+func (c *Direct) DoNoiseRequest(req *http.Request) (*http.Response, error) {
+	nc, err := c.getNoiseClient()
+	if err != nil {
+		return nil, err
+	}
+	return nc.Do(req)
+}
+
 // tsmpPing sends a Ping to pr.IP, and sends an http request back to pr.URL
 // with ping response data.
 func tsmpPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger) error {

+ 12 - 0
ipn/ipnlocal/local.go

@@ -3241,3 +3241,15 @@ func (b *LocalBackend) magicConn() (*magicsock.Conn, error) {
 	}
 	return mc, nil
 }
+
+// DoNoiseRequest sends a request to URL over the the control plane
+// Noise connection.
+func (b *LocalBackend) DoNoiseRequest(req *http.Request) (*http.Response, error) {
+	b.mu.Lock()
+	cc := b.cc
+	b.mu.Unlock()
+	if cc == nil {
+		return nil, errors.New("no client")
+	}
+	return cc.DoNoiseRequest(req)
+}

+ 5 - 0
ipn/ipnlocal/state_test.go

@@ -6,6 +6,7 @@ package ipnlocal
 
 import (
 	"context"
+	"net/http"
 	"sync"
 	"testing"
 	"time"
@@ -266,6 +267,10 @@ func (*mockControl) SetDNS(context.Context, *tailcfg.SetDNSRequest) error {
 	panic("unexpected SetDNS call")
 }
 
+func (*mockControl) DoNoiseRequest(*http.Request) (*http.Response, error) {
+	panic("unexpected DoNoiseRequest call")
+}
+
 // A very precise test of the sequence of function calls generated by
 // ipnlocal.Local into its controlclient instance, and the events it
 // produces upstream into the UI.

+ 66 - 12
ssh/tailssh/tailssh.go

@@ -15,6 +15,7 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"net/http"
 	"os"
 	"os/exec"
 	"os/user"
@@ -26,6 +27,7 @@ import (
 	"inet.af/netaddr"
 	"tailscale.com/envknob"
 	"tailscale.com/ipn/ipnlocal"
+	"tailscale.com/logtail/backoff"
 	"tailscale.com/net/tsaddr"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/logger"
@@ -200,19 +202,40 @@ func (srv *server) handleSSH(s ssh.Session) {
 		s.Exit(1)
 		return
 	}
-	if action.Message != "" {
-		io.WriteString(s.Stderr(), strings.Replace(action.Message, "\n", "\r\n", -1))
-	}
-	if action.Reject {
-		logf("ssh: access denied for %q from %v", ci.uprof.LoginName, ci.src.IP())
-		s.Exit(1)
-		return
-	}
-	if !action.Accept || action.HoldAndDelegate != "" {
-		fmt.Fprintf(s, "TODO: other SSHAction outcomes")
-		s.Exit(1)
-		return
+
+	// Loop processing/fetching Actions until one reaches a
+	// terminal state (Accept, Reject, or invalid Action), or
+	// until fetchSSHAction times out due to the context being
+	// done (client disconnect) or its 30 minute timeout passes.
+	// (Which is a long time for somebody to see login
+	// instructions and go to a URL to do something.)
+ProcessAction:
+	for {
+		if action.Message != "" {
+			io.WriteString(s.Stderr(), strings.Replace(action.Message, "\n", "\r\n", -1))
+		}
+		if action.Reject {
+			logf("ssh: access denied for %q from %v", ci.uprof.LoginName, ci.src.IP())
+			s.Exit(1)
+			return
+		}
+		if action.Accept {
+			break ProcessAction
+		}
+		url := action.HoldAndDelegate
+		if url == "" {
+			logf("ssh: access denied; SSHAction has neither Reject, Accept, or next step URL")
+			s.Exit(1)
+			return
+		}
+		action, err = srv.fetchSSHAction(s.Context(), url)
+		if err != nil {
+			logf("ssh: fetching SSAction from %s: %v", url, err)
+			s.Exit(1)
+			return
+		}
 	}
+
 	lu, err := user.Lookup(localUser)
 	if err != nil {
 		logf("ssh: user Lookup %q: %v", localUser, err)
@@ -235,6 +258,37 @@ func (srv *server) handleSSH(s ssh.Session) {
 	srv.handleAcceptedSSH(ctx, s, ci, lu)
 }
 
+func (srv *server) fetchSSHAction(ctx context.Context, url string) (*tailcfg.SSHAction, error) {
+	ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
+	defer cancel()
+	bo := backoff.NewBackoff("fetch-ssh-action", srv.logf, 10*time.Second)
+	for {
+		if err := ctx.Err(); err != nil {
+			return nil, err
+		}
+		req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+		if err != nil {
+			return nil, err
+		}
+		res, err := srv.lb.DoNoiseRequest(req)
+		if err != nil {
+			bo.BackOff(ctx, err)
+			continue
+		}
+		if res.StatusCode != 200 {
+			res.Body.Close()
+			bo.BackOff(ctx, fmt.Errorf("unexpected status: %v", res.Status))
+			continue
+		}
+		a := new(tailcfg.SSHAction)
+		if err := json.NewDecoder(res.Body).Decode(a); err != nil {
+			bo.BackOff(ctx, err)
+			continue
+		}
+		return a, nil
+	}
+}
+
 func (srv *server) handleSessionTermination(ctx context.Context, s ssh.Session, ci *sshConnInfo, cmd *exec.Cmd, exitOnce *sync.Once) {
 	<-ctx.Done()
 	// Either the process has already existed, in which case this does nothing.

+ 8 - 4
tailcfg/tailcfg.go

@@ -1615,10 +1615,14 @@ type SSHAction struct {
 	// 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
-	// provided long-polling URL serves a new SSHAction JSON
-	// value.
+	// 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. The URL must be fetched using the
+	// Noise transport (in package control/control{base,http}).
+	// If the long poll breaks before returning a complete HTTP
+	// response, it should be re-fetched as long as the SSH
+	// session is open.
 	HoldAndDelegate string `json:"holdAndDelegate,omitempty"`
 }