From e691a128a1c47200074c327705a279f0253a6269 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 27 Sep 2024 15:34:18 +0200 Subject: [PATCH] test using gorm serialiser instead of custom hooks Signed-off-by: Kristoffer Dalby --- hscontrol/db/db.go | 15 ++- hscontrol/db/db_test.go | 23 +++++ hscontrol/db/ip_test.go | 37 ------- hscontrol/db/node_test.go | 10 +- hscontrol/db/text_serialiser.go | 99 ++++++++++++++++++ hscontrol/policy/acls.go | 38 +++++-- hscontrol/poll.go | 13 ++- hscontrol/types/node.go | 171 +++----------------------------- integration/hsic/config.go | 2 + 9 files changed, 195 insertions(+), 213 deletions(-) create mode 100644 hscontrol/db/text_serialiser.go diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index accf439ec1..a858ccf418 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -19,8 +19,13 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" + "gorm.io/gorm/schema" ) +func init() { + schema.RegisterSerializer("text", TextSerialiser{}) +} + var errDatabaseNotSupported = errors.New("database type not supported") // KV is a key-value store in a psql table. For future use... @@ -31,7 +36,8 @@ type KV struct { } type HSDatabase struct { - DB *gorm.DB + DB *gorm.DB + cfg *types.DatabaseConfig baseDomain string } @@ -421,7 +427,8 @@ func NewHeadscaleDatabase( } db := HSDatabase{ - DB: dbConn, + DB: dbConn, + cfg: &cfg, baseDomain: baseDomain, } @@ -621,6 +628,10 @@ func (hsdb *HSDatabase) Close() error { return err } + if hsdb.cfg.Type == types.DatabaseSqlite && hsdb.cfg.Sqlite.WriteAheadLog { + db.Exec("VACUUM") + } + return db.Close() } diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index b32d93cec9..5fc6c29714 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -108,6 +108,29 @@ func TestMigrations(t *testing.T) { } }, }, + { + dbPath: "testdata/0-23-0-to-0-24-0-no-more-special-types.sqlite3", + wantFunc: func(t *testing.T, h *HSDatabase) { + nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) { + return ListNodes(rx) + }) + assert.NoError(t, err) + + for _, node := range nodes { + assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey") + assert.Contains(t, node.MachineKey.String(), "mkey:") + assert.Falsef(t, node.NodeKey.IsZero(), "expected non zero nodekey") + assert.Contains(t, node.NodeKey.String(), "nodekey:") + assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey") + assert.Contains(t, node.DiscoKey.String(), "discokey:") + assert.NotNil(t, node.IPv4) + assert.NotNil(t, node.IPv4) + assert.Len(t, node.Endpoints, 1) + assert.NotNil(t, node.Hostinfo) + assert.NotNil(t, node.MachineKey) + } + }, + }, } for _, tt := range tests { diff --git a/hscontrol/db/ip_test.go b/hscontrol/db/ip_test.go index ce9c134c3b..ae7ff251b2 100644 --- a/hscontrol/db/ip_test.go +++ b/hscontrol/db/ip_test.go @@ -1,7 +1,6 @@ package db import ( - "database/sql" "fmt" "net/netip" "strings" @@ -291,15 +290,7 @@ func TestBackfillIPAddresses(t *testing.T) { v4 := fmt.Sprintf("100.64.0.%d", i) v6 := fmt.Sprintf("fd7a:115c:a1e0::%d", i) return &types.Node{ - IPv4DatabaseField: sql.NullString{ - Valid: true, - String: v4, - }, IPv4: nap(v4), - IPv6DatabaseField: sql.NullString{ - Valid: true, - String: v6, - }, IPv6: nap(v6), } } @@ -331,15 +322,7 @@ func TestBackfillIPAddresses(t *testing.T) { want: types.Nodes{ &types.Node{ - IPv4DatabaseField: sql.NullString{ - Valid: true, - String: "100.64.0.1", - }, IPv4: nap("100.64.0.1"), - IPv6DatabaseField: sql.NullString{ - Valid: true, - String: "fd7a:115c:a1e0::1", - }, IPv6: nap("fd7a:115c:a1e0::1"), }, }, @@ -364,15 +347,7 @@ func TestBackfillIPAddresses(t *testing.T) { want: types.Nodes{ &types.Node{ - IPv4DatabaseField: sql.NullString{ - Valid: true, - String: "100.64.0.1", - }, IPv4: nap("100.64.0.1"), - IPv6DatabaseField: sql.NullString{ - Valid: true, - String: "fd7a:115c:a1e0::1", - }, IPv6: nap("fd7a:115c:a1e0::1"), }, }, @@ -397,10 +372,6 @@ func TestBackfillIPAddresses(t *testing.T) { want: types.Nodes{ &types.Node{ - IPv4DatabaseField: sql.NullString{ - Valid: true, - String: "100.64.0.1", - }, IPv4: nap("100.64.0.1"), }, }, @@ -425,10 +396,6 @@ func TestBackfillIPAddresses(t *testing.T) { want: types.Nodes{ &types.Node{ - IPv6DatabaseField: sql.NullString{ - Valid: true, - String: "fd7a:115c:a1e0::1", - }, IPv6: nap("fd7a:115c:a1e0::1"), }, }, @@ -474,13 +441,9 @@ func TestBackfillIPAddresses(t *testing.T) { comps := append(util.Comparers, cmpopts.IgnoreFields(types.Node{}, "ID", - "MachineKeyDatabaseField", - "NodeKeyDatabaseField", - "DiscoKeyDatabaseField", "User", "UserID", "Endpoints", - "HostinfoDatabaseField", "Hostinfo", "Routes", "CreatedAt", diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index bafb22ba30..f6ebf38ca0 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -201,7 +201,7 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) { nodeKey := key.NewNode() machineKey := key.NewMachine() - v4 := netip.MustParseAddr(fmt.Sprintf("100.64.0.%v", strconv.Itoa(index+1))) + v4 := netip.MustParseAddr(fmt.Sprintf("100.64.0.%d", index+1)) node := types.Node{ ID: types.NodeID(index), MachineKey: machineKey.Public(), @@ -239,6 +239,8 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) { adminNode, err := db.GetNodeByID(1) c.Logf("Node(%v), user: %v", adminNode.Hostname, adminNode.User) + c.Assert(adminNode.IPv4, check.NotNil) + c.Assert(adminNode.IPv6, check.IsNil) c.Assert(err, check.IsNil) testNode, err := db.GetNodeByID(2) @@ -247,9 +249,11 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) { adminPeers, err := db.ListPeers(adminNode.ID) c.Assert(err, check.IsNil) + c.Assert(len(adminPeers), check.Equals, 9) testPeers, err := db.ListPeers(testNode.ID) c.Assert(err, check.IsNil) + c.Assert(len(testPeers), check.Equals, 9) adminRules, _, err := policy.GenerateFilterAndSSHRulesForTests(aclPolicy, adminNode, adminPeers) c.Assert(err, check.IsNil) @@ -259,14 +263,14 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) { peersOfAdminNode := policy.FilterNodesByACL(adminNode, adminPeers, adminRules) peersOfTestNode := policy.FilterNodesByACL(testNode, testPeers, testRules) - + c.Log(peersOfAdminNode) c.Log(peersOfTestNode) + c.Assert(len(peersOfTestNode), check.Equals, 9) c.Assert(peersOfTestNode[0].Hostname, check.Equals, "testnode1") c.Assert(peersOfTestNode[1].Hostname, check.Equals, "testnode3") c.Assert(peersOfTestNode[3].Hostname, check.Equals, "testnode5") - c.Log(peersOfAdminNode) c.Assert(len(peersOfAdminNode), check.Equals, 9) c.Assert(peersOfAdminNode[0].Hostname, check.Equals, "testnode2") c.Assert(peersOfAdminNode[2].Hostname, check.Equals, "testnode4") diff --git a/hscontrol/db/text_serialiser.go b/hscontrol/db/text_serialiser.go new file mode 100644 index 0000000000..9c0beef475 --- /dev/null +++ b/hscontrol/db/text_serialiser.go @@ -0,0 +1,99 @@ +package db + +import ( + "context" + "encoding" + "fmt" + "reflect" + + "gorm.io/gorm/schema" +) + +// Got from https://github.com/xdg-go/strum/blob/main/types.go +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +func isTextUnmarshaler(rv reflect.Value) bool { + return rv.Type().Implements(textUnmarshalerType) +} + +func maybeInstantiatePtr(rv reflect.Value) { + if rv.Kind() == reflect.Ptr && rv.IsNil() { + np := reflect.New(rv.Type().Elem()) + rv.Set(np) + } +} + +func decodingError(name string, err error) error { + return fmt.Errorf("error decoding to %s: %w", name, err) +} + +// TextSerialiser implements the Serialiser interface for fields that +// have a type that implements encoding.TextUnmarshaler. +type TextSerialiser struct{} + +func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) (err error) { + fieldValue := reflect.New(field.FieldType) + + // If the field is a pointer, we need to dereference it to get the actual type + // so we do not end with a second pointer. + if fieldValue.Elem().Kind() == reflect.Ptr { + fieldValue = fieldValue.Elem() + } + + if dbValue != nil { + var bytes []byte + switch v := dbValue.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("failed to unmarshal text value: %#v", dbValue) + } + + if isTextUnmarshaler(fieldValue) { + maybeInstantiatePtr(fieldValue) + f := fieldValue.MethodByName("UnmarshalText") + args := []reflect.Value{reflect.ValueOf(bytes)} + ret := f.Call(args) + if !ret[0].IsNil() { + return decodingError(field.Name, ret[0].Interface().(error)) + } + + // If the underlying field is to a pointer type, we need to + // assign the value as a pointer to it. + // If it is not a pointer, we need to assign the value to the + // field. + dstField := field.ReflectValueOf(ctx, dst) + if dstField.Kind() == reflect.Ptr { + dstField.Set(fieldValue) + } else { + dstField.Set(fieldValue.Elem()) + } + return nil + } else { + return fmt.Errorf("unsupported type: %T", fieldValue.Interface()) + } + } + + return +} + +func (TextSerialiser) Value(ctx context.Context, field *schema.Field, dst reflect.Value, fieldValue interface{}) (interface{}, error) { + switch v := fieldValue.(type) { + case encoding.TextMarshaler: + // If the value is nil, we return nil, however, go nil values are not + // always comparable, particularly when reflection is involved: + // https://dev.to/arxeiss/in-go-nil-is-not-equal-to-nil-sometimes-jn8 + if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) { + return nil, nil + } + b, err := v.MarshalText() + if err != nil { + return nil, err + } + return string(b), nil + default: + return nil, fmt.Errorf("only encoding.TextMarshaler is supported, got %t", v) + } +} diff --git a/hscontrol/policy/acls.go b/hscontrol/policy/acls.go index b166df03c2..e86335fcc7 100644 --- a/hscontrol/policy/acls.go +++ b/hscontrol/policy/acls.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" @@ -593,6 +594,11 @@ func (pol *ACLPolicy) ExpandAlias( // excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones // that are correctly tagged since they should not be listed as being in the user // we assume in this function that we only have nodes from 1 user. +// +// TODO(kradalby): It is quite hard to understand what this function is doing, +// it seems like it trying to ensure that we dont include nodes that are tagged +// when we look up the nodes owned by a user. +// This should be refactored to be more clear as part of the Tags work in #1369 func excludeCorrectlyTaggedNodes( aclPolicy *ACLPolicy, nodes types.Nodes, @@ -611,17 +617,16 @@ func excludeCorrectlyTaggedNodes( for _, node := range nodes { found := false - if node.Hostinfo == nil { - continue - } - - for _, t := range node.Hostinfo.RequestTags { - if util.StringOrPrefixListContains(tags, t) { - found = true + if node.Hostinfo != nil { + for _, t := range node.Hostinfo.RequestTags { + if util.StringOrPrefixListContains(tags, t) { + found = true - break + break + } } } + if len(node.ForcedTags) > 0 { found = true } @@ -966,6 +971,14 @@ func filterNodesByUser(nodes types.Nodes, user string) types.Nodes { return out } +func FilterRulesToMatchers(filter []tailcfg.FilterRule) []matcher.Match { + matchers := make([]matcher.Match, len(filter)) + for i, rule := range filter { + matchers[i] = matcher.MatchFromFilterRule(rule) + } + return matchers +} + // FilterNodesByACL returns the list of peers authorized to be accessed from a given node. func FilterNodesByACL( node *types.Node, @@ -974,12 +987,19 @@ func FilterNodesByACL( ) types.Nodes { var result types.Nodes + // TODO(kradalby): Regenerate this everytime the filter change, instead of + // every time we use it. + matchers := FilterRulesToMatchers(filter) + for index, peer := range nodes { if peer.ID == node.ID { continue } - if node.CanAccess(filter, nodes[index]) || peer.CanAccess(filter, node) { + log.Printf("Checking if %s can access %s", node.Hostname, peer.Hostname) + + if node.CanAccess(matchers, nodes[index]) || peer.CanAccess(matchers, node) { + log.Printf("CAN ACCESS %s can access %s", node.Hostname, peer.Hostname) result = append(result, peer) } } diff --git a/hscontrol/poll.go b/hscontrol/poll.go index f73c970f72..ffeff6466b 100644 --- a/hscontrol/poll.go +++ b/hscontrol/poll.go @@ -450,13 +450,13 @@ func (m *mapSession) handleEndpointUpdate() { sendUpdate, routesChanged := hostInfoChanged(m.node.Hostinfo, m.req.Hostinfo) // The node might not set NetInfo if it has not changed and if - // the full HostInfo object is overrwritten, the information is lost. + // the full HostInfo object is overwritten, the information is lost. // If there is no NetInfo, keep the previous one. // From 1.66 the client only sends it if changed: // https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2 // TODO(kradalby): evaulate if we need better comparing of hostinfo // before we take the changes. - if m.req.Hostinfo.NetInfo == nil { + if m.req.Hostinfo.NetInfo == nil && m.node.Hostinfo != nil { m.req.Hostinfo.NetInfo = m.node.Hostinfo.NetInfo } m.node.Hostinfo = m.req.Hostinfo @@ -663,8 +663,15 @@ func hostInfoChanged(old, new *tailcfg.Hostinfo) (bool, bool) { return false, false } + if old == nil && new != nil { + return true, true + } + // Routes - oldRoutes := old.RoutableIPs + oldRoutes := make([]netip.Prefix, 0) + if old != nil { + oldRoutes = old.RoutableIPs + } newRoutes := new.RoutableIPs sort.Slice(oldRoutes, func(i, j int) bool { diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 04ca9f8d37..1ba496c3c8 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -1,8 +1,6 @@ package types import ( - "database/sql" - "encoding/json" "errors" "fmt" "net/netip" @@ -15,7 +13,6 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "go4.org/netipx" "google.golang.org/protobuf/types/known/timestamppb" - "gorm.io/gorm" "tailscale.com/tailcfg" "tailscale.com/types/key" ) @@ -51,54 +48,16 @@ func (id NodeID) String() string { type Node struct { ID NodeID `gorm:"primary_key"` - // MachineKeyDatabaseField is the string representation of MachineKey - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use MachineKey instead. - MachineKeyDatabaseField string `gorm:"column:machine_key;unique_index"` - MachineKey key.MachinePublic `gorm:"-"` - - // NodeKeyDatabaseField is the string representation of NodeKey - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use NodeKey instead. - NodeKeyDatabaseField string `gorm:"column:node_key"` - NodeKey key.NodePublic `gorm:"-"` - - // DiscoKeyDatabaseField is the string representation of DiscoKey - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use DiscoKey instead. - DiscoKeyDatabaseField string `gorm:"column:disco_key"` - DiscoKey key.DiscoPublic `gorm:"-"` - - // EndpointsDatabaseField is the string list representation of Endpoints - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use Endpoints instead. - EndpointsDatabaseField StringList `gorm:"column:endpoints"` - Endpoints []netip.AddrPort `gorm:"-"` - - // EndpointsDatabaseField is the string list representation of Endpoints - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use Endpoints instead. - HostinfoDatabaseField string `gorm:"column:host_info"` - Hostinfo *tailcfg.Hostinfo `gorm:"-"` - - // IPv4DatabaseField is the string representation of v4 address, - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use V4 instead. - IPv4DatabaseField sql.NullString `gorm:"column:ipv4"` - IPv4 *netip.Addr `gorm:"-"` - - // IPv6DatabaseField is the string representation of v4 address, - // it is _only_ used for reading and writing the key to the - // database and should not be used. - // Use V6 instead. - IPv6DatabaseField sql.NullString `gorm:"column:ipv6"` - IPv6 *netip.Addr `gorm:"-"` + MachineKey key.MachinePublic `gorm:"serializer:text"` + NodeKey key.NodePublic `gorm:"serializer:text"` + DiscoKey key.DiscoPublic `gorm:"serializer:text"` + + Endpoints []netip.AddrPort `gorm:"serializer:json"` + + Hostinfo *tailcfg.Hostinfo `gorm:"serializer:json"` + + IPv4 *netip.Addr `gorm:"serializer:text"` + IPv6 *netip.Addr `gorm:"serializer:text"` // Hostname represents the name given by the Tailscale // client during registration @@ -212,7 +171,7 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) { } } -func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool { +func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool { src := node.IPs() allowedIPs := node2.IPs() @@ -222,10 +181,7 @@ func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool { } } - for _, rule := range filter { - // TODO(kradalby): Cache or pregen this - matcher := matcher.MatchFromFilterRule(rule) - + for _, matcher := range matchers { if !matcher.SrcsContainsIPs(src) { continue } @@ -255,109 +211,6 @@ func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes { return found } -// BeforeSave is a hook that ensures that some values that -// cannot be directly marshalled into database values are stored -// correctly in the database. -// This currently means storing the keys as strings. -func (node *Node) BeforeSave(tx *gorm.DB) error { - node.MachineKeyDatabaseField = node.MachineKey.String() - node.NodeKeyDatabaseField = node.NodeKey.String() - node.DiscoKeyDatabaseField = node.DiscoKey.String() - - var endpoints StringList - for _, addrPort := range node.Endpoints { - endpoints = append(endpoints, addrPort.String()) - } - - node.EndpointsDatabaseField = endpoints - - hi, err := json.Marshal(node.Hostinfo) - if err != nil { - return fmt.Errorf("marshalling Hostinfo to store in db: %w", err) - } - node.HostinfoDatabaseField = string(hi) - - if node.IPv4 != nil { - node.IPv4DatabaseField.String, node.IPv4DatabaseField.Valid = node.IPv4.String(), true - } else { - node.IPv4DatabaseField.String, node.IPv4DatabaseField.Valid = "", false - } - - if node.IPv6 != nil { - node.IPv6DatabaseField.String, node.IPv6DatabaseField.Valid = node.IPv6.String(), true - } else { - node.IPv6DatabaseField.String, node.IPv6DatabaseField.Valid = "", false - } - - return nil -} - -// AfterFind is a hook that ensures that Node objects fields that -// has a different type in the database is unwrapped and populated -// correctly. -// This currently unmarshals all the keys, stored as strings, into -// the proper types. -func (node *Node) AfterFind(tx *gorm.DB) error { - var machineKey key.MachinePublic - if err := machineKey.UnmarshalText([]byte(node.MachineKeyDatabaseField)); err != nil { - return fmt.Errorf("unmarshalling machine key from db: %w", err) - } - node.MachineKey = machineKey - - var nodeKey key.NodePublic - if err := nodeKey.UnmarshalText([]byte(node.NodeKeyDatabaseField)); err != nil { - return fmt.Errorf("unmarshalling node key from db: %w", err) - } - node.NodeKey = nodeKey - - // DiscoKey might be empty if a node has not sent it to headscale. - // This means that this might fail if the disco key is empty. - if node.DiscoKeyDatabaseField != "" { - var discoKey key.DiscoPublic - if err := discoKey.UnmarshalText([]byte(node.DiscoKeyDatabaseField)); err != nil { - return fmt.Errorf("unmarshalling disco key from db: %w", err) - } - node.DiscoKey = discoKey - } - - endpoints := make([]netip.AddrPort, len(node.EndpointsDatabaseField)) - for idx, ep := range node.EndpointsDatabaseField { - addrPort, err := netip.ParseAddrPort(ep) - if err != nil { - return fmt.Errorf("parsing endpoint from db: %w", err) - } - - endpoints[idx] = addrPort - } - node.Endpoints = endpoints - - var hi tailcfg.Hostinfo - if err := json.Unmarshal([]byte(node.HostinfoDatabaseField), &hi); err != nil { - return fmt.Errorf("unmarshalling hostinfo from database: %w", err) - } - node.Hostinfo = &hi - - if node.IPv4DatabaseField.Valid { - ip, err := netip.ParseAddr(node.IPv4DatabaseField.String) - if err != nil { - return fmt.Errorf("parsing IPv4 from database: %w", err) - } - - node.IPv4 = &ip - } - - if node.IPv6DatabaseField.Valid { - ip, err := netip.ParseAddr(node.IPv6DatabaseField.String) - if err != nil { - return fmt.Errorf("parsing IPv6 from database: %w", err) - } - - node.IPv6 = &ip - } - - return nil -} - func (node *Node) Proto() *v1.Node { nodeProto := &v1.Node{ Id: uint64(node.ID), diff --git a/integration/hsic/config.go b/integration/hsic/config.go index 244470f28b..509052a300 100644 --- a/integration/hsic/config.go +++ b/integration/hsic/config.go @@ -16,6 +16,8 @@ func DefaultConfigEnv() map[string]string { "HEADSCALE_POLICY_PATH": "", "HEADSCALE_DATABASE_TYPE": "sqlite", "HEADSCALE_DATABASE_SQLITE_PATH": "/tmp/integration_test_db.sqlite3", + "HEADSCALE_DATABASE_DEBUG": "1", + "HEADSCALE_DATABASE_GORM_SLOW_THRESHOLD": "1", "HEADSCALE_EPHEMERAL_NODE_INACTIVITY_TIMEOUT": "30m", "HEADSCALE_PREFIXES_V4": "100.64.0.0/10", "HEADSCALE_PREFIXES_V6": "fd7a:115c:a1e0::/48",