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>
pull/17999/head
KevinLiang10 2 weeks ago committed by GitHub
parent 12c598de28
commit a0d059d74c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1401,6 +1401,23 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
if err != nil {
return false, err
}
var res struct {
UseSOMark bool `json:"useSoMark"`
}
if err := json.Unmarshal(body, &res); err != nil {
return false, fmt.Errorf("invalid JSON from check-so-mark-in-use: %w", err)
}
return res.UseSOMark, nil
}
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)

@ -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

@ -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 {

@ -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)
}

@ -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"),

@ -35,6 +35,7 @@ import (
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/logtail"
"tailscale.com/net/netns"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
@ -72,20 +73,21 @@ var handler = map[string]LocalAPIHandler{
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash:
"check-prefs": (*Handler).serveCheckPrefs,
"derpmap": (*Handler).serveDERPMap,
"goroutines": (*Handler).serveGoroutines,
"login-interactive": (*Handler).serveLoginInteractive,
"logout": (*Handler).serveLogout,
"ping": (*Handler).servePing,
"prefs": (*Handler).servePrefs,
"reload-config": (*Handler).reloadConfig,
"reset-auth": (*Handler).serveResetAuth,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"shutdown": (*Handler).serveShutdown,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"whois": (*Handler).serveWhoIs,
"check-prefs": (*Handler).serveCheckPrefs,
"check-so-mark-in-use": (*Handler).serveCheckSOMarkInUse,
"derpmap": (*Handler).serveDERPMap,
"goroutines": (*Handler).serveGoroutines,
"login-interactive": (*Handler).serveLoginInteractive,
"logout": (*Handler).serveLogout,
"ping": (*Handler).servePing,
"prefs": (*Handler).servePrefs,
"reload-config": (*Handler).reloadConfig,
"reset-auth": (*Handler).serveResetAuth,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"shutdown": (*Handler).serveShutdown,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"whois": (*Handler).serveWhoIs,
}
func init() {
@ -760,6 +762,23 @@ func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request)
})
}
// serveCheckSOMarkInUse reports whether SO_MARK is in use on the linux while
// running without TUN. For any other OS, it reports false.
func (h *Handler) serveCheckSOMarkInUse(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "SO_MARK check access denied", http.StatusForbidden)
return
}
usingSOMark := netns.UseSocketMark()
usingUserspaceNetworking := h.b.Sys().IsNetstack()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
UseSOMark bool
}{
UseSOMark: usingSOMark || usingUserspaceNetworking,
})
}
func (h *Handler) serveCheckReversePathFiltering(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "reverse path filtering check access denied", http.StatusForbidden)

@ -17,6 +17,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/ipproto"
"tailscale.com/util/dnsname"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
@ -673,7 +674,8 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
// ExpandProxyTargetValue expands the supported target values to be proxied
// allowing for input values to be a port number, a partial URL, or a full URL
// including a path.
// including a path. If it's for a service, remote addresses are allowed and
// there doesn't have to be a port specified.
//
// examples:
// - 3000
@ -683,17 +685,25 @@ func CheckFunnelPort(wantedPort uint16, node *ipnstate.PeerStatus) error {
// - https://localhost:3000
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
// - https://tailscale.com
func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultScheme string) (string, error) {
const host = "127.0.0.1"
// empty target is invalid
if target == "" {
return "", fmt.Errorf("empty target")
}
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
}
hasScheme := true
// prepend scheme if not present
if !strings.Contains(target, "://") {
target = defaultScheme + "://" + target
hasScheme = false
}
// make sure we can parse the target
@ -707,16 +717,28 @@ func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultSch
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
// validate port according to host.
if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" {
// require port for localhost targets
if u.Port() == "" {
return "", fmt.Errorf("port required for localhost target %q", target)
}
} else {
validHN := dnsname.ValidHostname(u.Hostname()) == nil
validIP := net.ParseIP(u.Hostname()) != nil
if !validHN && !validIP {
return "", fmt.Errorf("invalid hostname or IP address %q", u.Hostname())
}
// require scheme for non-localhost targets
if !hasScheme {
return "", fmt.Errorf("non-localhost target %q must include a scheme", target)
}
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
if u.Port() == "" {
return u.String(), nil // allow no port for remote destinations
}
return "", fmt.Errorf("invalid port %q", u.Port())
}

@ -260,12 +260,16 @@ func TestExpandProxyTargetDev(t *testing.T) {
{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: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true},
{name: "empty-input", input: "", expected: "", wantErr: true},
{name: "localhost-no-port", input: "localhost", expected: "", wantErr: true},
}
for _, tt := range tests {

@ -20,3 +20,7 @@ func control(logger.Logf, *netmon.Monitor) func(network, address string, c sysca
func controlC(network, address string, c syscall.RawConn) error {
return nil
}
func UseSocketMark() bool {
return false
}

@ -25,3 +25,7 @@ func parseAddress(address string) (addr netip.Addr, err error) {
return netip.ParseAddr(host)
}
func UseSocketMark() bool {
return false
}

Loading…
Cancel
Save