mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +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
|
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+
|
||||||
|
@ -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
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,
|
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.
|
||||||
|
@ -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.
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user