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

tailcfg, control/controlclient, ipn/ipnlocal: add c2n (control-to-node) system

This lets the control plane can make HTTP requests to nodes.

Then we can use this for future things rather than slapping more stuff
into MapResponse, etc.

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

+ 1 - 0
cmd/tailscaled/depaware.txt

@@ -404,6 +404,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         mime/quotedprintable                                         from mime/multipart
         net                                                          from crypto/tls+
         net/http                                                     from expvar+
+        net/http/httptest                                            from tailscale.com/control/controlclient
         net/http/httptrace                                           from github.com/tcnksm/go-httpstat+
         net/http/httputil                                            from github.com/aws/smithy-go/transport/http+
         net/http/internal                                            from net/http+

+ 64 - 2
control/controlclient/direct.go

@@ -5,6 +5,7 @@
 package controlclient
 
 import (
+	"bufio"
 	"bytes"
 	"context"
 	"encoding/binary"
@@ -16,6 +17,7 @@ import (
 	"io/ioutil"
 	"log"
 	"net/http"
+	"net/http/httptest"
 	"net/netip"
 	"net/url"
 	"os"
@@ -73,6 +75,7 @@ type Direct struct {
 	skipIPForwardingCheck  bool
 	pinger                 Pinger
 	popBrowser             func(url string) // or nil
+	c2nHandler             http.Handler     // or nil
 
 	mu             sync.Mutex        // mutex guards the following fields
 	serverKey      key.MachinePublic // original ("legacy") nacl crypto_box-based public key
@@ -108,6 +111,7 @@ type Options struct {
 	LinkMonitor          *monitor.Mon     // optional link monitor
 	PopBrowserURL        func(url string) // optional func to open browser
 	Dialer               *tsdial.Dialer   // non-nil
+	C2NHandler           http.Handler     // or nil
 
 	// GetNLPublicKey specifies an optional function to use
 	// Network Lock. If nil, it's not used.
@@ -210,6 +214,7 @@ func NewDirect(opts Options) (*Direct, error) {
 		skipIPForwardingCheck:  opts.SkipIPForwardingCheck,
 		pinger:                 opts.Pinger,
 		popBrowser:             opts.PopBrowserURL,
+		c2nHandler:             opts.C2NHandler,
 		dialer:                 opts.Dialer,
 	}
 	if opts.Hostinfo == nil {
@@ -1205,7 +1210,8 @@ func (c *Direct) isUniquePingRequest(pr *tailcfg.PingRequest) bool {
 
 func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
 	httpc := c.httpc
-	if pr.URLIsNoise {
+	useNoise := pr.URLIsNoise || pr.Types == "c2n" && c.noiseConfigured()
+	if useNoise {
 		nc, err := c.getNoiseClient()
 		if err != nil {
 			c.logf("failed to get noise client for ping request: %v", err)
@@ -1217,9 +1223,17 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
 		c.logf("invalid PingRequest with no URL")
 		return
 	}
-	if pr.Types == "" {
+	switch pr.Types {
+	case "":
 		answerHeadPing(c.logf, httpc, pr)
 		return
+	case "c2n":
+		if !useNoise && !envknob.Bool("TS_DEBUG_PERMIT_HTTP_C2N") {
+			c.logf("refusing to answer c2n ping without noise")
+			return
+		}
+		answerC2NPing(c.logf, c.c2nHandler, httpc, pr)
+		return
 	}
 	for _, t := range strings.Split(pr.Types, ",") {
 		switch pt := tailcfg.PingType(t); pt {
@@ -1253,6 +1267,54 @@ func answerHeadPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest) {
 	}
 }
 
+func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr *tailcfg.PingRequest) {
+	if c2nHandler == nil {
+		logf("answerC2NPing: c2nHandler not defined")
+		return
+	}
+	hreq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(pr.Payload)))
+	if err != nil {
+		logf("answerC2NPing: ReadRequest: %v", err)
+		return
+	}
+	if pr.Log {
+		logf("answerC2NPing: got c2n request for %v ...", hreq.RequestURI)
+	}
+	handlerTimeout := time.Minute
+	if v := hreq.Header.Get("C2n-Handler-Timeout"); v != "" {
+		handlerTimeout, _ = time.ParseDuration(v)
+	}
+	handlerCtx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
+	defer cancel()
+	hreq = hreq.WithContext(handlerCtx)
+	rec := httptest.NewRecorder()
+	c2nHandler.ServeHTTP(rec, hreq)
+	cancel()
+
+	c2nResBuf := new(bytes.Buffer)
+	rec.Result().Write(c2nResBuf)
+
+	replyCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(replyCtx, "POST", pr.URL, c2nResBuf)
+	if err != nil {
+		logf("answerC2NPing: NewRequestWithContext: %v", err)
+		return
+	}
+	if pr.Log {
+		logf("answerC2NPing: sending POST ping to %v ...", pr.URL)
+	}
+	t0 := time.Now()
+	_, err = c.Do(req)
+	d := time.Since(t0).Round(time.Millisecond)
+	if err != nil {
+		logf("answerC2NPing error: %v to %v (after %v)", err, pr.URL, d)
+	} else if pr.Log {
+		logf("answerC2NPing complete to %v (after %v)", pr.URL, d)
+	}
+}
+
 func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration) error {
 	const maxSleep = 5 * time.Minute
 	if d > maxSleep {

+ 21 - 0
ipn/ipnlocal/c2n.go

@@ -0,0 +1,21 @@
+// 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.
+
+package ipnlocal
+
+import (
+	"io"
+	"net/http"
+)
+
+func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
+	switch r.URL.Path {
+	case "/echo":
+		// Test handler.
+		body, _ := io.ReadAll(r.Body)
+		w.Write(body)
+	default:
+		http.Error(w, "unknown c2n path", http.StatusBadRequest)
+	}
+}

+ 1 - 0
ipn/ipnlocal/local.go

@@ -1075,6 +1075,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
 		PopBrowserURL:        b.tellClientToBrowseToURL,
 		Dialer:               b.Dialer(),
 		Status:               b.setClientStatus,
+		C2NHandler:           http.HandlerFunc(b.handleC2N),
 
 		// Don't warn about broken Linux IP forwarding when
 		// netstack is being used.

+ 21 - 7
tailcfg/tailcfg.go

@@ -1155,12 +1155,15 @@ const (
 // PingRequest with Types and IP, will send a ping to the IP and send a POST
 // request containing a PingResponse to the URL containing results.
 type PingRequest struct {
-	// URL is the URL to send a HEAD request to.
+	// URL is the URL to reply to the PingRequest to.
 	// It will be a unique URL each time. No auth headers are necessary.
-	//
 	// If the client sees multiple PingRequests with the same URL,
 	// subsequent ones should be ignored.
-	// If Types and IP are defined, then URL is the URL to send a POST request to.
+	//
+	// The HTTP method that the node should make back to URL depends on the other
+	// fields of the PingRequest. If Types is defined, then URL is the URL to
+	// send a POST request to. Otherwise, the node should just make a HEAD
+	// request to URL.
 	URL string
 
 	// URLIsNoise, if true, means that the client should hit URL over the Noise
@@ -1173,11 +1176,22 @@ type PingRequest struct {
 
 	// Types is the types of ping that are initiated. Can be any PingType, comma
 	// separated, e.g. "disco,TSMP"
-	Types string
-
-	// IP is the ping target.
-	// It is used in TSMP pings, if IP is invalid or empty then do a HEAD request to the URL.
+	//
+	// As a special case, if Types is "c2n", then this PingRequest is a
+	// client-to-node HTTP request. The HTTP request should be handled by this
+	// node's c2n handler and the HTTP response sent in a POST to URL. For c2n,
+	// the value of URLIsNoise is ignored and only the Noise transport (back to
+	// the control plane) will be used, as if URLIsNoise were true.
+	Types string `json:",omitempty"`
+
+	// IP is the ping target, when needed by the PingType(s) given in Types.
 	IP netip.Addr
+
+	// Payload is the ping payload.
+	//
+	// It is only used for c2n requests, in which case it's an HTTP/1.0 or
+	// HTTP/1.1-formatted HTTP request as parsable with http.ReadRequest.
+	Payload []byte `json:",omitempty"`
 }
 
 // PingResponse provides result information for a TSMP or Disco PingRequest.

+ 69 - 0
tstest/integration/integration_test.go

@@ -374,6 +374,74 @@ func TestAddPingRequest(t *testing.T) {
 	t.Error("all ping attempts failed")
 }
 
+func TestC2NPingRequest(t *testing.T) {
+	t.Parallel()
+	env := newTestEnv(t)
+	n1 := newTestNode(t, env)
+	n1.StartDaemon()
+
+	n1.AwaitListening()
+	n1.MustUp()
+	n1.AwaitRunning()
+
+	gotPing := make(chan bool, 1)
+	waitPing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Errorf("unexpected ping method %q", r.Method)
+		}
+		got, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("ping body read error: %v", err)
+		}
+		const want = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nabc"
+		if string(got) != want {
+			t.Errorf("body error\n got: %q\nwant: %q", got, want)
+		}
+		gotPing <- true
+	}))
+	defer waitPing.Close()
+
+	nodes := env.Control.AllNodes()
+	if len(nodes) != 1 {
+		t.Fatalf("expected 1 node, got %d nodes", len(nodes))
+	}
+
+	nodeKey := nodes[0].Key
+
+	// Check that we get at least one ping reply after 10 tries.
+	for try := 1; try <= 10; try++ {
+		t.Logf("ping %v ...", try)
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		if err := env.Control.AwaitNodeInMapRequest(ctx, nodeKey); err != nil {
+			t.Fatal(err)
+		}
+		cancel()
+
+		pr := &tailcfg.PingRequest{
+			URL:     fmt.Sprintf("%s/ping-%d", waitPing.URL, try),
+			Log:     true,
+			Types:   "c2n",
+			Payload: []byte("POST /echo HTTP/1.0\r\nContent-Length: 3\r\n\r\nabc"),
+		}
+		if !env.Control.AddPingRequest(nodeKey, pr) {
+			t.Logf("failed to AddPingRequest")
+			continue
+		}
+
+		// Wait for PingRequest to come back
+		pingTimeout := time.NewTimer(2 * time.Second)
+		defer pingTimeout.Stop()
+		select {
+		case <-gotPing:
+			t.Logf("got ping; success")
+			return
+		case <-pingTimeout.C:
+			// Try again.
+		}
+	}
+	t.Error("all ping attempts failed")
+}
+
 // Issue 2434: when "down" (WantRunning false), tailscaled shouldn't
 // be connected to control.
 func TestNoControlConnWhenDown(t *testing.T) {
@@ -737,6 +805,7 @@ func (n *testNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
 		cmd.Args = append(cmd.Args, "-verbose=2")
 	}
 	cmd.Env = append(os.Environ(),
+		"TS_DEBUG_PERMIT_HTTP_C2N=1",
 		"TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
 		"HTTP_PROXY="+n.env.TrafficTrapServer.URL,
 		"HTTPS_PROXY="+n.env.TrafficTrapServer.URL,