// Command nostrino-bridge is the gateway side of a Nostr-relayed BIP157 // connection. It listens on one or more public relays under a persistent // public key and forwards accepted streams to a chain node's P2P port. // // nostrino-bridge -relay wss://relay.example -node 127.0.0.1:18444 -chain regtest // // On first start it creates DATA/gateway.key and prints the // nostr+chaindata:// URI a client should use. package main import ( "context" "encoding/hex" "errors" "flag" "fmt" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "git.smesh.lol/nostrino/pkg/bridge" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/tunnel" ) type stringsFlag []string func (s *stringsFlag) String() string { return strings.Join(*s, ",") } func (s *stringsFlag) Set(v string) error { for _, part := range strings.Split(v, ",") { if part = strings.TrimSpace(part); part != "" { *s = append(*s, part) } } return nil } func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "nostrino-bridge:", err) os.Exit(1) } } func run() error { fs := flag.NewFlagSet("nostrino-bridge", flag.ContinueOnError) var relays stringsFlag fs.Var(&relays, "relay", "relay URL; repeat or comma-separate (default ws://127.0.0.1:3334)") node := fs.String("node", "", "chain node P2P address, host:port") chainName := fs.String("chain", "regtest", "chain profile: btc, testnet3, regtest, signet, or a name in -profile") profilePath := fs.String("profile", "", "chain profile JSON (for XBT or a custom network)") cpHeight := fs.Uint("checkpoint-height", 0, "checkpoint height (with -checkpoint-hash and -checkpoint-filter-header)") cpHash := fs.String("checkpoint-hash", "", "checkpoint block hash (display order)") cpFilter := fs.String("checkpoint-filter-header", "", "checkpoint filter header (display order)") var chains stringsFlag fs.Var(&chains, "chains", "advertised chain names (default: the profile name)") dataDir := fs.String("data", defaultDataDir(), "directory for the gateway key and URI") keyHex := fs.String("key", "", "gateway secret key (64 hex); default is DATA/gateway.key") var allow stringsFlag fs.Var(&allow, "allow", "client pubkey allowlist; repeat or comma-separate") var allowCommands stringsFlag fs.Var(&allowCommands, "allow-command", "extra P2P command to forward, for a client that needs it; repeat or comma-separate (default: headers, filters and handshake only)") maxSessions := fs.Int("max-sessions", 32, "maximum concurrent streams") maxPerClient := fs.Int("max-sessions-per-client", 2, "maximum concurrent streams per client pubkey") maxBytes := fs.Int64("max-bytes-per-sec", 4<<20, "per-direction byte rate per stream (0 = unlimited)") idle := fs.Duration("idle", 2*time.Minute, "close an idle stream after this long") presence := fs.Bool("presence", true, "publish a discoverable gateway announcement") presenceInterval := fs.Duration("presence-interval", 5*time.Minute, "gateway announcement period") bridgeListen := fs.String("bridge-listen", "", "WebSocket data-plane listen address (empty disables)") bridgePath := fs.String("bridge-path", "/nostrino", "WebSocket data-plane request path") bridgeURL := fs.String("bridge-url", "", "advertised bridge URL (use wss:// behind a proxy)") bridgeToken := fs.String("bridge-token", "", "require this token on the WebSocket upgrade") nodeTimeout := fs.Duration("node-timeout", 10*time.Second, "node dial timeout") check := fs.Bool("check", false, "check the node's P2P and compact-filter support, then exit") verbose := fs.Bool("v", false, "verbose logging") quiet := fs.Bool("q", false, "only print the connection URI") if err := fs.Parse(os.Args[1:]); err != nil { return err } if *check && *node == "" { return errors.New("-check needs -node") } profile, err := loadProfile(*profilePath, *chainName) if err != nil { return err } if *cpHash != "" || *cpFilter != "" { if *cpHash == "" || *cpFilter == "" { return errors.New("a checkpoint needs both -checkpoint-hash and -checkpoint-filter-header") } cp, err := chain.ParseCheckpoint(uint32(*cpHeight), *cpHash, *cpFilter) if err != nil { return err } profile.Checkpoint = cp } if err := profile.Validate(); err != nil { return err } logf := func(format string, args ...any) { if *verbose && !*quiet { fmt.Fprintf(os.Stderr, "bridge: "+format+"\n", args...) } } if *check { ctx, cancel := context.WithTimeout(context.Background(), *nodeTimeout+5*time.Second) defer cancel() info, err := bridge.CheckNode(ctx, *node, profile, *nodeTimeout) if err != nil { return err } if *quiet { fmt.Printf("%t\n", info.CompactFilters) return nil } fmt.Printf("protocol: %d\nservices: %#x\nuser agent: %s\ntip height: %d\ncompact filters: %t\n", info.ProtocolVersion, info.Services, info.UserAgent, info.TipHeight, info.CompactFilters) if !info.CompactFilters { return errors.New("node does not advertise NODE_COMPACT_FILTERS; enable blockfilterindex=basic") } return nil } if *node == "" { return errors.New("-node is required (or use -check)") } if len(relays) == 0 { relays = stringsFlag{"ws://127.0.0.1:3334"} } var key [32]byte if *keyHex != "" { key, err = tunnel.ParseKey(*keyHex) if err != nil { return err } } else { if err := os.MkdirAll(*dataDir, 0o700); err != nil { return err } key, err = tunnel.LoadOrCreateKey(filepath.Join(*dataDir, "gateway.key")) if err != nil { return err } } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() srv, err := bridge.Start(ctx, bridge.Config{ Relays: relays, NodeAddr: *node, Key: key, Profile: profile, Chains: chains, Allowlist: allow, AllowCommands: allowCommands, MaxSessions: *maxSessions, MaxSessionsPerClient: *maxPerClient, MaxBytesPerSec: *maxBytes, NodeDialTimeout: *nodeTimeout, IdleTimeout: *idle, Presence: *presence, PresenceInterval: *presenceInterval, BridgeListen: *bridgeListen, BridgePath: *bridgePath, BridgeURL: *bridgeURL, BridgeToken: *bridgeToken, Version: "nostrino/0.2.0", Logf: logf, }) if err != nil { return err } defer srv.Close() uri := srv.URI().String() pub := srv.PubKey() if !*quiet { fmt.Printf("gateway pubkey: %s\n", hex.EncodeToString(pub[:])) fmt.Printf("listening on: %s\n", strings.Join(relays, ", ")) fmt.Printf("serving node: %s (%s, magic %08x)\n", *node, profile.Name, profile.Magic) if profile.Checkpoint != nil { fmt.Printf("checkpoint: height %d %s\n", profile.Checkpoint.Height, profile.Checkpoint.BlockHash) } if len(allow) > 0 { fmt.Printf("client allowlist: %s\n", strings.Join(allow, ", ")) } } fmt.Printf("connection uri: %s\n", uri) if *dataDir != "" { path := filepath.Join(*dataDir, "gateway.uri") if err := os.WriteFile(path, []byte(uri+"\n"), 0o600); err == nil && *verbose { fmt.Fprintf(os.Stderr, "bridge: wrote %s\n", path) } } err = srv.Run(ctx) if errors.Is(err, context.Canceled) { return nil } return err } func loadProfile(path, name string) (*chain.Profile, error) { if path != "" { p, err := chain.LoadProfile(path) if err != nil { return nil, err } return p, nil } p, err := chain.ByName(name) if err != nil { return nil, err } if err := p.Validate(); err != nil { return nil, fmt.Errorf("chain %q is not configured in this build (use -profile): %w", name, err) } return p, nil } func defaultDataDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return ".nostrino/bridge" } return filepath.Join(home, ".nostrino", "bridge") }