[management] Add public connection ipv4 and ipv6 posture check (#6038)

This change enables admins to configure posture checks for connecting public IPs of their peers.

It changes the behavior of the check as well and now the evaluation is if the received network is part of the configured network.
This commit is contained in:
Misha Bragin
2026-04-30 18:36:50 +02:00
committed by GitHub
parent dcd1db42ef
commit c4b2da4c92
4 changed files with 247 additions and 15 deletions

View File

@@ -17,19 +17,48 @@ type PeerNetworkRangeCheck struct {
var _ Check = (*PeerNetworkRangeCheck)(nil)
// prefixContains reports whether outer fully contains inner (equal counts as contained).
// Requires the same address family, that outer is no more specific than inner (its
// netmask is shorter or equal), and that inner's network address falls inside outer.
// This is stricter than netip.Prefix.Contains(Addr) — a peer's /24 NIC will not match a
// configured /32 rule, since the rule covers a single host but the NIC describes a whole
// subnet whose host bits are unknown.
func prefixContains(outer, inner netip.Prefix) bool {
outer = outer.Masked()
inner = inner.Masked()
return outer.Bits() <= inner.Bits() &&
outer.Addr().BitLen() == inner.Addr().BitLen() && // same family
outer.Contains(inner.Addr())
}
// Check evaluates configured ranges against the peer's local network interface prefixes
// and its public connection IP (as a /32 or /128). A configured range matches when it
// fully contains one of those prefixes, so operators can target both private subnets
// and public CIDRs (e.g. 1.0.0.0/24, 2.2.2.2/32). Including the connection IP is what
// lets a public-range posture check work — peer.Meta.NetworkAddresses only carries
// local NIC addresses.
func (p *PeerNetworkRangeCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) {
if len(peer.Meta.NetworkAddresses) == 0 {
peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1)
for _, peerNetAddr := range peer.Meta.NetworkAddresses {
peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP)
}
// Unmap collapses 4-in-6 forms (::ffff:a.b.c.d) so an IPv4 range matches.
if connIP := peer.Location.ConnectionIP; len(connIP) > 0 {
if addr, ok := netip.AddrFromSlice(connIP); ok {
addr = addr.Unmap()
peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
}
}
if len(peerPrefixes) == 0 {
return false, fmt.Errorf("peer's does not contain peer network range addresses")
}
maskedPrefixes := make([]netip.Prefix, 0, len(p.Ranges))
for _, prefix := range p.Ranges {
maskedPrefixes = append(maskedPrefixes, prefix.Masked())
}
for _, peerNetAddr := range peer.Meta.NetworkAddresses {
peerMaskedPrefix := peerNetAddr.NetIP.Masked()
if slices.Contains(maskedPrefixes, peerMaskedPrefix) {
for _, peerPrefix := range peerPrefixes {
for _, rangePrefix := range p.Ranges {
if !prefixContains(rangePrefix, peerPrefix) {
continue
}
switch p.Action {
case CheckActionDeny:
return false, nil

View File

@@ -2,6 +2,7 @@ package posture
import (
"context"
"net"
"net/netip"
"testing"
@@ -134,6 +135,205 @@ func TestPeerNetworkRangeCheck_Check(t *testing.T) {
wantErr: true,
isValid: false,
},
{
name: "Peer connection IP matches the denied /32",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("109.41.115.194/32"),
},
},
peer: nbpeer.Peer{
Meta: nbpeer.PeerSystemMeta{
NetworkAddresses: []nbpeer.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.0.123/24")},
},
},
Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")},
},
wantErr: false,
isValid: false,
},
{
name: "Peer connection IP does not match the denied /32",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("109.41.115.194/32"),
},
},
peer: nbpeer.Peer{
Meta: nbpeer.PeerSystemMeta{
NetworkAddresses: []nbpeer.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.0.123/24")},
},
},
Location: nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")},
},
wantErr: false,
isValid: true,
},
{
name: "Peer connection IP matches the allowed /32 with no NetworkAddresses",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("109.41.115.194/32"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")},
},
wantErr: false,
isValid: true,
},
{
name: "IPv6 connection IP matches the denied /128",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("2001:db8::1/128"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::1")},
},
wantErr: false,
isValid: false,
},
{
name: "IPv6 connection IP does not match the denied /128",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("2001:db8::1/128"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::2")},
},
wantErr: false,
isValid: true,
},
{
name: "IPv4-mapped IPv6 connection IP matches IPv4 /32",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("109.41.115.194/32"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("::ffff:109.41.115.194")},
},
wantErr: false,
isValid: false,
},
{
name: "Connection IP falls inside an allowed /24 range",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("1.0.0.0/24"),
netip.MustParsePrefix("2.2.2.2/32"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.55")},
},
wantErr: false,
isValid: true,
},
{
name: "Connection IP falls inside an allowed /23 range",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("3.0.0.0/23"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("3.0.1.200")},
},
wantErr: false,
isValid: true,
},
{
name: "Connection IP outside the allowed /24 range",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("1.0.0.0/24"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.1.5")},
},
wantErr: false,
isValid: false,
},
{
name: "Connection IP inside a denied /24 range",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("1.0.0.0/24"),
},
},
peer: nbpeer.Peer{
Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.7")},
},
wantErr: false,
isValid: false,
},
{
name: "Local NIC /24 does not match a /32 rule even if host bit lines up",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("192.168.0.5/32"),
},
},
peer: nbpeer.Peer{
Meta: nbpeer.PeerSystemMeta{
NetworkAddresses: []nbpeer.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.0.5/24")},
},
},
},
wantErr: false,
isValid: false,
},
{
name: "Local NIC address inside an allowed /16 range",
check: PeerNetworkRangeCheck{
Action: CheckActionAllow,
Ranges: []netip.Prefix{
netip.MustParsePrefix("192.168.0.0/16"),
},
},
peer: nbpeer.Peer{
Meta: nbpeer.PeerSystemMeta{
NetworkAddresses: []nbpeer.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.5.7/24")},
},
},
},
wantErr: false,
isValid: true,
},
{
name: "Empty NetworkAddresses and empty ConnectionIP still errors",
check: PeerNetworkRangeCheck{
Action: CheckActionDeny,
Ranges: []netip.Prefix{
netip.MustParsePrefix("109.41.115.194/32"),
},
},
peer: nbpeer.Peer{},
wantErr: true,
isValid: false,
},
}
for _, tt := range tests {

View File

@@ -1687,15 +1687,18 @@ components:
- locations
- action
PeerNetworkRangeCheck:
description: Posture check for allow or deny access based on peer local network addresses
description: |
Posture check for allow or deny access based on the peer's IP addresses. A range matches when it
contains any of the peer's local network interface IPs or its public connection (NAT egress) IP,
so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128.
type: object
properties:
ranges:
description: List of peer network ranges in CIDR notation
description: List of network ranges in CIDR notation, matched against the peer's local interface IPs and its public connection IP
type: array
items:
type: string
example: [ "192.168.1.0/24", "10.0.0.0/8", "2001:db8:1234:1a00::/56" ]
example: [ "192.168.1.0/24", "10.0.0.0/8", "1.0.0.0/24", "2.2.2.2/32", "2001:db8:1234:1a00::/56" ]
action:
description: Action to take upon policy match
type: string

View File

@@ -1626,7 +1626,7 @@ type Checks struct {
// OsVersionCheck Posture check for the version of operating system
OsVersionCheck *OSVersionCheck `json:"os_version_check,omitempty"`
// PeerNetworkRangeCheck Posture check for allow or deny access based on peer local network addresses
// PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128.
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:"peer_network_range_check,omitempty"`
// ProcessCheck Posture Check for binaries exist and are running in the peers system
@@ -3312,12 +3312,12 @@ type PeerMinimum struct {
Name string `json:"name"`
}
// PeerNetworkRangeCheck Posture check for allow or deny access based on peer local network addresses
// PeerNetworkRangeCheck Posture check for allow or deny access based on the peer's IP addresses. A range matches when it contains any of the peer's local network interface IPs or its public connection (NAT egress) IP, so ranges may target private subnets, public CIDRs, or single hosts via a /32 or /128.
type PeerNetworkRangeCheck struct {
// Action Action to take upon policy match
Action PeerNetworkRangeCheckAction `json:"action"`
// Ranges List of peer network ranges in CIDR notation
// Ranges List of network ranges in CIDR notation, matched against the peer's local interface IPs and its public connection IP
Ranges []string `json:"ranges"`
}