diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 4c6f9a39f..d344e2bd4 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -3,7 +3,7 @@ package tailcfg -//go:generate go run tailscale.com/cmd/viewer --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan --clonefunc +//go:generate go run tailscale.com/cmd/viewer --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan,Location --clonefunc import ( "bytes" @@ -531,6 +531,24 @@ type Service struct { // TODO(apenwarr): add "tags" here for each service? } +// Location represents geographical location data about a +// Tailscale host. Location is optional and only set if +// explicitly declared by a node. +type Location struct { + Country string `json:",omitempty"` // User friendly country name, with proper capitalization, e.g "Canada" + CountryCode string `json:",omitempty"` // ISO 3166-1 alpha-2 in lower case, e.g "ca" + City string `json:",omitempty"` // User friendly city name, with proper capitalization, e.g. "Squamish" + CityCode string `json:",omitempty"` + + // Priority determines the priority an exit node is given when the + // location data between two or more nodes is tied. + // A higher value indicates that the exit node is more preferable + // for use. + // A value of 0 means the exit node does not have a priority + // preference. A negative int is not allowed. + Priority int `json:",omitempty"` +} + // Hostinfo contains a summary of a Tailscale host. // // Because it contains pointers (slices), this type should not be used @@ -585,6 +603,11 @@ type Hostinfo struct { Userspace opt.Bool `json:",omitempty"` // if the client is running in userspace (netstack) mode UserspaceRouter opt.Bool `json:",omitempty"` // if the client's subnet router is running in userspace (netstack) mode + // Location represents geographical location data about a + // Tailscale host. Location is optional and only set if + // explicitly declared by a node. + Location *Location `json:",omitempty"` + // NOTE: any new fields containing pointers in this type // require changes to Hostinfo.Equal. } diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 9d72124b4..7c68d1d80 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -119,6 +119,10 @@ func (src *Hostinfo) Clone() *Hostinfo { dst.Services = append(src.Services[:0:0], src.Services...) dst.NetInfo = src.NetInfo.Clone() dst.SSH_HostKeys = append(src.SSH_HostKeys[:0:0], src.SSH_HostKeys...) + if dst.Location != nil { + dst.Location = new(Location) + *dst.Location = *src.Location + } return dst } @@ -157,6 +161,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct { Cloud string Userspace opt.Bool UserspaceRouter opt.Bool + Location *Location }{}) // Clone makes a deep copy of NetInfo. @@ -458,9 +463,29 @@ var _ControlDialPlanCloneNeedsRegeneration = ControlDialPlan(struct { Candidates []ControlIPCandidate }{}) +// Clone makes a deep copy of Location. +// The result aliases no memory with the original. +func (src *Location) Clone() *Location { + if src == nil { + return nil + } + dst := new(Location) + *dst = *src + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _LocationCloneNeedsRegeneration = Location(struct { + Country string + CountryCode string + City string + CityCode string + Priority int +}{}) + // Clone duplicates src into dst and reports whether it succeeded. // To succeed, must be of types <*T, *T> or <*T, **T>, -// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan. +// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan,Location. func Clone(dst, src any) bool { switch src := src.(type) { case *User: @@ -589,6 +614,15 @@ func Clone(dst, src any) bool { *dst = src.Clone() return true } + case *Location: + switch dst := dst.(type) { + case *Location: + *dst = *src.Clone() + return true + case **Location: + *dst = src.Clone() + return true + } } return false } diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index b0e3f982e..2db6cb864 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -65,6 +65,7 @@ func TestHostinfoEqual(t *testing.T) { "Cloud", "Userspace", "UserspaceRouter", + "Location", } if have := fieldsOf(reflect.TypeOf(Hostinfo{})); !reflect.DeepEqual(have, hiHandles) { t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 9c195da1c..7837a5a5c 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -20,7 +20,7 @@ import ( "tailscale.com/types/views" ) -//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan +//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan,Location // View returns a readonly view of User. func (p *User) View() UserView { @@ -303,7 +303,15 @@ func (v HostinfoView) SSH_HostKeys() views.Slice[string] { return views.SliceOf( func (v HostinfoView) Cloud() string { return v.ж.Cloud } func (v HostinfoView) Userspace() opt.Bool { return v.ж.Userspace } func (v HostinfoView) UserspaceRouter() opt.Bool { return v.ж.UserspaceRouter } -func (v HostinfoView) Equal(v2 HostinfoView) bool { return v.ж.Equal(v2.ж) } +func (v HostinfoView) Location() *Location { + if v.ж.Location == nil { + return nil + } + x := *v.ж.Location + return &x +} + +func (v HostinfoView) Equal(v2 HostinfoView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _HostinfoViewNeedsRegeneration = Hostinfo(struct { @@ -340,6 +348,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct { Cloud string Userspace opt.Bool UserspaceRouter opt.Bool + Location *Location }{}) // View returns a readonly view of NetInfo. @@ -1077,3 +1086,63 @@ func (v ControlDialPlanView) Candidates() views.Slice[ControlIPCandidate] { var _ControlDialPlanViewNeedsRegeneration = ControlDialPlan(struct { Candidates []ControlIPCandidate }{}) + +// View returns a readonly view of Location. +func (p *Location) View() LocationView { + return LocationView{ж: p} +} + +// LocationView provides a read-only view over Location. +// +// Its methods should only be called if `Valid()` returns true. +type LocationView struct { + // ж is the underlying mutable value, named with a hard-to-type + // character that looks pointy like a pointer. + // It is named distinctively to make you think of how dangerous it is to escape + // to callers. You must not let callers be able to mutate it. + ж *Location +} + +// Valid reports whether underlying value is non-nil. +func (v LocationView) Valid() bool { return v.ж != nil } + +// AsStruct returns a clone of the underlying value which aliases no memory with +// the original. +func (v LocationView) AsStruct() *Location { + if v.ж == nil { + return nil + } + return v.ж.Clone() +} + +func (v LocationView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) } + +func (v *LocationView) UnmarshalJSON(b []byte) error { + if v.ж != nil { + return errors.New("already initialized") + } + if len(b) == 0 { + return nil + } + var x Location + if err := json.Unmarshal(b, &x); err != nil { + return err + } + v.ж = &x + return nil +} + +func (v LocationView) Country() string { return v.ж.Country } +func (v LocationView) CountryCode() string { return v.ж.CountryCode } +func (v LocationView) City() string { return v.ж.City } +func (v LocationView) CityCode() string { return v.ж.CityCode } +func (v LocationView) Priority() int { return v.ж.Priority } + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _LocationViewNeedsRegeneration = Location(struct { + Country string + CountryCode string + City string + CityCode string + Priority int +}{})