diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 627b18ec2..cb62f5790 100644 --- a/cmd/tailscaled/depaware.txt +++ b/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+ diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 4c5292d8e..80401f4dd 100644 --- a/control/controlclient/direct.go +++ b/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 { diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go new file mode 100644 index 000000000..00657371f --- /dev/null +++ b/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) + } +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c69bb54e9..729116b53 100644 --- a/ipn/ipnlocal/local.go +++ b/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. diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 7f54c04ca..da8dddcac 100644 --- a/tailcfg/tailcfg.go +++ b/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. diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go index c0ef0f071..c7939a490 100644 --- a/tstest/integration/integration_test.go +++ b/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,