ipn/ipnlocal/serve: error when PeerCaps serialisation fails

Also consolidates variable and header naming and amends the
CLI behavior
* multiple app-caps have to be specified as comma-separated
  list
* simple regex-based validation of app capability names is
  carried out during flag parsing

Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
This commit is contained in:
Gesa Stupperich
2025-10-22 09:41:19 +01:00
committed by Gesa Stupperich
parent d6fa899eba
commit d2e4a20f26
3 changed files with 150 additions and 21 deletions
+16 -2
View File
@@ -20,6 +20,7 @@ import (
"os/signal"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
@@ -100,12 +101,25 @@ type acceptAppCapsFlag struct {
Value *[]tailcfg.PeerCapability
}
// An application capability name has the form {domain}/{name}.
// Both parts must use the (simplified) FQDN label character set.
// The "name" can contain forward slashes.
// \pL = Unicode Letter, \pN = Unicode Number, - = Hyphen
var validAppCap = regexp.MustCompile(`^([\pL\pN-]+\.)+[\pL\pN-]+\/[\pL\pN-/]+$`)
// Set appends s to the list of appCaps to accept.
func (u *acceptAppCapsFlag) Set(s string) error {
if s == "" {
return nil
}
*u.Value = append(*u.Value, tailcfg.PeerCapability(s))
appCaps := strings.Split(s, ",")
for _, appCap := range appCaps {
appCap = strings.TrimSpace(appCap)
if !validAppCap.MatchString(appCap) {
return fmt.Errorf("%q does not match the form {domain}/{name}, where domain must be a fully qualified domain name", s)
}
*u.Value = append(*u.Value, tailcfg.PeerCapability(appCap))
}
return nil
}
@@ -221,7 +235,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
if subcmd == serve {
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
fs.Var(&acceptAppCapsFlag{Value: &e.acceptAppCaps}, "accept-app-caps", "App capability to forward to the server (can be specified multiple times)")
fs.Var(&acceptAppCapsFlag{Value: &e.acceptAppCaps}, "accept-app-caps", "App capabilities to forward to the server (specify multiple capabilities with a comma-separated list)")
}
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
+112 -1
View File
@@ -875,7 +875,7 @@ func TestServeDevConfigMutations(t *testing.T) {
},
},
{
command: cmd("serve --bg --accept-app-caps=example.com/cap/foo --accept-app-caps=example.com/cap/bar 3000"),
command: cmd("serve --bg --accept-app-caps=example.com/cap/foo,example.com/cap/bar 3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@@ -904,6 +904,15 @@ func TestServeDevConfigMutations(t *testing.T) {
},
},
},
{
name: "invalid_accept_caps_invalid_app_cap",
steps: []step{
{
command: cmd("serve --bg --accept-app-caps=example/cap/foo 3000"), // should be {domain.tld}/{name}
wantErr: anyErr(),
},
},
},
}
for _, group := range groups {
@@ -1220,6 +1229,108 @@ func TestSrcTypeFromFlags(t *testing.T) {
}
}
func TestAcceptSetAppCapsFlag(t *testing.T) {
testCases := []struct {
name string
inputs []string
expectErr bool
expectedValue []tailcfg.PeerCapability
}{
{
name: "valid_simple",
inputs: []string{"example.com/name"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"example.com/name"},
},
{
name: "valid_unicode",
inputs: []string{"bücher.de/something"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"bücher.de/something"},
},
{
name: "more_valid_unicode",
inputs: []string{"example.tw/某某某"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"example.tw/某某某"},
},
{
name: "valid_path_slashes",
inputs: []string{"domain.com/path/to/name"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"domain.com/path/to/name"},
},
{
name: "valid_multiple_sets",
inputs: []string{"one.com/foo", "two.com/bar"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"one.com/foo", "two.com/bar"},
},
{
name: "valid_empty_string",
inputs: []string{""},
expectErr: false,
expectedValue: nil, // Empty string should be a no-op and not append anything.
},
{
name: "invalid_path_chars",
inputs: []string{"domain.com/path_with_underscore"},
expectErr: true,
expectedValue: nil, // Slice should remain empty.
},
{
name: "valid_subdomain",
inputs: []string{"sub.domain.com/name"},
expectErr: false,
expectedValue: []tailcfg.PeerCapability{"sub.domain.com/name"},
},
{
name: "invalid_no_path",
inputs: []string{"domain.com/"},
expectErr: true,
expectedValue: nil,
},
{
name: "invalid_no_domain",
inputs: []string{"/path/only"},
expectErr: true,
expectedValue: nil,
},
{
name: "some_invalid_some_valid",
inputs: []string{"one.com/foo", "bad/bar", "two.com/baz"},
expectErr: true,
expectedValue: []tailcfg.PeerCapability{"one.com/foo"}, // Parsing will stop after first error
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var v []tailcfg.PeerCapability
flag := &acceptAppCapsFlag{Value: &v}
var err error
for _, s := range tc.inputs {
err = flag.Set(s)
if err != nil {
break
}
}
if tc.expectErr && err == nil {
t.Errorf("expected an error, but got none")
}
if !tc.expectErr && err != nil {
t.Errorf("did not expect an error, but got: %v", err)
}
if !reflect.DeepEqual(tc.expectedValue, v) {
t.Errorf("unexpected value, got: %q, want: %q", v, tc.expectedValue)
}
})
}
}
func TestCleanURLPath(t *testing.T) {
tests := []struct {
input string