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 <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2022-08-27 12:55:41 -07:00 committed by Brad Fitzpatrick
parent 08b3f5f070
commit c66f99fcdc
6 changed files with 176 additions and 8 deletions

View File

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

View File

@ -5,6 +5,7 @@
package controlclient package controlclient
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/binary" "encoding/binary"
@ -16,6 +17,7 @@
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
"net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
@ -73,6 +75,7 @@ type Direct struct {
skipIPForwardingCheck bool skipIPForwardingCheck bool
pinger Pinger pinger Pinger
popBrowser func(url string) // or nil popBrowser func(url string) // or nil
c2nHandler http.Handler // or nil
mu sync.Mutex // mutex guards the following fields mu sync.Mutex // mutex guards the following fields
serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key
@ -108,6 +111,7 @@ type Options struct {
LinkMonitor *monitor.Mon // optional link monitor LinkMonitor *monitor.Mon // optional link monitor
PopBrowserURL func(url string) // optional func to open browser PopBrowserURL func(url string) // optional func to open browser
Dialer *tsdial.Dialer // non-nil Dialer *tsdial.Dialer // non-nil
C2NHandler http.Handler // or nil
// GetNLPublicKey specifies an optional function to use // GetNLPublicKey specifies an optional function to use
// Network Lock. If nil, it's not used. // Network Lock. If nil, it's not used.
@ -210,6 +214,7 @@ func NewDirect(opts Options) (*Direct, error) {
skipIPForwardingCheck: opts.SkipIPForwardingCheck, skipIPForwardingCheck: opts.SkipIPForwardingCheck,
pinger: opts.Pinger, pinger: opts.Pinger,
popBrowser: opts.PopBrowserURL, popBrowser: opts.PopBrowserURL,
c2nHandler: opts.C2NHandler,
dialer: opts.Dialer, dialer: opts.Dialer,
} }
if opts.Hostinfo == nil { if opts.Hostinfo == nil {
@ -1205,7 +1210,8 @@ func (c *Direct) isUniquePingRequest(pr *tailcfg.PingRequest) bool {
func (c *Direct) answerPing(pr *tailcfg.PingRequest) { func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
httpc := c.httpc httpc := c.httpc
if pr.URLIsNoise { useNoise := pr.URLIsNoise || pr.Types == "c2n" && c.noiseConfigured()
if useNoise {
nc, err := c.getNoiseClient() nc, err := c.getNoiseClient()
if err != nil { if err != nil {
c.logf("failed to get noise client for ping request: %v", err) 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") c.logf("invalid PingRequest with no URL")
return return
} }
if pr.Types == "" { switch pr.Types {
case "":
answerHeadPing(c.logf, httpc, pr) answerHeadPing(c.logf, httpc, pr)
return 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, ",") { for _, t := range strings.Split(pr.Types, ",") {
switch pt := tailcfg.PingType(t); pt { 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 { func sleepAsRequested(ctx context.Context, logf logger.Logf, timeoutReset chan<- struct{}, d time.Duration) error {
const maxSleep = 5 * time.Minute const maxSleep = 5 * time.Minute
if d > maxSleep { if d > maxSleep {

21
ipn/ipnlocal/c2n.go Normal file
View File

@ -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)
}
}

View File

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

View File

@ -1155,12 +1155,15 @@ type DNSRecord struct {
// PingRequest with Types and IP, will send a ping to the IP and send a POST // 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. // request containing a PingResponse to the URL containing results.
type PingRequest struct { 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. // It will be a unique URL each time. No auth headers are necessary.
//
// If the client sees multiple PingRequests with the same URL, // If the client sees multiple PingRequests with the same URL,
// subsequent ones should be ignored. // 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 URL string
// URLIsNoise, if true, means that the client should hit URL over the Noise // 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 // Types is the types of ping that are initiated. Can be any PingType, comma
// separated, e.g. "disco,TSMP" // separated, e.g. "disco,TSMP"
Types string //
// 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. // IP is the ping target, when needed by the PingType(s) given in Types.
// It is used in TSMP pings, if IP is invalid or empty then do a HEAD request to the URL.
IP netip.Addr 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. // PingResponse provides result information for a TSMP or Disco PingRequest.

View File

@ -374,6 +374,74 @@ func TestAddPingRequest(t *testing.T) {
t.Error("all ping attempts failed") 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 // Issue 2434: when "down" (WantRunning false), tailscaled shouldn't
// be connected to control. // be connected to control.
func TestNoControlConnWhenDown(t *testing.T) { func TestNoControlConnWhenDown(t *testing.T) {
@ -737,6 +805,7 @@ func (n *testNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
cmd.Args = append(cmd.Args, "-verbose=2") cmd.Args = append(cmd.Args, "-verbose=2")
} }
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"TS_DEBUG_PERMIT_HTTP_C2N=1",
"TS_LOG_TARGET="+n.env.LogCatcherServer.URL, "TS_LOG_TARGET="+n.env.LogCatcherServer.URL,
"HTTP_PROXY="+n.env.TrafficTrapServer.URL, "HTTP_PROXY="+n.env.TrafficTrapServer.URL,
"HTTPS_PROXY="+n.env.TrafficTrapServer.URL, "HTTPS_PROXY="+n.env.TrafficTrapServer.URL,