mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
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:
parent
08b3f5f070
commit
c66f99fcdc
@ -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+
|
||||
|
@ -5,6 +5,7 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
@ -16,6 +17,7 @@
|
||||
"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
ipn/ipnlocal/c2n.go
Normal file
21
ipn/ipnlocal/c2n.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -1155,12 +1155,15 @@ type DNSRecord struct {
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// It is used in TSMP pings, if IP is invalid or empty then do a HEAD request to the URL.
|
||||
// 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.
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user