Some checks failed
Go / build-and-release (push) Has been cancelled
- Implement NIP-NRC protocol for remote relay access through public relay tunnel - Add NRC bridge service with NIP-44 encrypted message tunneling - Add NRC client library for applications - Add session management with subscription tracking and expiry - Add URI parsing for nostr+relayconnect:// scheme with secret and CAT auth - Add NRC API endpoints for connection management (create/list/delete/get-uri) - Add RelayConnectView.svelte component for managing NRC connections in web UI - Add NRC database storage for connection secrets and labels - Add NRC CLI commands (generate, list, revoke) - Add support for Cashu Access Tokens (CAT) in NRC URIs - Add ScopeNRC constant for Cashu token scope - Add wasm build infrastructure and stub files Files modified: - app/config/config.go: NRC configuration options - app/handle-nrc.go: New API handlers for NRC connections - app/main.go: NRC bridge startup integration - app/server.go: Register NRC API routes - app/web/src/App.svelte: Add Relay Connect tab - app/web/src/RelayConnectView.svelte: New NRC management component - app/web/src/api.js: NRC API client functions - main.go: NRC CLI command handlers - pkg/bunker/acl_adapter.go: Add NRC scope mapping - pkg/cashu/token/token.go: Add ScopeNRC constant - pkg/database/nrc.go: NRC connection storage - pkg/protocol/nrc/: New NRC protocol implementation - docs/NIP-NRC.md: NIP specification document 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
624 lines
16 KiB
Go
624 lines
16 KiB
Go
package nrc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.mleku.dev/mleku/nostr/crypto/encryption"
|
|
"git.mleku.dev/mleku/nostr/encoders/event"
|
|
"git.mleku.dev/mleku/nostr/encoders/filter"
|
|
"git.mleku.dev/mleku/nostr/encoders/hex"
|
|
"git.mleku.dev/mleku/nostr/encoders/kind"
|
|
"git.mleku.dev/mleku/nostr/encoders/tag"
|
|
"git.mleku.dev/mleku/nostr/encoders/timestamp"
|
|
"git.mleku.dev/mleku/nostr/interfaces/signer"
|
|
"git.mleku.dev/mleku/nostr/ws"
|
|
"lol.mleku.dev/chk"
|
|
"lol.mleku.dev/log"
|
|
|
|
"next.orly.dev/pkg/cashu/token"
|
|
"next.orly.dev/pkg/cashu/verifier"
|
|
)
|
|
|
|
const (
|
|
// KindNRCRequest is the event kind for NRC requests.
|
|
KindNRCRequest = 24891
|
|
// KindNRCResponse is the event kind for NRC responses.
|
|
KindNRCResponse = 24892
|
|
)
|
|
|
|
// BridgeConfig holds configuration for the NRC bridge.
|
|
type BridgeConfig struct {
|
|
// RendezvousURL is the WebSocket URL of the public relay.
|
|
RendezvousURL string
|
|
// LocalRelayURL is the WebSocket URL of the local private relay.
|
|
LocalRelayURL string
|
|
// Signer is the relay's signer for signing response events.
|
|
Signer signer.I
|
|
// AuthorizedSecrets maps derived pubkeys to device names (secret-based auth).
|
|
AuthorizedSecrets map[string]string
|
|
// CashuVerifier is used for CAT token verification (optional).
|
|
CashuVerifier *verifier.Verifier
|
|
// SessionTimeout is the inactivity timeout for sessions.
|
|
SessionTimeout time.Duration
|
|
}
|
|
|
|
// Bridge connects a private relay to a public rendezvous relay.
|
|
type Bridge struct {
|
|
config *BridgeConfig
|
|
sessions *SessionManager
|
|
|
|
// rendezvousConn is the connection to the rendezvous relay.
|
|
rendezvousConn *ws.Client
|
|
|
|
// mu protects connection state.
|
|
mu sync.RWMutex
|
|
|
|
// ctx is the bridge context.
|
|
ctx context.Context
|
|
// cancel cancels the bridge context.
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewBridge creates a new NRC bridge.
|
|
func NewBridge(config *BridgeConfig) *Bridge {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
timeout := config.SessionTimeout
|
|
if timeout == 0 {
|
|
timeout = DefaultSessionTimeout
|
|
}
|
|
return &Bridge{
|
|
config: config,
|
|
sessions: NewSessionManager(timeout),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start starts the bridge and begins listening for NRC requests.
|
|
func (b *Bridge) Start() error {
|
|
log.I.F("starting NRC bridge, rendezvous: %s, local: %s",
|
|
b.config.RendezvousURL, b.config.LocalRelayURL)
|
|
|
|
// Start session cleanup goroutine
|
|
go b.cleanupLoop()
|
|
|
|
// Start the main bridge loop with auto-reconnection
|
|
go b.runLoop()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the bridge.
|
|
func (b *Bridge) Stop() {
|
|
log.I.F("stopping NRC bridge")
|
|
b.cancel()
|
|
b.sessions.Close()
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.rendezvousConn != nil {
|
|
b.rendezvousConn.Close()
|
|
}
|
|
}
|
|
|
|
// UpdateAuthorizedSecrets updates the map of authorized secrets.
|
|
// This allows dynamic management of authorized connections through the UI.
|
|
func (b *Bridge) UpdateAuthorizedSecrets(secrets map[string]string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.config.AuthorizedSecrets = secrets
|
|
}
|
|
|
|
// cleanupLoop periodically cleans up expired sessions.
|
|
func (b *Bridge) cleanupLoop() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-b.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
removed := b.sessions.CleanupExpired()
|
|
if removed > 0 {
|
|
log.D.F("cleaned up %d expired NRC sessions", removed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// runLoop runs the main bridge loop with auto-reconnection.
|
|
func (b *Bridge) runLoop() {
|
|
delay := time.Second
|
|
|
|
for {
|
|
select {
|
|
case <-b.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
err := b.runOnce()
|
|
if err != nil {
|
|
if b.ctx.Err() != nil {
|
|
return // Context cancelled, exit cleanly
|
|
}
|
|
log.W.F("NRC bridge error: %v, reconnecting in %v", err, delay)
|
|
select {
|
|
case <-time.After(delay):
|
|
if delay < 30*time.Second {
|
|
delay *= 2
|
|
}
|
|
case <-b.ctx.Done():
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
delay = time.Second
|
|
}
|
|
}
|
|
|
|
// runOnce runs a single iteration of the bridge.
|
|
func (b *Bridge) runOnce() error {
|
|
// Connect to rendezvous relay
|
|
rendezvousConn, err := ws.RelayConnect(b.ctx, b.config.RendezvousURL)
|
|
if chk.E(err) {
|
|
return fmt.Errorf("%w: %v", ErrRendezvousConnectionFailed, err)
|
|
}
|
|
defer rendezvousConn.Close()
|
|
|
|
b.mu.Lock()
|
|
b.rendezvousConn = rendezvousConn
|
|
b.mu.Unlock()
|
|
|
|
// Subscribe to NRC request events
|
|
relayPubkeyHex := hex.Enc(b.config.Signer.Pub())
|
|
sub, err := rendezvousConn.Subscribe(
|
|
b.ctx,
|
|
filter.NewS(&filter.F{
|
|
Kinds: kind.NewS(kind.New(KindNRCRequest)),
|
|
Tags: tag.NewS(
|
|
tag.NewFromAny("p", relayPubkeyHex),
|
|
),
|
|
Since: ×tamp.T{V: time.Now().Unix()},
|
|
}),
|
|
)
|
|
if chk.E(err) {
|
|
return fmt.Errorf("subscription failed: %w", err)
|
|
}
|
|
defer sub.Unsub()
|
|
|
|
log.I.F("NRC bridge listening for requests on %s", b.config.RendezvousURL)
|
|
|
|
// Process incoming request events
|
|
for {
|
|
select {
|
|
case <-b.ctx.Done():
|
|
return nil
|
|
case ev := <-sub.Events:
|
|
if ev == nil {
|
|
return fmt.Errorf("subscription closed")
|
|
}
|
|
go b.handleRequest(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleRequest handles a single NRC request event.
|
|
func (b *Bridge) handleRequest(ev *event.E) {
|
|
ctx, cancel := context.WithTimeout(b.ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Extract session ID from tags
|
|
sessionID := ""
|
|
sessionTag := ev.Tags.GetFirst([]byte("session"))
|
|
if sessionTag != nil && sessionTag.Len() >= 2 {
|
|
sessionID = string(sessionTag.Value())
|
|
}
|
|
if sessionID == "" {
|
|
log.W.F("NRC request missing session tag from %s", hex.Enc(ev.Pubkey[:]))
|
|
return
|
|
}
|
|
|
|
// Verify authorization
|
|
conversationKey, authMode, deviceName, err := b.authorize(ctx, ev)
|
|
if err != nil {
|
|
log.W.F("NRC authorization failed for %s: %v", hex.Enc(ev.Pubkey[:]), err)
|
|
b.sendError(ctx, ev, sessionID, "unauthorized: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Get or create session
|
|
session := b.sessions.GetOrCreate(sessionID, ev.Pubkey[:], conversationKey, authMode, deviceName)
|
|
session.Touch()
|
|
|
|
// Decrypt request content
|
|
decrypted, err := encryption.Decrypt(conversationKey, string(ev.Content))
|
|
if err != nil {
|
|
log.W.F("NRC decryption failed: %v", err)
|
|
b.sendError(ctx, ev, sessionID, "decryption failed")
|
|
return
|
|
}
|
|
|
|
// Parse request message
|
|
reqMsg, err := ParseRequestContent([]byte(decrypted))
|
|
if err != nil {
|
|
log.W.F("NRC invalid request format: %v", err)
|
|
b.sendError(ctx, ev, sessionID, "invalid request format")
|
|
return
|
|
}
|
|
|
|
log.D.F("NRC request: type=%s session=%s from=%s",
|
|
reqMsg.Type, sessionID, hex.Enc(ev.Pubkey[:]))
|
|
|
|
// Forward to local relay and handle response
|
|
if err := b.forwardToLocalRelay(ctx, session, ev, reqMsg); err != nil {
|
|
log.W.F("NRC forward failed: %v", err)
|
|
b.sendError(ctx, ev, sessionID, "relay error: "+err.Error())
|
|
}
|
|
}
|
|
|
|
// authorize checks if the request is authorized and returns the conversation key.
|
|
func (b *Bridge) authorize(ctx context.Context, ev *event.E) (conversationKey []byte, authMode AuthMode, deviceName string, err error) {
|
|
clientPubkey := ev.Pubkey[:]
|
|
clientPubkeyHex := string(hex.Enc(clientPubkey))
|
|
|
|
// Check for CAT token in tags
|
|
cashuTag := ev.Tags.GetFirst([]byte("cashu"))
|
|
if cashuTag != nil && cashuTag.Len() >= 2 {
|
|
// CAT authentication
|
|
if b.config.CashuVerifier == nil {
|
|
err = fmt.Errorf("CAT auth not configured")
|
|
return
|
|
}
|
|
tokenStr := string(cashuTag.Value())
|
|
var tok *token.Token
|
|
tok, err = token.Parse(tokenStr)
|
|
if chk.E(err) {
|
|
err = fmt.Errorf("invalid CAT token: %w", err)
|
|
return
|
|
}
|
|
if err = b.config.CashuVerifier.VerifyForScope(ctx, tok, token.ScopeNRC, ""); chk.E(err) {
|
|
return
|
|
}
|
|
// CAT auth uses ECDH between relay key and client's Nostr key
|
|
conversationKey, err = encryption.GenerateConversationKey(
|
|
b.config.Signer.Sec(),
|
|
clientPubkey,
|
|
)
|
|
if chk.E(err) {
|
|
return
|
|
}
|
|
authMode = AuthModeCAT
|
|
return
|
|
}
|
|
|
|
// Secret-based authentication: check if client pubkey is in authorized list
|
|
if name, ok := b.config.AuthorizedSecrets[clientPubkeyHex]; ok {
|
|
// Secret auth uses ECDH between relay key and client's derived key
|
|
conversationKey, err = encryption.GenerateConversationKey(
|
|
b.config.Signer.Sec(),
|
|
clientPubkey,
|
|
)
|
|
if chk.E(err) {
|
|
return
|
|
}
|
|
authMode = AuthModeSecret
|
|
deviceName = name
|
|
return
|
|
}
|
|
|
|
err = ErrUnauthorized
|
|
return
|
|
}
|
|
|
|
// forwardToLocalRelay forwards a request to the local relay and handles responses.
|
|
func (b *Bridge) forwardToLocalRelay(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
|
|
// Connect to local relay
|
|
localConn, err := ws.RelayConnect(ctx, b.config.LocalRelayURL)
|
|
if chk.E(err) {
|
|
return fmt.Errorf("%w: %v", ErrRelayConnectionFailed, err)
|
|
}
|
|
defer localConn.Close()
|
|
|
|
// Handle different message types
|
|
switch reqMsg.Type {
|
|
case "REQ":
|
|
return b.handleREQ(ctx, session, reqEvent, reqMsg, localConn)
|
|
case "EVENT":
|
|
return b.handleEVENT(ctx, session, reqEvent, reqMsg, localConn)
|
|
case "CLOSE":
|
|
return b.handleCLOSE(ctx, session, reqEvent, reqMsg)
|
|
case "COUNT":
|
|
return b.handleCOUNT(ctx, session, reqEvent, reqMsg, localConn)
|
|
default:
|
|
return fmt.Errorf("unsupported message type: %s", reqMsg.Type)
|
|
}
|
|
}
|
|
|
|
// handleREQ handles a REQ message and forwards responses.
|
|
func (b *Bridge) handleREQ(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
|
|
// Extract subscription ID and filters from payload
|
|
// Payload: ["REQ", "<sub_id>", filter1, filter2, ...]
|
|
if len(reqMsg.Payload) < 3 {
|
|
return fmt.Errorf("invalid REQ payload")
|
|
}
|
|
subID, ok := reqMsg.Payload[1].(string)
|
|
if !ok {
|
|
return fmt.Errorf("invalid subscription ID")
|
|
}
|
|
|
|
// Parse filters from payload
|
|
var filters []*filter.F
|
|
for i := 2; i < len(reqMsg.Payload); i++ {
|
|
filterMap, ok := reqMsg.Payload[i].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
filterBytes, err := json.Marshal(filterMap)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var f filter.F
|
|
if err := json.Unmarshal(filterBytes, &f); err != nil {
|
|
continue
|
|
}
|
|
filters = append(filters, &f)
|
|
}
|
|
|
|
if len(filters) == 0 {
|
|
return fmt.Errorf("no valid filters in REQ")
|
|
}
|
|
|
|
// Add subscription to session
|
|
if err := session.AddSubscription(subID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create filter set
|
|
filterSet := filter.NewS(filters...)
|
|
|
|
// Subscribe to local relay
|
|
sub, err := conn.Subscribe(ctx, filterSet)
|
|
if chk.E(err) {
|
|
session.RemoveSubscription(subID)
|
|
return fmt.Errorf("local subscribe failed: %w", err)
|
|
}
|
|
defer sub.Unsub()
|
|
|
|
// Forward events until EOSE or timeout
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case ev := <-sub.Events:
|
|
if ev == nil {
|
|
// Subscription closed, send EOSE
|
|
resp := &ResponseMessage{
|
|
Type: "EOSE",
|
|
Payload: []any{"EOSE", subID},
|
|
}
|
|
return b.sendResponse(ctx, reqEvent, session, resp)
|
|
}
|
|
|
|
// Convert event to JSON-compatible map
|
|
eventBytes, err := json.Marshal(ev)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var eventMap map[string]any
|
|
if err := json.Unmarshal(eventBytes, &eventMap); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Send EVENT response
|
|
resp := &ResponseMessage{
|
|
Type: "EVENT",
|
|
Payload: []any{"EVENT", subID, eventMap},
|
|
}
|
|
if err := b.sendResponse(ctx, reqEvent, session, resp); err != nil {
|
|
log.W.F("failed to send event response: %v", err)
|
|
}
|
|
session.IncrementEventCount(subID)
|
|
case <-sub.EndOfStoredEvents:
|
|
// Send EOSE
|
|
session.MarkEOSE(subID)
|
|
resp := &ResponseMessage{
|
|
Type: "EOSE",
|
|
Payload: []any{"EOSE", subID},
|
|
}
|
|
return b.sendResponse(ctx, reqEvent, session, resp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleEVENT handles an EVENT message and forwards the OK response.
|
|
func (b *Bridge) handleEVENT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
|
|
// Extract event from payload: ["EVENT", {...event...}]
|
|
if len(reqMsg.Payload) < 2 {
|
|
return fmt.Errorf("invalid EVENT payload")
|
|
}
|
|
|
|
eventMap, ok := reqMsg.Payload[1].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("invalid event data")
|
|
}
|
|
|
|
// Parse event
|
|
eventBytes, err := json.Marshal(eventMap)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal event: %w", err)
|
|
}
|
|
|
|
var ev event.E
|
|
if err := json.Unmarshal(eventBytes, &ev); err != nil {
|
|
return fmt.Errorf("failed to unmarshal event: %w", err)
|
|
}
|
|
|
|
// Publish to local relay
|
|
err = conn.Publish(ctx, &ev)
|
|
success := err == nil
|
|
message := ""
|
|
if err != nil {
|
|
message = err.Error()
|
|
}
|
|
|
|
// Send OK response
|
|
resp := &ResponseMessage{
|
|
Type: "OK",
|
|
Payload: []any{"OK", string(hex.Enc(ev.ID[:])), success, message},
|
|
}
|
|
return b.sendResponse(ctx, reqEvent, session, resp)
|
|
}
|
|
|
|
// handleCLOSE handles a CLOSE message.
|
|
func (b *Bridge) handleCLOSE(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
|
|
// Extract subscription ID: ["CLOSE", "<sub_id>"]
|
|
if len(reqMsg.Payload) >= 2 {
|
|
if subID, ok := reqMsg.Payload[1].(string); ok {
|
|
session.RemoveSubscription(subID)
|
|
}
|
|
}
|
|
// CLOSE doesn't have a response
|
|
return nil
|
|
}
|
|
|
|
// handleCOUNT handles a COUNT message.
|
|
func (b *Bridge) handleCOUNT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
|
|
// COUNT is not supported via ws.Client directly, return error
|
|
resp := &ResponseMessage{
|
|
Type: "NOTICE",
|
|
Payload: []any{"NOTICE", "COUNT not supported through NRC tunnel"},
|
|
}
|
|
return b.sendResponse(ctx, reqEvent, session, resp)
|
|
}
|
|
|
|
// sendResponse encrypts and sends a response to the client.
|
|
func (b *Bridge) sendResponse(ctx context.Context, reqEvent *event.E, session *Session, resp *ResponseMessage) error {
|
|
// Marshal response content
|
|
content, err := MarshalResponseContent(resp)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal failed: %w", err)
|
|
}
|
|
|
|
// Encrypt content
|
|
encrypted, err := encryption.Encrypt(session.ConversationKey, content, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
|
|
}
|
|
|
|
// Build response event
|
|
respEvent := &event.E{
|
|
Content: []byte(encrypted),
|
|
CreatedAt: time.Now().Unix(),
|
|
Kind: KindNRCResponse,
|
|
Tags: tag.NewS(
|
|
tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
|
|
tag.NewFromAny("encryption", "nip44_v2"),
|
|
tag.NewFromAny("session", session.ID),
|
|
tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
|
|
),
|
|
}
|
|
|
|
// Sign with relay key
|
|
if err := respEvent.Sign(b.config.Signer); chk.E(err) {
|
|
return fmt.Errorf("signing failed: %w", err)
|
|
}
|
|
|
|
// Publish to rendezvous relay
|
|
b.mu.RLock()
|
|
conn := b.rendezvousConn
|
|
b.mu.RUnlock()
|
|
|
|
if conn == nil {
|
|
return fmt.Errorf("not connected to rendezvous relay")
|
|
}
|
|
|
|
if err := conn.Publish(ctx, respEvent); chk.E(err) {
|
|
return fmt.Errorf("publish failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendError sends an error response to the client.
|
|
func (b *Bridge) sendError(ctx context.Context, reqEvent *event.E, sessionID string, errMsg string) {
|
|
// For errors, we need to get or create a conversation key
|
|
// This is best-effort since we may not be able to authenticate
|
|
conversationKey, err := encryption.GenerateConversationKey(
|
|
b.config.Signer.Sec(),
|
|
reqEvent.Pubkey[:],
|
|
)
|
|
if err != nil {
|
|
log.W.F("failed to generate conversation key for error response: %v", err)
|
|
return
|
|
}
|
|
|
|
resp := &ResponseMessage{
|
|
Type: "NOTICE",
|
|
Payload: []any{"NOTICE", "nrc: " + errMsg},
|
|
}
|
|
|
|
content, err := MarshalResponseContent(resp)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
encrypted, err := encryption.Encrypt(conversationKey, content, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
respEvent := &event.E{
|
|
Content: []byte(encrypted),
|
|
CreatedAt: time.Now().Unix(),
|
|
Kind: KindNRCResponse,
|
|
Tags: tag.NewS(
|
|
tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
|
|
tag.NewFromAny("encryption", "nip44_v2"),
|
|
tag.NewFromAny("session", sessionID),
|
|
tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
|
|
),
|
|
}
|
|
|
|
if err := respEvent.Sign(b.config.Signer); err != nil {
|
|
return
|
|
}
|
|
|
|
b.mu.RLock()
|
|
conn := b.rendezvousConn
|
|
b.mu.RUnlock()
|
|
|
|
if conn != nil {
|
|
conn.Publish(ctx, respEvent)
|
|
}
|
|
}
|
|
|
|
// AddAuthorizedSecret adds an authorized secret (derived pubkey).
|
|
func (b *Bridge) AddAuthorizedSecret(pubkeyHex, deviceName string) {
|
|
b.config.AuthorizedSecrets[pubkeyHex] = deviceName
|
|
}
|
|
|
|
// RemoveAuthorizedSecret removes an authorized secret.
|
|
func (b *Bridge) RemoveAuthorizedSecret(pubkeyHex string) {
|
|
delete(b.config.AuthorizedSecrets, pubkeyHex)
|
|
}
|
|
|
|
// ListAuthorizedSecrets returns a copy of the authorized secrets map.
|
|
func (b *Bridge) ListAuthorizedSecrets() map[string]string {
|
|
result := make(map[string]string)
|
|
for k, v := range b.config.AuthorizedSecrets {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SessionCount returns the number of active sessions.
|
|
func (b *Bridge) SessionCount() int {
|
|
return b.sessions.Count()
|
|
}
|