cmd/tailscale/cli: flesh out serve CLI and tests (#6304)

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
pull/6374/head
shayne 2 years ago committed by GitHub
parent 5f6d63936f
commit a97369f097
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -190,11 +190,10 @@ change in the future.
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
serveCmd,
)
}
// Don't advertise the debug command, but it exists.
// Don't advertise these commands, but they're still explicitly available.
switch {
case slices.Contains(args, "debug"):
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
@ -202,6 +201,8 @@ change in the future.
rootCmd.Subcommands = append(rootCmd.Subcommands, loginCmd)
case slices.Contains(args, "switch"):
rootCmd.Subcommands = append(rootCmd.Subcommands, switchCmd)
case slices.Contains(args, "serve"):
rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd)
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)

@ -7,14 +7,27 @@ package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/exp/slices"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
var serveCmd = newServeCommand(&serveEnv{})
@ -23,30 +36,79 @@ var serveCmd = newServeCommand(&serveEnv{})
func newServeCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "serve",
ShortHelp: "TODO",
ShortUsage: "serve {show-config|https|tcp|ingress} <args>",
LongHelp: "", // TODO
ShortHelp: "[ALPHA] Serve from your Tailscale node",
ShortUsage: strings.TrimSpace(`
serve [flags] <mount-point> {proxy|path|text} <arg>
serve [flags] <sub-command> [sub-flags] <args>`),
LongHelp: strings.TrimSpace(`
*** ALPHA; all of this is subject to change ***
The 'tailscale serve' set of commands allows you to serve
content and local servers from your Tailscale node to
your tailnet.
You can also choose to enable the Tailscale Funnel with:
'tailscale serve funnel on'. Funnel allows you to publish
a 'tailscale serve' server publicly, open to the entire
internet. See https://tailscale.com/funnel.
EXAMPLES
- To proxy requests to a web server at 127.0.0.1:3000:
$ tailscale serve / proxy 3000
- To serve a single file or a directory of files:
$ tailscale serve / path /home/alice/blog/index.html
$ tailscale serve /images/ path /home/alice/blog/images
- To serve simple static text:
$ tailscale serve / text "Hello, world!"
`),
Exec: e.runServe,
FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {}),
FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {
fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config")
fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)")
}),
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "show-config",
Exec: e.runServeShowConfig,
ShortHelp: "show current serve config",
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "show current serve status",
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
{
Name: "tcp",
Exec: e.runServeTCP,
ShortHelp: "add or remove a TCP port forward",
LongHelp: strings.Join([]string{
"EXAMPLES",
" - Forward TLS over TCP to a local TCP server on port 5432:",
" $ tailscale serve tcp 5432",
"",
" - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:",
" $ tailscale serve --terminate-tls tcp 5432",
}, "\n"),
FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) {
fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection")
}),
UsageFunc: usageFunc,
},
{
Name: "ingress",
Exec: e.runServeIngress,
ShortHelp: "enable or disable ingress",
FlagSet: e.newFlags("serve-ingress", func(fs *flag.FlagSet) {}),
Name: "funnel",
Exec: e.runServeFunnel,
ShortUsage: "funnel [flags] {on|off}",
ShortHelp: "turn Tailscale Funnel on or off",
LongHelp: strings.Join([]string{
"Funnel allows you to publish a 'tailscale serve'",
"server publicly, open to the entire internet.",
"",
"Turning off Funnel only turns off serving to the internet.",
"It does not affect serving to your tailnet.",
}, "\n"),
UsageFunc: usageFunc,
},
},
}
@ -58,15 +120,46 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
// It also contains the flags, as registered with newServeCommand.
type serveEnv struct {
// flags
servePort uint // Port to serve on. Defaults to 443.
terminateTLS bool
remove bool // remove a serve config
json bool // output JSON (status only for now)
// optional stuff for tests:
testFlagOut io.Writer
testGetServeConfig func(context.Context) (*ipn.ServeConfig, error)
testSetServeConfig func(context.Context, *ipn.ServeConfig) error
testGetLocalClientStatus func(context.Context) (*ipnstate.Status, error)
testStdout io.Writer
}
func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) {
st, err := e.getLocalClientStatus(ctx)
if err != nil {
return "", fmt.Errorf("getting client status: %w", err)
}
return strings.TrimSuffix(st.Self.DNSName, "."), nil
}
func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, error) {
if e.testGetLocalClientStatus != nil {
return e.testGetLocalClientStatus(ctx)
}
st, err := localClient.Status(ctx)
if err != nil {
return nil, fixTailscaledConnectError(err)
}
description, ok := isRunningOrStarting(st)
if !ok {
fmt.Fprintf(os.Stderr, "%s\n", description)
os.Exit(1)
}
if st.Self == nil {
return nil, errors.New("no self node")
}
return st, nil
}
func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
onError, out := flag.ExitOnError, Stderr
if e.testFlagOut != nil {
@ -101,7 +194,39 @@ func (e *serveEnv) stdout() io.Writer {
return os.Stdout
}
// validateServePort returns --serve-port flag value,
// or an error if the port is not a valid port to serve on.
func (e *serveEnv) validateServePort() (port uint16, err error) {
// make sure e.servePort is uint16
port = uint16(e.servePort)
if uint(port) != e.servePort {
return 0, fmt.Errorf("serve-port %d is out of range", e.servePort)
}
// make sure e.servePort is 443, 8443 or 10000
if port != 443 && port != 8443 && port != 10000 {
return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", e.servePort)
}
return port, nil
}
// runServe is the entry point for the "serve" subcommand, managing Web
// serve config types like proxy, path, and text.
//
// Examples:
// - tailscale serve / proxy 3000
// - tailscale serve /images/ path /var/www/images/
// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!"
func (e *serveEnv) runServe(ctx context.Context, args []string) error {
if len(args) == 0 {
return flag.ErrHelp
}
srvPort, err := e.validateServePort()
if err != nil {
return err
}
srvPortStr := strconv.Itoa(int(srvPort))
// Undocumented debug command (not using ffcli subcommands) to set raw
// configs from stdin for now (2022-11-13).
if len(args) == 1 && args[0] == "set-raw" {
@ -115,14 +240,266 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
}
return localClient.SetServeConfig(ctx, sc)
}
panic("TODO")
if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
mount, err := cleanMountPoint(args[0])
if err != nil {
return err
}
if e.remove {
return e.handleWebServeRemove(ctx, mount)
}
func (e *serveEnv) runServeShowConfig(ctx context.Context, args []string) error {
h := new(ipn.HTTPHandler)
switch args[1] {
case "path":
if version.IsSandboxedMacOS() {
// don't allow path serving for now on macOS (2022-11-15)
return fmt.Errorf("path serving is not supported if sandboxed on macOS")
}
if !filepath.IsAbs(args[2]) {
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
return flag.ErrHelp
}
fi, err := os.Stat(args[2])
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
return flag.ErrHelp
}
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
// dir mount points must end in /
// for relative file links to work
mount += "/"
}
h.Path = args[2]
case "proxy":
t, err := expandProxyTarget(args[2])
if err != nil {
return err
}
h.Proxy = t
case "text":
h.Text = args[2]
default:
fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
return flag.ErrHelp
}
cursc, err := e.getServeConfig(ctx)
if err != nil {
return err
}
sc := cursc.Clone() // nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
if isTCPForwardingOnPort(sc, srvPort) {
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
return flag.ErrHelp
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, h)
for k, v := range sc.Web[hp].Handlers {
if v == h {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
continue
}
}
if !reflect.DeepEqual(cursc, sc) {
if err := e.setServeConfig(ctx, sc); err != nil {
return err
}
}
return nil
}
func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
srvPort, err := e.validateServePort()
if err != nil {
return err
}
srvPortStr := strconv.Itoa(int(srvPort))
sc, err := e.getServeConfig(ctx)
if err != nil {
return err
}
if sc == nil {
return errors.New("error: serve config does not exist")
}
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
if isTCPForwardingOnPort(sc, srvPort) {
return errors.New("cannot remove web handler; currently serving TCP")
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
if !httpHandlerExists(sc, hp, mount) {
return errors.New("error: serve config does not exist")
}
// delete existing handler, then cascade delete if empty
delete(sc.Web[hp].Handlers, mount)
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.TCP, srvPort)
}
// clear empty maps mostly for testing
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
if err := e.setServeConfig(ctx, sc); err != nil {
return err
}
return nil
}
func httpHandlerExists(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) bool {
h := getHTTPHandler(sc, hp, mount)
return h != nil
}
func getHTTPHandler(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) *ipn.HTTPHandler {
if sc != nil && sc.Web[hp] != nil {
return sc.Web[hp].Handlers[mount]
}
return nil
}
func cleanMountPoint(mount string) (string, error) {
if mount == "" {
return "", errors.New("mount point cannot be empty")
}
if !strings.HasPrefix(mount, "/") {
mount = "/" + mount
}
c := path.Clean(mount)
if mount == c || mount == c+"/" {
return mount, nil
}
return "", fmt.Errorf("invalid mount point %q", mount)
}
func expandProxyTarget(target string) (string, error) {
if allNumeric(target) {
p, err := strconv.ParseUint(target, 10, 16)
if p == 0 || err != nil {
return "", fmt.Errorf("invalid port %q", target)
}
return "http://127.0.0.1:" + target, nil
}
if !strings.Contains(target, "://") {
target = "http://" + target
}
u, err := url.ParseRequestURI(target)
if err != nil {
return "", fmt.Errorf("parsing url: %w", err)
}
switch u.Scheme {
case "http", "https", "https+insecure":
// ok
default:
return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
}
host := u.Hostname()
switch host {
// TODO(shayne,bradfitz): do we want to do this?
case "localhost", "127.0.0.1":
host = "127.0.0.1"
default:
return "", fmt.Errorf("only localhost or 127.0.0.1 proxies are currently supported")
}
url := u.Scheme + "://" + host
if u.Port() != "" {
url += ":" + u.Port()
}
return url, nil
}
// isTCPForwardingAny checks if any TCP port is being forwarded.
func isTCPForwardingAny(sc *ipn.ServeConfig) bool {
if sc == nil || len(sc.TCP) == 0 {
return false
}
for _, h := range sc.TCP {
if h.TCPForward != "" {
return true
}
}
return false
}
// isTCPForwardingOnPort checks serve config to see if
// we're specifically forwarding TCP on the given port.
func isTCPForwardingOnPort(sc *ipn.ServeConfig, port uint16) bool {
if sc == nil || sc.TCP[port] == nil {
return false
}
return !sc.TCP[port].HTTPS
}
// isServingWeb checks serve config to see if
// we're serving a web handler on the given port.
func isServingWeb(sc *ipn.ServeConfig, port uint16) bool {
if sc == nil || sc.Web == nil || sc.TCP == nil ||
sc.TCP[port] == nil || sc.TCP[port].HTTPS == false {
// not listening on port
return false
}
return true
}
func allNumeric(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return s != ""
}
// runServeStatus prints the current serve config.
//
// Examples:
// - tailscale status
// - tailscale status --json
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
sc, err := e.getServeConfig(ctx)
if err != nil {
return err
}
if e.json {
j, err := json.MarshalIndent(sc, "", " ")
if err != nil {
return err
@ -131,15 +508,203 @@ func (e *serveEnv) runServeShowConfig(ctx context.Context, args []string) error
e.stdout().Write(j)
return nil
}
if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
printf("No serve config\n")
return nil
}
st, err := e.getLocalClientStatus(ctx)
if err != nil {
return err
}
if isTCPForwardingAny(sc) {
if err := printTCPStatusTree(ctx, sc, st); err != nil {
return err
}
printf("\n")
}
for hp := range sc.Web {
printWebStatusTree(sc, hp)
printf("\n")
}
// warn when funnel on without handlers
for hp, a := range sc.AllowFunnel {
if !a {
continue
}
_, portStr, _ := net.SplitHostPort(string(hp))
p, _ := strconv.ParseUint(portStr, 10, 16)
if _, ok := sc.TCP[uint16(p)]; !ok {
printf("WARNING: funnel=on for %s, but no serve config\n", hp)
}
}
return nil
}
func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
for p, h := range sc.TCP {
if h.TCPForward == "" {
continue
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p))))
tlsStatus := "TLS over TCP"
if h.TerminateTLS != "" {
tlsStatus = "TLS terminated"
}
fStatus := "tailnet only"
if isFunnelOn(sc, hp) {
fStatus = "Funnel on"
}
printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus)
for _, a := range st.TailscaleIPs {
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
printf("|-- tcp://%s\n", ipp)
}
printf("|--> tcp://%s\n", h.TCPForward)
}
return nil
}
func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
if sc == nil {
return
}
fStatus := "tailnet only"
if isFunnelOn(sc, hp) {
fStatus = "Funnel on"
}
host, portStr, _ := net.SplitHostPort(string(hp))
if portStr == "443" {
printf("https://%s (%s)\n", host, fStatus)
} else {
printf("https://%s:%s (%s)\n", host, portStr, fStatus)
}
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
return "path", h.Path
case h.Proxy != "":
return "proxy", h.Proxy
case h.Text != "":
return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
}
return "", ""
}
var mounts []string
for k := range sc.Web[hp].Handlers {
mounts = append(mounts, k)
}
sort.Slice(mounts, func(i, j int) bool {
return len(mounts[i]) < len(mounts[j])
})
maxLen := len(mounts[len(mounts)-1])
for _, m := range mounts {
h := sc.Web[hp].Handlers[m]
t, d := srvTypeAndDesc(h)
printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
}
}
func elipticallyTruncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
// runServeTCP is the entry point for the "serve tcp" subcommand and
// manages the serve config for TCP forwarding.
//
// Examples:
// - tailscale serve tcp 5432
// - tailscale --serve-port=8443 tcp 4430
// - tailscale --serve-port=10000 --terminate-tls tcp 8080
func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
panic("TODO")
if len(args) != 1 {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
srvPort, err := e.validateServePort()
if err != nil {
return err
}
portStr := args[0]
p, err := strconv.ParseUint(portStr, 10, 16)
if p == 0 || err != nil {
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr)
}
cursc, err := e.getServeConfig(ctx)
if err != nil {
return err
}
sc := cursc.Clone() // nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
fwdAddr := "127.0.0.1:" + portStr
if e.remove {
if isServingWeb(sc, srvPort) {
return errors.New("cannot remove TCP port; currently serving web")
}
if sc.TCP != nil && sc.TCP[srvPort] != nil &&
sc.TCP[srvPort].TCPForward == fwdAddr {
delete(sc.TCP, srvPort)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
return e.setServeConfig(ctx, sc)
}
return errors.New("error: serve config does not exist")
}
if isServingWeb(sc, srvPort) {
fmt.Fprintf(os.Stderr, "error: cannot serve TCP; already serving Web\n\n")
return flag.ErrHelp
}
func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
if e.terminateTLS {
sc.TCP[srvPort].TerminateTLS = dnsName
}
if !reflect.DeepEqual(cursc, sc) {
if err := e.setServeConfig(ctx, sc); err != nil {
return err
}
}
return nil
}
// runServeFunnel is the entry point for the "serve funnel" subcommand and
// manages turning on/off funnel. Funnel is off by default.
//
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
}
srvPort, err := e.validateServePort()
if err != nil {
return err
}
srvPortStr := strconv.Itoa(int(srvPort))
var on bool
switch args[0] {
case "on", "off":
@ -151,9 +716,17 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
if err != nil {
return err
}
var key ipn.HostPort = "foo:123" // TODO(bradfitz,shayne): fix
if on && sc != nil && sc.AllowIngress[key] ||
!on && (sc == nil || !sc.AllowIngress[key]) {
st, err := e.getLocalClientStatus(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
if !slices.Contains(st.Self.Capabilities, tailcfg.NodeAttrFunnel) {
return errors.New("Funnel not available. See https://tailscale.com/s/no-funnel")
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":" + srvPortStr)
if on && sc != nil && sc.AllowFunnel[hp] ||
!on && (sc == nil || !sc.AllowFunnel[hp]) {
// Nothing to do.
return nil
}
@ -161,9 +734,20 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
sc = &ipn.ServeConfig{}
}
if on {
mak.Set(&sc.AllowIngress, "foo:123", true)
mak.Set(&sc.AllowFunnel, hp, true)
} else {
delete(sc.AllowIngress, "foo:123")
delete(sc.AllowFunnel, hp)
// clear map mostly for testing
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
return e.setServeConfig(ctx, sc)
}
if err := e.setServeConfig(ctx, sc); err != nil {
return err
}
return nil
}
func isFunnelOn(sc *ipn.ServeConfig, hp ipn.HostPort) bool {
return sc != nil && sc.AllowFunnel[hp]
}

@ -9,14 +9,46 @@ import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
func TestCleanMountPoint(t *testing.T) {
tests := []struct {
mount string
want string
wantErr bool
}{
{"foo", "/foo", false}, // missing prefix
{"/foo/", "/foo/", false}, // keep trailing slash
{"////foo", "", true}, // too many slashes
{"/foo//", "", true}, // too many slashes
{"", "", true}, // empty
{"https://tailscale.com", "", true}, // not a path
}
for _, tt := range tests {
mp, err := cleanMountPoint(tt.mount)
if err != nil && tt.wantErr {
continue
}
if err != nil {
t.Fatal(err)
}
if mp != tt.want {
t.Fatalf("got %q, want %q", mp, tt.want)
}
}
}
func TestServeConfigMutations(t *testing.T) {
// Stateful mutations, starting from an empty config.
type step struct {
@ -32,25 +64,521 @@ func TestServeConfigMutations(t *testing.T) {
steps = append(steps, s)
}
// funnel
add(step{reset: true})
add(step{
command: cmd("funnel on"),
want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}},
})
add(step{
command: cmd("funnel on"),
want: nil, // nothing to save
})
add(step{
command: cmd("funnel off"),
want: &ipn.ServeConfig{},
})
add(step{
command: cmd("funnel off"),
want: nil, // nothing to save
})
add(step{
command: cmd("funnel"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
// https
add(step{reset: true})
add(step{
command: cmd("ingress on"),
want: &ipn.ServeConfig{AllowIngress: map[ipn.HostPort]bool{"foo:123": true}},
command: cmd("/ proxy 0"), // invalid port, too low
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy 65536"), // invalid port, too high
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy somehost"), // invalid host
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy http://otherhost"), // invalid host
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy 3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{
command: cmd("--serve-port=9999 /abc proxy 3001"),
wantErr: anyErr(),
}) // invalid port
add(step{
command: cmd("--serve-port=8443 /abc proxy 3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("--serve-port=10000 / text hi"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "hi"},
}},
},
},
})
add(step{
command: cmd("--remove /foo"),
want: nil, // nothing to save
wantErr: anyErr(),
}) // handler doesn't exist, so we get an error
add(step{
command: cmd("--remove --serve-port=10000 /"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("--remove /"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("--remove --serve-port=8443 /abc"),
want: &ipn.ServeConfig{},
})
add(step{
command: cmd("bar proxy https://127.0.0.1:8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "https://127.0.0.1:8443"},
}},
},
},
})
add(step{
command: cmd("ingress on"),
command: cmd("bar proxy https://127.0.0.1:8443"),
want: nil, // nothing to save
})
add(step{reset: true})
add(step{
command: cmd("ingress off"),
want: &ipn.ServeConfig{AllowIngress: map[ipn.HostPort]bool{}},
command: cmd("/ proxy https+insecure://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "https+insecure://127.0.0.1:3001"},
}},
},
},
})
add(step{reset: true})
add(step{
command: cmd("ingress off"),
command: cmd("/foo proxy localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // test a second handler on the same port
command: cmd("--serve-port=8443 /foo proxy localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
// tcp
add(step{reset: true})
add(step{
command: cmd("tcp 5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:5432"},
},
},
})
add(step{
command: cmd("tcp -terminate-tls 8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:8443",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("tcp -terminate-tls 8443"),
want: nil, // nothing to save
})
add(step{
command: cmd("ingress"),
command: cmd("tcp --terminate-tls 8444"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:8444",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("tcp -terminate-tls=false 8445"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:8445"},
},
},
})
add(step{reset: true})
add(step{
command: cmd("tcp 123"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:123"},
},
},
})
add(step{
command: cmd("--remove tcp 321"),
wantErr: anyErr(),
}) // handler doesn't exist, so we get an error
add(step{
command: cmd("--remove tcp 123"),
want: &ipn.ServeConfig{},
})
// text
add(step{reset: true})
add(step{
command: cmd("/ text hello"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "hello"},
}},
},
},
})
// path
td := t.TempDir()
writeFile := func(suffix, contents string) {
if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
add(step{reset: true})
writeFile("foo", "this is foo")
add(step{
command: cmd("/ path " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "foo")},
}},
},
},
})
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
writeFile("subdir/file-a", "this is A")
add(step{
command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "foo")},
"/some/where": {Path: filepath.Join(td, "subdir/file-a")},
}},
},
},
})
add(step{
command: cmd("/ path missing"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{reset: true})
add(step{
command: cmd("/ path " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
add(step{
command: cmd("--remove /"),
want: &ipn.ServeConfig{},
})
// combos
add(step{reset: true})
add(step{
command: cmd("/ proxy 3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{
command: cmd("funnel on"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // serving on secondary port doesn't change funnel
command: cmd("--serve-port=8443 /bar proxy 3001"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{ // turn funnel on for secondary port
command: cmd("--serve-port=8443 funnel on"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{ // turn funnel off for primary port 443
command: cmd("funnel off"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
"/bar": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{ // remove secondary port
command: cmd("--serve-port=8443 --remove /bar"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // start a tcp forwarder on 8443
command: cmd("--serve-port=8443 tcp 5432"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // remove primary port http handler
command: cmd("--remove /"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
},
})
add(step{ // remove tcp forwarder
command: cmd("--serve-port=8443 --remove tcp 5432"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
},
})
add(step{ // turn off funnel
command: cmd("--serve-port=8443 funnel off"),
want: &ipn.ServeConfig{},
})
// tricky steps
add(step{reset: true})
add(step{ // a directory with a trailing slash mount point
command: cmd("/dir path " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
add(step{ // this should overwrite the previous one
command: cmd("/dir path " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir": {Path: filepath.Join(td, "foo")},
}},
},
},
})
add(step{reset: true}) // reset and do the opposite
add(step{ // a file without a trailing slash mount point
command: cmd("/dir path " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir": {Path: filepath.Join(td, "foo")},
}},
},
},
})
add(step{ // this should overwrite the previous one
command: cmd("/dir path " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/dir/": {Path: filepath.Join(td, "subdir/")},
}},
},
},
})
// error states
add(step{reset: true})
add(step{ // make sure we can't add "tcp" as if it was a mount
command: cmd("tcp text foo"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{ // "/tcp" is fine though as a mount
command: cmd("/tcp text foo"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/tcp": {Text: "foo"},
}},
},
},
})
add(step{reset: true})
add(step{ // tcp forward 5432 on serve port 443
command: cmd("tcp 5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:5432"},
},
},
})
add(step{ // try to start a web handler on the same port
command: cmd("/ proxy 3000"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{reset: true})
add(step{ // start a web handler on port 443
command: cmd("/ proxy 3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // try to start a tcp forwarder on the same port
command: cmd("tcp 5432"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
@ -72,6 +600,14 @@ func TestServeConfigMutations(t *testing.T) {
e := &serveEnv{
testFlagOut: &flagOut,
testStdout: &stdout,
testGetLocalClientStatus: func(context.Context) (*ipnstate.Status, error) {
return &ipnstate.Status{
Self: &ipnstate.PeerStatus{
DNSName: "foo.test.ts.net",
Capabilities: []string{tailcfg.NodeAttrFunnel},
},
}, nil
},
testGetServeConfig: func(context.Context) (*ipn.ServeConfig, error) {
return current, nil
},
@ -100,6 +636,11 @@ func TestServeConfigMutations(t *testing.T) {
if !reflect.DeepEqual(newState, st.want) {
t.Fatalf("[%d] %v: bad state. got:\n%s\n\nwant:\n%s\n",
i, st.command, asJSON(newState), asJSON(st.want))
// NOTE: asJSON will omit empty fields, which might make
// result in bad state got/want diffs being the same, even
// though the actual state is different. Use below to debug:
// t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n",
// i, st.command, newState, st.want)
}
if newState != nil {
current = newState
@ -121,6 +662,15 @@ func exactErr(want error, optName ...string) func(error) string {
}
}
// anyErr returns an error checker that wants any error.
func anyErr() func(error) string {
return func(got error) string {
return ""
}
}
func cmd(s string) []string {
return strings.Fields(s)
cmds := strings.Fields(s)
fmt.Printf("cmd: %v", cmds)
return cmds
}

@ -76,10 +76,10 @@ func (src *ServeConfig) Clone() *ServeConfig {
dst.Web[k] = v.Clone()
}
}
if dst.AllowIngress != nil {
dst.AllowIngress = map[HostPort]bool{}
for k, v := range src.AllowIngress {
dst.AllowIngress[k] = v
if dst.AllowFunnel != nil {
dst.AllowFunnel = map[HostPort]bool{}
for k, v := range src.AllowFunnel {
dst.AllowFunnel[k] = v
}
}
return dst
@ -89,7 +89,7 @@ func (src *ServeConfig) Clone() *ServeConfig {
var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
AllowIngress map[HostPort]bool
AllowFunnel map[HostPort]bool
}{})
// Clone makes a deep copy of TCPPortHandler.

@ -176,15 +176,15 @@ func (v ServeConfigView) Web() views.MapFn[HostPort, *WebServerConfig, WebServer
})
}
func (v ServeConfigView) AllowIngress() views.Map[HostPort, bool] {
return views.MapOf(v.ж.AllowIngress)
func (v ServeConfigView) AllowFunnel() views.Map[HostPort, bool] {
return views.MapOf(v.ж.AllowFunnel)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
TCP map[uint16]*TCPPortHandler
Web map[HostPort]*WebServerConfig
AllowIngress map[HostPort]bool
AllowFunnel map[HostPort]bool
}{})
// View returns a readonly view of TCPPortHandler.

@ -2236,13 +2236,13 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) {
// optimization hint to know primarily which nodes are NOT using ingress, to
// avoid doing work for regular nodes.
//
// Even if the user's ServeConfig.AllowIngress map was manually edited in raw
// Even if the user's ServeConfig.AllowFunnel map was manually edited in raw
// mode and contains map entries with false values, sending true (from Len > 0)
// is still fine. This is only an optimization hint for the control plane and
// doesn't affect security or correctness. And we also don't expect people to
// modify their ServeConfig in raw mode.
func (b *LocalBackend) wantIngressLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.AllowIngress().Len() > 0
return b.serveConfig.Valid() && b.serveConfig.AllowFunnel().Len() > 0
}
// setPrefsLockedOnEntry requires b.mu be held to call it, but it

@ -234,7 +234,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ip
return
}
if !sc.AllowIngress().Get(target) {
if !sc.AllowFunnel().Get(target) {
b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
sendRST()
return

@ -108,9 +108,9 @@ type ServeConfig struct {
// keyed by mount point ("/", "/foo", etc)
Web map[HostPort]*WebServerConfig `json:",omitempty"`
// AllowIngress is the set of SNI:port values for which ingress
// AllowFunnel is the set of SNI:port values for which funnel
// traffic is allowed, from trusted ingress peers.
AllowIngress map[HostPort]bool `json:",omitempty"`
AllowFunnel map[HostPort]bool `json:",omitempty"`
}
// HostPort is an SNI name and port number, joined by a colon.
@ -119,7 +119,7 @@ type HostPort string
// WebServerConfig describes a web server's configuration.
type WebServerConfig struct {
Handlers map[string]*HTTPHandler
Handlers map[string]*HTTPHandler // mountPoint => handler
}
// TCPPortHandler describes what to do when handling a TCP

@ -1682,6 +1682,11 @@ const (
CapabilityIngress = "https://tailscale.com/cap/ingress"
)
const (
// NodeAttrFunnel grants the ability for a node to host ingress traffic.
NodeAttrFunnel = "funnel"
)
// SetDNSRequest is a request to add a DNS record.
//
// This is used for ACME DNS-01 challenges (so people can use

Loading…
Cancel
Save