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) }