From 6f3c85a1ef600097cb84578dbc9b1efdb35a997b Mon Sep 17 00:00:00 2001 From: DavHau Date: Mon, 22 Jun 2026 16:01:34 +0200 Subject: [PATCH] feat: add mDNS peer discovery for LAN environments (#102) Co-authored-by: Max --- flake.nix | 7 ++++ go.mod | 1 + go.sum | 7 ++++ nixos/test.nix | 92 ++++++++++++++++++++++++++++++++++++++++++++++ node/node.go | 10 ++++- p2p/addrs.go | 53 ++++++++++++++++++++++++++ p2p/addrs_linux.go | 31 ++++++++++++++++ p2p/discover.go | 27 ++++++++++++++ p2p/node.go | 11 +++++- package.nix | 2 +- 10 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 nixos/test.nix create mode 100644 p2p/addrs.go create mode 100644 p2p/addrs_linux.go diff --git a/flake.nix b/flake.nix index 9c2f670..0f5bd51 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; + }; }; }; } diff --git a/go.mod b/go.mod index af0c725..0589efe 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c572e7e..5d6118f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/nixos/test.nix b/nixos/test.nix new file mode 100644 index 0000000..aef81ca --- /dev/null +++ b/nixos/test.nix @@ -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 + ''; +} diff --git a/node/node.go b/node/node.go index 186de6a..852594c 100644 --- a/node/node.go +++ b/node/node.go @@ -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") diff --git a/p2p/addrs.go b/p2p/addrs.go new file mode 100644 index 0000000..23a3e1b --- /dev/null +++ b/p2p/addrs.go @@ -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 +} diff --git a/p2p/addrs_linux.go b/p2p/addrs_linux.go new file mode 100644 index 0000000..4325c6c --- /dev/null +++ b/p2p/addrs_linux.go @@ -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 +} diff --git a/p2p/discover.go b/p2p/discover.go index 6c52cfe..8477283 100644 --- a/p2p/discover.go +++ b/p2p/discover.go @@ -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 diff --git a/p2p/node.go b/p2p/node.go index b87af82..b78be23 100644 --- a/p2p/node.go +++ b/p2p/node.go @@ -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, diff --git a/package.nix b/package.nix index db336f0..9400cc1 100644 --- a/package.nix +++ b/package.nix @@ -22,7 +22,7 @@ buildGoModule { env.CGO_ENABLED = "0"; - vendorHash = "sha256-nss0xfg2a2TrcPEUj3G5FaiiCTXMnV9rQRJhdizKzDk="; + vendorHash = "sha256-S3406DHFcRb8f2ZqaJwzpt7oE22ys4+NXcjlDeAHsRo="; ldflags = [ "-s"