Skip to content

Commit b42d2f4

Browse files
authored
Merge pull request #5 from dc-holdings-spa/feat/revocation-token-heartbeat
feat(client): revocationToken heartbeat → dashboard LIVE status
2 parents 882fc05 + ac763b9 commit b42d2f4

3 files changed

Lines changed: 83 additions & 33 deletions

File tree

client/config.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ const ConfigSchemaVersion = 1
2929

3030
// Config is the persisted pairing state.
3131
type Config struct {
32-
Version int `json:"version"`
33-
TunnelIP string `json:"tunnelIp"`
34-
PrivateKey string `json:"privateKey"`
35-
ServerPubKey string `json:"serverPubKey"`
36-
ServerHost string `json:"serverHost"`
37-
UserEmail string `json:"userEmail"`
38-
PairedAt string `json:"pairedAt"`
39-
ArenaBaseURL string `json:"arenaBaseURL,omitempty"`
40-
DeviceID string `json:"deviceId,omitempty"`
32+
Version int `json:"version"`
33+
TunnelIP string `json:"tunnelIp"`
34+
PrivateKey string `json:"privateKey"`
35+
ServerPubKey string `json:"serverPubKey"`
36+
ServerHost string `json:"serverHost"`
37+
UserEmail string `json:"userEmail"`
38+
PairedAt string `json:"pairedAt"`
39+
ArenaBaseURL string `json:"arenaBaseURL,omitempty"`
40+
DeviceID string `json:"deviceId,omitempty"`
41+
RevocationToken string `json:"revocationToken,omitempty"`
4142
}
4243

4344
// Complete reports whether Config carries the minimum fields required to

client/main.go

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

client/pair.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,13 @@ type pairInitResponse struct {
104104
// pairClaimedResponse is what /pair/poll returns on 200 and what
105105
// /pair/claim returns on the headless path.
106106
type pairClaimedResponse struct {
107-
TunnelIP string `json:"tunnelIp"`
108-
PrivateKey string `json:"privateKey"`
109-
ServerPubKey string `json:"serverPubKey"`
110-
ServerHost string `json:"serverHost"`
111-
UserEmail string `json:"userEmail"`
112-
DeviceID string `json:"deviceId,omitempty"`
107+
TunnelIP string `json:"tunnelIp"`
108+
PrivateKey string `json:"privateKey"`
109+
ServerPubKey string `json:"serverPubKey"`
110+
ServerHost string `json:"serverHost"`
111+
UserEmail string `json:"userEmail"`
112+
DeviceID string `json:"deviceId,omitempty"`
113+
RevocationToken string `json:"revocationToken,omitempty"`
113114
}
114115

115116
// pairOptions packages everything pair flow callers may want to tweak.
@@ -510,15 +511,16 @@ func PairAndPersist(ctx context.Context, opts pairOptions) (*Config, int, error)
510511
}
511512

512513
cfg := &Config{
513-
Version: ConfigSchemaVersion,
514-
TunnelIP: claimed.TunnelIP,
515-
PrivateKey: claimed.PrivateKey,
516-
ServerPubKey: claimed.ServerPubKey,
517-
ServerHost: claimed.ServerHost,
518-
UserEmail: claimed.UserEmail,
519-
PairedAt: time.Now().UTC().Format(time.RFC3339),
520-
ArenaBaseURL: opts.ArenaBaseURL,
521-
DeviceID: claimed.DeviceID,
514+
Version: ConfigSchemaVersion,
515+
TunnelIP: claimed.TunnelIP,
516+
PrivateKey: claimed.PrivateKey,
517+
ServerPubKey: claimed.ServerPubKey,
518+
ServerHost: claimed.ServerHost,
519+
UserEmail: claimed.UserEmail,
520+
PairedAt: time.Now().UTC().Format(time.RFC3339),
521+
ArenaBaseURL: opts.ArenaBaseURL,
522+
DeviceID: claimed.DeviceID,
523+
RevocationToken: claimed.RevocationToken,
522524
}
523525

524526
if err := SaveConfig(opts.ConfigPath, cfg); err != nil {

0 commit comments

Comments
 (0)