summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--cmd/tailscale/cli/debug-location.go195
-rw-r--r--cmd/tailscale/cli/debug.go1
-rw-r--r--ipn/prefs.go24
-rw-r--r--tailcfg/tailcfg.go9
-rw-r--r--types/geo/point.go118
-rw-r--r--types/geo/point_test.go45
6 files changed, 388 insertions, 4 deletions
diff --git a/cmd/tailscale/cli/debug-location.go b/cmd/tailscale/cli/debug-location.go
new file mode 100644
index 000000000..3771dc9d8
--- /dev/null
+++ b/cmd/tailscale/cli/debug-location.go
@@ -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 node’s 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 node’s 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
+}
diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go
index fb062fd17..29c1c79fd 100644
--- a/cmd/tailscale/cli/debug.go
+++ b/cmd/tailscale/cli/debug.go
@@ -374,6 +374,7 @@ func debugCmd() *ffcli.Command {
ShortHelp: "Print the current set of candidate peer relay servers",
Exec: runPeerRelayServers,
},
+ ccall(locationCmd),
}...),
}
}
diff --git a/ipn/prefs.go b/ipn/prefs.go
index 71a80b182..393b49fe2 100644
--- a/ipn/prefs.go
+++ b/ipn/prefs.go
@@ -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
diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go
index 307b39f93..579bd530f 100644
--- a/tailcfg/tailcfg.go
+++ b/tailcfg/tailcfg.go
@@ -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"`
diff --git a/types/geo/point.go b/types/geo/point.go
index d7160ac59..abd048f63 100644
--- a/types/geo/point.go
+++ b/types/geo/point.go
@@ -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.
diff --git a/types/geo/point_test.go b/types/geo/point_test.go
index 308c1a183..8c4ca166c 100644
--- a/types/geo/point_test.go
+++ b/types/geo/point_test.go
@@ -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)
}