cmd/tailscale/cli: allow remote target as service destination (#17607)

This commit enables user to set service backend to remote destinations, that can be a partial
URL or a full URL. The commit also prevents user to set remote destinations on linux system
when socket mark is not working. For user on any version of mac extension they can't serve a
service either. The socket mark usability is determined by a new local api.

Fixes tailscale/corp#24783

Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com>
This commit is contained in:
KevinLiang10
2025-11-19 12:29:08 -05:00
committed by GitHub
parent 12c598de28
commit a0d059d74c
10 changed files with 221 additions and 44 deletions
+1
View File
@@ -149,6 +149,7 @@ type localServeClient interface {
IncrementCounter(ctx context.Context, name string, delta int) error
GetPrefs(ctx context.Context) (*ipn.Prefs, error)
EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
CheckSOMarkInUse(ctx context.Context) (bool, error)
}
// serveEnv is the environment the serve command runs within. All I/O should be
+5
View File
@@ -860,6 +860,7 @@ type fakeLocalServeClient struct {
setCount int // counts calls to SetServeConfig
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
prefs *ipn.Prefs // fake preferences, used to test GetPrefs and SetPrefs
SOMarkInUse bool // fake SO mark in use status
statusWithoutPeers *ipnstate.Status // nil for fakeStatus
}
@@ -937,6 +938,10 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin
return nil // unused in tests
}
func (lc *fakeLocalServeClient) CheckSOMarkInUse(ctx context.Context) (bool, error) {
return lc.SOMarkInUse, 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 {
+89 -11
View File
@@ -21,6 +21,7 @@ import (
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"sort"
"strconv"
@@ -33,6 +34,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/ipproto"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/prompt"
"tailscale.com/util/set"
@@ -516,6 +518,9 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
if len(args) > 0 {
target = args[0]
}
if err := e.shouldWarnRemoteDestCompatibility(ctx, target); err != nil {
return err
}
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.acceptAppCaps, int(e.proxyProtocol))
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
}
@@ -999,16 +1004,17 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveTy
}
var (
msgFunnelAvailable = "Available on the internet:"
msgServeAvailable = "Available within your tailnet:"
msgServiceWaitingApproval = "This machine is configured as a service proxy for %s, but approval from an admin is required. Once approved, it will be available in your Tailnet as:"
msgRunningInBackground = "%s started and running in the background."
msgRunningTunService = "IPv4 and IPv6 traffic to %s is being routed to your operating system."
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
msgDisableServiceProxy = "To disable the proxy, run: tailscale serve --service=%s --%s=%d off"
msgDisableServiceTun = "To disable the service in TUN mode, run: tailscale serve --service=%s --tun off"
msgDisableService = "To remove config for the service, run: tailscale serve clear %s"
msgToExit = "Press Ctrl+C to exit."
msgFunnelAvailable = "Available on the internet:"
msgServeAvailable = "Available within your tailnet:"
msgServiceWaitingApproval = "This machine is configured as a service proxy for %s, but approval from an admin is required. Once approved, it will be available in your Tailnet as:"
msgRunningInBackground = "%s started and running in the background."
msgRunningTunService = "IPv4 and IPv6 traffic to %s is being routed to your operating system."
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
msgDisableServiceProxy = "To disable the proxy, run: tailscale serve --service=%s --%s=%d off"
msgDisableServiceTun = "To disable the service in TUN mode, run: tailscale serve --service=%s --tun off"
msgDisableService = "To remove config for the service, run: tailscale serve clear %s"
msgWarnRemoteDestCompatibility = "Warning: %s doesn't support connecting to remote destinations from non-default route, see tailscale.com/kb/1552/tailscale-services for detail."
msgToExit = "Press Ctrl+C to exit."
)
// messageForPort returns a message for the given port based on the
@@ -1134,6 +1140,77 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
return output.String()
}
// isRemote reports whether the given destination from serve config
// is a remote destination.
func isRemote(target string) bool {
// target being a port number means it's localhost
if _, err := strconv.ParseUint(target, 10, 16); err == nil {
return false
}
// prepend tmp:// if no scheme is present just to help parsing
if !strings.Contains(target, "://") {
target = "tmp://" + target
}
// make sure we can parse the target, wether it's a full URL or just a host:port
u, err := url.ParseRequestURI(target)
if err != nil {
// If we can't parse the target, it doesn't matter if it's remote or not
return false
}
validHN := dnsname.ValidHostname(u.Hostname()) == nil
validIP := net.ParseIP(u.Hostname()) != nil
if !validHN && !validIP {
return false
}
if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" {
return false
}
return true
}
// shouldWarnRemoteDestCompatibility reports whether we should warn the user
// that their current OS/environment may not be compatible with
// service's proxy destination.
func (e *serveEnv) shouldWarnRemoteDestCompatibility(ctx context.Context, target string) error {
// no target means nothing to check
if target == "" {
return nil
}
if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") {
// local path or text target, nothing to check
return nil
}
// only check for remote destinations
if !isRemote(target) {
return nil
}
// Check if running as Mac extension and warn
if version.IsMacAppStore() || version.IsMacSysExt() {
return fmt.Errorf(msgWarnRemoteDestCompatibility, "the MacOS extension")
}
// Check for linux, if it's running with TS_FORCE_LINUX_BIND_TO_DEVICE=true
// and tailscale bypass mark is not working. If any of these conditions are true, and the dest is
// a remote destination, return true.
if runtime.GOOS == "linux" {
SOMarkInUse, err := e.lc.CheckSOMarkInUse(ctx)
if err != nil {
log.Printf("error checking SO mark in use: %v", err)
return nil
}
if !SOMarkInUse {
return fmt.Errorf(msgWarnRemoteDestCompatibility, "the Linux tailscaled without SO_MARK")
}
}
return nil
}
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target, mds string, caps []tailcfg.PeerCapability) error {
h := new(ipn.HTTPHandler)
switch {
@@ -1193,6 +1270,8 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
return fmt.Errorf("invalid TCP target %q", target)
}
svcName := tailcfg.AsServiceName(dnsName)
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp")
if err != nil {
return fmt.Errorf("unable to expand target: %v", err)
@@ -1204,7 +1283,6 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
}
// TODO: needs to account for multiple configs from foreground mode
svcName := tailcfg.AsServiceName(dnsName)
if sc.IsServingWeb(srcPort, svcName) {
return fmt.Errorf("cannot serve TCP; already serving web on %d for %s", srcPort, dnsName)
}
+33 -10
View File
@@ -220,10 +220,20 @@ func TestServeDevConfigMutations(t *testing.T) {
}},
},
{
name: "invalid_host",
name: "ip_host",
initialState: fakeLocalServeClient{
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host
wantErr: anyErr(),
command: cmd("serve --https=443 --bg http://192.168.1.1: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://192.168.1.1:3000"},
}},
},
},
}},
},
{
@@ -233,6 +243,16 @@ func TestServeDevConfigMutations(t *testing.T) {
wantErr: anyErr(),
}},
},
{
name: "no_scheme_remote_host_tcp",
initialState: fakeLocalServeClient{
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --https=443 --bg 192.168.1.1:3000"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "turn_off_https",
steps: []step{
@@ -402,15 +422,11 @@ func TestServeDevConfigMutations(t *testing.T) {
},
}},
},
{
name: "unknown_host_tcp",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "tcp_port_too_low",
initialState: fakeLocalServeClient{
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"),
wantErr: exactErrMsg(errHelp),
@@ -418,6 +434,9 @@ func TestServeDevConfigMutations(t *testing.T) {
},
{
name: "tcp_port_too_high",
initialState: fakeLocalServeClient{
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
wantErr: exactErrMsg(errHelp),
@@ -532,6 +551,9 @@ func TestServeDevConfigMutations(t *testing.T) {
},
{
name: "bad_path",
initialState: fakeLocalServeClient{
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --bg --https=443 bad/path"),
wantErr: exactErrMsg(errHelp),
@@ -832,6 +854,7 @@ func TestServeDevConfigMutations(t *testing.T) {
},
CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"},
},
SOMarkInUse: true,
},
steps: []step{{
command: cmd("serve --service=svc:foo --http=80 text:foo"),