cmd/tailscale/cli, ipn/ipnlocal: [funnel] add stream mode

Adds ability to start Funnel in the foreground and stream incoming
connections. When foreground process is stopped, Funnel is turned
back off for the port.

Exampe usage:
```
TAILSCALE_FUNNEL_V2=on tailscale funnel 8080
```

Updates #8489

Signed-off-by: Marwan Sulaiman <marwan@tailscale.com>
This commit is contained in:
Marwan Sulaiman 2023-08-17 11:47:35 -04:00 committed by Marwan Sulaiman
parent cb4a61f951
commit 35ff5bf5a6
11 changed files with 399 additions and 2 deletions

View File

@ -1057,6 +1057,29 @@ func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) er
return nil
}
// StreamServe returns an io.ReadCloser that streams serve/Funnel
// connections made to the provided HostPort.
//
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
// the backend enables it for the duration of the context's lifespan and
// then turns it back off once the context is closed. If either are already enabled,
// then they remain that way but logs are still streamed
func (lc *LocalClient) StreamServe(ctx context.Context, hp ipn.ServeStreamRequest) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/stream-serve", jsonBody(hp))
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).

View File

@ -120,7 +120,7 @@ change in the future.
pingCmd,
ncCmd,
sshCmd,
funnelCmd,
funnelCmd(),
serveCmd,
versionCmd,
webCmd,

View File

@ -20,7 +20,16 @@ import (
"tailscale.com/util/mak"
)
var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient})
var funnelCmd = func() *ffcli.Command {
se := &serveEnv{lc: &localClient}
// This flag is used to switch to an in-development
// implementation of the tailscale funnel command.
// See https://github.com/tailscale/tailscale/issues/7844
if os.Getenv("TAILSCALE_FUNNEL_DEV") == "on" {
return newFunnelDevCommand(se)
}
return newFunnelCommand(se)
}
// newFunnelCommand returns a new "funnel" subcommand using e as its environment.
// The funnel subcommand is used to turn on/off the Funnel service.

View File

@ -0,0 +1,112 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
)
// newFunnelDevCommand returns a new "funnel" subcommand using e as its environment.
// The funnel subcommand is used to turn on/off the Funnel service.
// Funnel is off by default.
// Funnel allows you to publish a 'tailscale serve' server publicly,
// open to the entire internet.
// newFunnelCommand shares the same serveEnv as the "serve" subcommand.
// See newServeCommand and serve.go for more details.
func newFunnelDevCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "funnel",
ShortHelp: "Turn on/off Funnel service",
ShortUsage: strings.Join([]string{
"funnel <port>",
"funnel status [--json]",
}, "\n "),
LongHelp: strings.Join([]string{
"Funnel allows you to expose your local",
"server publicly to the entire internet.",
"Note that it only supports https servers at this point.",
"This command is in development and is unsupported",
}, "\n"),
Exec: e.runFunnelDev,
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "show current serve/Funnel status",
FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
},
}
}
// runFunnelDev is the entry point for the "tailscale funnel" subcommand and
// manages turning on/off Funnel. Funnel is off by default.
//
// Note: funnel is only supported on single DNS name for now. (2023-08-18)
func (e *serveEnv) runFunnelDev(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
}
var source string
port64, err := strconv.ParseUint(args[0], 10, 16)
if err == nil {
source = fmt.Sprintf("http://127.0.0.1:%d", port64)
} else {
source, err = expandProxyTarget(args[0])
}
if err != nil {
return err
}
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
return err
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":443") // TODO(marwan-at-work): support the 2 other ports
// In the streaming case, the process stays running in the
// foreground and prints out connections to the HostPort.
//
// The local backend handles updating the ServeConfig as
// necessary, then restores it to its original state once
// the process's context is closed or the client turns off
// Tailscale.
return e.streamServe(ctx, ipn.ServeStreamRequest{
HostPort: hp,
Source: source,
MountPoint: "/", // TODO(marwan-at-work): support multiple mount points
})
}
func (e *serveEnv) streamServe(ctx context.Context, req ipn.ServeStreamRequest) error {
stream, err := e.lc.StreamServe(ctx, req)
if err != nil {
return err
}
defer stream.Close()
fmt.Fprintf(os.Stderr, "Funnel started on \"https://%s\".\n", strings.TrimSuffix(string(req.HostPort), ":443"))
fmt.Fprintf(os.Stderr, "Press Ctrl-C to stop Funnel.\n\n")
_, err = io.Copy(os.Stdout, stream)
return err
}

View File

@ -135,6 +135,7 @@ type localServeClient interface {
QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error)
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error)
IncrementCounter(ctx context.Context, name string, delta int) error
StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) // TODO: testing :)
}
// serveEnv is the environment the serve command runs within. All I/O should be

View File

@ -9,6 +9,7 @@ import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
@ -900,6 +901,11 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin
return nil // unused in tests
}
func (lc *fakeLocalServeClient) StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) {
// TODO: testing :)
return nil, nil
}
// exactError returns an error checker that wants exactly the provided want error.
// If optName is non-empty, it's used in the error message.
func exactErr(want error, optName ...string) func(error) string {

View File

@ -93,6 +93,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/ipn/ipnlocal
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
@ -438,6 +439,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
crypto/tls from github.com/tcnksm/go-httpstat+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
W debug/dwarf from debug/pe
W debug/pe from github.com/dblohm7/wingoes/pe
embed from tailscale.com+

View File

@ -244,6 +244,9 @@ type LocalBackend struct {
serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy
// serveStreamers is a map for those running Funnel in the foreground
// and streaming incoming requests.
serveStreamers map[uint16]map[uint32]func(ipn.FunnelRequestLog) // serve port => map of stream loggers (key is UUID)
// statusLock must be held before calling statusChanged.Wait() or
// statusChanged.Broadcast().

View File

@ -23,6 +23,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"tailscale.com/ipn"
"tailscale.com/logtail/backoff"
"tailscale.com/net/netutil"
@ -257,6 +258,165 @@ func (b *LocalBackend) ServeConfig() ipn.ServeConfigView {
return b.serveConfig
}
// StreamServe opens a stream to write any incoming connections made
// to the given HostPort out to the listening io.Writer.
//
// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig,
// the backend enables it for the duration of the context's lifespan and
// then turns it back off once the context is closed. If either are already enabled,
// then they remain that way but logs are still streamed
func (b *LocalBackend) StreamServe(ctx context.Context, w io.Writer, req ipn.ServeStreamRequest) (err error) {
f, ok := w.(http.Flusher)
if !ok {
return errors.New("writer not a flusher")
}
f.Flush()
port, err := req.HostPort.Port()
if err != nil {
return err
}
// Turn on Funnel for the given HostPort.
sc := b.ServeConfig().AsStruct()
if sc == nil {
sc = &ipn.ServeConfig{}
}
setHandler(sc, req)
if err := b.SetServeConfig(sc); err != nil {
return fmt.Errorf("errro setting serve config: %w", err)
}
// Defer turning off Funnel once stream ends.
defer func() {
sc := b.ServeConfig().AsStruct()
deleteHandler(sc, req, port)
err = errors.Join(err, b.SetServeConfig(sc))
}()
var writeErrs []error
writeToStream := func(log ipn.FunnelRequestLog) {
jsonLog, err := json.Marshal(log)
if err != nil {
writeErrs = append(writeErrs, err)
return
}
if _, err := fmt.Fprintf(w, "%s\n", jsonLog); err != nil {
writeErrs = append(writeErrs, err)
return
}
f.Flush()
}
// Hook up connections stream.
b.mu.Lock()
mak.NonNilMapForJSON(&b.serveStreamers)
if b.serveStreamers[port] == nil {
b.serveStreamers[port] = make(map[uint32]func(ipn.FunnelRequestLog))
}
id := uuid.New().ID()
b.serveStreamers[port][id] = writeToStream
b.mu.Unlock()
// Clean up streamer when done.
defer func() {
b.mu.Lock()
mak.NonNilMapForJSON(&b.serveStreamers)
delete(b.serveStreamers[port], id)
b.mu.Unlock()
}()
select {
case <-ctx.Done():
// Triggered by foreground `tailscale funnel` process
// (the streamer) getting closed, or by turning off Tailscale.
}
return errors.Join(writeErrs...)
}
func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest) {
if sc.TCP == nil {
sc.TCP = make(map[uint16]*ipn.TCPPortHandler)
}
if _, ok := sc.TCP[443]; !ok {
sc.TCP[443] = &ipn.TCPPortHandler{
HTTPS: true,
}
}
if sc.Web == nil {
sc.Web = make(map[ipn.HostPort]*ipn.WebServerConfig)
}
wsc, ok := sc.Web[req.HostPort]
if !ok {
wsc = &ipn.WebServerConfig{}
sc.Web[req.HostPort] = wsc
}
if wsc.Handlers == nil {
wsc.Handlers = make(map[string]*ipn.HTTPHandler)
}
wsc.Handlers[req.MountPoint] = &ipn.HTTPHandler{
Proxy: req.Source,
}
if sc.AllowFunnel == nil {
sc.AllowFunnel = make(map[ipn.HostPort]bool)
}
sc.AllowFunnel[req.HostPort] = true
}
func deleteHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, port uint16) {
delete(sc.AllowFunnel, req.HostPort)
if sc.TCP != nil {
delete(sc.TCP, port)
}
if sc.Web == nil {
return
}
if sc.Web[req.HostPort] == nil {
return
}
wsc, ok := sc.Web[req.HostPort]
if !ok {
return
}
if wsc.Handlers == nil {
return
}
if _, ok := wsc.Handlers[req.MountPoint]; !ok {
return
}
delete(wsc.Handlers, req.MountPoint)
if len(wsc.Handlers) == 0 {
delete(sc.Web, req.HostPort)
}
}
func (b *LocalBackend) maybeLogServeConnection(destPort uint16, srcAddr netip.AddrPort) {
b.mu.Lock()
streamers := b.serveStreamers[destPort]
b.mu.Unlock()
if len(streamers) == 0 {
return
}
var log ipn.FunnelRequestLog
log.SrcAddr = srcAddr
log.Time = time.Now() // TODO: use a different clock somewhere?
if node, user, ok := b.WhoIs(srcAddr); ok {
log.NodeName = node.ComputedName()
if node.IsTagged() {
log.NodeTags = node.Tags().AsSlice()
} else {
log.UserLoginName = user.LoginName
log.UserDisplayName = user.DisplayName
}
}
for _, stream := range streamers {
stream(log)
}
}
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) {
b.mu.Lock()
sc := b.serveConfig
@ -359,6 +519,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort)
if backDst := tcph.TCPForward(); backDst != "" {
return func(conn net.Conn) error {
defer conn.Close()
b.maybeLogServeConnection(dport, srcAddr)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
cancel()
@ -527,6 +688,9 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if c, ok := getServeHTTPContext(r); ok {
b.maybeLogServeConnection(c.DestPort, c.SrcAddr)
}
if s := h.Text(); s != "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, s)

View File

@ -99,6 +99,7 @@ var handler = map[string]localAPIHandler{
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"stream-serve": (*Handler).serveStreamServe,
"tka/init": (*Handler).serveTKAInit,
"tka/log": (*Handler).serveTKALog,
"tka/modify": (*Handler).serveTKAModify,
@ -857,6 +858,31 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
}
}
// serveStreamServe handles foreground serve and funnel streams. This is
// currently in development per https://github.com/tailscale/tailscale/issues/8489
func (h *Handler) serveStreamServe(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
// Write permission required because we modify the ServeConfig.
http.Error(w, "serve stream denied", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req ipn.ServeStreamRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, fmt.Errorf("decoding HostPort: %w", err))
return
}
w.Header().Set("Content-Type", "application/json")
if err := h.b.StreamServe(r.Context(), w, req); err != nil {
writeErrorJSON(w, fmt.Errorf("streaming serve: %w", err))
return
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)

View File

@ -12,6 +12,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"tailscale.com/tailcfg"
)
@ -42,6 +43,21 @@ type ServeConfig struct {
// There is no implicit port 443. It must contain a colon.
type HostPort string
// Port extracts just the port number from hp.
// An error is reported in the case that the hp does not
// have a valid numeric port ending.
func (hp HostPort) Port() (uint16, error) {
_, port, err := net.SplitHostPort(string(hp))
if err != nil {
return 0, err
}
port16, err := strconv.ParseUint(port, 10, 16)
if err != nil {
return 0, err
}
return uint16(port16), nil
}
// A FunnelConn wraps a net.Conn that is coming over a
// Funnel connection. It can be used to determine further
// information about the connection, like the source address
@ -62,6 +78,41 @@ type FunnelConn struct {
Src netip.AddrPort
}
// ServeStreamRequest defines the json request body
// for the serve stream endpoint
type ServeStreamRequest struct {
// HostPort is the DNS and port of the tailscale
// URL.
HostPort HostPort `json:",omitempty"`
// Source is the user's serve destination
// such as their localhost server.
Source string `json:",omitempty"`
// MountPoint is the path prefix for
// the given HostPort.
MountPoint string `json:",omitempty"`
}
// FunnelRequestLog is the JSON type written out to io.Writers
// watching funnel connections via ipnlocal.StreamServe.
//
// This structure is in development and subject to change.
type FunnelRequestLog struct {
Time time.Time `json:",omitempty"` // time of request forwarding
// SrcAddr is the address that initiated the Funnel request.
SrcAddr netip.AddrPort `json:",omitempty"`
// The following fields are only populated if the connection
// initiated from another node on the client's tailnet.
NodeName string `json:",omitempty"` // src node MagicDNS name
NodeTags []string `json:",omitempty"` // src node tags
UserLoginName string `json:",omitempty"` // src node's owner login (if not tagged)
UserDisplayName string `json:",omitempty"` // src node's owner name (if not tagged)
}
// WebServerConfig describes a web server's configuration.
type WebServerConfig struct {
Handlers map[string]*HTTPHandler // mountPoint => handler