Redo route code (#2422)

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby 2025-02-26 07:22:55 -08:00 committed by GitHub
parent 16868190c8
commit 7891378f57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2977 additions and 6251 deletions

View file

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/netip"
"slices"
"strconv"
"strings"
"time"
@ -25,8 +26,11 @@ var (
)
type NodeID uint64
type NodeIDs []NodeID
// type NodeConnectedMap *xsync.MapOf[NodeID, bool]
func (n NodeIDs) Len() int { return len(n) }
func (n NodeIDs) Less(i, j int) bool { return n[i] < n[j] }
func (n NodeIDs) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (id NodeID) StableID() tailcfg.StableNodeID {
return tailcfg.StableNodeID(strconv.FormatUint(uint64(id), util.Base10))
@ -84,10 +88,21 @@ type Node struct {
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
AuthKey *PreAuthKey
LastSeen *time.Time
Expiry *time.Time
Expiry *time.Time
Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
// LastSeen is when the node was last in contact with
// headscale. It is best effort and not persisted.
LastSeen *time.Time `gorm:"-"`
// DEPRECATED: Use the ApprovedRoutes field instead.
// TODO(kradalby): remove when ApprovedRoutes is used all over the code.
// Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
// ApprovedRoutes is a list of routes that the node is allowed to announce
// as a subnet router. They are not necessarily the routes that the node
// announces at the moment.
// See [Node.Hostinfo]
ApprovedRoutes []netip.Prefix `gorm:"column:approved_routes;serializer:json"`
CreatedAt time.Time
UpdatedAt time.Time
@ -96,9 +111,7 @@ type Node struct {
IsOnline *bool `gorm:"-"`
}
type (
Nodes []*Node
)
type Nodes []*Node
// GivenNameHasBeenChanged returns whether the `givenName` can be automatically changed based on the `Hostname` of the node.
func (node *Node) GivenNameHasBeenChanged() bool {
@ -185,23 +198,22 @@ func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool {
// TODO(kradalby): Regenerate this every time the filter change, instead of
// every time we use it.
// Part of #2416
matchers := make([]matcher.Match, len(filter))
for i, rule := range filter {
matchers[i] = matcher.MatchFromFilterRule(rule)
}
for _, route := range node2.Routes {
if route.Enabled {
allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix).Addr())
}
}
for _, matcher := range matchers {
if !matcher.SrcsContainsIPs(src) {
if !matcher.SrcsContainsIPs(src...) {
continue
}
if matcher.DestsContainsIP(allowedIPs) {
if matcher.DestsContainsIP(allowedIPs...) {
return true
}
if matcher.DestsOverlapsPrefixes(node2.SubnetRoutes()...) {
return true
}
}
@ -245,11 +257,14 @@ func (node *Node) Proto() *v1.Node {
DiscoKey: node.DiscoKey.String(),
// TODO(kradalby): replace list with v4, v6 field?
IpAddresses: node.IPsAsString(),
Name: node.Hostname,
GivenName: node.GivenName,
User: node.User.Proto(),
ForcedTags: node.ForcedTags,
IpAddresses: node.IPsAsString(),
Name: node.Hostname,
GivenName: node.GivenName,
User: node.User.Proto(),
ForcedTags: node.ForcedTags,
ApprovedRoutes: util.PrefixesToString(node.ApprovedRoutes),
AvailableRoutes: util.PrefixesToString(node.AnnouncedRoutes()),
SubnetRoutes: util.PrefixesToString(node.SubnetRoutes()),
RegisterMethod: node.RegisterMethodToV1Enum(),
@ -297,6 +312,29 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) {
return hostname, nil
}
// AnnouncedRoutes returns the list of routes that the node announces.
// It should be used instead of checking Hostinfo.RoutableIPs directly.
func (node *Node) AnnouncedRoutes() []netip.Prefix {
if node.Hostinfo == nil {
return nil
}
return node.Hostinfo.RoutableIPs
}
// SubnetRoutes returns the list of routes that the node announces and are approved.
func (node *Node) SubnetRoutes() []netip.Prefix {
var routes []netip.Prefix
for _, route := range node.AnnouncedRoutes() {
if slices.Contains(node.ApprovedRoutes, route) {
routes = append(routes, route)
}
}
return routes
}
// func (node *Node) String() string {
// return node.Hostname
// }

View file

@ -1,102 +1,31 @@
package types
import (
"fmt"
"net/netip"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"tailscale.com/net/tsaddr"
)
// Deprecated: Approval of routes is denormalised onto the relevant node.
// Struct is kept for GORM migrations only.
type Route struct {
gorm.Model
NodeID uint64 `gorm:"not null"`
Node *Node
// TODO(kradalby): change this custom type to netip.Prefix
Prefix netip.Prefix `gorm:"serializer:text"`
// Advertised is now only stored as part of [Node.Hostinfo].
Advertised bool
Enabled bool
IsPrimary bool
// Enabled is stored directly on the node as ApprovedRoutes.
Enabled bool
// IsPrimary is only determined in memory as it is only relevant
// when the server is up.
IsPrimary bool
}
// Deprecated: Approval of routes is denormalised onto the relevant node.
type Routes []Route
func (r *Route) String() string {
return fmt.Sprintf("%s:%s", r.Node.Hostname, netip.Prefix(r.Prefix).String())
}
func (r *Route) IsExitRoute() bool {
return tsaddr.IsExitRoute(r.Prefix)
}
func (r *Route) IsAnnouncable() bool {
return r.Advertised && r.Enabled
}
func (rs Routes) Prefixes() []netip.Prefix {
prefixes := make([]netip.Prefix, len(rs))
for i, r := range rs {
prefixes[i] = netip.Prefix(r.Prefix)
}
return prefixes
}
// Primaries returns Primary routes from a list of routes.
func (rs Routes) Primaries() Routes {
res := make(Routes, 0)
for _, route := range rs {
if route.IsPrimary {
res = append(res, route)
}
}
return res
}
func (rs Routes) PrefixMap() map[netip.Prefix][]Route {
res := map[netip.Prefix][]Route{}
for _, route := range rs {
if _, ok := res[route.Prefix]; ok {
res[route.Prefix] = append(res[route.Prefix], route)
} else {
res[route.Prefix] = []Route{route}
}
}
return res
}
func (rs Routes) Proto() []*v1.Route {
protoRoutes := []*v1.Route{}
for _, route := range rs {
protoRoute := v1.Route{
Id: uint64(route.ID),
Prefix: route.Prefix.String(),
Advertised: route.Advertised,
Enabled: route.Enabled,
IsPrimary: route.IsPrimary,
CreatedAt: timestamppb.New(route.CreatedAt),
UpdatedAt: timestamppb.New(route.UpdatedAt),
}
if route.Node != nil {
protoRoute.Node = route.Node.Proto()
}
if route.DeletedAt.Valid {
protoRoute.DeletedAt = timestamppb.New(route.DeletedAt.Time)
}
protoRoutes = append(protoRoutes, &protoRoute)
}
return protoRoutes
}

View file

@ -1,89 +0,0 @@
package types
import (
"fmt"
"net/netip"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/util"
)
func TestPrefixMap(t *testing.T) {
ipp := func(s string) netip.Prefix { return netip.MustParsePrefix(s) }
tests := []struct {
rs Routes
want map[netip.Prefix][]Route
}{
{
rs: Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
},
},
want: map[netip.Prefix][]Route{
ipp("10.0.0.0/24"): Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
},
},
},
},
{
rs: Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
},
Route{
Prefix: ipp("10.0.1.0/24"),
},
},
want: map[netip.Prefix][]Route{
ipp("10.0.0.0/24"): Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
},
},
ipp("10.0.1.0/24"): Routes{
Route{
Prefix: ipp("10.0.1.0/24"),
},
},
},
},
{
rs: Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
Enabled: true,
},
Route{
Prefix: ipp("10.0.0.0/24"),
Enabled: false,
},
},
want: map[netip.Prefix][]Route{
ipp("10.0.0.0/24"): Routes{
Route{
Prefix: ipp("10.0.0.0/24"),
Enabled: true,
},
Route{
Prefix: ipp("10.0.0.0/24"),
Enabled: false,
},
},
},
},
}
for idx, tt := range tests {
t.Run(fmt.Sprintf("test-%d", idx), func(t *testing.T) {
got := tt.rs.PrefixMap()
if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" {
t.Errorf("PrefixMap() unexpected result (-want +got):\n%s", diff)
}
})
}
}

View file

@ -55,6 +55,13 @@ type User struct {
ProfilePicURL string
}
func (u *User) StringID() string {
if u == nil {
return ""
}
return strconv.FormatUint(uint64(u.ID), 10)
}
// Username is the main way to get the username of a user,
// it will return the email if it exists, the name if it exists,
// the OIDCIdentifier if it exists, and the ID if nothing else exists.
@ -63,7 +70,11 @@ type User struct {
// should be used throughout headscale, in information returned to the
// user and the Policy engine.
func (u *User) Username() string {
return cmp.Or(u.Email, u.Name, u.ProviderIdentifier.String, strconv.FormatUint(uint64(u.ID), 10))
return cmp.Or(
u.Email,
u.Name,
u.ProviderIdentifier.String,
u.StringID())
}
// DisplayNameOrUsername returns the DisplayName if it exists, otherwise