main.go raw
1 // Command nostrino-bridge is the gateway side of a Nostr-relayed BIP157
2 // connection. It listens on one or more public relays under a persistent
3 // public key and forwards accepted streams to a chain node's P2P port.
4 //
5 // nostrino-bridge -relay wss://relay.example -node 127.0.0.1:18444 -chain regtest
6 //
7 // On first start it creates DATA/gateway.key and prints the
8 // nostr+chaindata:// URI a client should use.
9 package main
10
11 import (
12 "context"
13 "encoding/hex"
14 "errors"
15 "flag"
16 "fmt"
17 "os"
18 "os/signal"
19 "path/filepath"
20 "strings"
21 "syscall"
22 "time"
23
24 "git.smesh.lol/nostrino/pkg/bridge"
25 "git.smesh.lol/nostrino/pkg/chain"
26 "git.smesh.lol/nostrino/pkg/tunnel"
27 )
28
29 type stringsFlag []string
30
31 func (s *stringsFlag) String() string { return strings.Join(*s, ",") }
32 func (s *stringsFlag) Set(v string) error {
33 for _, part := range strings.Split(v, ",") {
34 if part = strings.TrimSpace(part); part != "" {
35 *s = append(*s, part)
36 }
37 }
38 return nil
39 }
40
41 func main() {
42 if err := run(); err != nil {
43 fmt.Fprintln(os.Stderr, "nostrino-bridge:", err)
44 os.Exit(1)
45 }
46 }
47
48 func run() error {
49 fs := flag.NewFlagSet("nostrino-bridge", flag.ContinueOnError)
50 var relays stringsFlag
51 fs.Var(&relays, "relay", "relay URL; repeat or comma-separate (default ws://127.0.0.1:3334)")
52 node := fs.String("node", "", "chain node P2P address, host:port")
53 chainName := fs.String("chain", "regtest", "chain profile: btc, testnet3, regtest, signet, or a name in -profile")
54 profilePath := fs.String("profile", "", "chain profile JSON (for XBT or a custom network)")
55 cpHeight := fs.Uint("checkpoint-height", 0, "checkpoint height (with -checkpoint-hash and -checkpoint-filter-header)")
56 cpHash := fs.String("checkpoint-hash", "", "checkpoint block hash (display order)")
57 cpFilter := fs.String("checkpoint-filter-header", "", "checkpoint filter header (display order)")
58 var chains stringsFlag
59 fs.Var(&chains, "chains", "advertised chain names (default: the profile name)")
60 dataDir := fs.String("data", defaultDataDir(), "directory for the gateway key and URI")
61 keyHex := fs.String("key", "", "gateway secret key (64 hex); default is DATA/gateway.key")
62 var allow stringsFlag
63 fs.Var(&allow, "allow", "client pubkey allowlist; repeat or comma-separate")
64 var allowCommands stringsFlag
65 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)")
66 maxSessions := fs.Int("max-sessions", 32, "maximum concurrent streams")
67 maxPerClient := fs.Int("max-sessions-per-client", 2, "maximum concurrent streams per client pubkey")
68 maxBytes := fs.Int64("max-bytes-per-sec", 4<<20, "per-direction byte rate per stream (0 = unlimited)")
69 idle := fs.Duration("idle", 2*time.Minute, "close an idle stream after this long")
70 presence := fs.Bool("presence", true, "publish a discoverable gateway announcement")
71 presenceInterval := fs.Duration("presence-interval", 5*time.Minute, "gateway announcement period")
72 bridgeListen := fs.String("bridge-listen", "", "WebSocket data-plane listen address (empty disables)")
73 bridgePath := fs.String("bridge-path", "/nostrino", "WebSocket data-plane request path")
74 bridgeURL := fs.String("bridge-url", "", "advertised bridge URL (use wss:// behind a proxy)")
75 bridgeToken := fs.String("bridge-token", "", "require this token on the WebSocket upgrade")
76 nodeTimeout := fs.Duration("node-timeout", 10*time.Second, "node dial timeout")
77 check := fs.Bool("check", false, "check the node's P2P and compact-filter support, then exit")
78 verbose := fs.Bool("v", false, "verbose logging")
79 quiet := fs.Bool("q", false, "only print the connection URI")
80 if err := fs.Parse(os.Args[1:]); err != nil {
81 return err
82 }
83 if *check && *node == "" {
84 return errors.New("-check needs -node")
85 }
86
87 profile, err := loadProfile(*profilePath, *chainName)
88 if err != nil {
89 return err
90 }
91 if *cpHash != "" || *cpFilter != "" {
92 if *cpHash == "" || *cpFilter == "" {
93 return errors.New("a checkpoint needs both -checkpoint-hash and -checkpoint-filter-header")
94 }
95 cp, err := chain.ParseCheckpoint(uint32(*cpHeight), *cpHash, *cpFilter)
96 if err != nil {
97 return err
98 }
99 profile.Checkpoint = cp
100 }
101 if err := profile.Validate(); err != nil {
102 return err
103 }
104
105 logf := func(format string, args ...any) {
106 if *verbose && !*quiet {
107 fmt.Fprintf(os.Stderr, "bridge: "+format+"\n", args...)
108 }
109 }
110
111 if *check {
112 ctx, cancel := context.WithTimeout(context.Background(), *nodeTimeout+5*time.Second)
113 defer cancel()
114 info, err := bridge.CheckNode(ctx, *node, profile, *nodeTimeout)
115 if err != nil {
116 return err
117 }
118 if *quiet {
119 fmt.Printf("%t\n", info.CompactFilters)
120 return nil
121 }
122 fmt.Printf("protocol: %d\nservices: %#x\nuser agent: %s\ntip height: %d\ncompact filters: %t\n",
123 info.ProtocolVersion, info.Services, info.UserAgent, info.TipHeight, info.CompactFilters)
124 if !info.CompactFilters {
125 return errors.New("node does not advertise NODE_COMPACT_FILTERS; enable blockfilterindex=basic")
126 }
127 return nil
128 }
129 if *node == "" {
130 return errors.New("-node is required (or use -check)")
131 }
132 if len(relays) == 0 {
133 relays = stringsFlag{"ws://127.0.0.1:3334"}
134 }
135
136 var key [32]byte
137 if *keyHex != "" {
138 key, err = tunnel.ParseKey(*keyHex)
139 if err != nil {
140 return err
141 }
142 } else {
143 if err := os.MkdirAll(*dataDir, 0o700); err != nil {
144 return err
145 }
146 key, err = tunnel.LoadOrCreateKey(filepath.Join(*dataDir, "gateway.key"))
147 if err != nil {
148 return err
149 }
150 }
151
152 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
153 defer cancel()
154
155 srv, err := bridge.Start(ctx, bridge.Config{
156 Relays: relays,
157 NodeAddr: *node,
158 Key: key,
159 Profile: profile,
160 Chains: chains,
161 Allowlist: allow,
162 AllowCommands: allowCommands,
163 MaxSessions: *maxSessions,
164 MaxSessionsPerClient: *maxPerClient,
165 MaxBytesPerSec: *maxBytes,
166 NodeDialTimeout: *nodeTimeout,
167 IdleTimeout: *idle,
168 Presence: *presence,
169 PresenceInterval: *presenceInterval,
170 BridgeListen: *bridgeListen,
171 BridgePath: *bridgePath,
172 BridgeURL: *bridgeURL,
173 BridgeToken: *bridgeToken,
174 Version: "nostrino/0.2.0",
175 Logf: logf,
176 })
177 if err != nil {
178 return err
179 }
180 defer srv.Close()
181
182 uri := srv.URI().String()
183 pub := srv.PubKey()
184 if !*quiet {
185 fmt.Printf("gateway pubkey: %s\n", hex.EncodeToString(pub[:]))
186 fmt.Printf("listening on: %s\n", strings.Join(relays, ", "))
187 fmt.Printf("serving node: %s (%s, magic %08x)\n", *node, profile.Name, profile.Magic)
188 if profile.Checkpoint != nil {
189 fmt.Printf("checkpoint: height %d %s\n", profile.Checkpoint.Height, profile.Checkpoint.BlockHash)
190 }
191 if len(allow) > 0 {
192 fmt.Printf("client allowlist: %s\n", strings.Join(allow, ", "))
193 }
194 }
195 fmt.Printf("connection uri: %s\n", uri)
196 if *dataDir != "" {
197 path := filepath.Join(*dataDir, "gateway.uri")
198 if err := os.WriteFile(path, []byte(uri+"\n"), 0o600); err == nil && *verbose {
199 fmt.Fprintf(os.Stderr, "bridge: wrote %s\n", path)
200 }
201 }
202
203 err = srv.Run(ctx)
204 if errors.Is(err, context.Canceled) {
205 return nil
206 }
207 return err
208 }
209
210 func loadProfile(path, name string) (*chain.Profile, error) {
211 if path != "" {
212 p, err := chain.LoadProfile(path)
213 if err != nil {
214 return nil, err
215 }
216 return p, nil
217 }
218 p, err := chain.ByName(name)
219 if err != nil {
220 return nil, err
221 }
222 if err := p.Validate(); err != nil {
223 return nil, fmt.Errorf("chain %q is not configured in this build (use -profile): %w", name, err)
224 }
225 return p, nil
226 }
227
228 func defaultDataDir() string {
229 home, err := os.UserHomeDir()
230 if err != nil || home == "" {
231 return ".nostrino/bridge"
232 }
233 return filepath.Join(home, ".nostrino", "bridge")
234 }
235