feat: add mDNS peer discovery for LAN environments (#102)

Co-authored-by: Max <max@privatevoid.net>
This commit is contained in:
DavHau 2026-06-22 16:01:34 +02:00 committed by GitHub
parent 7e03fcc1d5
commit 6f3c85a1ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 238 additions and 3 deletions

View File

@ -72,6 +72,13 @@
generateSchemasProgram = config.apps.dev-generate-schemas.program;
};
};
checks = pkgs.lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
vm-test = import ./nixos/test.nix {
inherit pkgs;
self = inputs.self;
};
};
};
};
}

1
go.mod
View File

@ -27,6 +27,7 @@ require (
github.com/google/btree v1.1.2 // indirect
github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
github.com/libp2p/zeroconf/v2 v2.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect

7
go.sum
View File

@ -296,6 +296,8 @@ github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQsc
github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
github.com/libp2p/go-yamux/v5 v5.0.1 h1:f0WoX/bEF2E8SbE4c/k1Mo+/9z0O4oC/hWEA+nfYRSg=
github.com/libp2p/go-yamux/v5 v5.0.1/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU=
github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q=
github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/marcopolo/simnet v0.0.4 h1:50Kx4hS9kFGSRIbrt9xUS3NJX33EyPqHVmpXvaKLqrY=
github.com/marcopolo/simnet v0.0.4/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0=
@ -307,6 +309,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.42/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8=
@ -615,6 +618,7 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@ -693,6 +697,8 @@ golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -715,6 +721,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=

92
nixos/test.nix Normal file
View File

@ -0,0 +1,92 @@
{
pkgs,
self,
}:
let
peer1 = {
privateKey = "z23jhTbcjBgHmxFeeC11RXxeXb32Ce9eMvMy75ke2TrCsXpNrNvVsH98qSumzxPBbF7TqyBsd7macsAYfnVQzUApd51ZgC";
id = "12D3KooWP6VEGDDp6eiCSubLai8A9hoAWuFN9A2A5BysyaSbXtRN";
};
peer2 = {
privateKey = "z23jhTbwi6M5cGWn1GeC7eXV2TbJpFhgmtVpsUjKshKKrSCy2seByS6da3epoBtEsMc8HA5oToay8RVG2w9nACHdt5WUge";
id = "12D3KooWNfMAtF1ZWb2j9MYpFz1ZmRzUAyq1CPYAwGttomNmqY46";
};
dummyTunPrefix = "10.99.0";
mkPeerConfig =
{
privateKeyFile,
peers,
dummyTunAddr,
}:
{
imports = [ self.nixosModules.default ];
networking.firewall.enable = false;
services.resolved.enable = true;
services.hyprspace = {
enable = true;
inherit privateKeyFile;
settings.peers = peers;
};
systemd.services.dummytun = {
description = "Dummy tunnel interface for anti-loop test";
before = [ "hyprspace.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
script = ''
${pkgs.iproute2}/bin/ip tuntap add dev dummytun mode tun
${pkgs.iproute2}/bin/ip addr add ${dummyTunAddr}/24 dev dummytun
${pkgs.iproute2}/bin/ip link set dummytun up
'';
};
};
in
pkgs.testers.nixosTest {
name = "hyprspace";
nodes = {
peer1 = mkPeerConfig {
privateKeyFile = pkgs.writeText "hs-key-peer1" peer1.privateKey;
dummyTunAddr = "${dummyTunPrefix}.1";
peers = [
{
id = peer2.id;
name = "peer2";
}
];
};
peer2 = mkPeerConfig {
privateKeyFile = pkgs.writeText "hs-key-peer2" peer2.privateKey;
dummyTunAddr = "${dummyTunPrefix}.2";
peers = [
{
id = peer1.id;
name = "peer1";
}
];
};
};
testScript = ''
start_all()
for node in [peer1, peer2]:
node.wait_for_unit("hyprspace.service")
node.wait_until_succeeds(
"${pkgs.iproute2}/bin/ip addr show hyprspace | grep -q 'inet 100\.64\.'",
timeout=10,
)
peer1.wait_until_succeeds("ping -c1 -W3 peer2.hyprspace", timeout=60)
peer2.wait_until_succeeds("ping -c1 -W3 peer1.hyprspace", timeout=60)
for i, node in enumerate([peer1, peer2], start=1):
dummyTunAddr = f"${dummyTunPrefix}.{i}"
status = node.succeed("hyprspace status")
print(status)
assert dummyTunAddr not in status
'';
}

View File

@ -163,9 +163,17 @@ func (node *Node) Run() error {
logger.Debug("Setting up Node discovery via DHT")
// Setup P2P Discovery
// Setup DHT Discovery
go p2p.Discover(node.ctx, node.wg, node.p2p, node.dht, node.cfg.Peers)
// Setup mDNS Discovery for LAN peers
if !node.cfg.FilterPrivateAddresses {
err = p2p.SetupMDNS(node.p2p, node.cfg.Peers)
if err != nil {
logger.With(err).Warn("Failed to start mDNS discovery")
}
}
// Configure path for lock
node.lockPath = filepath.Join(filepath.Dir(node.cfg.Path), node.cfg.Interface+".lock")

53
p2p/addrs.go Normal file
View File

@ -0,0 +1,53 @@
package p2p
import (
ma "github.com/multiformats/go-multiaddr"
manet "github.com/multiformats/go-multiaddr/net"
)
// resolveListenAddrs expands unspecified listen addresses (0.0.0.0, ::) into
// concrete per-interface addresses of physical interfaces.
// Specific addresses are returned as-is.
func resolveListenAddrs(addrs []ma.Multiaddr) ([]ma.Multiaddr, error) {
physAddrs, err := physicalInterfaceAddrs()
if err != nil {
// If we can't enumerate interfaces, fall back to the
// original (unresolved) addresses.
logger.With(err).Warn("failed to enumerate interfaces")
return addrs, nil
}
if len(physAddrs) == 0 {
return addrs, nil
}
ifaceAddrs, err := manet.InterfaceMultiaddrs()
if err != nil {
return addrs, nil
}
filtered := ma.FilterAddrs(
ifaceAddrs,
func(addr ma.Multiaddr) bool {
return !manet.IsIPLoopback(addr)
},
func(addr ma.Multiaddr) bool {
ipAddr, err := manet.ToIP(addr)
if err != nil {
return true
} else {
for _, physAddr := range physAddrs {
if physAddr.Equal(ipAddr) {
return true
}
}
return false
}
},
)
resolved, err := manet.ResolveUnspecifiedAddresses(addrs, filtered)
if err != nil {
return addrs, nil
}
return resolved, nil
}

31
p2p/addrs_linux.go Normal file
View File

@ -0,0 +1,31 @@
//go:build linux
package p2p
import (
"net"
"github.com/vishvananda/netlink"
)
// physicalInterfaceAddrs returns all IP addresses assigned to physical interfaces.
func physicalInterfaceAddrs() ([]net.IP, error) {
links, err := netlink.LinkList()
if err != nil {
return nil, err
}
var ips []net.IP
for _, link := range links {
if link.Type() != "device" {
continue
}
addrs, err := netlink.AddrList(link, netlink.FAMILY_ALL)
if err != nil {
continue
}
for _, addr := range addrs {
ips = append(ips, addr.IP)
}
}
return ips, nil
}

View File

@ -11,12 +11,39 @@ import (
"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/p2p/discovery/mdns"
"github.com/multiformats/go-multiaddr"
"go.uber.org/zap"
)
var discoverNow = make(chan bool)
var logger = log.Logger("hyprspace/p2p")
// mdnsNotifee handles peers discovered via mDNS.
type mdnsNotifee struct {
h host.Host
peers []config.Peer
}
func (n *mdnsNotifee) HandlePeerFound(pi peer.AddrInfo) {
// Only connect to peers that are in our VPN config.
if _, ok := config.FindPeer(n.peers, pi.ID); !ok {
return
}
logger.With(zap.String("peer", pi.ID.String()), zap.Int("addrs", len(pi.Addrs))).Info("Discovered peer via mDNS")
ctx, _ := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
go n.h.Connect(ctx, pi)
}
// SetupMDNS starts the mDNS discovery service. Discovered peers that match
// the VPN's peer list are added to the peerstore so the connection loop
// can reach them without DHT bootstrap nodes.
func SetupMDNS(h host.Host, peers []config.Peer) error {
notifee := &mdnsNotifee{h: h, peers: peers}
svc := mdns.NewMdnsService(h, "_p2p._udp", notifee)
return svc.Start()
}
// Discover starts up a DHT based discovery system finding and adding nodes with the same rendezvous string.
func Discover(ctx context.Context, wg *sync.WaitGroup, h host.Host, dht *dht.IpfsDHT, peers []config.Peer) {
dur := time.Second * 1

View File

@ -138,10 +138,19 @@ func CreateNode(ctx context.Context, privateKey crypto.PrivKey, listenAddreses [
peerChan := make(chan peer.AddrInfo)
logger.Debug("Creating libp2p node")
// Resolve unspecified listen addresses (0.0.0.0, ::) to concrete
// per-interface IPs, excluding tunnel devices to prevent advertising
// tunnel IPs via mDNS (VPN-over-VPN loop prevention).
resolved, err := resolveListenAddrs(listenAddreses)
if err != nil {
return nil, nil, err
}
// Create libp2p node
basicHost, err := libp2p.New(
maybePrivateNet,
libp2p.ListenAddrs(listenAddreses...),
libp2p.ListenAddrs(resolved...),
libp2p.Identity(privateKey),
libp2p.UserAgent("hyprspace"),
libp2p.DefaultSecurity,

View File

@ -22,7 +22,7 @@ buildGoModule {
env.CGO_ENABLED = "0";
vendorHash = "sha256-nss0xfg2a2TrcPEUj3G5FaiiCTXMnV9rQRJhdizKzDk=";
vendorHash = "sha256-S3406DHFcRb8f2ZqaJwzpt7oE22ys4+NXcjlDeAHsRo=";
ldflags = [
"-s"