diff --git a/cmd/tl-longchain/tl-longchain.go b/cmd/tl-longchain/tl-longchain.go new file mode 100644 index 000000000..c92714505 --- /dev/null +++ b/cmd/tl-longchain/tl-longchain.go @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Program tl-longchain prints commands to re-sign Tailscale nodes that have +// long rotation signature chains. +// +// There is an implicit limit on the number of rotation signatures that can +// be chained before the signature becomes too long. This program helps +// tailnet admins to identify nodes that have signatures with long chains and +// prints commands to re-sign those node keys with a fresh direct signature. +// Commands are printed to stdout, while log messages are printed to stderr. +// +// Note that the Tailscale client this command is executed on must have +// ACL visibility to all other nodes to be able to see their signatures. +// https://tailscale.com/kb/1087/device-visibility +package main + +import ( + "context" + "flag" + "fmt" + "log" + "time" + + "tailscale.com/client/tailscale" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tka" + "tailscale.com/types/key" +) + +var ( + flagSocket = flag.String("socket", "", "custom path to tailscaled socket") + maxRotations = flag.Int("rotations", 10, "number of rotation signatures before re-signing (max 16)") + showFiltered = flag.Bool("show-filtered", false, "include nodes with invalid signatures") +) + +func main() { + flag.Parse() + + lc := tailscale.LocalClient{Socket: *flagSocket} + if lc.Socket != "" { + lc.UseSocketOnly = true + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + st, err := lc.NetworkLockStatus(ctx) + if err != nil { + log.Fatalf("could not get Tailnet Lock status: %v", err) + } + if !st.Enabled { + log.Print("Tailnet Lock is not enabled") + return + } + print("Self", *st.NodeKey, *st.NodeKeySignature) + if len(st.VisiblePeers) > 0 { + log.Print("Visible peers with valid signatures:") + for _, peer := range st.VisiblePeers { + print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) + } + } + if *showFiltered && len(st.FilteredPeers) > 0 { + log.Print("Visible peers with invalid signatures:") + for _, peer := range st.FilteredPeers { + print(peerInfo(peer), peer.NodeKey, peer.NodeKeySignature) + } + } +} + +// peerInfo returns a string with information about a peer. +func peerInfo(peer *ipnstate.TKAPeer) string { + return fmt.Sprintf("Peer %s (%s) nodeid=%s, current signature kind=%v", peer.Name, peer.TailscaleIPs[0], peer.StableID, peer.NodeKeySignature.SigKind) +} + +// print prints a message about a node key signature and a re-signing command if needed. +func print(info string, nodeKey key.NodePublic, sig tka.NodeKeySignature) { + if l := chainLength(sig); l > *maxRotations { + log.Printf("%s: chain length %d, printing command to re-sign", info, l) + wrapping, _ := sig.UnverifiedWrappingPublic() + fmt.Printf("tailscale lock sign %s %s\n", nodeKey, key.NLPublicFromEd25519Unsafe(wrapping).CLIString()) + } else { + log.Printf("%s: does not need re-signing", info) + } +} + +// chainLength returns the length of the rotation signature chain. +func chainLength(sig tka.NodeKeySignature) int { + if sig.SigKind != tka.SigRotation { + return 1 + } + return 1 + chainLength(*sig.Nested) +} diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index 90d3e30e7..6b64ea625 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -53,7 +53,7 @@ type tkaState struct { profile ipn.ProfileID authority *tka.Authority storage *tka.FS - filtered []ipnstate.TKAFilteredPeer + filtered []ipnstate.TKAPeer } // tkaFilterNetmapLocked checks the signatures on each node key, dropping @@ -99,7 +99,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) { // nm.Peers is ordered, so deletion must be order-preserving. if len(toDelete) > 0 || len(obsoleteByRotation) > 0 { peers := make([]tailcfg.NodeView, 0, len(nm.Peers)) - filtered := make([]ipnstate.TKAFilteredPeer, 0, len(toDelete)+len(obsoleteByRotation)) + filtered := make([]ipnstate.TKAPeer, 0, len(toDelete)+len(obsoleteByRotation)) for i, p := range nm.Peers { if !toDelete[i] && !obsoleteByRotation.Contains(p.Key()) { peers = append(peers, p) @@ -108,20 +108,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) { b.logf("Network lock is dropping peer %v(%v) due to key rotation", p.ID(), p.StableID()) } // Record information about the node we filtered out. - fp := ipnstate.TKAFilteredPeer{ - Name: p.Name(), - ID: p.ID(), - StableID: p.StableID(), - TailscaleIPs: make([]netip.Addr, p.Addresses().Len()), - NodeKey: p.Key(), - } - for i := range p.Addresses().Len() { - addr := p.Addresses().At(i) - if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) { - fp.TailscaleIPs[i] = addr.Addr() - } - } - filtered = append(filtered, fp) + filtered = append(filtered, tkaStateFromPeer(p)) } } nm.Peers = peers @@ -546,11 +533,17 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus { } } - filtered := make([]*ipnstate.TKAFilteredPeer, len(b.tka.filtered)) + filtered := make([]*ipnstate.TKAPeer, len(b.tka.filtered)) for i := range len(filtered) { filtered[i] = b.tka.filtered[i].Clone() } + visible := make([]*ipnstate.TKAPeer, len(b.netMap.Peers)) + for i, p := range b.netMap.Peers { + s := tkaStateFromPeer(p) + visible[i] = &s + } + stateID1, _ := b.tka.authority.StateIDs() return &ipnstate.NetworkLockStatus{ @@ -562,10 +555,32 @@ func (b *LocalBackend) NetworkLockStatus() *ipnstate.NetworkLockStatus { NodeKeySignature: nodeKeySignature, TrustedKeys: outKeys, FilteredPeers: filtered, + VisiblePeers: visible, StateID: stateID1, } } +func tkaStateFromPeer(p tailcfg.NodeView) ipnstate.TKAPeer { + fp := ipnstate.TKAPeer{ + Name: p.Name(), + ID: p.ID(), + StableID: p.StableID(), + TailscaleIPs: make([]netip.Addr, 0, p.Addresses().Len()), + NodeKey: p.Key(), + } + for i := range p.Addresses().Len() { + addr := p.Addresses().At(i) + if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) { + fp.TailscaleIPs = append(fp.TailscaleIPs, addr.Addr()) + } + } + var decoded tka.NodeKeySignature + if err := decoded.Unserialize(p.KeySignature().AsSlice()); err == nil { + fp.NodeKeySignature = decoded + } + return fp +} + // NetworkLockInit enables network-lock for the tailnet, with the tailnets' // key authority initialized to trust the provided keys. // diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index b38d75e5a..9f8bd34f6 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -26,7 +26,7 @@ import ( "tailscale.com/version" ) -//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAFilteredPeer +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAPeer // Status represents the entire state of the IPN network. type Status struct { @@ -94,15 +94,14 @@ type TKAKey struct { Votes uint } -// TKAFilteredPeer describes a peer which was removed from the netmap -// (i.e. no connectivity) because it failed tailnet lock -// checks. -type TKAFilteredPeer struct { - Name string // DNS - ID tailcfg.NodeID - StableID tailcfg.StableNodeID - TailscaleIPs []netip.Addr // Tailscale IP(s) assigned to this node - NodeKey key.NodePublic +// TKAPeer describes a peer and its network lock details. +type TKAPeer struct { + Name string // DNS + ID tailcfg.NodeID + StableID tailcfg.StableNodeID + TailscaleIPs []netip.Addr // Tailscale IP(s) assigned to this node + NodeKey key.NodePublic + NodeKeySignature tka.NodeKeySignature } // NetworkLockStatus represents whether network-lock is enabled, @@ -134,10 +133,14 @@ type NetworkLockStatus struct { // to network-lock. TrustedKeys []TKAKey + // VisiblePeers describes peers which are visible in the netmap that + // have valid Tailnet Lock signatures signatures. + VisiblePeers []*TKAPeer + // FilteredPeers describes peers which were removed from the netmap // (i.e. no connectivity) because they failed tailnet lock // checks. - FilteredPeers []*TKAFilteredPeer + FilteredPeers []*TKAPeer // StateID is a nonce associated with the network lock authority, // generated upon enablement. This field is not populated if the diff --git a/ipn/ipnstate/ipnstate_clone.go b/ipn/ipnstate/ipnstate_clone.go index 262daf3f2..20ae43c5f 100644 --- a/ipn/ipnstate/ipnstate_clone.go +++ b/ipn/ipnstate/ipnstate_clone.go @@ -9,26 +9,29 @@ import ( "net/netip" "tailscale.com/tailcfg" + "tailscale.com/tka" "tailscale.com/types/key" ) -// Clone makes a deep copy of TKAFilteredPeer. +// Clone makes a deep copy of TKAPeer. // The result aliases no memory with the original. -func (src *TKAFilteredPeer) Clone() *TKAFilteredPeer { +func (src *TKAPeer) Clone() *TKAPeer { if src == nil { return nil } - dst := new(TKAFilteredPeer) + dst := new(TKAPeer) *dst = *src dst.TailscaleIPs = append(src.TailscaleIPs[:0:0], src.TailscaleIPs...) + dst.NodeKeySignature = *src.NodeKeySignature.Clone() return dst } // A compilation failure here means this code must be regenerated, with the command at the top of this file. -var _TKAFilteredPeerCloneNeedsRegeneration = TKAFilteredPeer(struct { - Name string - ID tailcfg.NodeID - StableID tailcfg.StableNodeID - TailscaleIPs []netip.Addr - NodeKey key.NodePublic +var _TKAPeerCloneNeedsRegeneration = TKAPeer(struct { + Name string + ID tailcfg.NodeID + StableID tailcfg.StableNodeID + TailscaleIPs []netip.Addr + NodeKey key.NodePublic + NodeKeySignature tka.NodeKeySignature }{}) diff --git a/tka/sig.go b/tka/sig.go index 6c68a588e..d3fe0ff6c 100644 --- a/tka/sig.go +++ b/tka/sig.go @@ -19,6 +19,8 @@ import ( "tailscale.com/types/tkatype" ) +//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=NodeKeySignature + // SigKind describes valid NodeKeySignature types. type SigKind uint8 diff --git a/tka/tka_clone.go b/tka/tka_clone.go new file mode 100644 index 000000000..323a824fe --- /dev/null +++ b/tka/tka_clone.go @@ -0,0 +1,32 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT. + +package tka + +// Clone makes a deep copy of NodeKeySignature. +// The result aliases no memory with the original. +func (src *NodeKeySignature) Clone() *NodeKeySignature { + if src == nil { + return nil + } + dst := new(NodeKeySignature) + *dst = *src + dst.Pubkey = append(src.Pubkey[:0:0], src.Pubkey...) + dst.KeyID = append(src.KeyID[:0:0], src.KeyID...) + dst.Signature = append(src.Signature[:0:0], src.Signature...) + dst.Nested = src.Nested.Clone() + dst.WrappingPubkey = append(src.WrappingPubkey[:0:0], src.WrappingPubkey...) + return dst +} + +// A compilation failure here means this code must be regenerated, with the command at the top of this file. +var _NodeKeySignatureCloneNeedsRegeneration = NodeKeySignature(struct { + SigKind SigKind + Pubkey []byte + KeyID []byte + Signature []byte + Nested *NodeKeySignature + WrappingPubkey []byte +}{})