Skip to content

Commit 9a662f0

Browse files
authored
Merge pull request #225 from aojea/router_reenroll
Router reenroll
2 parents 8a82e66 + 764eea4 commit 9a662f0

4 files changed

Lines changed: 196 additions & 15 deletions

File tree

‎internal/node/enroll.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ func (n *SamNode) Enroll(ctx context.Context, hubURL string, jwt string) error {
150150
n.keysMu.Unlock()
151151

152152
// Connect and Auth to hub after enrollment to join the mesh
153+
if len(enrollResp.HubAddresses) == 0 {
154+
return fmt.Errorf("failed to connect and authenticate after HTTP enrollment: control plane returned no hub addresses")
155+
}
156+
153157
var lastAuthErr error
154158
var authed bool
155159
for _, addrStr := range enrollResp.HubAddresses {
@@ -329,6 +333,10 @@ func (n *SamNode) EnrollBootstrap(ctx context.Context, hubURL string, bootstrapT
329333
n.keysMu.Unlock()
330334

331335
// Connect and Auth to hub after enrollment to join the mesh
336+
if len(enrollResp.HubAddresses) == 0 {
337+
return fmt.Errorf("failed to connect and authenticate after bootstrap enrollment: control plane returned no hub addresses")
338+
}
339+
332340
var lastAuthErr error
333341
var authed bool
334342
for _, addrStr := range enrollResp.HubAddresses {

‎internal/node/node.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -638,13 +638,13 @@ func (n *SamNode) startConnectionMonitor(ctx context.Context, bootstrapDuration,
638638
}
639639

640640
func (n *SamNode) RegisterStaticServices(ctx context.Context, services []api.ServiceConfig) error {
641-
// Wait for DHT to be ready (size > 0)
641+
// Wait for node to be connected to a hub or DHT to be ready
642642
// This avoids failure if we try to register immediately after enrollment
643-
// before the DHT has discovered peers.
643+
// before the connection is established.
644644
ticker := time.NewTicker(100 * time.Millisecond)
645645
defer ticker.Stop()
646646

647-
timeout := time.After(10 * time.Second) // 10 seconds timeout for DHT readiness
647+
timeout := time.After(10 * time.Second)
648648

649649
dhtLoop:
650650
for {
@@ -654,7 +654,7 @@ dhtLoop:
654654
case <-timeout:
655655
return fmt.Errorf("timeout waiting for DHT to be ready before registering static services")
656656
case <-ticker.C:
657-
if n.DHT.RoutingTable().Size() > 0 {
657+
if n.IsConnected() || (n.DHT != nil && n.DHT.RoutingTable().Size() > 0) {
658658
break dhtLoop
659659
}
660660
}

‎internal/router/router.go‎

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ type Router struct {
9494
biscuitExpiration time.Time
9595
trustedPublicKeys []ed25519.PublicKey
9696
keysMu sync.RWMutex
97+
enrollMu sync.Mutex
9798
privKey crypto.PrivKey
9899

99100
// Control contexts
@@ -320,9 +321,9 @@ func (r *Router) enroll(peerID peer.ID) error {
320321
return err
321322
}
322323

324+
r.keysMu.Lock()
323325
r.biscuitToken = enrollResp.BiscuitToken
324326
r.biscuitExpiration = time.Unix(enrollResp.Expiration, 0)
325-
r.keysMu.Lock()
326327
r.trustedPublicKeys = []ed25519.PublicKey{enrollResp.HubPublicKey}
327328
r.keysMu.Unlock()
328329

@@ -442,9 +443,9 @@ func (r *Router) enrollBootstrap(peerID peer.ID) error {
442443
return fmt.Errorf("enrollment not approved (status: %v)", enrollResp.Status)
443444
}
444445

446+
r.keysMu.Lock()
445447
r.biscuitToken = enrollResp.BiscuitToken
446448
r.biscuitExpiration = time.Unix(enrollResp.Expiration, 0)
447-
r.keysMu.Lock()
448449
r.trustedPublicKeys = []ed25519.PublicKey{enrollResp.HubPublicKey}
449450
r.keysMu.Unlock()
450451

@@ -456,6 +457,22 @@ func (r *Router) enrollBootstrap(peerID peer.ID) error {
456457
return nil
457458
}
458459

460+
func (r *Router) reEnroll() error {
461+
r.enrollMu.Lock()
462+
defer r.enrollMu.Unlock()
463+
464+
if r.Host == nil {
465+
return fmt.Errorf("cannot re-enroll: router host is not initialized")
466+
}
467+
468+
if r.config.BootstrapToken != "" {
469+
return r.enrollBootstrap(r.Host.ID())
470+
} else if r.config.OIDCToken != "" {
471+
return r.enroll(r.Host.ID())
472+
}
473+
return fmt.Errorf("no enrollment token available for re-enrollment")
474+
}
475+
459476
func (r *Router) syncKeys() error {
460477
client := &http.Client{Timeout: 10 * time.Second}
461478
resp, err := client.Get(r.config.ControlPlaneURL + "/keys")
@@ -534,7 +551,11 @@ func (r *Router) runLeaseRenewalLoop() {
534551
}
535552

536553
func (r *Router) renewLease() {
537-
if len(r.biscuitToken) == 0 {
554+
r.keysMu.RLock()
555+
biscuit := r.biscuitToken
556+
r.keysMu.RUnlock()
557+
558+
if len(biscuit) == 0 {
538559
logger.Warn("Cannot renew lease: router is not enrolled (no biscuit)")
539560
return
540561
}
@@ -564,7 +585,7 @@ func (r *Router) renewLease() {
564585
req := &api.RouterLeaseRequest{
565586
PeerId: r.Host.ID().String(),
566587
Addresses: addrs,
567-
Biscuit: r.biscuitToken,
588+
Biscuit: biscuit,
568589
ConnectedPeers: connectedPeers,
569590
DhtSize: dhtSize,
570591
}
@@ -578,6 +599,18 @@ func (r *Router) renewLease() {
578599
}
579600
defer func() { _ = resp.Body.Close() }()
580601

602+
if resp.StatusCode == http.StatusUnauthorized {
603+
body, _ := io.ReadAll(resp.Body)
604+
logger.Warnf("Control plane lease renewal rejected (401 Unauthorized: %s), attempting re-enrollment...", string(body))
605+
if err := r.reEnroll(); err != nil {
606+
logger.Errorf("Re-enrollment failed after 401 Unauthorized lease renewal: %v", err)
607+
return
608+
}
609+
logger.Info("Successfully re-enrolled after 401 Unauthorized lease renewal, retrying lease renewal...")
610+
r.renewLease()
611+
return
612+
}
613+
581614
if resp.StatusCode != http.StatusOK {
582615
body, _ := io.ReadAll(resp.Body)
583616
logger.Errorf("Control plane lease renewal rejected, status %s: %s", resp.Status, string(body))
@@ -718,10 +751,14 @@ func (r *Router) HandleAuthHandshake(s network.Stream) {
718751
logger.Infof("[AuthN] Successfully authenticated peer %s", remotePeer)
719752

720753
// Send mutual response (our biscuit)
754+
r.keysMu.RLock()
755+
ourBiscuit := r.biscuitToken
756+
r.keysMu.RUnlock()
757+
721758
writer := msgio.NewVarintWriter(s)
722759
resp := &api.AuthResponse{
723760
Success: true,
724-
Biscuit: r.biscuitToken,
761+
Biscuit: ourBiscuit,
725762
}
726763
respBytes, _ := proto.Marshal(resp)
727764
if err := writer.WriteMsg(respBytes); err != nil {
@@ -859,10 +896,11 @@ func isLoopbackOrLinkLocal(addr multiaddr.Multiaddr) bool {
859896

860897
// RefreshEnrollment trades the expiring biscuit token for a new one using a cryptographic challenge.
861898
func (r *Router) RefreshEnrollment(ctx context.Context) error {
862-
r.keysMu.Lock()
863-
defer r.keysMu.Unlock()
899+
r.keysMu.RLock()
900+
currentBiscuit := r.biscuitToken
901+
r.keysMu.RUnlock()
864902

865-
if len(r.biscuitToken) == 0 {
903+
if len(currentBiscuit) == 0 {
866904
return fmt.Errorf("router not enrolled (no biscuit)")
867905
}
868906

@@ -890,8 +928,7 @@ func (r *Router) RefreshEnrollment(ctx context.Context) error {
890928
return fmt.Errorf("failed to create http request: %w", err)
891929
}
892930
httpReq.Header.Set("Content-Type", "application/x-protobuf")
893-
// Set current biscuit in authorization header
894-
b64Biscuit := base64.StdEncoding.EncodeToString(r.biscuitToken)
931+
b64Biscuit := base64.StdEncoding.EncodeToString(currentBiscuit)
895932
httpReq.Header.Set("Authorization", "Bearer "+b64Biscuit)
896933

897934
client := &http.Client{Timeout: 10 * time.Second}
@@ -901,6 +938,12 @@ func (r *Router) RefreshEnrollment(ctx context.Context) error {
901938
}
902939
defer func() { _ = resp.Body.Close() }()
903940

941+
if resp.StatusCode == http.StatusUnauthorized {
942+
body, _ := io.ReadAll(resp.Body)
943+
logger.Warnf("Proactive biscuit refresh rejected (401 Unauthorized: %s), attempting re-enrollment...", string(body))
944+
return r.reEnroll()
945+
}
946+
904947
if resp.StatusCode == http.StatusForbidden {
905948
logger.Errorf("Refresh rejected: Router is banned (403 Forbidden). Hard-killing router.")
906949
if r.Host != nil {
@@ -928,9 +971,12 @@ func (r *Router) RefreshEnrollment(ctx context.Context) error {
928971
return fmt.Errorf("refresh error: %s", refreshResp.ErrorMessage)
929972
}
930973

931-
// Update local biscuit token and expiration
974+
// Update local biscuit token and expiration under lock
975+
r.keysMu.Lock()
932976
r.biscuitToken = refreshResp.BiscuitToken
933977
r.biscuitExpiration = time.Unix(refreshResp.ExpiresAt, 0)
978+
r.keysMu.Unlock()
979+
934980
logger.Infof("Router biscuit token refreshed successfully.")
935981
return nil
936982
}

‎internal/router/router_test.go‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,130 @@ func TestRouterProactiveTokenRefresh(t *testing.T) {
457457
t.Errorf("expected refreshed expiration %v to be after initial expiration %v", r.biscuitExpiration, initialExpiration)
458458
}
459459
}
460+
461+
func TestRouterLeaseRenewalReEnrollOn401(t *testing.T) {
462+
issuer, mintToken := startCustomMockOIDC(t)
463+
cp, cpStore, cpURL := setupControlPlane(t, issuer)
464+
defer func() {
465+
_ = cp.Close()
466+
_ = cpStore.Close()
467+
}()
468+
469+
tempDir := t.TempDir()
470+
routerKeyPath := filepath.Join(tempDir, "router.key")
471+
472+
routerJWT := mintToken(map[string]interface{}{
473+
"sub": "router-reenroll-test",
474+
"groups": []string{"routers"},
475+
})
476+
477+
rOpts := Options{
478+
ControlPlaneURL: cpURL,
479+
ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
480+
KeysSyncInterval: 20 * time.Second,
481+
LeaseRenewInterval: 20 * time.Second,
482+
OIDCToken: routerJWT,
483+
KeysDBPath: routerKeyPath,
484+
AllowLoopback: true,
485+
BiscuitTimeout: 1 * time.Second,
486+
}
487+
488+
r, err := NewRouter(context.Background(), rOpts)
489+
if err != nil {
490+
t.Fatalf("failed to create router: %v", err)
491+
}
492+
493+
if err := r.Start(); err != nil {
494+
t.Fatalf("failed to start router: %v", err)
495+
}
496+
defer func() { _ = r.Close() }()
497+
498+
// Corrupt router's biscuit token to simulate key rotation/expiration
499+
r.keysMu.Lock()
500+
r.biscuitToken = []byte("invalid-corrupted-biscuit")
501+
r.keysMu.Unlock()
502+
503+
// Call renewLease which should fail with 401, trigger reEnroll, and succeed
504+
r.renewLease()
505+
506+
// Verify that biscuitToken was replaced with a valid non-corrupted token
507+
r.keysMu.RLock()
508+
currentToken := r.biscuitToken
509+
r.keysMu.RUnlock()
510+
511+
if bytes.Equal(currentToken, []byte("invalid-corrupted-biscuit")) {
512+
t.Error("expected biscuitToken to be updated after 401 re-enrollment, but it remained corrupted")
513+
}
514+
}
515+
516+
func TestRouterReEnrollUninitializedHostGuard(t *testing.T) {
517+
r := &Router{}
518+
err := r.reEnroll()
519+
if err == nil {
520+
t.Fatal("expected reEnroll to return error when r.Host is nil, got nil")
521+
}
522+
expectedMsg := "cannot re-enroll: router host is not initialized"
523+
if err.Error() != expectedMsg {
524+
t.Fatalf("expected error %q, got %q", expectedMsg, err.Error())
525+
}
526+
}
527+
528+
func TestRouterProactiveRefreshReEnrollOn401(t *testing.T) {
529+
issuer, mintToken := startCustomMockOIDC(t)
530+
cp, cpStore, cpURL := setupControlPlane(t, issuer)
531+
defer func() {
532+
_ = cp.Close()
533+
_ = cpStore.Close()
534+
}()
535+
536+
tempDir := t.TempDir()
537+
routerKeyPath := filepath.Join(tempDir, "router.key")
538+
539+
routerJWT := mintToken(map[string]interface{}{
540+
"sub": "router-refresh-401-test",
541+
"groups": []string{"routers"},
542+
})
543+
544+
rOpts := Options{
545+
ControlPlaneURL: cpURL,
546+
ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"},
547+
KeysSyncInterval: 20 * time.Second,
548+
LeaseRenewInterval: 20 * time.Second,
549+
OIDCToken: routerJWT,
550+
KeysDBPath: routerKeyPath,
551+
AllowLoopback: true,
552+
BiscuitTimeout: 1 * time.Second,
553+
}
554+
555+
r, err := NewRouter(context.Background(), rOpts)
556+
if err != nil {
557+
t.Fatalf("failed to create router: %v", err)
558+
}
559+
560+
if err := r.Start(); err != nil {
561+
t.Fatalf("failed to start router: %v", err)
562+
}
563+
defer func() { _ = r.Close() }()
564+
565+
// Corrupt router's biscuit token to simulate key rotation
566+
r.keysMu.Lock()
567+
r.biscuitToken = []byte("stale-biscuit-signed-by-purged-key")
568+
r.keysMu.Unlock()
569+
570+
// Trigger proactive refresh which should fail with 401, trigger reEnroll, and succeed
571+
err = r.RefreshEnrollment(context.Background())
572+
if err != nil {
573+
t.Fatalf("expected RefreshEnrollment to recover via reEnroll, but got error: %v", err)
574+
}
575+
576+
// Verify that biscuitToken was replaced with a valid fresh token
577+
r.keysMu.RLock()
578+
currentToken := r.biscuitToken
579+
r.keysMu.RUnlock()
580+
581+
if bytes.Equal(currentToken, []byte("stale-biscuit-signed-by-purged-key")) {
582+
t.Error("expected biscuitToken to be updated after 401 refresh fallback, but it remained stale")
583+
}
584+
}
585+
586+

0 commit comments

Comments
 (0)