Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions api/sam.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions api/sam.proto
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ message ControlPlaneInfoResponse {
string client_id = 2;
string audience = 3;
repeated string router_addresses = 4;
// The complete set of currently banned peer IDs. Consumers reconcile their
// local blocklist against this list rather than merging into it, so that a
// peer the control plane no longer bans is unbanned everywhere without
// needing an event of its own. MeshEvent_BANNED stays the fast path for
// sub-second eviction; this is how a node that restarted or was offline
// when the event was published catches up.
repeated string banned_peer_ids = 5;
}

message RouterLeaseRequest {
Expand Down
8 changes: 6 additions & 2 deletions cmd/sam-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func main() {
var controlPlanePubKey ed25519.PublicKey
var routerAddrs []multiaddr.Multiaddr

storedPubKey, syncedAddrs, err := node.SyncMeshConfig(context.Background(), store)
storedPubKey, syncedAddrs, bannedPeerIDs, err := node.SyncMeshConfig(context.Background(), store)
if err != nil {
logger.Warnf("Failed to sync mesh config: %v", err)
}
Expand Down Expand Up @@ -475,6 +475,7 @@ func main() {
ControlPlanePubKey: controlPlanePubKey,
RouterAddrs: routerAddrs,
Store: store,
BannedPeerIDs: bannedPeerIDs,
MeshID: meshFlag,
DiscoveryInterval: discoveryIntervalFlag,
ListenAddrs: listenAddrs,
Expand Down Expand Up @@ -542,6 +543,7 @@ func main() {
PrivKey: priv,
RouterAddrs: initRouterAddrs,
Store: store,
BannedPeerIDs: bannedPeerIDs,
MeshID: meshFlag,
DiscoveryInterval: discoveryIntervalFlag,
ListenAddrs: listenAddrs,
Expand Down Expand Up @@ -599,18 +601,20 @@ func main() {
}
enrollCancel()

storedPubKey, newRouterAddrs, err := node.SyncMeshConfig(context.Background(), store)
storedPubKey, newRouterAddrs, postEnrollBannedPeerIDs, err := node.SyncMeshConfig(context.Background(), store)
if err != nil {
logger.Warnf("Failed to sync mesh config post-enrollment: %v", err)
}
controlPlanePubKey = storedPubKey
bannedPeerIDs = postEnrollBannedPeerIDs

logger.Debugf("listenAddrs: %v, allowLoopback: %v", listenAddrs, allowLoopbackFlag)
meshNode, err = node.NewSamNode(node.Options{
PrivKey: priv,
ControlPlanePubKey: controlPlanePubKey,
RouterAddrs: newRouterAddrs,
Store: store,
BannedPeerIDs: bannedPeerIDs,
MeshID: meshFlag,
DiscoveryInterval: discoveryIntervalFlag,
ListenAddrs: listenAddrs,
Expand Down
14 changes: 14 additions & 0 deletions internal/controlplane/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,25 @@ func (s *Server) HandleInfo(w http.ResponseWriter, r *http.Request) {
routerAddrs = append(routerAddrs, r.Addresses...)
}

// The ban set is published here, rather than only as a MeshEvent, because
// the event is broadcast once and gossip has no replay: a node or router
// that restarted or was offline at the time would otherwise never learn
// the ban. A failure to read it must not be served as an empty list --
// that would read as "nothing is banned" and unban everyone -- so it is
// treated like any other failure to build this response.
bannedPeerIDs, err := s.store.ListBannedPeerIDs(r.Context())
if err != nil {
logger.Errorf("Failed to retrieve banned peers: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}

resp := &api.ControlPlaneInfoResponse{
OidcIssuer: issuer,
ClientId: clientID,
Audience: aud,
RouterAddresses: routerAddrs, // Reused this field for back-compatibility with bootstrap routers list
BannedPeerIds: bannedPeerIDs,
}

respData, err := proto.Marshal(resp)
Expand Down
63 changes: 63 additions & 0 deletions internal/controlplane/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2611,3 +2611,66 @@ func TestAdminBootstrapTokensList(t *testing.T) {
t.Errorf("unauthenticated GET status = %s, want 401", resp.Status)
}
}

// /info carries the whole ban set so a node or router that restarted, or was
// offline when MeshEvent_BANNED was published, can reconcile against it. A
// peer that is not banned must not appear, or consumers would blocklist it.
func TestHandleInfoPublishesBanSet(t *testing.T) {
issuer, _ := startCustomMockOIDC(t)
srv, st, _ := setupTestServer(t, issuer)
defer func() {
_ = srv.Close()
_ = st.Close()
}()
ctx := context.Background()

const (
bannedPeer = "12D3KooWBanned000000000000000000000000000000000001"
allowedPeer = "12D3KooWAllowed00000000000000000000000000000000002"
)
for _, peerID := range []string{bannedPeer, allowedPeer} {
if err := st.EnrollNode(ctx, &storage.EnrolledNode{
PeerID: peerID,
PublicKey: []byte("test-public-key"),
Biscuit: []byte("test-biscuit"),
Role: "agent",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("EnrollNode(%s): %v", peerID, err)
}
}
// The path /admin/revoke takes: enrollment always starts unbanned.
if err := st.SetNodeBanned(ctx, bannedPeer, true); err != nil {
t.Fatalf("SetNodeBanned: %v", err)
}

rec := httptest.NewRecorder()
srv.HandleInfo(rec, httptest.NewRequest(http.MethodGet, "/info", nil))
if rec.Code != http.StatusOK {
t.Fatalf("unexpected /info status: %d", rec.Code)
}
var info api.ControlPlaneInfoResponse
if err := proto.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatalf("unmarshal ControlPlaneInfoResponse: %v", err)
}

got := info.GetBannedPeerIds()
if len(got) != 1 || got[0] != bannedPeer {
t.Errorf("banned_peer_ids = %v, want exactly [%s]", got, bannedPeer)
}

// An unban must show up as absence, since that is the only signal
// consumers get: there is no unban event.
if err := st.SetNodeBanned(ctx, bannedPeer, false); err != nil {
t.Fatalf("SetNodeBanned(false): %v", err)
}
rec = httptest.NewRecorder()
srv.HandleInfo(rec, httptest.NewRequest(http.MethodGet, "/info", nil))
var after api.ControlPlaneInfoResponse
if err := proto.Unmarshal(rec.Body.Bytes(), &after); err != nil {
t.Fatalf("unmarshal ControlPlaneInfoResponse: %v", err)
}
if len(after.GetBannedPeerIds()) != 0 {
t.Errorf("after unban banned_peer_ids = %v, want empty", after.GetBannedPeerIds())
}
}
21 changes: 15 additions & 6 deletions internal/node/controlplane.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,26 @@ func mergeTrustedKeys(existing []TrustedKey, fetched []ed25519.PublicKey, now ti

// SyncMeshConfig loads the mesh configuration from the store, attempts to refresh it
// via HTTP from the control plane, and updates the store if successful.
// It returns the control plane public key and the latest multiaddresses.
func SyncMeshConfig(ctx context.Context, s *Store) ([]byte, []multiaddr.Multiaddr, error) {
// It returns the control plane public key, the latest multiaddresses, and the
// control plane's current ban set.
//
// The ban set is deliberately not persisted. MeshEvent_BANNED is published once
// and gossip has no replay, so a node that restarted or was offline has to be
// told again; /info is that catch-up, and reading it fresh each start is also
// what makes an unban take effect. Nil means the control plane was not reached,
// which is not the same as "nothing is banned": callers must not treat it as an
// instruction to clear anything.
func SyncMeshConfig(ctx context.Context, s *Store) ([]byte, []multiaddr.Multiaddr, []string, error) {
pubKey, storedAddrsStr, err := s.LoadMeshConfig()
if err != nil {
return nil, nil, fmt.Errorf("failed to load mesh config from store: %w", err)
return nil, nil, nil, fmt.Errorf("failed to load mesh config from store: %w", err)
}

controlPlaneURL, err := s.LoadControlPlaneURL()
if err != nil {
return nil, nil, fmt.Errorf("failed to load control plane URL from store: %w", err)
return nil, nil, nil, fmt.Errorf("failed to load control plane URL from store: %w", err)
}
var bannedPeerIDs []string
var routerAddrs []multiaddr.Multiaddr

// Parse stored addresses
Expand All @@ -163,7 +172,7 @@ func SyncMeshConfig(ctx context.Context, s *Store) ([]byte, []multiaddr.Multiadd
info, err := FetchControlPlaneInfo(ctx, controlPlaneURL)
if err != nil {
logger.Warnf("Failed to fetch updated addresses via HTTP (using cached): %v", err)
} else if len(info.RouterAddresses) > 0 {
} else if bannedPeerIDs = info.GetBannedPeerIds(); len(info.RouterAddresses) > 0 {
Comment on lines 173 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Assigning bannedPeerIDs inside the else if initialization statement is hard to read and potentially error-prone. Even if len(info.RouterAddresses) > 0 is false, the assignment still executes, but this behavior is non-obvious and can easily be broken or misunderstood during future refactoring.\n\nIt is much cleaner and more idiomatic to separate the assignment of bannedPeerIDs from the conditional check for info.RouterAddresses. For example:\n\ngo\ninfo, err := FetchControlPlaneInfo(ctx, controlPlaneURL)\nif err != nil {\n\tlogger.Warnf(\"Failed to fetch updated addresses via HTTP (using cached): %v\", err)\n} else {\n\tbannedPeerIDs = info.GetBannedPeerIds()\n\tif len(info.RouterAddresses) > 0 {\n\t\t// ... rest of the block ...\n\t}\n}\n

logger.Infof("Discovered latest router addresses: %v", info.RouterAddresses)
var newRouterAddrs []multiaddr.Multiaddr
for _, addrStr := range info.RouterAddresses {
Expand Down Expand Up @@ -199,7 +208,7 @@ func SyncMeshConfig(ctx context.Context, s *Store) ([]byte, []multiaddr.Multiadd
}
}

return pubKey, routerAddrs, nil
return pubKey, routerAddrs, bannedPeerIDs, nil
}

// FetchMeshPolicy retrieves the latest mesh policy from the control plane's /policies endpoint using a biscuit token.
Expand Down
7 changes: 5 additions & 2 deletions internal/node/controlplane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,13 @@ func TestSyncMeshConfig(t *testing.T) {
defer store.Close() //nolint:errcheck

// Initial store is empty, so SyncMeshConfig should just return empty
pubKey, addrs, err := SyncMeshConfig(context.Background(), store)
pubKey, addrs, bannedPeerIDs, err := SyncMeshConfig(context.Background(), store)
if err != nil {
t.Fatalf("SyncMeshConfig failed: %v", err)
}
if len(bannedPeerIDs) != 0 {
t.Errorf("Expected no banned peers for empty store, got %v", bannedPeerIDs)
}
if len(pubKey) != 0 || len(addrs) != 0 {
t.Errorf("Expected empty result for empty store, got pubKey=%v, addrs=%v", pubKey, addrs)
}
Expand All @@ -180,7 +183,7 @@ func TestSyncMeshConfig(t *testing.T) {
}

// Call SyncMeshConfig, it should fetch new addrs from server
pubKey, addrs, err = SyncMeshConfig(context.Background(), store)
pubKey, addrs, _, err = SyncMeshConfig(context.Background(), store)
if err != nil {
t.Fatalf("SyncMeshConfig failed: %v", err)
}
Expand Down
3 changes: 0 additions & 3 deletions internal/node/debug_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,6 @@ func (n *SamNode) connectPeer(ctx context.Context, peerAddr string) error {
if n.revokedPeers != nil && n.revokedPeers.Contains(addrInfo.ID.String()) {
return fmt.Errorf("failed to dial: failed to dial %s: gater disallows connection to peer", addrInfo.ID)
}
if n.Store.IsBanned(addrInfo.ID) {
return fmt.Errorf("failed to dial: failed to dial %s: gater disallows connection to peer", addrInfo.ID)
}
conns := n.Host.Network().ConnsToPeer(addrInfo.ID)
connectedness := n.Host.Network().Connectedness(addrInfo.ID)
logger.Debugf("[connect-peer] Target peer %s, connectedness: %v, active conns: %d", addrInfo.ID, connectedness, len(conns))
Expand Down
9 changes: 0 additions & 9 deletions internal/node/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,6 @@ func (g *nodeConnGate) InterceptPeerDial(p peer.ID) (allow bool) {
logger.Warnf("[Gater] InterceptPeerDial denying %s: in revoked cache", p)
return false
}
banned := g.node.Store.IsBanned(p)
if banned {
logger.Warnf("[Gater] InterceptPeerDial denying %s: banned in store", p)
return false
}
logger.Debugf("[Gater] InterceptPeerDial allowing %s", p)
return true
}
Expand All @@ -70,10 +65,6 @@ func (g *nodeConnGate) InterceptSecured(dir network.Direction, p peer.ID, n netw
logger.Warnf("[Gater] InterceptSecured denying %s: in revoked cache", p)
return false
}
if g.node.Store.IsBanned(p) {
logger.Warnf("[Gater] InterceptSecured denying %s: banned in store", p)
return false
}
logger.Debugf("[Gater] InterceptSecured allowing %s", p)
return true
}
Expand Down
48 changes: 38 additions & 10 deletions internal/node/gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/core/protocol"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.etcd.io/bbolt"
)

func TestConnectionGater(t *testing.T) {
Expand Down Expand Up @@ -82,20 +81,49 @@ func TestConnectionGater(t *testing.T) {
t.Errorf("expected InterceptSecured to deny peer2 (in revoked cache)")
}

// Case 3: Peer is in persistent store (banned)
err = store.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketBannedPeers))
return b.Put([]byte(peer3.String()), []byte("true"))
})
// Case 3: a peer the control plane does not ban stays reachable, even
// after another peer has been banned.
if !gater.InterceptPeerDial(peer3) {
t.Errorf("expected InterceptPeerDial to allow peer3")
}
if !gater.InterceptSecured(network.DirInbound, peer3, nil) {
t.Errorf("expected InterceptSecured to allow peer3")
}
}

// The revocation cache is seeded from the control plane's ban set at startup
// (Options.BannedPeerIDs, filled by SyncMeshConfig). Without that a restarted
// node would enforce no ban at all until the next MeshEvent_BANNED, which for
// a ban published while it was down never arrives.
func TestGaterEnforcesSeededBans(t *testing.T) {
priv, _, _ := crypto.GenerateEd25519Key(nil)
banned, err := peer.IDFromPrivateKey(priv)
if err != nil {
t.Fatal(err)
}
otherPriv, _, _ := crypto.GenerateEd25519Key(nil)
allowed, err := peer.IDFromPrivateKey(otherPriv)
if err != nil {
t.Fatal(err)
}

if gater.InterceptPeerDial(peer3) {
t.Errorf("expected InterceptPeerDial to deny peer3 (in store)")
cache, err := lru.New[string, int64](100)
if err != nil {
t.Fatal(err)
}
node := &SamNode{revokedPeers: cache}
// Mirrors what NewSamNode does with Options.BannedPeerIDs.
node.revokedPeers.Add(banned.String(), time.Now().Unix())

gater := &nodeConnGate{node: node}
if gater.InterceptPeerDial(banned) {
t.Error("a peer in the control plane's ban set must not be dialled")
}
if gater.InterceptSecured(network.DirInbound, banned, nil) {
t.Error("a peer in the control plane's ban set must not be accepted")
}
if gater.InterceptSecured(network.DirInbound, peer3, nil) {
t.Errorf("expected InterceptSecured to deny peer3 (in store)")
if !gater.InterceptPeerDial(allowed) {
t.Error("seeding a ban must not deny unrelated peers")
}
}

Expand Down
8 changes: 4 additions & 4 deletions internal/node/identity_evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,11 @@ func (n *SamNode) buildPeerEvidence(requested peer.ID, observation peerBiscuitOb
}, nil
}

// peerIsRevoked reports whether the peer is in the revocation cache, which is
// seeded from the control plane's ban set at startup (see SyncMeshConfig) and
// updated by MeshEvent_BANNED.
func (n *SamNode) peerIsRevoked(peerID peer.ID) bool {
if n.revokedPeers != nil && n.revokedPeers.Contains(peerID.String()) {
return true
}
return n.Store != nil && n.Store.IsBanned(peerID)
return n.revokedPeers != nil && n.revokedPeers.Contains(peerID.String())
}

type biscuitClaims struct {
Expand Down
Loading
Loading