// Package tunnel carries a reliable, ordered, encrypted byte stream over // NIP-01 events on a public Nostr relay. // // The point is to give a Bitcoin P2P peer (Neutrino) a net.Conn where the // network would normally be a TCP socket. The bridge listens on the relay, and // each accepted stream is connected to a chain node's P2P port. The relay sees // only signed events with opaque NIP-44 content; the gateway sees the P2P // bytes and nothing else. // // The stream is a windowed protocol: sequence numbers, cumulative // acknowledgements, gap detection with negative acknowledgements, and // retransmission. Relay delivery is normally ordered and reliable, so the // retransmission path is a safety net for a relay that drops an event, not the // common path. Frames are bounded by the relay's event size limit. package tunnel import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "net" "sync" "time" "git.smesh.lol/nostrino/pkg/crypto/secp256k1" "git.smesh.lol/nostrino/pkg/nip44" "git.smesh.lol/nostrino/pkg/nostr" "git.smesh.lol/nostrino/pkg/relay" ) // Protocol version carried in the URI, HELLO and every envelope. const protocolVersion = 1 // Event kinds. They are in the ephemeral range (20000..29999) on purpose: the // stream is live traffic, not history, and a relay must not store it. A // dropped session is restarted by the caller, which is also what a TCP reset // means. const ( // KindClient carries client-to-gateway frames. KindClient = 23196 // KindGateway carries gateway-to-client frames. KindGateway = 23197 ) // Bounds and timings. const ( defaultMTU = 32 << 10 minMTU = 1024 maxMTU = 96 << 10 defaultWindow = 32 minWindow = 4 maxWindow = 256 defaultHandshake = 15 * time.Second defaultHeartbeat = 15 * time.Second defaultIdle = 90 * time.Second ackEvery = 1 nackDelay = 300 * time.Millisecond retransmitAfter = 1500 * time.Millisecond maxFrameRetries = 8 sendQueueFrames = 64 inboxFrames = 128 ) var ( // ErrClosed is returned after the stream or the relay connection ends. ErrClosed = errors.New("tunnel: stream closed") // ErrTimeout is returned when a deadline expires. ErrTimeout = timeoutError{} // ErrFrameAhead means a peer sent a frame further ahead of the one this side expects // than a whole window allows, which correct flow control cannot produce. ErrFrameAhead = errors.New("tunnel: frame is too far ahead") ) type timeoutError struct{} func (timeoutError) Error() string { return "tunnel: i/o timeout" } func (timeoutError) Timeout() bool { return true } func (timeoutError) Temporary() bool { return true } type side int const ( sideClient side = iota sideGateway ) func (s side) String() string { if s == sideGateway { return "gateway" } return "client" } // DialConfig configures the client end of a tunnel. type DialConfig struct { // URI is required: it names the gateway pubkey and the relay. URI *URI // Key is the client's ephemeral transport key. The zero value generates // one for this connection, which is the intended privacy model: the // gateway and the relay do not learn a stable client identity. Key [32]byte // Chain is the chain name sent in HELLO, for example "btc" or "xbt". Chain string // MTU is the largest plaintext data frame. Zero means 32 KiB, which stays // below a 60 KB encrypted-event limit with base64 expansion. MTU int // Window is the number of unacknowledged frames allowed in flight. Window int // HandshakeTimeout bounds connect, subscribe, EOSE and HELLO_ACK. HandshakeTimeout time.Duration // Heartbeat is the ping interval. Zero means 15s. Heartbeat time.Duration // IdleTimeout closes a stream that has received nothing. Zero means 90s. IdleTimeout time.Duration // Logf receives diagnostics. Nil discards them. Logf func(string, ...any) // TransportFactory overrides the relay WebSocket dialer, which is how the // wasm build supplies its syscall/js transport and how tests inject one. TransportFactory func(url string) (relay.Transport, error) } // Conn is one tunnel stream and implements net.Conn. type Conn struct { sess *session local string remote string } // Read implements net.Conn. func (c *Conn) Read(p []byte) (int, error) { return c.sess.read(p) } // Write implements net.Conn. It returns once the bytes are queued for // transmission, subject to the tunnel window. func (c *Conn) Write(p []byte) (int, error) { return c.sess.write(p) } // Close closes the stream and, on the client end, its relay connection. func (c *Conn) Close() error { c.sess.closeWithErr(ErrClosed) return nil } // Reset aborts the stream instead of closing it in order. It is the // cancellation primitive: a browser worker that is being torn down, or a // client abandoning a filter range and switching gateway, uses Reset so the // peer releases the node connection without waiting for the stream to drain. func (c *Conn) Reset() error { c.sess.resetWithErr(errors.New("tunnel: reset by local end")) return nil } // LocalAddr implements net.Conn. func (c *Conn) LocalAddr() net.Addr { return netAddr(c.local) } // RemoteAddr implements net.Conn. func (c *Conn) RemoteAddr() net.Addr { return netAddr(c.remote) } // SetDeadline implements net.Conn. func (c *Conn) SetDeadline(t time.Time) error { c.sess.setReadDeadline(t) c.sess.setWriteDeadline(t) return nil } // SetReadDeadline implements net.Conn. func (c *Conn) SetReadDeadline(t time.Time) error { c.sess.setReadDeadline(t) return nil } // SetWriteDeadline implements net.Conn. func (c *Conn) SetWriteDeadline(t time.Time) error { c.sess.setWriteDeadline(t) return nil } // Peer is the remote transport public key. func (c *Conn) Peer() [32]byte { return c.sess.peerPub } // PeerHex is Peer as lowercase hex. func (c *Conn) PeerHex() string { return nostr.Hex(c.sess.peerPub[:]) } // Chain is the chain name negotiated in HELLO. func (c *Conn) Chain() string { return c.sess.chain } type netAddr string func (a netAddr) Network() string { return "nostr" } func (a netAddr) String() string { return string(a) } func defaultDialConfig(cfg DialConfig) (DialConfig, error) { if cfg.URI == nil { return cfg, errors.New("tunnel: nil uri") } if cfg.MTU == 0 { cfg.MTU = defaultMTU } if cfg.MTU < minMTU || cfg.MTU > maxMTU { return cfg, fmt.Errorf("tunnel: mtu %d outside [%d,%d]", cfg.MTU, minMTU, maxMTU) } if cfg.Window == 0 { cfg.Window = defaultWindow } if cfg.Window < minWindow || cfg.Window > maxWindow { return cfg, fmt.Errorf("tunnel: window %d outside [%d,%d]", cfg.Window, minWindow, maxWindow) } if cfg.HandshakeTimeout == 0 { cfg.HandshakeTimeout = defaultHandshake } if cfg.Heartbeat == 0 { cfg.Heartbeat = defaultHeartbeat } if cfg.IdleTimeout == 0 { cfg.IdleTimeout = defaultIdle } if cfg.TransportFactory == nil { cfg.TransportFactory = relay.DialTransport } if cfg.Logf == nil { cfg.Logf = func(string, ...any) {} } return cfg, nil } // Dial opens a tunnel to the gateway named by cfg.URI. func Dial(ctx context.Context, cfg DialConfig) (*Conn, error) { cfg, err := defaultDialConfig(cfg) if err != nil { return nil, err } var key [32]byte if cfg.Key == ([32]byte{}) { if _, err := rand.Read(key[:]); err != nil { return nil, fmt.Errorf("tunnel: generate key: %w", err) } } else { key = cfg.Key } localPub, ok := secp256k1.PubKey(key) if !ok { return nil, errors.New("tunnel: invalid client key") } if !secp256k1.ValidatePubKey(cfg.URI.Gateway) { return nil, errors.New("tunnel: invalid gateway key") } dialCtx, cancel := context.WithTimeout(ctx, cfg.HandshakeTimeout) defer cancel() sessID, err := newSessionID() if err != nil { return nil, err } var ( clients []*relay.Client subs []*relay.Subscription lastErr error ) filter := nostr.Filter{ Kinds: []int{KindGateway}, Tags: map[string][]string{"p": {nostr.Hex(localPub[:])}, "s": {sessID}}, } for _, relayURL := range cfg.URI.Relays { t, err := cfg.TransportFactory(relayURL) if err != nil { lastErr = err cfg.Logf("tunnel: relay %s: %v", relayURL, err) continue } c, err := relay.New(t, key, relay.Options{RelayURL: relayURL}) if err != nil { t.Close() lastErr = err cfg.Logf("tunnel: relay %s: %v", relayURL, err) continue } sub, err := c.Subscribe(filter) if err == nil { err = c.WaitEOSE(dialCtx, sub) } if err != nil { c.Close() lastErr = err cfg.Logf("tunnel: relay %s: %v", relayURL, err) continue } clients = append(clients, c) subs = append(subs, sub) } if len(clients) == 0 { if lastErr == nil { lastErr = errors.New("no relay") } return nil, fmt.Errorf("tunnel: connect relay: %w", lastErr) } closed := false defer func() { if !closed { for _, c := range clients { c.Close() } } }() s, err := newSession(sideClient, key, localPub, cfg.URI.Gateway, sessID, cfg) if err != nil { return nil, err } s.chain = cfg.Chain s.setRelays(clients, subs) s.start() hello := &envelope{ V: protocolVersion, S: sessID, T: frameHello, MTU: cfg.MTU, Win: cfg.Window, PV: protocolVersion, Chain: cfg.Chain, } if err := s.sendEnvelope(hello); err != nil { s.closeWithErr(err) return nil, fmt.Errorf("tunnel: hello: %w", err) } select { case <-s.established: case <-dialCtx.Done(): s.closeWithErr(dialCtx.Err()) return nil, fmt.Errorf("tunnel: waiting for hello_ack: %w", dialCtx.Err()) case <-s.done: return nil, fmt.Errorf("tunnel: handshake: %w", s.Err()) } closed = true // the session owns the relay client now return &Conn{ sess: s, local: "client:" + nostr.Hex(localPub[:]), remote: "gateway:" + nostr.Hex(cfg.URI.Gateway[:]), }, nil } // DialURI is Dial with a parsed connection string. func DialURI(ctx context.Context, raw string, cfg DialConfig) (*Conn, error) { u, err := ParseURI(raw) if err != nil { return nil, err } cfg.URI = u return Dial(ctx, cfg) } func newSessionID() (string, error) { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("tunnel: session id: %w", err) } return hex.EncodeToString(b[:]), nil } // ListenConfig configures the gateway end of a tunnel. type ListenConfig struct { // Relays are all relays the gateway announces and listens on. Relays []string // Key is the gateway's persistent transport key. Key [32]byte // Chains are the chain names this gateway serves, for example // ["btc","xbt"]. Empty serves any chain name. Chains []string // Allowlist is an optional set of client public keys (hex). Empty accepts // any client, which is the public chain-data model. Allowlist []string // MaxSessions bounds concurrent streams. Zero means 64. MaxSessions int // MaxPerClient bounds one client pubkey's concurrent streams. Zero means 2. MaxPerClient int // MTU and Window are negotiated downward with the client. MTU int Window int // HandshakeTimeout bounds relay connect and EOSE. Zero means 15s. HandshakeTimeout time.Duration // Heartbeat and IdleTimeout are per-session liveness bounds. Heartbeat time.Duration IdleTimeout time.Duration Logf func(string, ...any) TransportFactory func(url string) (relay.Transport, error) } // Listener accepts tunnel streams from any client that addresses the gateway // public key on one of its relays. type Listener struct { cfg ListenConfig key [32]byte pub [32]byte relays []*relay.Client subs []*relay.Subscription mu sync.Mutex sessions map[string]*session perClient map[string]int accept chan *Conn closed bool done chan struct{} closeErr error wg sync.WaitGroup } // Listen connects to every configured relay and starts accepting streams. func Listen(ctx context.Context, cfg ListenConfig) (*Listener, error) { if cfg.Key == ([32]byte{}) { return nil, errors.New("tunnel: gateway key is required") } pub, ok := secp256k1.PubKey(cfg.Key) if !ok { return nil, errors.New("tunnel: invalid gateway key") } if len(cfg.Relays) == 0 { return nil, errors.New("tunnel: no relay configured") } if cfg.MTU == 0 { cfg.MTU = defaultMTU } if cfg.Window == 0 { cfg.Window = defaultWindow } if cfg.MaxSessions == 0 { cfg.MaxSessions = 64 } if cfg.MaxPerClient == 0 { cfg.MaxPerClient = 2 } if cfg.HandshakeTimeout == 0 { cfg.HandshakeTimeout = defaultHandshake } if cfg.Heartbeat == 0 { cfg.Heartbeat = defaultHeartbeat } if cfg.IdleTimeout == 0 { cfg.IdleTimeout = defaultIdle } if cfg.TransportFactory == nil { cfg.TransportFactory = relay.DialTransport } if cfg.Logf == nil { cfg.Logf = func(string, ...any) {} } l := &Listener{ cfg: cfg, key: cfg.Key, pub: pub, sessions: make(map[string]*session), perClient: make(map[string]int), accept: make(chan *Conn, 8), done: make(chan struct{}), } handshakeCtx, cancel := context.WithTimeout(ctx, cfg.HandshakeTimeout) ready := make(chan struct{}, len(cfg.Relays)) for _, rawURL := range cfg.Relays { l.wg.Add(1) go l.relayLoop(rawURL, ready) } select { case <-ready: cancel() return l, nil case <-handshakeCtx.Done(): cancel() l.Close() return nil, errors.New("tunnel: no relay connected") } } // Publish sends a signed event on every connected relay. It returns nil when // at least one relay accepted it. func (l *Listener) Publish(ctx context.Context, ev *nostr.Event) error { l.mu.Lock() relays := append([]*relay.Client(nil), l.relays...) l.mu.Unlock() if len(relays) == 0 { return errors.New("tunnel: no relay connected") } var lastErr error sent := 0 for _, rc := range relays { if err := rc.Publish(ctx, ev); err != nil { lastErr = err continue } sent++ } if sent == 0 { return fmt.Errorf("tunnel: publish: %w", lastErr) } return nil } // PubKey is the gateway transport public key. func (l *Listener) PubKey() [32]byte { return l.pub } // PubKeyHex is PubKey as lowercase hex. func (l *Listener) PubKeyHex() string { return nostr.Hex(l.pub[:]) } // Accept waits for the next handshaken stream. func (l *Listener) Accept(ctx context.Context) (*Conn, error) { select { case c := <-l.accept: return c, nil case <-l.done: if l.closeErr != nil { return nil, l.closeErr } return nil, ErrClosed case <-ctx.Done(): return nil, ctx.Err() } } // Close ends every session and relay connection. func (l *Listener) Close() error { l.mu.Lock() if l.closed { l.mu.Unlock() return nil } l.closed = true l.closeErr = ErrClosed close(l.done) sessions := make([]*session, 0, len(l.sessions)) for _, s := range l.sessions { sessions = append(sessions, s) } subs := append([]*relay.Subscription(nil), l.subs...) relays := append([]*relay.Client(nil), l.relays...) l.mu.Unlock() for _, s := range sessions { s.closeWithErr(ErrClosed) } for _, sub := range subs { sub.Close() } for _, rc := range relays { rc.Close() } l.wg.Wait() return nil } // relayLoop maintains one relay connection: connect, subscribe, read, and // reconnect with backoff when the relay or the network drops it. A public // relay is not a durable socket, and a gateway that stops listening after the // first idle timeout is not a gateway. func (l *Listener) relayLoop(rawURL string, ready chan struct{}) { defer l.wg.Done() signaled := false backoff := time.Second for { select { case <-l.done: return default: } t, err := l.cfg.TransportFactory(rawURL) if err != nil { l.cfg.Logf("tunnel: relay %s: %v", rawURL, err) if !l.sleep(backoff) { return } backoff = nextBackoff(backoff) continue } rc, err := relay.New(t, l.key, relay.Options{RelayURL: rawURL}) if err != nil { t.Close() if !l.sleep(backoff) { return } backoff = nextBackoff(backoff) continue } sub, err := rc.Subscribe(nostr.Filter{ Kinds: []int{KindClient}, Tags: map[string][]string{"p": {nostr.Hex(l.pub[:])}}, }) if err == nil { ectx, ecancel := context.WithTimeout(context.Background(), l.cfg.HandshakeTimeout) err = rc.WaitEOSE(ectx, sub) ecancel() } if err != nil { rc.Close() l.cfg.Logf("tunnel: relay %s: %v", rawURL, err) if !l.sleep(backoff) { return } backoff = nextBackoff(backoff) continue } l.mu.Lock() if l.closed { l.mu.Unlock() rc.Close() return } l.relays = append(l.relays, rc) l.subs = append(l.subs, sub) l.mu.Unlock() l.cfg.Logf("tunnel: listening on %s as %s", rawURL, nostr.Hex(l.pub[:])) backoff = time.Second if !signaled { signaled = true select { case ready <- struct{}{}: default: } } l.readLoop(rc, sub) l.mu.Lock() for i, c := range l.relays { if c == rc { l.relays = append(l.relays[:i], l.relays[i+1:]...) break } } for i, sb := range l.subs { if sb == sub { l.subs = append(l.subs[:i], l.subs[i+1:]...) break } } l.mu.Unlock() rc.Close() if !l.sleep(backoff) { return } backoff = nextBackoff(backoff) } } func (l *Listener) sleep(d time.Duration) bool { select { case <-time.After(d): return true case <-l.done: return false } } func nextBackoff(d time.Duration) time.Duration { d *= 2 if d > 30*time.Second { d = 30 * time.Second } return d } // readLoop dispatches one relay's client events to sessions. func (l *Listener) readLoop(rc *relay.Client, sub *relay.Subscription) { for { select { case <-l.done: return case <-sub.Done(): return case ev := <-sub.Events: if ev.Kind != KindClient || ev.PubKey == l.pub { continue } peer := ev.PubKey if !l.allowed(peer) { continue } conv, err := nip44.ConversationKey(l.key, peer) if err != nil { continue } raw, err := nip44.Decrypt(conv, ev.Content) if err != nil { continue } env, err := parseEnvelope(raw) if err != nil { continue } if env.T == frameHello { l.handleHello(rc, conv, peer, env) continue } l.mu.Lock() s := l.sessions[env.S] l.mu.Unlock() if s != nil { s.addRelay(rc) s.enqueue(env) } } } } func (l *Listener) allowed(peer [32]byte) bool { if len(l.cfg.Allowlist) == 0 { return true } hexKey := nostr.Hex(peer[:]) for _, allowed := range l.cfg.Allowlist { if allowed == hexKey { return true } } return false } func (l *Listener) chainAllowed(chain string) bool { if len(l.cfg.Chains) == 0 { return true } for _, c := range l.cfg.Chains { if c == chain { return true } } return false } func (l *Listener) handleHello(rc *relay.Client, conv [32]byte, peer [32]byte, env *envelope) { chain := env.Chain if !l.chainAllowed(chain) { l.rejectHello(rc, conv, peer, env, "chain not served: "+chain) return } if env.PV != protocolVersion { l.rejectHello(rc, conv, peer, env, "unsupported protocol version") return } mtu := env.MTU if mtu == 0 || mtu > l.cfg.MTU { mtu = l.cfg.MTU } if mtu < minMTU { mtu = minMTU } window := env.Win if window == 0 || window > l.cfg.Window { window = l.cfg.Window } if window < minWindow { window = minWindow } l.mu.Lock() if existing := l.sessions[env.S]; existing != nil { l.mu.Unlock() existing.addRelay(rc) existing.sendHelloAck() return } if len(l.sessions) >= l.cfg.MaxSessions { l.mu.Unlock() l.rejectHello(rc, conv, peer, env, "gateway at session limit") return } peerHex := nostr.Hex(peer[:]) if l.perClient[peerHex] >= l.cfg.MaxPerClient { l.mu.Unlock() l.rejectHello(rc, conv, peer, env, "client at session limit") return } s, err := newSession(sideGateway, l.key, l.pub, peer, env.S, DialConfig{ Chain: chain, MTU: mtu, Window: window, Heartbeat: l.cfg.Heartbeat, IdleTimeout: l.cfg.IdleTimeout, HandshakeTimeout: l.cfg.HandshakeTimeout, Logf: l.cfg.Logf, }) if err != nil { l.mu.Unlock() l.cfg.Logf("tunnel: client %s: %v", nostr.Hex(peer[:]), err) return } l.perClient[peerHex]++ s.chain = chain s.setRelays([]*relay.Client{rc}, nil) s.onClose = func() { l.mu.Lock() delete(l.sessions, env.S) if l.perClient[peerHex] > 0 { l.perClient[peerHex]-- } l.mu.Unlock() } l.sessions[env.S] = s l.mu.Unlock() s.start() s.sendHelloAck() conn := &Conn{ sess: s, local: "gateway:" + nostr.Hex(l.pub[:]), remote: "client:" + nostr.Hex(peer[:]), } select { case l.accept <- conn: case <-l.done: s.closeWithErr(ErrClosed) default: s.closeWithErr(errors.New("tunnel: accept backlog full")) } } func (l *Listener) rejectHello(rc *relay.Client, conv [32]byte, peer [32]byte, env *envelope, reason string) { s, err := newSession(sideGateway, l.key, l.pub, peer, env.S, DialConfig{ Heartbeat: l.cfg.Heartbeat, IdleTimeout: l.cfg.IdleTimeout, HandshakeTimeout: l.cfg.HandshakeTimeout, Logf: l.cfg.Logf, }) if err != nil { return } s.setRelays([]*relay.Client{rc}, nil) _ = s.sendEnvelope(&envelope{ V: protocolVersion, S: env.S, T: frameClose, Reason: reason, }) s.closeWithErr(errors.New(reason)) }