@@ -543,23 +543,24 @@ func runConnect(ctx context.Context, rf *rootFlags, cfg *Config) int {
543543 wsURL := (& url.URL {Scheme : "wss" , Host : host , Path : "/tunnel" }).String ()
544544 go runShovel (shovelCtx , udpConn , wsURL )
545545
546- // Poll server every 60 s to detect server-side revocation. If the peer
547- // is revoked (admin action, re-pair replace, etc.) while the tunnel is
548- // running, shut down cleanly so the student knows to re-pair rather than
549- // sitting on a zombie tunnel that silently drops all traffic.
550- if cfg .ArenaBaseURL != "" && cfg .PrivateKey != "" {
546+ // Poll /api/users/me/c2-state every 60 s with the revocationToken Bearer.
547+ // Two jobs in one call:
548+ // 1. Bumps Byoc2Peer.lastCliFetchAt so the dashboard shows LIVE (not OFFLINE).
549+ // 2. Detects server-side revocation — if byoc2.status == "revoked", exit.
550+ // Falls back to pubkey-based /peer/status when no revocationToken is stored
551+ // (legacy configs from before v1.6.0).
552+ if cfg .ArenaBaseURL != "" {
551553 go func () {
552554 ticker := time .NewTicker (60 * time .Second )
553555 defer ticker .Stop ()
554556 for {
555557 select {
556558 case <- ticker .C :
557- status , err := fetchPeerStatus (shovelCtx , cfg .ArenaBaseURL , cfg .PrivateKey )
559+ revoked , err := pingC2State (shovelCtx , cfg .ArenaBaseURL , cfg . RevocationToken , cfg .PrivateKey )
558560 if err != nil {
559- // transient — ignore, try next tick
560- continue
561+ continue // transient — ignore, try next tick
561562 }
562- if status == " revoked" {
563+ if revoked {
563564 log .Println ("[!] peer revoked server-side — shutting down. Run `arena-byoc pair` to re-pair." )
564565 cancel ()
565566 return
@@ -688,6 +689,52 @@ func runStatus(ctx context.Context, rf *rootFlags) int {
688689 return ExitOK
689690}
690691
692+ // pingC2State calls GET /api/users/me/c2-state with a Bearer revocationToken.
693+ // Two effects: bumps lastCliFetchAt (→ dashboard shows LIVE) and returns
694+ // revoked=true when the peer has been revoked server-side.
695+ // Falls back to fetchPeerStatus (pubkey) when no revocationToken is available.
696+ func pingC2State (ctx context.Context , arena , revToken , privB64 string ) (revoked bool , err error ) {
697+ if revToken == "" {
698+ // Legacy config — fall back to pubkey-based check.
699+ status , e := fetchPeerStatus (ctx , arena , privB64 )
700+ return status == "revoked" , e
701+ }
702+ endpoint , e := joinURL (arena , "/api/users/me/c2-state" )
703+ if e != nil {
704+ return false , e
705+ }
706+ reqCtx , cancel := context .WithTimeout (ctx , 5 * time .Second )
707+ defer cancel ()
708+ req , e := http .NewRequestWithContext (reqCtx , http .MethodGet , endpoint , nil )
709+ if e != nil {
710+ return false , e
711+ }
712+ req .Header .Set ("Authorization" , "Bearer " + revToken )
713+ req .Header .Set ("Accept" , "application/json" )
714+ req .Header .Set ("User-Agent" , userAgent (version ))
715+ resp , e := newHTTPClient ().Do (req )
716+ if e != nil {
717+ return false , e
718+ }
719+ defer resp .Body .Close ()
720+ if resp .StatusCode == http .StatusUnauthorized {
721+ // Token revoked or expired — treat as revoked peer.
722+ return true , nil
723+ }
724+ if resp .StatusCode >= 400 {
725+ return false , fmt .Errorf ("HTTP %d" , resp .StatusCode )
726+ }
727+ var body struct {
728+ Byoc2 * struct {
729+ Status string `json:"status"`
730+ } `json:"byoc2"`
731+ }
732+ if e := json .NewDecoder (resp .Body ).Decode (& body ); e != nil {
733+ return false , e
734+ }
735+ return body .Byoc2 != nil && body .Byoc2 .Status == "revoked" , nil
736+ }
737+
691738// fetchPeerStatus calls /api/byoc2/peer/status?pubkey=<b64> with a short
692739// timeout. Returns the textual state ("active"/"revoked"/"unknown") or
693740// an error if the server can't be reached.
0 commit comments