2023-09-24 11:42:05 +00:00
|
|
|
package types
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"net/netip"
|
2024-02-23 09:59:24 +00:00
|
|
|
"strconv"
|
2023-09-24 11:42:05 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
2024-02-23 09:59:24 +00:00
|
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
2023-09-24 11:42:05 +00:00
|
|
|
"go4.org/netipx"
|
|
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"tailscale.com/tailcfg"
|
|
|
|
"tailscale.com/types/key"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
ErrNodeAddressesInvalid = errors.New("failed to parse node addresses")
|
2023-12-09 17:09:24 +00:00
|
|
|
ErrHostnameTooLong = errors.New("hostname too long, cannot except 255 ASCII chars")
|
|
|
|
ErrNodeHasNoGivenName = errors.New("node has no given name")
|
|
|
|
ErrNodeUserHasNoName = errors.New("node user has no name")
|
2023-09-24 11:42:05 +00:00
|
|
|
)
|
|
|
|
|
2024-02-23 09:59:24 +00:00
|
|
|
type NodeID uint64
|
2024-04-21 16:28:17 +00:00
|
|
|
|
|
|
|
// type NodeConnectedMap *xsync.MapOf[NodeID, bool]
|
2024-02-23 09:59:24 +00:00
|
|
|
|
|
|
|
func (id NodeID) StableID() tailcfg.StableNodeID {
|
|
|
|
return tailcfg.StableNodeID(strconv.FormatUint(uint64(id), util.Base10))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (id NodeID) NodeID() tailcfg.NodeID {
|
|
|
|
return tailcfg.NodeID(id)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (id NodeID) Uint64() uint64 {
|
|
|
|
return uint64(id)
|
|
|
|
}
|
|
|
|
|
2024-05-24 08:15:34 +00:00
|
|
|
func (id NodeID) String() string {
|
|
|
|
return strconv.FormatUint(id.Uint64(), util.Base10)
|
|
|
|
}
|
|
|
|
|
2023-09-24 11:42:05 +00:00
|
|
|
// Node is a Headscale client.
|
|
|
|
type Node struct {
|
2024-02-23 09:59:24 +00:00
|
|
|
ID NodeID `gorm:"primary_key"`
|
2023-11-19 21:37:04 +00:00
|
|
|
|
2024-10-02 09:41:58 +00:00
|
|
|
MachineKey key.MachinePublic `gorm:"serializer:text"`
|
|
|
|
NodeKey key.NodePublic `gorm:"serializer:text"`
|
|
|
|
DiscoKey key.DiscoPublic `gorm:"serializer:text"`
|
|
|
|
|
|
|
|
Endpoints []netip.AddrPort `gorm:"serializer:json"`
|
|
|
|
|
2024-10-02 16:12:25 +00:00
|
|
|
Hostinfo *tailcfg.Hostinfo `gorm:"column:host_info;serializer:json"`
|
2024-10-02 09:41:58 +00:00
|
|
|
|
2024-10-02 16:12:25 +00:00
|
|
|
IPv4 *netip.Addr `gorm:"column:ipv4;serializer:text"`
|
|
|
|
IPv6 *netip.Addr `gorm:"column:ipv6;serializer:text"`
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
// Hostname represents the name given by the Tailscale
|
|
|
|
// client during registration
|
|
|
|
Hostname string
|
|
|
|
|
|
|
|
// Givenname represents either:
|
|
|
|
// a DNS normalized version of Hostname
|
|
|
|
// a valid name set by the User
|
|
|
|
//
|
|
|
|
// GivenName is the name used in all DNS related
|
|
|
|
// parts of headscale.
|
|
|
|
GivenName string `gorm:"type:varchar(63);unique_index"`
|
|
|
|
UserID uint
|
2024-05-16 00:40:14 +00:00
|
|
|
User User `gorm:"constraint:OnDelete:CASCADE;"`
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
RegisterMethod string
|
|
|
|
|
2024-10-02 09:41:58 +00:00
|
|
|
ForcedTags []string `gorm:"serializer:json"`
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
// TODO(kradalby): This seems like irrelevant information?
|
2024-07-18 08:01:59 +00:00
|
|
|
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
|
2024-05-16 00:40:14 +00:00
|
|
|
AuthKey *PreAuthKey `gorm:"constraint:OnDelete:SET NULL;"`
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
LastSeen *time.Time
|
|
|
|
Expiry *time.Time
|
|
|
|
|
2024-05-16 00:40:14 +00:00
|
|
|
Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
UpdatedAt time.Time
|
|
|
|
DeletedAt *time.Time
|
2023-12-09 17:09:24 +00:00
|
|
|
|
|
|
|
IsOnline *bool `gorm:"-"`
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type (
|
|
|
|
Nodes []*Node
|
|
|
|
)
|
|
|
|
|
2024-10-17 15:45:33 +00:00
|
|
|
// GivenNameHasBeenChanged returns whether the `givenName` can be automatically changed based on the `Hostname` of the node.
|
|
|
|
func (node *Node) GivenNameHasBeenChanged() bool {
|
|
|
|
return node.GivenName == util.ConvertWithFQDNRules(node.Hostname)
|
|
|
|
}
|
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
// IsExpired returns whether the node registration has expired.
|
|
|
|
func (node Node) IsExpired() bool {
|
|
|
|
// If Expiry is not set, the client has not indicated that
|
|
|
|
// it wants an expiry time, it is therefor considered
|
|
|
|
// to mean "not expired"
|
|
|
|
if node.Expiry == nil || node.Expiry.IsZero() {
|
|
|
|
return false
|
|
|
|
}
|
2023-09-24 11:42:05 +00:00
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
return time.Since(*node.Expiry) > 0
|
|
|
|
}
|
2023-09-24 11:42:05 +00:00
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
// IsEphemeral returns if the node is registered as an Ephemeral node.
|
|
|
|
// https://tailscale.com/kb/1111/ephemeral-nodes/
|
|
|
|
func (node *Node) IsEphemeral() bool {
|
|
|
|
return node.AuthKey != nil && node.AuthKey.Ephemeral
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
func (node *Node) IPs() []netip.Addr {
|
|
|
|
var ret []netip.Addr
|
|
|
|
|
|
|
|
if node.IPv4 != nil {
|
|
|
|
ret = append(ret, *node.IPv4)
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.IPv6 != nil {
|
|
|
|
ret = append(ret, *node.IPv6)
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
return ret
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
func (node *Node) Prefixes() []netip.Prefix {
|
2023-09-24 11:42:05 +00:00
|
|
|
addrs := []netip.Prefix{}
|
2024-04-17 05:03:06 +00:00
|
|
|
for _, nodeAddress := range node.IPs() {
|
2023-09-24 11:42:05 +00:00
|
|
|
ip := netip.PrefixFrom(nodeAddress, nodeAddress.BitLen())
|
|
|
|
addrs = append(addrs, ip)
|
|
|
|
}
|
|
|
|
|
|
|
|
return addrs
|
|
|
|
}
|
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
func (node *Node) IPsAsString() []string {
|
|
|
|
var ret []string
|
|
|
|
|
|
|
|
if node.IPv4 != nil {
|
|
|
|
ret = append(ret, node.IPv4.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.IPv6 != nil {
|
|
|
|
ret = append(ret, node.IPv6.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
func (node *Node) InIPSet(set *netipx.IPSet) bool {
|
|
|
|
for _, nodeAddr := range node.IPs() {
|
2023-09-24 11:42:05 +00:00
|
|
|
if set.Contains(nodeAddr) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
// AppendToIPSet adds the individual ips in NodeAddresses to a
|
|
|
|
// given netipx.IPSetBuilder.
|
2024-04-17 05:03:06 +00:00
|
|
|
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
|
|
|
for _, ip := range node.IPs() {
|
2023-09-24 11:42:05 +00:00
|
|
|
build.Add(ip)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool {
|
2024-04-17 05:03:06 +00:00
|
|
|
src := node.IPs()
|
|
|
|
allowedIPs := node2.IPs()
|
2024-02-12 10:44:37 +00:00
|
|
|
|
2024-10-02 09:41:58 +00:00
|
|
|
// TODO(kradalby): Regenerate this everytime the filter change, instead of
|
|
|
|
// every time we use it.
|
|
|
|
matchers := make([]matcher.Match, len(filter))
|
|
|
|
for i, rule := range filter {
|
|
|
|
matchers[i] = matcher.MatchFromFilterRule(rule)
|
|
|
|
}
|
|
|
|
|
2024-02-12 10:44:37 +00:00
|
|
|
for _, route := range node2.Routes {
|
|
|
|
if route.Enabled {
|
|
|
|
allowedIPs = append(allowedIPs, netip.Prefix(route.Prefix).Addr())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-02 09:41:58 +00:00
|
|
|
for _, matcher := range matchers {
|
2024-04-17 05:03:06 +00:00
|
|
|
if !matcher.SrcsContainsIPs(src) {
|
2023-09-24 11:42:05 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2024-02-12 10:44:37 +00:00
|
|
|
if matcher.DestsContainsIP(allowedIPs) {
|
2023-09-24 11:42:05 +00:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes {
|
2024-04-17 05:03:06 +00:00
|
|
|
var found Nodes
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
for _, node := range nodes {
|
2024-04-17 05:03:06 +00:00
|
|
|
if node.IPv4 != nil && ip == *node.IPv4 {
|
|
|
|
found = append(found, node)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.IPv6 != nil && ip == *node.IPv6 {
|
|
|
|
found = append(found, node)
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return found
|
|
|
|
}
|
|
|
|
|
2024-11-22 12:23:05 +00:00
|
|
|
func (nodes Nodes) ContainsNodeKey(nodeKey key.NodePublic) bool {
|
|
|
|
for _, node := range nodes {
|
|
|
|
if node.NodeKey == nodeKey {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2023-09-24 11:42:05 +00:00
|
|
|
func (node *Node) Proto() *v1.Node {
|
|
|
|
nodeProto := &v1.Node{
|
2024-02-23 09:59:24 +00:00
|
|
|
Id: uint64(node.ID),
|
2023-11-19 21:37:04 +00:00
|
|
|
MachineKey: node.MachineKey.String(),
|
2023-09-24 11:42:05 +00:00
|
|
|
|
2024-04-17 05:03:06 +00:00
|
|
|
NodeKey: node.NodeKey.String(),
|
|
|
|
DiscoKey: node.DiscoKey.String(),
|
|
|
|
|
|
|
|
// TODO(kradalby): replace list with v4, v6 field?
|
|
|
|
IpAddresses: node.IPsAsString(),
|
2023-09-24 11:42:05 +00:00
|
|
|
Name: node.Hostname,
|
|
|
|
GivenName: node.GivenName,
|
|
|
|
User: node.User.Proto(),
|
|
|
|
ForcedTags: node.ForcedTags,
|
|
|
|
|
2024-07-17 11:12:16 +00:00
|
|
|
RegisterMethod: node.RegisterMethodToV1Enum(),
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
CreatedAt: timestamppb.New(node.CreatedAt),
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.AuthKey != nil {
|
|
|
|
nodeProto.PreAuthKey = node.AuthKey.Proto()
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.LastSeen != nil {
|
|
|
|
nodeProto.LastSeen = timestamppb.New(*node.LastSeen)
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.Expiry != nil {
|
|
|
|
nodeProto.Expiry = timestamppb.New(*node.Expiry)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nodeProto
|
|
|
|
}
|
|
|
|
|
Redo OIDC configuration (#2020)
expand user, add claims to user
This commit expands the user table with additional fields that
can be retrieved from OIDC providers (and other places) and
uses this data in various tailscale response objects if it is
available.
This is the beginning of implementing
https://docs.google.com/document/d/1X85PMxIaVWDF6T_UPji3OeeUqVBcGj_uHRM5CI-AwlY/edit
trying to make OIDC more coherant and maintainable in addition
to giving the user a better experience and integration with a
provider.
remove usernames in magic dns, normalisation of emails
this commit removes the option to have usernames as part of MagicDNS
domains and headscale will now align with Tailscale, where there is a
root domain, and the machine name.
In addition, the various normalisation functions for dns names has been
made lighter not caring about username and special character that wont
occur.
Email are no longer normalised as part of the policy processing.
untagle oidc and regcache, use typed cache
This commits stops reusing the registration cache for oidc
purposes and switches the cache to be types and not use any
allowing the removal of a bunch of casting.
try to make reauth/register branches clearer in oidc
Currently there was a function that did a bunch of stuff,
finding the machine key, trying to find the node, reauthing
the node, returning some status, and it was called validate
which was very confusing.
This commit tries to split this into what to do if the node
exists, if it needs to register etc.
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2024-10-02 12:50:17 +00:00
|
|
|
func (node *Node) GetFQDN(baseDomain string) (string, error) {
|
2024-08-19 09:41:05 +00:00
|
|
|
if node.GivenName == "" {
|
|
|
|
return "", fmt.Errorf("failed to create valid FQDN: %w", ErrNodeHasNoGivenName)
|
|
|
|
}
|
2023-12-09 17:09:24 +00:00
|
|
|
|
2024-08-19 09:41:05 +00:00
|
|
|
hostname := node.GivenName
|
|
|
|
|
|
|
|
if baseDomain != "" {
|
2023-09-24 11:42:05 +00:00
|
|
|
hostname = fmt.Sprintf(
|
2024-06-26 11:44:40 +00:00
|
|
|
"%s.%s",
|
2023-09-24 11:42:05 +00:00
|
|
|
node.GivenName,
|
|
|
|
baseDomain,
|
|
|
|
)
|
2024-08-19 09:41:05 +00:00
|
|
|
}
|
2024-06-26 11:44:40 +00:00
|
|
|
|
2024-08-19 09:41:05 +00:00
|
|
|
if len(hostname) > MaxHostnameLength {
|
|
|
|
return "", fmt.Errorf(
|
|
|
|
"failed to create valid FQDN (%s): %w",
|
|
|
|
hostname,
|
|
|
|
ErrHostnameTooLong,
|
|
|
|
)
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return hostname, nil
|
|
|
|
}
|
|
|
|
|
2023-12-09 17:09:24 +00:00
|
|
|
// func (node *Node) String() string {
|
|
|
|
// return node.Hostname
|
|
|
|
// }
|
|
|
|
|
|
|
|
// PeerChangeFromMapRequest takes a MapRequest and compares it to the node
|
|
|
|
// to produce a PeerChange struct that can be used to updated the node and
|
|
|
|
// inform peers about smaller changes to the node.
|
|
|
|
// When a field is added to this function, remember to also add it to:
|
|
|
|
// - node.ApplyPeerChange
|
2024-01-16 15:04:03 +00:00
|
|
|
// - logTracePeerChange in poll.go.
|
2023-12-09 17:09:24 +00:00
|
|
|
func (node *Node) PeerChangeFromMapRequest(req tailcfg.MapRequest) tailcfg.PeerChange {
|
|
|
|
ret := tailcfg.PeerChange{
|
|
|
|
NodeID: tailcfg.NodeID(node.ID),
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.NodeKey.String() != req.NodeKey.String() {
|
|
|
|
ret.Key = &req.NodeKey
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.DiscoKey.String() != req.DiscoKey.String() {
|
|
|
|
ret.DiscoKey = &req.DiscoKey
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.Hostinfo != nil &&
|
|
|
|
node.Hostinfo.NetInfo != nil &&
|
|
|
|
req.Hostinfo != nil &&
|
|
|
|
req.Hostinfo.NetInfo != nil &&
|
|
|
|
node.Hostinfo.NetInfo.PreferredDERP != req.Hostinfo.NetInfo.PreferredDERP {
|
|
|
|
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.Hostinfo != nil && req.Hostinfo.NetInfo != nil {
|
|
|
|
// If there is no stored Hostinfo or NetInfo, use
|
|
|
|
// the new PreferredDERP.
|
|
|
|
if node.Hostinfo == nil {
|
|
|
|
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
|
|
|
|
} else if node.Hostinfo.NetInfo == nil {
|
|
|
|
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
|
|
|
|
} else {
|
|
|
|
// If there is a PreferredDERP check if it has changed.
|
|
|
|
if node.Hostinfo.NetInfo.PreferredDERP != req.Hostinfo.NetInfo.PreferredDERP {
|
|
|
|
ret.DERPRegion = req.Hostinfo.NetInfo.PreferredDERP
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO(kradalby): Find a good way to compare updates
|
|
|
|
ret.Endpoints = req.Endpoints
|
|
|
|
|
|
|
|
now := time.Now()
|
|
|
|
ret.LastSeen = &now
|
|
|
|
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
2024-07-17 11:12:16 +00:00
|
|
|
func (node *Node) RegisterMethodToV1Enum() v1.RegisterMethod {
|
|
|
|
switch node.RegisterMethod {
|
|
|
|
case "authkey":
|
|
|
|
return v1.RegisterMethod_REGISTER_METHOD_AUTH_KEY
|
|
|
|
case "oidc":
|
|
|
|
return v1.RegisterMethod_REGISTER_METHOD_OIDC
|
|
|
|
case "cli":
|
|
|
|
return v1.RegisterMethod_REGISTER_METHOD_CLI
|
|
|
|
default:
|
|
|
|
return v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-17 15:45:33 +00:00
|
|
|
// ApplyHostnameFromHostInfo takes a Hostinfo struct and updates the node.
|
|
|
|
func (node *Node) ApplyHostnameFromHostInfo(hostInfo *tailcfg.Hostinfo) {
|
|
|
|
if hostInfo == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if node.Hostname != hostInfo.Hostname {
|
|
|
|
if node.GivenNameHasBeenChanged() {
|
|
|
|
node.GivenName = util.ConvertWithFQDNRules(hostInfo.Hostname)
|
|
|
|
}
|
|
|
|
|
|
|
|
node.Hostname = hostInfo.Hostname
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-09 17:09:24 +00:00
|
|
|
// ApplyPeerChange takes a PeerChange struct and updates the node.
|
|
|
|
func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) {
|
|
|
|
if change.Key != nil {
|
|
|
|
node.NodeKey = *change.Key
|
|
|
|
}
|
|
|
|
|
|
|
|
if change.DiscoKey != nil {
|
|
|
|
node.DiscoKey = *change.DiscoKey
|
|
|
|
}
|
|
|
|
|
|
|
|
if change.Online != nil {
|
|
|
|
node.IsOnline = change.Online
|
|
|
|
}
|
|
|
|
|
|
|
|
if change.Endpoints != nil {
|
|
|
|
node.Endpoints = change.Endpoints
|
|
|
|
}
|
|
|
|
|
|
|
|
// This might technically not be useful as we replace
|
|
|
|
// the whole hostinfo blob when it has changed.
|
|
|
|
if change.DERPRegion != 0 {
|
|
|
|
if node.Hostinfo == nil {
|
|
|
|
node.Hostinfo = &tailcfg.Hostinfo{
|
|
|
|
NetInfo: &tailcfg.NetInfo{
|
|
|
|
PreferredDERP: change.DERPRegion,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
} else if node.Hostinfo.NetInfo == nil {
|
|
|
|
node.Hostinfo.NetInfo = &tailcfg.NetInfo{
|
|
|
|
PreferredDERP: change.DERPRegion,
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
node.Hostinfo.NetInfo.PreferredDERP = change.DERPRegion
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
node.LastSeen = change.LastSeen
|
2023-09-24 11:42:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (nodes Nodes) String() string {
|
|
|
|
temp := make([]string, len(nodes))
|
|
|
|
|
|
|
|
for index, node := range nodes {
|
|
|
|
temp[index] = node.Hostname
|
|
|
|
}
|
|
|
|
|
|
|
|
return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
|
|
|
|
}
|
|
|
|
|
2024-02-23 09:59:24 +00:00
|
|
|
func (nodes Nodes) IDMap() map[NodeID]*Node {
|
|
|
|
ret := map[NodeID]*Node{}
|
2023-09-24 11:42:05 +00:00
|
|
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
ret[node.ID] = node
|
|
|
|
}
|
|
|
|
|
|
|
|
return ret
|
|
|
|
}
|