summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2022-11-07 15:32:53 -0800
committerBrad Fitzpatrick <brad@danga.com>2022-11-08 19:39:07 -0800
commit2daf0f146c5f644e6345f79fe9c538ae730485c2 (patch)
tree829cb1b006d06b75f175aaf5ae1167a2f341e9eb
parentacf5839dd23dd661edfed1d159ac963251e2380d (diff)
downloadtailscale-bradfitz/port_intercept.tar.xz
tailscale-bradfitz/port_intercept.zip
ipn/ipnlocal, wgengine/netstack: start handling ports for future servingbradfitz/port_intercept
Updates tailscale/corp#7515 Change-Id: I966e936e72a2ee99be8d0f5f16872b48cc150258 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
-rw-r--r--cmd/tailscale/cli/debug.go10
-rw-r--r--cmd/tailscaled/depaware.txt2
-rw-r--r--ipn/ipnlocal/cert_js.go18
-rw-r--r--ipn/ipnlocal/local.go154
-rw-r--r--ipn/store.go51
-rw-r--r--wgengine/netstack/netstack.go13
6 files changed, 239 insertions, 9 deletions
diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go
index de68a7ef6..526da75b5 100644
--- a/cmd/tailscale/cli/debug.go
+++ b/cmd/tailscale/cli/debug.go
@@ -568,5 +568,13 @@ func runDevStoreSet(ctx context.Context, args []string) error {
if !devStoreSetArgs.danger {
return errors.New("this command is dangerous; use --danger to proceed")
}
- return localClient.SetDevStoreKeyValue(ctx, args[0], args[1])
+ key, val := args[0], args[1]
+ if val == "-" {
+ valb, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return err
+ }
+ val = string(valb)
+ }
+ return localClient.SetDevStoreKeyValue(ctx, key, val)
}
diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt
index ff43ec4cd..0439e1e78 100644
--- a/cmd/tailscaled/depaware.txt
+++ b/cmd/tailscaled/depaware.txt
@@ -290,7 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/strs from tailscale.com/hostinfo+
tailscale.com/util/systemd from tailscale.com/control/controlclient+
- tailscale.com/util/uniq from tailscale.com/wgengine/magicsock
+ tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
💣 tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
diff --git a/ipn/ipnlocal/cert_js.go b/ipn/ipnlocal/cert_js.go
new file mode 100644
index 000000000..c14d6094d
--- /dev/null
+++ b/ipn/ipnlocal/cert_js.go
@@ -0,0 +1,18 @@
+// 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 (
+ "context"
+ "errors"
+)
+
+type TLSCertKeyPair struct {
+ CertPEM, KeyPEM []byte
+}
+
+func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
+ return nil, errors.New("not implemented for js/wasm")
+}
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index a9e3e3ef4..518ac3373 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -6,10 +6,13 @@ package ipnlocal
import (
"context"
+ "crypto/tls"
"encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"io"
+ "math"
"net"
"net/http"
"net/netip"
@@ -25,6 +28,7 @@ import (
"sync/atomic"
"time"
+ "go4.org/mem"
"go4.org/netipx"
"golang.org/x/exp/slices"
"tailscale.com/client/tailscale/apitype"
@@ -62,6 +66,7 @@ import (
"tailscale.com/util/multierr"
"tailscale.com/util/osshare"
"tailscale.com/util/systemd"
+ "tailscale.com/util/uniq"
"tailscale.com/version"
"tailscale.com/version/distro"
"tailscale.com/wgengine"
@@ -135,8 +140,9 @@ type LocalBackend struct {
sshAtomicBool atomic.Bool
shutdownCalled bool // if Shutdown has been called
- filterAtomic atomic.Pointer[filter.Filter]
- containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
+ filterAtomic atomic.Pointer[filter.Filter]
+ containsViaIPFuncAtomic syncs.AtomicValue[func(netip.Addr) bool]
+ shouldInterceptTCPPortAtomic syncs.AtomicValue[func(uint16) bool]
// The mutex protects the following elements.
mu sync.Mutex
@@ -192,6 +198,10 @@ type LocalBackend struct {
directFileDoFinalRename bool // false on macOS, true on several NAS platforms
componentLogUntil map[string]componentLogState
+ // ServeConfig fields. (also guarded by mu)
+ lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
+ serveConfig ipn.ServeConfig
+
// statusLock must be held before calling statusChanged.Wait() or
// statusChanged.Broadcast().
statusLock sync.Mutex
@@ -257,6 +267,8 @@ func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, diale
// Default filter blocks everything and logs nothing, until Start() is called.
b.setFilter(filter.NewAllowNone(logf, &netipx.IPSet{}))
+ b.setTCPPortsIntercepted(nil)
+
b.statusChanged = sync.NewCond(&b.statusLock)
b.e.SetStatusCallback(b.setWgengineStatus)
@@ -1142,6 +1154,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
}
b.setAtomicValuesFromPrefs(b.prefs)
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
}
wantRunning := b.prefs.WantRunning()
@@ -1906,6 +1919,7 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err
// value instead of making up a new one.
b.logf("using frontend prefs: %s", prefs.Pretty())
b.prefs = prefs.Clone().View()
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
b.writeServerModeStartState(b.userID, b.prefs)
return nil
}
@@ -1926,6 +1940,7 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err
prefs.WantRunning = false
b.logf("using backend prefs; created empty state for %q: %s", key, prefs.Pretty())
b.prefs = prefs.View()
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
return nil
case err != nil:
return fmt.Errorf("backend prefs: store.ReadState(%q): %v", key, err)
@@ -1952,10 +1967,49 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err
b.prefs = prefs.View()
b.setAtomicValuesFromPrefs(b.prefs)
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
return nil
}
+// setTCPPortsIntercepted populates b.shouldInterceptTCPPortAtomic with an
+// efficient func for ShouldInterceptTCPPort to use, which is called on every
+// incoming packet.
+func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
+ slices.Sort(ports)
+ uniq.ModifySlice(&ports)
+ b.logf("localbackend: handling TCP ports = %v", ports)
+ var f func(uint16) bool
+ switch len(ports) {
+ case 0:
+ f = func(uint16) bool { return false }
+ case 1:
+ f = func(p uint16) bool { return ports[0] == p }
+ case 2:
+ f = func(p uint16) bool { return ports[0] == p || ports[1] == p }
+ case 3:
+ f = func(p uint16) bool { return ports[0] == p || ports[1] == p || ports[2] == p }
+ default:
+ if len(ports) > 16 {
+ m := map[uint16]bool{}
+ for _, p := range ports {
+ m[p] = true
+ }
+ f = func(p uint16) bool { return m[p] }
+ } else {
+ f = func(p uint16) bool {
+ for _, x := range ports {
+ if p == x {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ }
+ b.shouldInterceptTCPPortAtomic.Store(f)
+}
+
// setAtomicValuesFromPrefs populates sshAtomicBool and containsViaIPFuncAtomic
// from the prefs p, which may be nil.
func (b *LocalBackend) setAtomicValuesFromPrefs(p ipn.PrefsView) {
@@ -1963,6 +2017,7 @@ func (b *LocalBackend) setAtomicValuesFromPrefs(p ipn.PrefsView) {
if !p.Valid() {
b.containsViaIPFuncAtomic.Store(tsaddr.NewContainsIPFunc(nil))
+ b.setTCPPortsIntercepted(nil)
} else {
b.containsViaIPFuncAtomic.Store(tsaddr.NewContainsIPFunc(p.AdvertiseRoutes().Filter(tsaddr.IsViaPrefix)))
}
@@ -2283,8 +2338,8 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
// anyway. No-op if no exit node resolution is needed.
findExitNodeIDLocked(newp, netMap)
b.prefs = newp.View()
-
b.setAtomicValuesFromPrefs(b.prefs)
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
b.inServerMode = b.prefs.ForceDaemon()
// We do this to avoid holding the lock while doing everything else.
@@ -3281,6 +3336,7 @@ func (b *LocalBackend) ResetForClientDisconnect() {
b.authURLSticky = ""
b.activeLogin = ""
b.setAtomicValuesFromPrefs(b.prefs)
+ b.setTCPPortsIntercepted(nil)
}
func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && envknob.CanSSHD() }
@@ -3402,6 +3458,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
b.capFileSharing = fs
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
if nm == nil {
b.nodeByAddr = nil
return
@@ -3436,6 +3493,41 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
}
+// setTCPPortsInterceptedFromNetmapAndPrefsLocked calls setTCPPortsIntercepted with
+// the ports that tailscaled should handle as a function of b.netMap and b.prefs.
+//
+// b.mu must be held.
+func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked() {
+ handlePorts := make([]uint16, 0, 4)
+
+ prefs := b.prefs
+ if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
+ handlePorts = append(handlePorts, 22)
+ }
+
+ nm := b.netMap
+ if nm != nil && nm.SelfNode != nil {
+ profileID := fmt.Sprintf("node-%s", nm.SelfNode.StableID) // TODO(maisem,bradfitz): something else?
+ confKey := ipn.ServeConfigKey(profileID)
+ if confj, err := b.store.ReadState(confKey); err == nil {
+ if !b.lastServeConfJSON.Equal(mem.B(confj)) {
+ b.lastServeConfJSON = mem.B(confj)
+ var conf ipn.ServeConfig
+ if err := json.Unmarshal(confj, &conf); err != nil {
+ b.logf("invalid ServeConfig %q in StateStore: %v", confKey, err)
+ }
+ b.serveConfig = conf
+ }
+ for p := range b.serveConfig.TCP {
+ if p > 0 && p <= math.MaxUint16 {
+ handlePorts = append(handlePorts, uint16(p))
+ }
+ }
+ }
+ }
+ b.setTCPPortsIntercepted(handlePorts)
+}
+
// operatorUserName returns the current pref's OperatorUser's name, or the
// empty string if none.
func (b *LocalBackend) operatorUserName() string {
@@ -3976,5 +4068,59 @@ func (b *LocalBackend) SetDevStateStore(key, value string) error {
}
err := b.store.WriteState(ipn.StateKey(key), []byte(value))
b.logf("SetDevStateStore(%q, %q) = %v", key, value, err)
- return err
+
+ if err != nil {
+ return err
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.setTCPPortsInterceptedFromNetmapAndPrefsLocked()
+
+ return nil
+}
+
+// ShouldInterceptTCPPort reports whether the given TCP port number to a
+// Tailscale IP (not a subnet router, service IP, etc) should be intercepted by
+// Tailscaled and handled in-process.
+func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
+ return b.shouldInterceptTCPPortAtomic.Load()(port)
+}
+
+var runDevWebServer = envknob.RegisterBool("TS_DEV_WEBSERVER")
+
+func (b *LocalBackend) HandleInterceptedTCPConn(c net.Conn) {
+ if !runDevWebServer() {
+ b.logf("localbackend: closing TCP conn from %v to %v", c.RemoteAddr(), c.LocalAddr())
+ c.Close()
+ return
+ }
+
+ // TODO(bradfitz): look up how; sniff SNI if ambiguous
+ hs := &http.Server{
+ TLSConfig: &tls.Config{
+ GetCertificate: b.getTLSServeCert,
+ },
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "<h1>hello world</h1>this is tailscaled")
+ }),
+ }
+ hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
+}
+
+func (b *LocalBackend) getTLSServeCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ if hi == nil || hi.ServerName == "" {
+ return nil, errors.New("no SNI ServerName")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ pair, err := b.GetCertPEM(ctx, hi.ServerName)
+ if err != nil {
+ return nil, err
+ }
+ cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
+ if err != nil {
+ return nil, err
+ }
+ return &cert, nil
}
diff --git a/ipn/store.go b/ipn/store.go
index b2f9f82d2..70eaf2ef2 100644
--- a/ipn/store.go
+++ b/ipn/store.go
@@ -64,3 +64,54 @@ func ReadStoreInt(store StateStore, id StateKey) (int64, error) {
func PutStoreInt(store StateStore, id StateKey, val int64) error {
return store.WriteState(id, fmt.Appendf(nil, "%d", val))
}
+
+// ServeConfigKey returns a StateKey that stores the
+// JSON-encoded ServeConfig for a config profile.
+func ServeConfigKey(profileID string) StateKey {
+ return StateKey("_serve/" + profileID)
+}
+
+// ServeConfig is the JSON type stored in the StateStore for
+// StateKey "_serve/$PROFILE_ID" as returned by ServeConfigKey.
+type ServeConfig struct {
+ // TCP are the list of TCP port numbers that tailscaled should handle for
+ // the Tailscale IP addresses. (not subnet routers, etc)
+ TCP map[int]*TCPPortHandler `json:",omitempty"`
+
+ // Web maps from "$SNI_NAME:$PORT" to a set of HTTP handlers
+ // keyed by mount point ("/", "/foo", etc)
+ Web map[string]map[string]*HTTPHandler `json:",omitempty"`
+}
+
+// TCPPortHandler describes what to do when handling a TCP
+// connection.
+type TCPPortHandler struct {
+ // HTTPS, if true, means that tailscaled should handle this connection as an
+ // HTTPS request as configured by ServeConfig.Web.
+ //
+ // It is mutually exclusive with TCPForward.
+ HTTPS bool `json:",omitempty"`
+
+ // TCPForward is the IP:port to forward TCP connections to.
+ // Whether or not TLS is terminated by tailscaled depends on
+ // TerminateTLS.
+ //
+ // It is mutually exclusive with HTTPS.
+ TCPForward string `json:",omitempty"`
+
+ // TerminateTLS is whether tailscaled should terminate TLS
+ // connections before forwarding them to TCPForward. It is only
+ // used if TCPForward is non-empty. (the HTTPS mode )
+ TerminateTLS bool `json:",omitempty"`
+}
+
+// HTTPHandler is either a path or a proxy to serve.
+type HTTPHandler struct {
+ // Exactly one of the following may be set.
+
+ Path string `json:",omitempty"` // absolute path to directory or file to serve
+ Proxy string `json:",omitempty"` // http://localhost:3000/, localhost:3030, 3030
+
+ // TODO(bradfitz): bool to not enumerate directories? TTL on mapping for
+ // temporary ones? Error codes? Redirects?
+}
diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go
index aa79a275c..4adb14894 100644
--- a/wgengine/netstack/netstack.go
+++ b/wgengine/netstack/netstack.go
@@ -554,9 +554,8 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
if dport == peerAPIPort {
return true
}
-
- // Also handle SSH connections, if enabled.
- if dport == 22 && ns.lb.ShouldRunSSH() {
+ // Also handle SSH connections, webserver, etc, if enabled:
+ if ns.lb.ShouldInterceptTCPPort(dport) {
return true
}
}
@@ -894,6 +893,14 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
ns.lb.HandleQuad100Port80Conn(c)
return
}
+ if ns.lb.ShouldInterceptTCPPort(reqDetails.LocalPort) && ns.isLocalIP(dialIP) {
+ c := createConn()
+ if c == nil {
+ return
+ }
+ ns.lb.HandleInterceptedTCPConn(c)
+ return
+ }
}
if ns.ForwardTCPIn != nil {