Files
tailscale/ipn/serve_test.go
Simon Law 00699abdfb tailcfg,tailcfg/{nodecap,selfcap}: split capability constants to their own packages (#20639)
Package tailcfg defines the types and constants used by the Tailscale
protocol, but since everything is all in one package, it’s difficult
to sift through the docs: https://pkg.go.dev/tailscale.com/tailcfg

We define and enumerate capabilities as string constants for
tailcfg.NodeCapability and tailcfg.PeerCapability. This PR extracts
them into their own packages:

- tailcfg.CapabilityFileSharing becomes nodecap.FileSharing
- tailcfg.NodeAttrOnlyTCP443 becomes nodecap.OnlyTCP443
- tailcfg.PeerCapabilityTaildrive becomes peercap.Taildrive

We originally intended for CapabilityFoo to grant an entitlement or
permission for Foo, and for NodeAttrBar to configure Bar in the
nodeAttrs section of the policy file. However, there was no technical
enforcement of this convention, so new capabilities have used the
NodeAttr prefix regardless of meaning. Therefore, this PR unifies
tailcfg.CapabilityFoo and tailcfg.NodeAttrBar into a single package as
nodecap.Foo and nodecap.Bar.

Ran `go fix -inline ./...` and committed the changes that replaced
uses of the tailcfg aliases with the authoritative ones.

Updates #20259

Change-Id: Ieb7e7e6c8247c39faf42fdf15c68cdc7c621c730
Signed-off-by: Simon Law <sfllaw@tailscale.com>
2026-08-07 16:30:35 -07:00

568 lines
14 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
)
func TestCheckFunnelAccess(t *testing.T) {
caps := func(c ...nodecap.Cap) []nodecap.Cap { return c }
const portAttr nodecap.Cap = "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443,"
tests := []struct {
port uint16
caps []nodecap.Cap
wantErr bool
}{
{443, caps(portAttr), true}, // No "funnel" attribute
{443, caps(portAttr, nodecap.Funnel), true},
{443, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), false},
{8443, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), false},
{8321, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), true},
{8083, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), false},
{8091, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), true},
{3000, caps(portAttr, nodecap.HTTPS, nodecap.Funnel), true},
}
for _, tt := range tests {
cm := tailcfg.NodeCapMap{}
for _, c := range tt.caps {
cm[c] = nil
}
err := CheckFunnelAccess(tt.port, &ipnstate.PeerStatus{CapMap: cm})
switch {
case err != nil && tt.wantErr,
err == nil && !tt.wantErr:
continue
case tt.wantErr:
t.Fatalf("got no error, want error")
case !tt.wantErr:
t.Fatalf("got error %v, want no error", err)
}
}
}
func TestHasPathHandler(t *testing.T) {
tests := []struct {
name string
cfg ServeConfig
want bool
}{
{
name: "empty-config",
cfg: ServeConfig{},
want: false,
},
{
name: "with-bg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
want: true,
},
{
name: "with-fg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{
443: {HTTPS: true},
},
Foreground: map[string]*ServeConfig{
"abc123": {
TCP: map[uint16]*TCPPortHandler{80: {HTTP: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {Handlers: map[string]*HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
},
},
want: true,
},
{
name: "with-no-bg-path-handler",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
},
want: false,
},
{
name: "with-no-fg-path-handler",
cfg: ServeConfig{
Foreground: map[string]*ServeConfig{
"abc123": {
TCP: map[uint16]*TCPPortHandler{443: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
AllowFunnel: map[HostPort]bool{"foo.test.ts.net:443": true},
},
},
},
want: false,
},
{
name: "with-service-path-handler",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Path: "/tmp"},
}},
},
},
},
},
want: true,
},
{
name: "with-service-proxy-handler",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.HasPathHandler()
if tt.want != got {
t.Errorf("HasPathHandler() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsTCPForwardingOnPort(t *testing.T) {
tests := []struct {
name string
cfg ServeConfig
svcName tailcfg.ServiceName
port uint16
want bool
}{
{
name: "empty-config",
cfg: ServeConfig{},
svcName: "",
port: 80,
want: false,
},
{
name: "node-tcp-config-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "",
port: 80,
want: true,
},
{
name: "node-tcp-config-no-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "",
port: 443,
want: false,
},
{
name: "node-tcp-config-no-match-with-service",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
svcName: "svc:bar",
port: 80,
want: false,
},
{
name: "node-web-config-no-match",
cfg: ServeConfig{
TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Text: "Hello, world!"},
},
},
},
},
svcName: "",
port: 80,
want: false,
},
{
name: "service-tcp-config-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
},
},
svcName: "svc:foo",
port: 80,
want: true,
},
{
name: "service-tcp-config-no-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}},
},
},
},
svcName: "svc:bar",
port: 80,
want: false,
},
{
name: "service-web-config-no-match",
cfg: ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}},
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Text: "Hello, world!"},
},
},
},
},
},
},
svcName: "svc:foo",
port: 80,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.IsTCPForwardingOnPort(tt.port, tt.svcName)
if tt.want != got {
t.Errorf("IsTCPForwardingOnPort() = %v, want %v", got, tt.want)
}
})
}
}
func TestExpandProxyTargetDev(t *testing.T) {
tests := []struct {
name string
input string
defaultScheme string
supportedSchemes []string
expected string
wantErr bool
}{
{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
{name: "hostname-and-port", input: "localhost:8080", expected: "http://localhost:8080"},
{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
{name: "https-scheme", input: "https://localhost:8080", expected: "https://localhost:8080"},
{name: "https-insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://localhost:8080"},
{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://localhost:8080"},
{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://localhost:8080"},
{name: "remote-target", input: "https://example.com:8080", expected: "https://example.com:8080"},
{name: "remote-IP-target", input: "http://120.133.20.2:8080", expected: "http://120.133.20.2:8080"},
{name: "remote-target-no-port", input: "https://example.com", expected: "https://example.com"},
// errors
{name: "invalid-port", input: "localhost:9999999", wantErr: true},
{name: "invalid-hostname", input: "192.168.1:8080", wantErr: true},
{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
{name: "empty-input", input: "", expected: "", wantErr: true},
{name: "localhost-no-port", input: "localhost", expected: "", wantErr: true},
}
for _, tt := range tests {
defaultScheme := "http"
supportedSchemes := []string{"http", "https", "https+insecure"}
if tt.supportedSchemes != nil {
supportedSchemes = tt.supportedSchemes
}
if tt.defaultScheme != "" {
defaultScheme = tt.defaultScheme
}
t.Run(tt.name, func(t *testing.T) {
actual, err := ExpandProxyTargetValue(tt.input, supportedSchemes, defaultScheme)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}
func TestIsFunnelOn(t *testing.T) {
tests := []struct {
name string
sc *ServeConfig
want bool
}{
{
name: "nil_config",
},
{
name: "empty_config",
sc: &ServeConfig{},
},
{
name: "funnel_enabled_in_background",
sc: &ServeConfig{
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:443": true,
},
},
want: true,
},
{
name: "funnel_disabled_in_background",
sc: &ServeConfig{
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:443": false,
},
},
},
{
name: "funnel_enabled_in_foreground",
sc: &ServeConfig{
Foreground: map[string]*ServeConfig{
"abc123": {
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:443": true,
},
},
},
},
want: true,
},
{
name: "funnel_disabled_in_both",
sc: &ServeConfig{
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:443": false,
},
Foreground: map[string]*ServeConfig{
"abc123": {
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:8443": false,
},
},
},
},
},
{
name: "funnel_enabled_in_both",
sc: &ServeConfig{
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:443": true,
},
Foreground: map[string]*ServeConfig{
"abc123": {
AllowFunnel: map[HostPort]bool{
"tailnet.xyz:8443": true,
},
},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.sc.IsFunnelOn(); got != tt.want {
t.Errorf("ServeConfig.IsFunnelOn() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsServingUnixAny(t *testing.T) {
tests := []struct {
name string
sc *ServeConfig
want bool
}{
{
name: "empty",
sc: &ServeConfig{},
want: false,
},
{
name: "nil",
sc: &ServeConfig{},
want: false,
},
{
name: "no_unix",
sc: &ServeConfig{
TCP: map[uint16]*TCPPortHandler{
80: {TCPForward: "localhost:8080"},
},
},
want: false,
},
{
name: "tcp_to_unix",
sc: &ServeConfig{
TCP: map[uint16]*TCPPortHandler{
80: {TCPForward: "unix:/var/run/foo.sock"},
},
},
want: true,
},
{
name: "web_to_unix",
sc: &ServeConfig{
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Proxy: "unix:/var/run/foo.sock"},
},
},
},
},
want: true,
},
{
name: "foreground_tcp_to_unix",
sc: &ServeConfig{
Foreground: map[string]*ServeConfig{
"abc": {
TCP: map[uint16]*TCPPortHandler{
80: {TCPForward: "unix:/var/run/foo.sock"},
},
},
},
},
want: true,
},
{
name: "foreground_web_to_unix",
sc: &ServeConfig{
Foreground: map[string]*ServeConfig{
"abc": {
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Proxy: "unix:/var/run/foo.sock"},
},
},
},
},
},
},
want: true,
},
{
name: "services_tcp_to_unix",
sc: &ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
TCP: map[uint16]*TCPPortHandler{
80: {TCPForward: "unix:/var/run/foo.sock"},
},
},
},
},
want: true,
},
{
name: "services_web_to_unix",
sc: &ServeConfig{
Services: map[tailcfg.ServiceName]*ServiceConfig{
"svc:foo": {
Web: map[HostPort]*WebServerConfig{
"foo.test.ts.net:80": {
Handlers: map[string]*HTTPHandler{
"/": {Proxy: "unix:/var/run/foo.sock"},
},
},
},
},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.sc.IsServingUnixAny(); got != tt.want {
t.Errorf("ServeConfig.IsServingUnixAny() = %v, want %v", got, tt.want)
}
})
}
}
// The structural checks below guard against new vulnerabilities in serving Unix
// socket targets. If [ServeConfig] or [ServiceConfig] is updated, these checks
// will fail, forcing you to consider updating [ServeConfig.IsServingUnixAny].
//
// If your updates have created a new way to serve Unix sockets, please update
// IsServingUnixAny accordingly. Otherwise, you can just add your new field
// below and move on.
//
// IsServingUnixAny helps avoid a class of vulnerabilities in which tailscaled
// (by means of tailscale serve) gives non-root users access to Unix sockets
// they otherwise would not have access to (e.g. /var/run/docker.sock). As of
// 2026-06-03, serving Unix sockets at all requires root permissions, and
// IsServingUnixAny is how tailscaled knows when to enforce this restriction.
//
// See https://github.com/tailscale/corp/issues/41998
var _ ServeConfig = struct {
TCP map[uint16]*TCPPortHandler `json:",omitempty"`
Web map[HostPort]*WebServerConfig `json:",omitempty"`
Services map[tailcfg.ServiceName]*ServiceConfig `json:",omitempty"`
AllowFunnel map[HostPort]bool `json:",omitempty"`
Foreground map[string]*ServeConfig `json:",omitempty"`
ETag string `json:"-"`
}{}
var _ ServiceConfig = struct {
TCP map[uint16]*TCPPortHandler `json:",omitempty"`
Web map[HostPort]*WebServerConfig `json:",omitempty"`
Tun bool `json:",omitempty"`
}{}