Skip to content

Commit a0d059d

Browse files
authored
cmd/tailscale/cli: allow remote target as service destination (tailscale#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 <[email protected]>
1 parent 12c598d commit a0d059d

File tree

10 files changed

+221
-44
lines changed

10 files changed

+221
-44
lines changed

client/local/local.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,6 +1401,23 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
14011401
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
14021402
}
14031403

1404+
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
1405+
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
1406+
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
1407+
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
1408+
if err != nil {
1409+
return false, err
1410+
}
1411+
var res struct {
1412+
UseSOMark bool `json:"useSoMark"`
1413+
}
1414+
1415+
if err := json.Unmarshal(body, &res); err != nil {
1416+
return false, fmt.Errorf("invalid JSON from check-so-mark-in-use: %w", err)
1417+
}
1418+
return res.UseSOMark, nil
1419+
}
1420+
14041421
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
14051422
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
14061423
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)

cmd/tailscale/cli/serve_legacy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ type localServeClient interface {
149149
IncrementCounter(ctx context.Context, name string, delta int) error
150150
GetPrefs(ctx context.Context) (*ipn.Prefs, error)
151151
EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
152+
CheckSOMarkInUse(ctx context.Context) (bool, error)
152153
}
153154

154155
// serveEnv is the environment the serve command runs within. All I/O should be

cmd/tailscale/cli/serve_legacy_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,7 @@ type fakeLocalServeClient struct {
860860
setCount int // counts calls to SetServeConfig
861861
queryFeatureResponse *mockQueryFeatureResponse // mock response to QueryFeature calls
862862
prefs *ipn.Prefs // fake preferences, used to test GetPrefs and SetPrefs
863+
SOMarkInUse bool // fake SO mark in use status
863864
statusWithoutPeers *ipnstate.Status // nil for fakeStatus
864865
}
865866

@@ -937,6 +938,10 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin
937938
return nil // unused in tests
938939
}
939940

941+
func (lc *fakeLocalServeClient) CheckSOMarkInUse(ctx context.Context) (bool, error) {
942+
return lc.SOMarkInUse, nil
943+
}
944+
940945
// exactError returns an error checker that wants exactly the provided want error.
941946
// If optName is non-empty, it's used in the error message.
942947
func exactErr(want error, optName ...string) func(error) string {

cmd/tailscale/cli/serve_v2.go

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"path"
2222
"path/filepath"
2323
"regexp"
24+
"runtime"
2425
"slices"
2526
"sort"
2627
"strconv"
@@ -33,6 +34,7 @@ import (
3334
"tailscale.com/ipn/ipnstate"
3435
"tailscale.com/tailcfg"
3536
"tailscale.com/types/ipproto"
37+
"tailscale.com/util/dnsname"
3638
"tailscale.com/util/mak"
3739
"tailscale.com/util/prompt"
3840
"tailscale.com/util/set"
@@ -516,6 +518,9 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
516518
if len(args) > 0 {
517519
target = args[0]
518520
}
521+
if err := e.shouldWarnRemoteDestCompatibility(ctx, target); err != nil {
522+
return err
523+
}
519524
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.acceptAppCaps, int(e.proxyProtocol))
520525
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
521526
}
@@ -999,16 +1004,17 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveTy
9991004
}
10001005

10011006
var (
1002-
msgFunnelAvailable = "Available on the internet:"
1003-
msgServeAvailable = "Available within your tailnet:"
1004-
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:"
1005-
msgRunningInBackground = "%s started and running in the background."
1006-
msgRunningTunService = "IPv4 and IPv6 traffic to %s is being routed to your operating system."
1007-
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
1008-
msgDisableServiceProxy = "To disable the proxy, run: tailscale serve --service=%s --%s=%d off"
1009-
msgDisableServiceTun = "To disable the service in TUN mode, run: tailscale serve --service=%s --tun off"
1010-
msgDisableService = "To remove config for the service, run: tailscale serve clear %s"
1011-
msgToExit = "Press Ctrl+C to exit."
1007+
msgFunnelAvailable = "Available on the internet:"
1008+
msgServeAvailable = "Available within your tailnet:"
1009+
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:"
1010+
msgRunningInBackground = "%s started and running in the background."
1011+
msgRunningTunService = "IPv4 and IPv6 traffic to %s is being routed to your operating system."
1012+
msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off"
1013+
msgDisableServiceProxy = "To disable the proxy, run: tailscale serve --service=%s --%s=%d off"
1014+
msgDisableServiceTun = "To disable the service in TUN mode, run: tailscale serve --service=%s --tun off"
1015+
msgDisableService = "To remove config for the service, run: tailscale serve clear %s"
1016+
msgWarnRemoteDestCompatibility = "Warning: %s doesn't support connecting to remote destinations from non-default route, see tailscale.com/kb/1552/tailscale-services for detail."
1017+
msgToExit = "Press Ctrl+C to exit."
10121018
)
10131019

10141020
// 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
11341140
return output.String()
11351141
}
11361142

1143+
// isRemote reports whether the given destination from serve config
1144+
// is a remote destination.
1145+
func isRemote(target string) bool {
1146+
// target being a port number means it's localhost
1147+
if _, err := strconv.ParseUint(target, 10, 16); err == nil {
1148+
return false
1149+
}
1150+
1151+
// prepend tmp:// if no scheme is present just to help parsing
1152+
if !strings.Contains(target, "://") {
1153+
target = "tmp://" + target
1154+
}
1155+
1156+
// make sure we can parse the target, wether it's a full URL or just a host:port
1157+
u, err := url.ParseRequestURI(target)
1158+
if err != nil {
1159+
// If we can't parse the target, it doesn't matter if it's remote or not
1160+
return false
1161+
}
1162+
validHN := dnsname.ValidHostname(u.Hostname()) == nil
1163+
validIP := net.ParseIP(u.Hostname()) != nil
1164+
if !validHN && !validIP {
1165+
return false
1166+
}
1167+
if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" {
1168+
return false
1169+
}
1170+
return true
1171+
}
1172+
1173+
// shouldWarnRemoteDestCompatibility reports whether we should warn the user
1174+
// that their current OS/environment may not be compatible with
1175+
// service's proxy destination.
1176+
func (e *serveEnv) shouldWarnRemoteDestCompatibility(ctx context.Context, target string) error {
1177+
// no target means nothing to check
1178+
if target == "" {
1179+
return nil
1180+
}
1181+
1182+
if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") {
1183+
// local path or text target, nothing to check
1184+
return nil
1185+
}
1186+
1187+
// only check for remote destinations
1188+
if !isRemote(target) {
1189+
return nil
1190+
}
1191+
1192+
// Check if running as Mac extension and warn
1193+
if version.IsMacAppStore() || version.IsMacSysExt() {
1194+
return fmt.Errorf(msgWarnRemoteDestCompatibility, "the MacOS extension")
1195+
}
1196+
1197+
// Check for linux, if it's running with TS_FORCE_LINUX_BIND_TO_DEVICE=true
1198+
// and tailscale bypass mark is not working. If any of these conditions are true, and the dest is
1199+
// a remote destination, return true.
1200+
if runtime.GOOS == "linux" {
1201+
SOMarkInUse, err := e.lc.CheckSOMarkInUse(ctx)
1202+
if err != nil {
1203+
log.Printf("error checking SO mark in use: %v", err)
1204+
return nil
1205+
}
1206+
if !SOMarkInUse {
1207+
return fmt.Errorf(msgWarnRemoteDestCompatibility, "the Linux tailscaled without SO_MARK")
1208+
}
1209+
}
1210+
1211+
return nil
1212+
}
1213+
11371214
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target, mds string, caps []tailcfg.PeerCapability) error {
11381215
h := new(ipn.HTTPHandler)
11391216
switch {
@@ -1193,6 +1270,8 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
11931270
return fmt.Errorf("invalid TCP target %q", target)
11941271
}
11951272

1273+
svcName := tailcfg.AsServiceName(dnsName)
1274+
11961275
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp")
11971276
if err != nil {
11981277
return fmt.Errorf("unable to expand target: %v", err)
@@ -1204,7 +1283,6 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
12041283
}
12051284

12061285
// TODO: needs to account for multiple configs from foreground mode
1207-
svcName := tailcfg.AsServiceName(dnsName)
12081286
if sc.IsServingWeb(srcPort, svcName) {
12091287
return fmt.Errorf("cannot serve TCP; already serving web on %d for %s", srcPort, dnsName)
12101288
}

cmd/tailscale/cli/serve_v2_test.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,20 @@ func TestServeDevConfigMutations(t *testing.T) {
220220
}},
221221
},
222222
{
223-
name: "invalid_host",
223+
name: "ip_host",
224+
initialState: fakeLocalServeClient{
225+
SOMarkInUse: true,
226+
},
224227
steps: []step{{
225-
command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host
226-
wantErr: anyErr(),
228+
command: cmd("serve --https=443 --bg http://192.168.1.1:3000"),
229+
want: &ipn.ServeConfig{
230+
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
231+
Web: map[ipn.HostPort]*ipn.WebServerConfig{
232+
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
233+
"/": {Proxy: "http://192.168.1.1:3000"},
234+
}},
235+
},
236+
},
227237
}},
228238
},
229239
{
@@ -233,6 +243,16 @@ func TestServeDevConfigMutations(t *testing.T) {
233243
wantErr: anyErr(),
234244
}},
235245
},
246+
{
247+
name: "no_scheme_remote_host_tcp",
248+
initialState: fakeLocalServeClient{
249+
SOMarkInUse: true,
250+
},
251+
steps: []step{{
252+
command: cmd("serve --https=443 --bg 192.168.1.1:3000"),
253+
wantErr: exactErrMsg(errHelp),
254+
}},
255+
},
236256
{
237257
name: "turn_off_https",
238258
steps: []step{
@@ -402,22 +422,21 @@ func TestServeDevConfigMutations(t *testing.T) {
402422
},
403423
}},
404424
},
405-
{
406-
name: "unknown_host_tcp",
407-
steps: []step{{
408-
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"),
409-
wantErr: exactErrMsg(errHelp),
410-
}},
411-
},
412425
{
413426
name: "tcp_port_too_low",
427+
initialState: fakeLocalServeClient{
428+
SOMarkInUse: true,
429+
},
414430
steps: []step{{
415431
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"),
416432
wantErr: exactErrMsg(errHelp),
417433
}},
418434
},
419435
{
420436
name: "tcp_port_too_high",
437+
initialState: fakeLocalServeClient{
438+
SOMarkInUse: true,
439+
},
421440
steps: []step{{
422441
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
423442
wantErr: exactErrMsg(errHelp),
@@ -532,6 +551,9 @@ func TestServeDevConfigMutations(t *testing.T) {
532551
},
533552
{
534553
name: "bad_path",
554+
initialState: fakeLocalServeClient{
555+
SOMarkInUse: true,
556+
},
535557
steps: []step{{
536558
command: cmd("serve --bg --https=443 bad/path"),
537559
wantErr: exactErrMsg(errHelp),
@@ -832,6 +854,7 @@ func TestServeDevConfigMutations(t *testing.T) {
832854
},
833855
CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"},
834856
},
857+
SOMarkInUse: true,
835858
},
836859
steps: []step{{
837860
command: cmd("serve --service=svc:foo --http=80 text:foo"),

ipn/localapi/localapi.go

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"tailscale.com/ipn/ipnlocal"
3636
"tailscale.com/ipn/ipnstate"
3737
"tailscale.com/logtail"
38+
"tailscale.com/net/netns"
3839
"tailscale.com/net/netutil"
3940
"tailscale.com/tailcfg"
4041
"tailscale.com/tstime"
@@ -72,20 +73,21 @@ var handler = map[string]LocalAPIHandler{
7273

7374
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
7475
// without a trailing slash:
75-
"check-prefs": (*Handler).serveCheckPrefs,
76-
"derpmap": (*Handler).serveDERPMap,
77-
"goroutines": (*Handler).serveGoroutines,
78-
"login-interactive": (*Handler).serveLoginInteractive,
79-
"logout": (*Handler).serveLogout,
80-
"ping": (*Handler).servePing,
81-
"prefs": (*Handler).servePrefs,
82-
"reload-config": (*Handler).reloadConfig,
83-
"reset-auth": (*Handler).serveResetAuth,
84-
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
85-
"shutdown": (*Handler).serveShutdown,
86-
"start": (*Handler).serveStart,
87-
"status": (*Handler).serveStatus,
88-
"whois": (*Handler).serveWhoIs,
76+
"check-prefs": (*Handler).serveCheckPrefs,
77+
"check-so-mark-in-use": (*Handler).serveCheckSOMarkInUse,
78+
"derpmap": (*Handler).serveDERPMap,
79+
"goroutines": (*Handler).serveGoroutines,
80+
"login-interactive": (*Handler).serveLoginInteractive,
81+
"logout": (*Handler).serveLogout,
82+
"ping": (*Handler).servePing,
83+
"prefs": (*Handler).servePrefs,
84+
"reload-config": (*Handler).reloadConfig,
85+
"reset-auth": (*Handler).serveResetAuth,
86+
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
87+
"shutdown": (*Handler).serveShutdown,
88+
"start": (*Handler).serveStart,
89+
"status": (*Handler).serveStatus,
90+
"whois": (*Handler).serveWhoIs,
8991
}
9092

9193
func init() {
@@ -760,6 +762,23 @@ func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request)
760762
})
761763
}
762764

765+
// serveCheckSOMarkInUse reports whether SO_MARK is in use on the linux while
766+
// running without TUN. For any other OS, it reports false.
767+
func (h *Handler) serveCheckSOMarkInUse(w http.ResponseWriter, r *http.Request) {
768+
if !h.PermitRead {
769+
http.Error(w, "SO_MARK check access denied", http.StatusForbidden)
770+
return
771+
}
772+
usingSOMark := netns.UseSocketMark()
773+
usingUserspaceNetworking := h.b.Sys().IsNetstack()
774+
w.Header().Set("Content-Type", "application/json")
775+
json.NewEncoder(w).Encode(struct {
776+
UseSOMark bool
777+
}{
778+
UseSOMark: usingSOMark || usingUserspaceNetworking,
779+
})
780+
}
781+
763782
func (h *Handler) serveCheckReversePathFiltering(w http.ResponseWriter, r *http.Request) {
764783
if !h.PermitRead {
765784
http.Error(w, "reverse path filtering check access denied", http.StatusForbidden)

0 commit comments

Comments
 (0)