cmd/tailscale/cli: add subcommand: tailscale debug location

Updates tailscale/corp#29968

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law 2025-07-18 02:18:41 -07:00
parent c87f44b687
commit ae68c7865c
No known key found for this signature in database
GPG Key ID: B83D1EE07548341D
6 changed files with 388 additions and 4 deletions

View File

@ -0,0 +1,195 @@
package cli
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/types/geo"
)
var locationCmd = func() *ffcli.Command {
if !envknob.UseWIPCode() {
return nil
}
return &ffcli.Command{
Name: "location",
Exec: runLocation,
ShortHelp: "Print or change location data, for testing",
ShortUsage: "" +
" Print all fields: tailscale debug location\n" +
" Print a field: tailscale debug location FIELD\n" +
" Clear a field: tailscale debug location FIELD=\n" +
" Change field[s]: tailscale debug location FIELD=VALUE [...]",
LongHelp: "" +
"FIELDS\n" +
locationFields.help(),
}
}
func runLocation(ctx context.Context, args []string) error {
var getks []locationGetK
var setvs []locationSetV
if len(args) == 0 {
// Print all fields:
for _, k := range slices.Sorted(maps.Keys(locationFields)) {
getk := locationGetK{
get: locationFields[k].get,
k: k,
}
getks = append(getks, getk)
}
return nil
}
// Parse all args first, to avoid having to abort halfway through.
for _, arg := range args {
k, v, set := strings.Cut(arg, "=")
field, known := locationFields[k]
if !known {
return fmt.Errorf("unknown field: %s", k)
}
if set {
// Change or clear these fields:
setv := locationSetV{
set: field.set,
k: k,
v: v,
}
setvs = append(setvs, setv)
} else {
// Print a field:
getk := locationGetK{
get: field.get,
k: k,
}
getks = append(getks, getk)
}
}
if len(getks) > 0 && len(setvs) > 0 {
gk, sv := getks[0], setvs[0]
return fmt.Errorf("cannot mix %s and %s=%q", gk.k, sv.k, sv.v)
}
if len(setvs) > 0 {
// Perform the change or clear:
prefs := &ipn.MaskedPrefs{Prefs: ipn.Prefs{}}
for _, sv := range setvs {
if err := sv.set(prefs, sv.v); err != nil {
return err
}
}
ctx = apitype.RequestReasonKey.WithValue(ctx, "debug location")
if _, err := localClient.EditPrefs(ctx, prefs); err != nil {
return err
}
return nil
// TODO(sfllaw): [LocalBackend.applyPrefsToHostinfoLocked]
// [Auto.SetHostinfo]
// [Hostinfo.RoutableIPs] corresponds to --advertise-routes
}
prefs, err := localClient.GetPrefs(ctx)
if err != nil {
return err
}
switch len(getks) {
case 1:
// Print one fields, without key name:
fmt.Printf("%s", getks[0].get(prefs))
default:
// Print multiple fields:
for _, gk := range getks {
fmt.Printf("%s=%s", gk.k, gk.get(prefs))
}
}
return nil
}
type locationFieldsT map[string]locationField
var locationFields = locationFieldsT{
"city": {
get: func(p *ipn.Prefs) string {
return p.LocationCity
},
set: func(p *ipn.MaskedPrefs, v string) error {
p.LocationCity = v
p.LocationCitySet = true
return nil
},
help: " city=NAME\n" +
"\tNAME of this nodes city",
},
"coords": {
get: func(p *ipn.Prefs) string {
return p.LocationCoords.FormatLatLng()
},
set: func(p *ipn.MaskedPrefs, v string) error {
pt, err := geo.ParsePoint(v)
if err != nil {
return err
}
pt = pt.Quantize()
s, err := pt.MarshalText()
if err != nil {
return err
}
p.LocationCoords = s
p.LocationCoordsSet = true
return nil
},
help: " coords=(+|-)LATITUDE(+|-)LONGITUDE\n" +
"\tLATITUDE and LONGITUDE for this node, in decimal degrees \"+45.5-73.6\"",
},
"country": {
get: func(p *ipn.Prefs) string {
return p.LocationCountry
},
set: func(p *ipn.MaskedPrefs, v string) error {
p.LocationCountry = v
p.LocationCountrySet = true
return nil
},
help: " country=NAME\n" +
"\tNAME of this nodes country",
},
}
func (lf locationFieldsT) help() string {
var txt []string
for _, k := range slices.Sorted(maps.Keys(lf)) {
txt = append(txt, lf[k].help)
}
return strings.Join(txt, "\n")
}
type locationField struct {
get func(*ipn.Prefs) string
set func(*ipn.MaskedPrefs, string) error
help string
}
type locationGetK struct {
get func(*ipn.Prefs) string
k string
help string
}
type locationSetV struct {
set func(*ipn.MaskedPrefs, string) error
k string
v string
}

View File

@ -374,6 +374,7 @@ func debugCmd() *ffcli.Command {
ShortHelp: "Print the current set of candidate peer relay servers",
Exec: runPeerRelayServers,
},
ccall(locationCmd),
}...),
}
}

View File

@ -51,7 +51,7 @@ func IsLoginServerSynonym(val any) bool {
// Prefs are the user modifiable settings of the Tailscale node agent.
// When you add a Pref to this struct, remember to add a corresponding
// field in MaskedPrefs, and check your field for equality in Prefs.Equals().
// field in [MaskedPrefs], and check your field for equality in Prefs.Equals().
type Prefs struct {
// ControlURL is the URL of the control server to use.
//
@ -294,6 +294,25 @@ type Prefs struct {
// We can maybe do that once we're sure which module should persist
// it (backend or frontend?)
Persist *persist.Persist `json:"Config"`
// Location fields configure the location overrides for the client node.
// These overrides are self-reported hints to the control server, to
// help users pick an appropriate node based on its location.
//
// LocationCoords are the geographical coordinates that give the
// approximate location for this node. The coordinates are encoded as
// ±latitude±longitude in decimal degrees. These coordinates are never
// shared with Tailscale peers.
//
// LocationCountry and LocationCity are top-level and local-level names
// that describe the location for this node. They may be actual country
// and city names, in any language or localization; but they can also be
// arbitrary groupings. Tailscale clients may use these names to group
// nodes by LocationCountry and then LocationCity when presenting a
// hierarchical node selector.
LocationCity string `json:",omitempty"`
LocationCoords string `json:",omitempty"`
LocationCountry string `json:",omitempty"`
}
// AutoUpdatePrefs are the auto update settings for the node agent.
@ -371,6 +390,9 @@ type MaskedPrefs struct {
NetfilterKindSet bool `json:",omitempty"`
DriveSharesSet bool `json:",omitempty"`
RelayServerPortSet bool `json:",omitempty"`
LocationCitySet bool `json:",omitempty"`
LocationCoordsSet bool `json:",omitempty"`
LocationCountrySet bool `json:",omitempty"`
}
// SetsInternal reports whether mp has any of the Internal*Set field bools set

View File

@ -797,8 +797,13 @@ type Location struct {
// IATA, ICAO or ISO 3166-2 codes are recommended ("YSE")
CityCode string `json:",omitempty"`
// Latitude, Longitude are optional geographical coordinates of the node, in degrees.
// No particular accuracy level is promised; the coordinates may simply be the center of the city or country.
// Latitude and Longitude are optional geographical coordinates of the
// node, in decimal degrees. No particular accuracy level is promised;
// the coordinates may simply be the center of the city or country.
//
// To reliably distinguish between an empty {Latitude, Longitude} pair
// and the value {Latitude: 0, Longitude: 0}, the latter may be encoded
// as {Latitude: 0, Longitude: math.SmallestNonzeroFloat32}.
Latitude float64 `json:",omitempty"`
Longitude float64 `json:",omitempty"`

View File

@ -153,6 +153,124 @@ func (p Point) String() string {
return lat.String() + " " + lng.String()
}
// FormatLatLng returns a compact encoding of p: "±latitude±longitude" where
// latitude and longitude are in decimal degrees. If p was not initialized, this
// will return "nowhere".
func (p Point) FormatLatLng() string {
lat, lng, err := p.LatLng()
if err != nil {
if err == ErrBadPoint {
return "nowhere"
}
panic(err)
}
var b []byte
b, err = lat.AppendText(b)
if err != nil {
panic(err)
}
b, err = lng.AppendText(b)
if err != nil {
panic(err)
}
return string(b)
}
// ParseLatLng parses the output of [FormatLatLng] and returns its [Point]. If s
// is an empty string, or is "nowhere", then this function returns the zero
// Point.
func ParseLatLng(s string) (Point, error) {
var zero Point
if s == "" || s == "nowhere" {
return zero, nil
}
type State int
const (
start State = iota + 1
latInt
latDec
lngInt
lngDec
done
)
var latI int // index of last character + 1
state := start
for i, last := 0, len(s)-1; i <= last; i++ {
c := s[i]
switch state {
case start:
switch c {
case '-', '+': // must start with sign: either + or -
state = latInt
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
case latInt:
switch c {
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
case '.':
state = latDec
case '-', '+':
latI = i
state = lngInt
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
case latDec:
switch c {
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
case '-', '+':
latI = i
state = lngInt
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
case lngInt:
switch c {
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
case '.':
state = lngDec
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
case lngDec:
switch c {
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
// no-op
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
default:
panic(fmt.Sprintf("invalid state: %d", state))
}
}
// Did we see both lat and lng?
switch state {
case lngInt, lngDec:
// no-op
default:
return zero, fmt.Errorf("%w: invalid syntax: %q", ErrBadPoint, s)
}
// Latitude
lat, err := strconv.ParseFloat(string(s[0:latI]), 64)
if err != nil {
return zero, fmt.Errorf("%w: invalid latitude: %w", ErrBadPoint, err)
}
// Longitude
lng, err := strconv.ParseFloat(string(s[latI:]), 64)
if err != nil {
return zero, fmt.Errorf("%w: invalid longitude: %w", ErrBadPoint, err)
}
return MakePoint(Degrees(lat), Degrees(lng)), nil
}
// AppendBinary implements [encoding.BinaryAppender]. The output consists of two
// float32s in big-endian byte order: latitude and longitude offset by 180°.
// If p is not a valid, the output will be an 8-byte zero value.

View File

@ -56,6 +56,7 @@ func TestPoint(t *testing.T) {
wantLat geo.Degrees
wantLng geo.Degrees
wantString string
wantLatLng string
wantText string
}{
{
@ -65,6 +66,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +0.0,
wantString: "+0° +0°",
wantLatLng: "+0+0",
wantText: "POINT (0 0)",
},
{
@ -74,6 +76,7 @@ func TestPoint(t *testing.T) {
wantLat: +90.0,
wantLng: +0.0,
wantString: "+90° +0°",
wantLatLng: "+90+0",
wantText: "POINT (0 90)",
},
{
@ -83,6 +86,7 @@ func TestPoint(t *testing.T) {
wantLat: -90.0,
wantLng: +0.0,
wantString: "-90° +0°",
wantLatLng: "-90+0",
wantText: "POINT (0 -90)",
},
{
@ -92,6 +96,7 @@ func TestPoint(t *testing.T) {
wantLat: +90.0,
wantLng: +0.0,
wantString: "+90° +0°",
wantLatLng: "+90+0",
wantText: "POINT (0 90)",
},
{
@ -101,6 +106,7 @@ func TestPoint(t *testing.T) {
wantLat: -90.0,
wantLng: +0.0,
wantString: "-90° +0°",
wantLatLng: "-90+0",
wantText: "POINT (0 -90)",
},
{
@ -110,6 +116,7 @@ func TestPoint(t *testing.T) {
wantLat: +89.0,
wantLng: +0.0,
wantString: "+89° +0°",
wantLatLng: "+89+0",
wantText: "POINT (0 89)",
},
{
@ -119,6 +126,7 @@ func TestPoint(t *testing.T) {
wantLat: +89.0,
wantLng: +180.0,
wantString: "+89° +180°",
wantLatLng: "+89+180",
wantText: "POINT (180 89)",
},
{
@ -128,6 +136,7 @@ func TestPoint(t *testing.T) {
wantLat: -89.0,
wantLng: +0.0,
wantString: "-89° +0°",
wantLatLng: "-89+0",
wantText: "POINT (0 -89)",
},
{
@ -137,6 +146,7 @@ func TestPoint(t *testing.T) {
wantLat: -89.0,
wantLng: +180.0,
wantString: "-89° +180°",
wantLatLng: "-89+180",
wantText: "POINT (180 -89)",
},
{
@ -146,6 +156,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +180.0,
wantString: "+0° +180°",
wantLatLng: "+0+180",
wantText: "POINT (180 0)",
},
{
@ -155,6 +166,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +180.0,
wantString: "+0° +180°",
wantLatLng: "+0+180",
wantText: "POINT (180 0)",
},
{
@ -164,6 +176,7 @@ func TestPoint(t *testing.T) {
wantLat: +1.0,
wantLng: +180.0,
wantString: "+1° +180°",
wantLatLng: "+1+180",
wantText: "POINT (180 1)",
},
{
@ -173,6 +186,7 @@ func TestPoint(t *testing.T) {
wantLat: -1.0,
wantLng: +180.0,
wantString: "-1° +180°",
wantLatLng: "-1+180",
wantText: "POINT (180 -1)",
},
{
@ -182,6 +196,7 @@ func TestPoint(t *testing.T) {
wantLat: -1.0,
wantLng: +180.0,
wantString: "-1° +180°",
wantLatLng: "-1+180",
wantText: "POINT (180 -1)",
},
{
@ -191,6 +206,7 @@ func TestPoint(t *testing.T) {
wantLat: +1.0,
wantLng: +180.0,
wantString: "+1° +180°",
wantLatLng: "+1+180",
wantText: "POINT (180 1)",
},
{
@ -200,6 +216,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +1.0,
wantString: "+0° +1°",
wantLatLng: "+0+1",
wantText: "POINT (1 0)",
},
{
@ -209,6 +226,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +1.0,
wantString: "+0° +1°",
wantLatLng: "+0+1",
wantText: "POINT (1 0)",
},
{
@ -218,6 +236,7 @@ func TestPoint(t *testing.T) {
wantLat: -1.0,
wantLng: +1.0,
wantString: "-1° +1°",
wantLatLng: "-1+1",
wantText: "POINT (1 -1)",
},
{
@ -227,6 +246,7 @@ func TestPoint(t *testing.T) {
wantLat: +1.0,
wantLng: +1.0,
wantString: "+1° +1°",
wantLatLng: "+1+1",
wantText: "POINT (1 1)",
},
{
@ -236,6 +256,7 @@ func TestPoint(t *testing.T) {
wantLat: +1.0,
wantLng: +1.0,
wantString: "+1° +1°",
wantLatLng: "+1+1",
wantText: "POINT (1 1)",
},
{
@ -245,6 +266,7 @@ func TestPoint(t *testing.T) {
wantLat: -1.0,
wantLng: +1.0,
wantString: "-1° +1°",
wantLatLng: "-1+1",
wantText: "POINT (1 -1)",
},
{
@ -254,6 +276,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +180.0,
wantString: "+0° +180°",
wantLatLng: "+0+180",
wantText: "POINT (180 0)",
},
{
@ -263,6 +286,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +180.0,
wantString: "+0° +180°",
wantLatLng: "+0+180",
wantText: "POINT (180 0)",
},
{
@ -272,6 +296,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +179.0,
wantString: "+0° +179°",
wantLatLng: "+0+179",
wantText: "POINT (179 0)",
},
{
@ -281,6 +306,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: -179.0,
wantString: "+0° -179°",
wantLatLng: "+0-179",
wantText: "POINT (-179 0)",
},
{
@ -290,6 +316,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: -179.0,
wantString: "+0° -179°",
wantLatLng: "+0-179",
wantText: "POINT (-179 0)",
},
{
@ -299,6 +326,7 @@ func TestPoint(t *testing.T) {
wantLat: +0.0,
wantLng: +179.0,
wantString: "+0° +179°",
wantLatLng: "+0+179",
wantText: "POINT (179 0)",
},
{
@ -308,6 +336,7 @@ func TestPoint(t *testing.T) {
wantLat: +45.508888,
wantLng: -73.561668,
wantString: "+45.508888° -73.561668°",
wantLatLng: "+45.508888-73.561668",
wantText: "POINT (-73.561668 45.508888)",
},
{
@ -317,6 +346,7 @@ func TestPoint(t *testing.T) {
wantLat: 57.550480044655636,
wantLng: -98.41680517868062,
wantString: "+57.550480044655636° -98.41680517868062°",
wantLatLng: "+57.550480044655636-98.41680517868062",
wantText: "POINT (-98.41680517868062 57.550480044655636)",
},
} {
@ -338,6 +368,19 @@ func TestPoint(t *testing.T) {
t.Errorf("String: got %q, wantString %q", got, tt.wantString)
}
ll := p.FormatLatLng()
if ll != tt.wantLatLng {
t.Errorf("FormatLatLng: got %q, wantLatLng %q", ll, tt.wantLatLng)
}
q, err := geo.ParseLatLng(ll)
if err != nil {
t.Fatalf("ParseLatLng: err %q, expected nil", err)
}
if q != p {
t.Errorf("ParseLatLng: got %v, want %v", q, p)
}
txt, err := p.MarshalText()
if err != nil {
t.Errorf("Text: err %q, expected nil", err)
@ -350,7 +393,7 @@ func TestPoint(t *testing.T) {
t.Fatalf("MarshalBinary: err %q, expected nil", err)
}
var q geo.Point
q = geo.Point{}
if err := q.UnmarshalBinary(b); err != nil {
t.Fatalf("UnmarshalBinary: err %q, expected nil", err)
}