ipn, cmd/tailscale/cli: allow setting FQDN sans dot as an exit node

In #10057, @seigel pointed out an inconsistency in the help text for
`exit-node list` and `set --exit-node`:

1.  Use `tailscale exit-node list`, which has a column titled "hostname"
    and tells you that you can use a hostname with `set --exit-node`:

    ```console
    $ tailscale exit-node list
     IP                  HOSTNAME                               COUNTRY            CITY                   STATUS
     100.98.193.6        linode-vps.tailfa84dd.ts.net           -                  -                      -
    […]
     100.93.242.75       ua-iev-wg-001.mullvad.ts.net           Ukraine            Kyiv                   -

    # To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.
    # To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.
    # To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.
    ```

    (This is the same format hostnames are presented in the admin
    console.)

2.  Try copy/pasting a hostname into `set --exit-node`:

    ```console
    $ tailscale set --exit-node=linode-vps.tailfa84dd.ts.net
    invalid value "linode-vps.tailfa84dd.ts.net" for --exit-node; must be IP or unique node name
    ```

3.  Note that the command allows some hostnames, if they're from nodes
    in a different tailnet:

    ```console
    $ tailscale set --exit-node= ua-iev-wg-001.mullvad.ts.net
    $ echo $?
    0
    ```

This patch addresses the inconsistency in two ways:

1.  Allow using `tailscale set --exit-node=` with an FQDN that's missing
    the trailing dot, matching the formatting used in `exit-node list`
    and the admin console.

2.  Make the description of valid exit nodes consistent across commands
    ("hostname or IP").

Updates #10057

Change-Id: If5d74f950cc1a9cc4b0ebc0c2f2d70689ffe4d73
Signed-off-by: Alex Chan <alexc@tailscale.com>
main
Alex Chan 4 weeks ago committed by Alex Chan
parent 4ffb92d7f6
commit 5b62f98894
  1. 2
      cmd/tailscale/cli/cli_test.go
  2. 2
      cmd/tailscale/cli/exitnode.go
  3. 15
      ipn/prefs.go
  4. 19
      ipn/prefs_test.go

@ -769,7 +769,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
args: upArgsT{
exitNodeIP: "foo",
},
wantErr: `invalid value "foo" for --exit-node; must be IP or unique node name`,
wantErr: `invalid value "foo" for --exit-node; must be IP or hostname`,
},
{
name: "error_exit_node_allow_lan_without_exit_node",

@ -138,7 +138,7 @@ func runExitNodeList(ctx context.Context, args []string) error {
fmt.Fprintln(w)
fmt.Fprintln(w)
fmt.Fprintln(w, "# To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.")
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.")
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the IP or hostname.")
if hasAnyExitNodeSuggestions(peers) {
fmt.Fprintln(w, "# To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.")
}

@ -896,8 +896,17 @@ func exitNodeIPOfArg(s string, st *ipnstate.Status) (ip netip.Addr, err error) {
}
match := 0
for _, ps := range st.Peer {
baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix)
if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, ps.DNSName) {
// Compare to the peer name in three forms:
//
// - base name ("example")
// - FQDN ("example.tail1234.ts.net.")
// - FQDN sans dot ("example.tail1234.ts.net", as returned by `tailscale exit-node list`
// and the admin console)
//
fqdn := ps.DNSName
baseName := dnsname.TrimSuffix(fqdn, st.MagicDNSSuffix)
fqdnSansDot := dnsname.TrimSuffix(fqdn, ".")
if !strings.EqualFold(s, baseName) && !strings.EqualFold(s, fqdn) && !strings.EqualFold(s, fqdnSansDot) {
continue
}
match++
@ -911,7 +920,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 unique node name", s)
return ip, fmt.Errorf("invalid value %q for --exit-node; must be IP or hostname", s)
case 1:
if !isRemoteIP(st, ip) {
return ip, ExitNodeLocalIPError{s}

@ -1009,7 +1009,7 @@ func TestExitNodeIPOfArg(t *testing.T) {
name: "no_match",
arg: "unknown",
st: &ipnstate.Status{MagicDNSSuffix: ".foo"},
wantErr: `invalid value "unknown" for --exit-node; must be IP or unique node name`,
wantErr: `invalid value "unknown" for --exit-node; must be IP or hostname`,
},
{
name: "name",
@ -1041,6 +1041,21 @@ func TestExitNodeIPOfArg(t *testing.T) {
},
want: mustIP("1.0.0.2"),
},
{
name: "name_fqdn_sans_dot",
arg: "skippy.foo",
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,
},
},
},
want: mustIP("1.0.0.2"),
},
{
name: "name_not_exit",
arg: "skippy",
@ -1067,7 +1082,7 @@ func TestExitNodeIPOfArg(t *testing.T) {
},
},
},
wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or unique node name`,
wantErr: `invalid value "skippy.bar." for --exit-node; must be IP or hostname`,
},
{
name: "ambiguous",

Loading…
Cancel
Save