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