ipn: improve --exit-node hostname error during startup

When parsing the `tailscale up --exit-node=ARG` argument, we try to
resolve hostnames by searching the list of peers. However, at startup,
the peer list is empty, causing hostname lookups to trivially fail with
an unhelpful "invalid value" erorr.

Improve the error message when the peer list is empty to inform the user
that hostnames cannot be resolved during startup, and advise them to use
the exit node's Tailscale IP address instead.

Also, clarify that hostnames must be peer hostnames, not arbitrary
hostnames.

Fixes #19882

Change-Id: I9390a427c2863d657cf46c5e33b43cb3c5363764
Signed-off-by: Alex Chan <alexc@tailscale.com>
This commit is contained in:
Alex Chan
2026-05-28 16:43:45 +01:00
committed by Alex Chan
parent 4b8115bb2c
commit 446ae97491
3 changed files with 49 additions and 7 deletions
+17 -1
View File
@@ -30,6 +30,7 @@ import (
"tailscale.com/tka"
"tailscale.com/tstest"
"tailscale.com/tstest/deptest"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/types/persist"
@@ -769,7 +770,22 @@ func TestPrefsFromUpArgs(t *testing.T) {
args: upArgsT{
exitNodeIP: "foo",
},
wantErr: `invalid value "foo" for --exit-node; must be IP or hostname`,
st: &ipnstate.Status{
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
DNSName: "example.com.",
TailscaleIPs: []netip.Addr{netip.MustParseAddr("1.0.0.2")},
},
},
},
wantErr: `invalid value "foo" for --exit-node; must be IP or peer hostname`,
},
{
name: "error_exit_node_not_started",
args: upArgsT{
exitNodeIP: "foo",
},
wantErr: `cannot resolve exit node by hostname while Tailscale is starting up; please use its Tailscale IP address instead`,
},
{
name: "error_exit_node_allow_lan_without_exit_node",
+12 -1
View File
@@ -872,10 +872,14 @@ func (e ExitNodeLocalIPError) Error() string {
return fmt.Sprintf("cannot use %s as an exit node as it is a local IP address to this machine", e.hostOrIP)
}
// exitNodeIPOfArg returns the IP address of the exit node based on the
// user-provided string.
func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
if s == "" {
return ip, os.ErrInvalid
}
// If the string is a valid IP address, that's the exit node.
ip, err = netip.ParseAddr(s)
if err == nil {
if !isRemoteIP(st, ip) {
@@ -894,6 +898,13 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
}
return ip, nil
}
// If the string is not a valid IP address, assume it's a hostname.
// Search the list of peers for a matching hostname.
if len(st.Peer) == 0 {
return ip, errors.New("cannot resolve exit node by hostname while Tailscale is starting up; " +
"please use its Tailscale IP address instead")
}
match := 0
for _, ps := range st.Peer {
// Compare to the peer name in three forms:
@@ -920,7 +931,7 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
}
switch match {
case 0:
return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or hostname", s)
return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or peer hostname", s)
case 1:
if !isRemoteIP(st, ip) {
return ip, ExitNodeLocalIPError{s}
+20 -5
View File
@@ -1006,10 +1006,19 @@ func TestExitNodeIPOfArg(t *testing.T) {
want: mustIP("1.2.3.4"),
},
{
name: "no_match",
arg: "unknown",
st: &ipnstate.Status{MagicDNSSuffix: ".foo"},
wantErr: `invalid value "unknown" for --exit-node; must be IP or hostname`,
name: "no_match",
arg: "unknown",
st: &ipnstate.Status{
MagicDNSSuffix: ".foo",
Peer: map[key.NodePublic]*ipnstate.PeerStatus{
key.NewNode().Public(): {
DNSName: "skippy.foo.",
TailscaleIPs: []netip.Addr{mustIP("1.0.0.2")},
ExitNodeOption: true,
},
},
},
wantErr: `invalid value "unknown" for --exit-node; must be IP or peer hostname`,
},
{
name: "name",
@@ -1056,6 +1065,12 @@ func TestExitNodeIPOfArg(t *testing.T) {
},
want: mustIP("1.0.0.2"),
},
{
name: "hostname_no_peer",
arg: "skippy.foo",
st: &ipnstate.Status{},
wantErr: `cannot resolve exit node by hostname while Tailscale is starting up; please use its Tailscale IP address instead`,
},
{
name: "name_not_exit",
arg: "skippy",
@@ -1082,7 +1097,7 @@ func TestExitNodeIPOfArg(t *testing.T) {
},
},
},
wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or hostname`,
wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or peer hostname`,
},
{
name: "ambiguous",