tailcfg: add Node.UnsignedPeerAPIOnly to let server mark node as peerapi-only

capver 48

Change-Id: I20b2fa81d61ef8cc8a84e5f2afeefb68832bd904
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2022-11-02 13:13:26 -07:00
committed by Brad Fitzpatrick
parent 3367136d9e
commit e55ae53169
9 changed files with 193 additions and 11 deletions

View File

@@ -1303,6 +1303,14 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
localNetsB.AddPrefix(p)
}
packetFilter = netMap.PacketFilter
if packetFilterPermitsUnlockedNodes(netMap.Peers, packetFilter) {
err := errors.New("server sent invalid packet filter permitting traffic to unlocked nodes; rejecting all packets for safety")
health.SetValidUnsignedNodes(err)
packetFilter = nil
} else {
health.SetValidUnsignedNodes(nil)
}
}
if prefs.Valid() {
ar := prefs.AdvertiseRoutes()
@@ -1375,6 +1383,45 @@ func (b *LocalBackend) updateFilterLocked(netMap *netmap.NetworkMap, prefs ipn.P
}
}
// packetFilterPermitsUnlockedNodes reports any peer in peers with the
// UnsignedPeerAPIOnly bool set true has any of its allowed IPs in the packet
// filter.
//
// If this reports true, the packet filter is invalid (the server is either broken
// or malicious) and should be ignored for safety.
func packetFilterPermitsUnlockedNodes(peers []*tailcfg.Node, packetFilter []filter.Match) bool {
var b netipx.IPSetBuilder
var numUnlocked int
for _, p := range peers {
if !p.UnsignedPeerAPIOnly {
continue
}
numUnlocked++
for _, a := range p.AllowedIPs { // not only addresses!
b.AddPrefix(a)
}
}
if numUnlocked == 0 {
return false
}
s, err := b.IPSet()
if err != nil {
// Shouldn't happen, but if it does, fail closed.
return true
}
for _, m := range packetFilter {
for _, r := range m.Srcs {
if !s.OverlapsPrefix(r) {
continue
}
if len(m.Dsts) != 0 {
return true
}
}
}
return false
}
func (b *LocalBackend) setFilter(f *filter.Filter) {
b.filterAtomic.Store(f)
b.e.SetFilter(f)

View File

@@ -22,6 +22,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/wgcfg"
)
@@ -638,3 +639,109 @@ func TestInternalAndExternalInterfaces(t *testing.T) {
})
}
}
func TestPacketFilterPermitsUnlockedNodes(t *testing.T) {
tests := []struct {
name string
peers []*tailcfg.Node
filter []filter.Match
want bool
}{
{
name: "empty",
want: false,
},
{
name: "no-unsigned",
peers: []*tailcfg.Node{
{ID: 1},
},
want: false,
},
{
name: "unsigned-good",
peers: []*tailcfg.Node{
{ID: 1, UnsignedPeerAPIOnly: true},
},
want: false,
},
{
name: "unsigned-bad",
peers: []*tailcfg.Node{
{
ID: 1,
UnsignedPeerAPIOnly: true,
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/32"),
},
},
},
filter: []filter.Match{
{
Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")},
Dsts: []filter.NetPortRange{
{
Net: netip.MustParsePrefix("100.99.0.0/32"),
},
},
},
},
want: true,
},
{
name: "unsigned-bad-src-is-superset",
peers: []*tailcfg.Node{
{
ID: 1,
UnsignedPeerAPIOnly: true,
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/32"),
},
},
},
filter: []filter.Match{
{
Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/24")},
Dsts: []filter.NetPortRange{
{
Net: netip.MustParsePrefix("100.99.0.0/32"),
},
},
},
},
want: true,
},
{
name: "unsigned-okay-because-no-dsts",
peers: []*tailcfg.Node{
{
ID: 1,
UnsignedPeerAPIOnly: true,
AllowedIPs: []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/32"),
},
},
},
filter: []filter.Match{
{
Srcs: []netip.Prefix{netip.MustParsePrefix("100.64.0.0/32")},
Caps: []filter.CapMatch{
{
Dst: netip.MustParsePrefix("100.99.0.0/32"),
Cap: "foo",
},
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := packetFilterPermitsUnlockedNodes(tt.peers, tt.filter); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -23,6 +23,7 @@ import (
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/tkatype"
"tailscale.com/util/mak"
)
// TODO(tom): RPC retry/backoff was broken and has been removed. Fix?
@@ -38,7 +39,7 @@ type tkaState struct {
}
// tkaFilterNetmapLocked checks the signatures on each node key, dropping
// nodes from the netmap who's signature does not verify.
// nodes from the netmap whose signature does not verify.
//
// b.mu must be held.
func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
@@ -49,27 +50,33 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
return // TKA not enabled.
}
toDelete := make(map[int]struct{}, len(nm.Peers))
var toDelete map[int]bool // peer index => true
for i, p := range nm.Peers {
if p.UnsignedPeerAPIOnly {
// Not subject to TKA.
continue
}
if len(p.KeySignature) == 0 {
b.logf("Network lock is dropping peer %v(%v) due to missing signature", p.ID, p.StableID)
toDelete[i] = struct{}{}
mak.Set(&toDelete, i, true)
} else {
if err := b.tka.authority.NodeKeyAuthorized(p.Key, p.KeySignature); err != nil {
b.logf("Network lock is dropping peer %v(%v) due to failed signature check: %v", p.ID, p.StableID, err)
toDelete[i] = struct{}{}
mak.Set(&toDelete, i, true)
}
}
}
// nm.Peers is ordered, so deletion must be order-preserving.
peers := make([]*tailcfg.Node, 0, len(nm.Peers))
for i, p := range nm.Peers {
if _, delete := toDelete[i]; !delete {
peers = append(peers, p)
if len(toDelete) > 0 {
peers := make([]*tailcfg.Node, 0, len(nm.Peers))
for i, p := range nm.Peers {
if !toDelete[i] {
peers = append(peers, p)
}
}
nm.Peers = peers
}
nm.Peers = peers
}
// tkaSyncIfNeeded examines TKA info reported from the control plane,