From c531fd300d608e428e548e6ee1176f552f71b4f2 Mon Sep 17 00:00:00 2001 From: Roberto Urbano Date: Sat, 11 Jul 2026 23:03:42 +0200 Subject: [PATCH 1/3] Added go-binding for mobile port Signed-off-by: Roberto Urbano --- mobile/mobile.go | 489 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 mobile/mobile.go diff --git a/mobile/mobile.go b/mobile/mobile.go new file mode 100644 index 0000000..912fe5b --- /dev/null +++ b/mobile/mobile.go @@ -0,0 +1,489 @@ +// Package mobile provides a gomobile-compatible facade for running a Hyprspace +// node on Android. It bridges Android's VpnService TUN file descriptor with the +// Hyprspace libp2p networking layer. +// +// Build with: +// +// gomobile bind -javapkg hyprspace -target=android -androidapi 26 -o hyprspace.aar ./mobile +package mobile + +import ( + "context" + "encoding/binary" + "errors" + "io/fs" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/hyprspace/hyprspace/config" + "github.com/hyprspace/hyprspace/p2p" + "github.com/ipfs/go-log/v2" + "github.com/libp2p/go-cidranger" + dht "github.com/libp2p/go-libp2p-kad-dht" + "github.com/libp2p/go-libp2p/core/connmgr" + "github.com/libp2p/go-libp2p/core/control" + "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" + ma "github.com/multiformats/go-multiaddr" + "github.com/multiformats/go-multibase" + "go.uber.org/zap" +) + +var logger = log.Logger("hyprspace/mobile") + +// Identity is this node's stable cryptographic identity, returned by +// GenerateIdentity for first-launch config creation. +type Identity struct { + // PrivateKey is the multibase (Base58BTC) encoded libp2p private key. + // Persist it verbatim in the config's "privateKey" field. + PrivateKey string + // PeerID is the libp2p peer ID derived from the key, for display. + PeerID string +} + +// GenerateIdentity creates a fresh Ed25519 libp2p identity. Call it once on +// first launch when no config file exists; persist PrivateKey in the config's +// "privateKey" field. The peer ID and tunnel addresses are all derived from +// this key (use GetVPNConfig for the addresses after the file is written). +func GenerateIdentity() (*Identity, error) { + privKey, _, err := crypto.GenerateKeyPair(crypto.Ed25519, 256) + if err != nil { + return nil, err + } + keyBytes, err := crypto.MarshalPrivateKey(privKey) + if err != nil { + return nil, err + } + peerID, err := peer.IDFromPrivateKey(privKey) + if err != nil { + return nil, err + } + return &Identity{ + PrivateKey: multibase.MustNewEncoder(multibase.Base58BTC).Encode(keyBytes), + PeerID: peerID.String(), + }, nil +} + +// VPNConfig contains the network parameters that Android's VpnService.Builder +// needs before calling establish() to create the TUN device. +type VPNConfig struct { + // Address4 is the IPv4 address with prefix, e.g. "100.64.1.2/32". + Address4 string + // Address6 is the IPv6 address with prefix, e.g. "fd00:...:abcd/128". + Address6 string + // MTU for the tunnel interface. + MTU int + // Routes is a newline-separated list of CIDRs to route through the VPN. + Routes string +} + +// GetVPNConfig reads the hyprspace configuration and returns the network +// parameters needed to configure Android's VpnService.Builder. +// Call this before StartNode to know what addresses/routes to set up. +func GetVPNConfig(configPath string) (*VPNConfig, error) { + cfg, err := config.Read(configPath) + if err != nil { + return nil, err + } + + allRoutes4, err := cfg.PeerLookup.ByRoute.CoveredNetworks(*cidranger.AllIPv4) + if err != nil { + return nil, err + } + allRoutes6, err := cfg.PeerLookup.ByRoute.CoveredNetworks(*cidranger.AllIPv6) + if err != nil { + return nil, err + } + + var routes []string + for _, r := range allRoutes4 { + n := r.Network() + routes = append(routes, n.String()) + } + for _, r := range allRoutes6 { + n := r.Network() + routes = append(routes, n.String()) + } + + return &VPNConfig{ + Address4: cfg.BuiltinAddr4.String() + "/32", + Address6: cfg.BuiltinAddr6.String() + "/128", + MTU: 1420, + Routes: strings.Join(routes, "\n"), + }, nil +} + +// Node is a running Hyprspace instance on Android. +type Node struct { + cfg *config.Config + host host.Host + dht *dht.IpfsDHT + tunFile *os.File + activeStreams map[peer.ID]*sharedStream + streamsMu sync.Mutex + healthNotify *network.NotifyBundle + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + + events Events + stateMu sync.Mutex + lastState string + lastConnected int +} + +// Events receives node lifecycle and health notifications. It is implemented on +// the Android side and passed to StartNode. +// +// Methods may be called from arbitrary goroutines (libp2p network +// notifications, the TUN read loop, etc.), so the implementation must be +// thread-safe and MUST NOT block — post to a Handler or update a thread-safe +// holder such as a StateFlow. Do not call back into Go synchronously from these +// callbacks. +type Events interface { + // OnStateChange reports a lifecycle transition. state is one of: + // "running" – node started; no configured peer connected yet + // "connected" – at least one configured peer is reachable + // "stopped" – clean shutdown + // "error" – fatal; detail holds the message; the node is down + OnStateChange(state string, detail string) + + // OnPeerCountChange reports how many configured peers are currently + // connected, out of the total configured. + OnPeerCountChange(connected int, total int) +} + +// emit reports a lifecycle state change, deduplicating repeated states so the +// Android side is not spammed. The Events callback is invoked without holding +// stateMu to avoid deadlocks if the implementation re-enters. +func (n *Node) emit(state, detail string) { + if n.events == nil { + return + } + n.stateMu.Lock() + if state == n.lastState { + n.stateMu.Unlock() + return + } + n.lastState = state + n.stateMu.Unlock() + n.events.OnStateChange(state, detail) +} + +// emitPeers reports a change in the number of connected configured peers, +// deduplicating identical counts. +func (n *Node) emitPeers(connected, total int) { + if n.events == nil { + return + } + n.stateMu.Lock() + if connected == n.lastConnected { + n.stateMu.Unlock() + return + } + n.lastConnected = connected + n.stateMu.Unlock() + n.events.OnPeerCountChange(connected, total) +} + +// registerHealth subscribes to libp2p connection events and reports how many +// configured peers are currently reachable, transitioning between the +// "running" (no peers) and "connected" (>=1 peer) states. +func (n *Node) registerHealth() { + // Notify callbacks can be invoked while libp2p is mutating/closing network + // state. Bounce health calculation to a separate goroutine so the callback + // does not synchronously re-enter n.host.Network(). + n.healthNotify = &network.NotifyBundle{ + ConnectedF: func(network.Network, network.Conn) { go n.updateHealth() }, + DisconnectedF: func(network.Network, network.Conn) { go n.updateHealth() }, + } + n.host.Network().Notify(n.healthNotify) +} + +func (n *Node) updateHealth() { + if n.ctx.Err() != nil { + return + } + + connected := 0 + for _, p := range n.cfg.Peers { + if n.host.Network().Connectedness(p.ID) == network.Connected { + connected++ + } + } + n.emitPeers(connected, len(n.cfg.Peers)) + if connected > 0 { + n.emit("connected", "") + } else { + n.emit("running", "") + } +} + +type sharedStream struct { + stream network.Stream + mu sync.Mutex +} + +// StartNode starts a Hyprspace node using a TUN file descriptor from Android's +// VpnService. The fd must be obtained via ParcelFileDescriptor.detachFd() after +// VpnService.Builder.establish(). configPath is the path to the hyprspace JSON +// config file on the Android filesystem. events receives lifecycle and health +// notifications and may be nil. +// For Android: Call `pfd.detachFd()` and pass that int; never close the `ParcelFileDescriptor` +// yourself. (it would double-close) +func StartNode(fd int, configPath string, events Events) (*Node, error) { + log.SetLogLevel("hyprspace", "info") + log.SetLogLevelRegex("^hyprspace/", "info") + + cfg, err := config.Read(configPath) + if err != nil { + return nil, err + } + cfg.Interface = "hyprspace" + + tunFile := os.NewFile(uintptr(fd), "vpn-tun") + if tunFile == nil { + return nil, errors.New("invalid file descriptor") + } + + ctx, cancel := context.WithCancel(context.Background()) + + n := &Node{ + cfg: cfg, + tunFile: tunFile, + activeStreams: make(map[peer.ID]*sharedStream), + ctx: ctx, + cancel: cancel, + events: events, + } + + // Use passthrough gater since Android VpnService handles routing protection + // and netlink is unavailable. + var gater connmgr.ConnectionGater = passthroughGater{} + + n.host, n.dht, err = p2p.CreateNode( + ctx, + cfg.PrivateKey, + cfg.ListenAddresses, + n.streamHandler, + p2p.NewClosedCircuitRelayFilter(cfg.Peers), + gater, + cfg.Peers, + ) + if err != nil { + cancel() + tunFile.Close() + return nil, err + } + + n.host.SetStreamHandler(p2p.PeXProtocol, p2p.NewPeXStreamHandler(n.host, cfg)) + + for _, p := range cfg.Peers { + n.host.ConnManager().Protect(p.ID, "/hyprspace/peer") + } + + go p2p.Discover(ctx, &n.wg, n.host, n.dht, cfg.Peers) + go p2p.PeXService(ctx, &n.wg, n.host, cfg) + go p2p.RouteMetricsService(ctx, &n.wg, n.host, cfg) + + go n.readLoop() + + n.registerHealth() + n.emit("running", "") + + logger.Info("Mobile node started") + return n, nil +} + +// Stop gracefully shuts down the Hyprspace node. +func (n *Node) Stop() error { + started := time.Now() + logger.Info("Stopping mobile node") + + logger.Info("Stopping mobile node: cancel context") + n.cancel() + + if n.healthNotify != nil { + logger.Info("Stopping mobile node: unregistering health notifier") + n.host.Network().StopNotify(n.healthNotify) + n.healthNotify = nil + logger.With(zap.Duration("elapsed", time.Since(started))).Info("Stopping mobile node: health notifier unregistered") + } + + logger.Info("Stopping mobile node: closing TUN fd") + if tunErr := n.tunFile.Close(); tunErr != nil { + logger.With(zap.Error(tunErr)).Warn("Stopping mobile node: TUN fd close returned error") + } + logger.With(zap.Duration("elapsed", time.Since(started))).Info("Stopping mobile node: TUN fd closed") + + logger.Info("Stopping mobile node: closing host") + err := n.host.Close() + if err != nil { + logger.With(zap.Error(err)).Warn("Stopping mobile node: host close returned error") + } + logger.With(zap.Duration("elapsed", time.Since(started))).Info("Stopping mobile node: host closed") + + logger.Info("Stopping mobile node: waiting for background services") + n.wg.Wait() + logger.With(zap.Duration("elapsed", time.Since(started))).Info("Stopping mobile node: background services stopped") + + n.emit("stopped", "") + logger.With(zap.Duration("elapsed", time.Since(started))).Info("Mobile node stopped") + return err +} + +// Rebootstrap forces a DHT refresh and peer rediscovery. +func (n *Node) Rebootstrap() { + n.host.ConnManager().TrimOpenConns(context.Background()) + <-n.dht.ForceRefresh() + p2p.Rediscover() +} + +// readLoop reads IP packets from the TUN fd and dispatches them to peers. +func (n *Node) readLoop() { + for { + packet := make([]byte, 1420) + plen, err := n.tunFile.Read(packet) + if err != nil { + if errors.Is(err, fs.ErrClosed) || errors.Is(err, os.ErrClosed) { + logger.Warn("TUN closed, stopping read loop") + // A close that did not originate from Stop() means the tunnel + // died on its own; surface it as a fatal error. + if n.ctx.Err() == nil { + n.emit("error", "tunnel closed unexpectedly") + } + return + } + if n.ctx.Err() != nil { + return + } + logger.With(zap.Error(err)).Error("Failed to read from TUN") + continue + } + + var dstIP net.IP + proto := packet[0] & 0xf0 + + if proto == 0x40 { + dstIP = net.IP(packet[16:20]) + if n.cfg.BuiltinAddr4.Equal(dstIP) { + continue + } + } else if proto == 0x60 { + dstIP = net.IP(packet[24:40]) + if n.cfg.BuiltinAddr6.Equal(dstIP) { + continue + } + } else { + continue + } + + route, found := n.cfg.FindRouteForIP(dstIP) + if found { + go n.sendPacket(route.Target.ID, packet, plen) + } + } +} + +// streamHandler handles incoming packets from peers and writes them to the TUN. +func (n *Node) streamHandler(stream network.Stream) { + if _, ok := config.FindPeer(n.cfg.Peers, stream.Conn().RemotePeer()); !ok { + stream.Reset() + return + } + packet := make([]byte, 1420) + packetSize := make([]byte, 2) + for { + _, err := stream.Read(packetSize) + if err != nil { + stream.Close() + return + } + + size := binary.LittleEndian.Uint16(packetSize) + + var plen uint16 + for plen < size { + tmp, err := stream.Read(packet[plen:size]) + plen += uint16(tmp) + if err != nil { + stream.Close() + return + } + } + _ = stream.SetWriteDeadline(time.Now().Add(25 * time.Second)) + _, _ = n.tunFile.Write(packet[:size]) + } +} + +// sendPacket sends a packet to a peer, reusing existing streams when possible. +func (n *Node) sendPacket(dst peer.ID, packet []byte, plen int) { + n.streamsMu.Lock() + ss, ok := n.activeStreams[dst] + n.streamsMu.Unlock() + + if ok { + ss.mu.Lock() + err := binary.Write(ss.stream, binary.LittleEndian, uint16(plen)) + if err == nil { + _, err = ss.stream.Write(packet[:plen]) + if err == nil { + err = ss.stream.SetWriteDeadline(time.Now().Add(25 * time.Second)) + } + } + ss.mu.Unlock() + + if err == nil { + return + } + ss.stream.Close() + n.streamsMu.Lock() + delete(n.activeStreams, dst) + n.streamsMu.Unlock() + } + + stream, err := n.host.NewStream(n.ctx, dst, p2p.Protocol) + if err != nil { + logger.With(zap.String("dst", dst.String()), zap.Error(err)).Error("Failed to open stream") + go p2p.Rediscover() + return + } + _ = stream.SetWriteDeadline(time.Now().Add(25 * time.Second)) + + err = binary.Write(stream, binary.LittleEndian, uint16(plen)) + if err != nil { + stream.Close() + return + } + _, err = stream.Write(packet[:plen]) + if err != nil { + stream.Close() + return + } + + n.streamsMu.Lock() + n.activeStreams[dst] = &sharedStream{stream: stream} + n.streamsMu.Unlock() +} + +// passthroughGater allows all connections. On Android, the VpnService routing +// prevents recursion (tunnel traffic going back into the VPN), so the +// netlink-based RecursionGater from desktop is unnecessary. +type passthroughGater struct{} + +func (passthroughGater) InterceptPeerDial(_ peer.ID) bool { return true } +func (passthroughGater) InterceptAddrDial(_ peer.ID, _ ma.Multiaddr) bool { + return true +} +func (passthroughGater) InterceptAccept(_ network.ConnMultiaddrs) bool { return true } +func (passthroughGater) InterceptSecured(_ network.Direction, _ peer.ID, _ network.ConnMultiaddrs) bool { + return true +} +func (passthroughGater) InterceptUpgraded(_ network.Conn) (bool, control.DisconnectReason) { + return true, 0 +} From bc197d89d906694897b25e77457d93779eb8a623 Mon Sep 17 00:00:00 2001 From: Roberto Urbano Date: Sat, 11 Jul 2026 23:42:50 +0200 Subject: [PATCH 2/3] Fixed gomobile bindings and build process Signed-off-by: Roberto Urbano --- docs/content/development/hacking.md | 62 +++++++++++++++++++++++++++-- go.mod | 19 +++++---- go.sum | 18 +++++++++ mobile/mobile.go | 5 ++- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/docs/content/development/hacking.md b/docs/content/development/hacking.md index deea03b..fa96f16 100644 --- a/docs/content/development/hacking.md +++ b/docs/content/development/hacking.md @@ -7,13 +7,13 @@ Hyprspace is built with [Nix](https://nixos.org). The Hyprspace flake includes a To use it, simply run: ```shell-session -$ nix develop +nix develop ``` You can also use [direnv](https://direnv.net). ```shell-session -$ direnv allow +direnv allow ``` ## Building @@ -21,11 +21,65 @@ $ direnv allow To build Hyprspace for testing during development, you first need to generate the [configuration schema](config-schema.html) code. This is always done automatically upon entering the devShell. If you made changes to the config schema, you can regenerate the Go code (requires Nix): ```shell-session -$ go generate ./schema +go generate ./schema ``` Then you can build the binary as usual: ```shell-session -$ go build +go build ``` + +## Android Mobile Bindings + +The Android .aar binding in `mobile/` is built with [gomobile](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile). + +### Prerequisites + +- Go 1.24+ with gomobile installed: `go install golang.org/x/mobile/cmd/gomobile@latest` +- Android SDK (`ANDROID_HOME` pointing to the SDK root) +- Android NDK (`ANDROID_NDK_HOME` pointing to the NDK version directory) +- A JDK with `javac` on `$PATH` (bundled with Android Studio) + +### Build + +```shell-session +export ANDROID_HOME=/path/to/Android/Sdk +export ANDROID_NDK_HOME=/path/to/Android/Sdk/ndk/30.0.14904198 +export PATH="/path/to/jdk/bin:$PATH" +gomobile bind -ldflags "-checklinkname=0" -javapkg hyprspace -target=android -androidapi 26 -o hyprspace.aar ./mobile +``` + +> [!WARNING] +> The `-ldflags "-checklinkname=0"` flag is required because a transitive dependency +> (`github.com/wlynxg/anet`) uses `//go:linkname` to reference Go's internal +> `net.zoneCache` type, which was restricted in Go 1.23+. +> See [DECISION-ANDROID-GOMOBILE.md](../../decisions/DECISION-ANDROID-GOMOBILE.md) for details. + +### Why gomobile needs this flag + +`wlynxg/anet` provides Android-compatible replacements for `net.Interfaces()` and +`net.InterfaceAddrs()` that bypass Android's NETLINK permission restrictions. The package +uses `//go:linkname` to reach into Go internals (`net.zoneCache`). Since Go 1.23, +`//go:linkname` references are validated by the linker and fail because `zoneCache` +is no longer part of the public API contract. + +This dependency was already present before mobile bindings — it's pulled in by +`libp2p`, `go-libp2p-kad-dht`, `boxo`, and several `pion/*` packages. It only becomes +a build failure during `gomobile bind` because gomobile compiles with the `android` +build tag, which enables `anet`'s Android-specific file that uses `linkname`. + +Without this flag, gomobile fails with: + +``` +link: github.com/wlynxg/anet: invalid reference to net.zoneCache +``` + +**Decision rationale:** Disabling linker name checks is safe here because: + +- `anet` is an indirect dependency we don't control +- The linker only validates the *target* of the linkname, not the *source* — so our +code can't craft arbitrary linknames. The risk is that future Go versions may silently +break the internal `zoneCache` structure, which would only surface at runtime. +- No maintained alternative exists. +- We pin the Go toolchain version, so this won't regress silently. diff --git a/go.mod b/go.mod index 5e81342..efd509e 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,8 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/mock v0.6.0 // indirect - golang.org/x/telemetry v0.0.0-20260619171412-e028bae49277 // indirect + golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect golang.org/x/time v0.15.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -124,15 +125,17 @@ require ( go.uber.org/fx v1.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +tool golang.org/x/mobile/cmd/gobind diff --git a/go.sum b/go.sum index 92dec7c..357f879 100644 --- a/go.sum +++ b/go.sum @@ -328,6 +328,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= @@ -335,9 +337,13 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -350,6 +356,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -357,6 +365,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -370,14 +380,20 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260619171412-e028bae49277 h1:DR4cwnu522QGU16KB5ASM308Sckh5PnqiM1f5lcsQU4= golang.org/x/telemetry v0.0.0-20260619171412-e028bae49277/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -388,6 +404,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= diff --git a/mobile/mobile.go b/mobile/mobile.go index 912fe5b..4af58a7 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -4,7 +4,7 @@ // // Build with: // -// gomobile bind -javapkg hyprspace -target=android -androidapi 26 -o hyprspace.aar ./mobile +// gomobile bind -ldflags "-checklinkname=0" -javapkg hyprspace -target=android -androidapi 26 -o hyprspace.aar ./mobile package mobile import ( @@ -270,6 +270,7 @@ func StartNode(fd int, configPath string, events Events) (*Node, error) { ctx, cfg.PrivateKey, cfg.ListenAddresses, + cfg.BootstrapPeers, n.streamHandler, p2p.NewClosedCircuitRelayFilter(cfg.Peers), gater, @@ -447,7 +448,7 @@ func (n *Node) sendPacket(dst peer.ID, packet []byte, plen int) { n.streamsMu.Unlock() } - stream, err := n.host.NewStream(n.ctx, dst, p2p.Protocol) + stream, err := n.host.NewStream(n.ctx, dst, p2p.ProtocolV1) if err != nil { logger.With(zap.String("dst", dst.String()), zap.Error(err)).Error("Failed to open stream") go p2p.Rediscover() From cdca400689dffb77281b221000f913656018c8fe Mon Sep 17 00:00:00 2001 From: Roberto Urbano Date: Tue, 14 Jul 2026 23:02:20 +0200 Subject: [PATCH 3/3] Added support for testability via Ping method Signed-off-by: Roberto Urbano --- mobile/mobile.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/mobile/mobile.go b/mobile/mobile.go index 4af58a7..031a948 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -8,9 +8,13 @@ package mobile import ( + "bytes" "context" + "crypto/sha256" "encoding/binary" "errors" + "fmt" + "io" "io/fs" "net" "os" @@ -34,7 +38,24 @@ import ( "go.uber.org/zap" ) -var logger = log.Logger("hyprspace/mobile") +// PingProtocolID is the libp2p stream protocol for ping/echo testing. +const PingProtocolID = "/hyprspace/ping/1.0.0" + +// PingResult holds the result of a ping round-trip. +type PingResult struct { + // LatencyMs is the round-trip latency in milliseconds. + LatencyMs float64 + // Success indicates whether the echo payload matched. + Success bool + // Error is non-empty if the ping failed for a reason other than connection failure. + Error string +} + +var ( + logger = log.Logger("hyprspace/mobile") + // currentNode holds the most recently started node, exported for tests. + currentNode *Node +) // Identity is this node's stable cryptographic identity, returned by // GenerateIdentity for first-launch config creation. @@ -69,6 +90,44 @@ func GenerateIdentity() (*Identity, error) { }, nil } +// PingNode sends a payload to a remote peer over a dedicated libp2p stream +// and reads the echo response. This is an exported helper for integration tests +// that need to verify data-plane connectivity through the currently running node. +func PingNode(peerIDString string, payload string) *PingResult { + n := currentNode + if n == nil { + return &PingResult{ + Error: "no node running", + } + } + return n.Ping(peerIDString, payload) +} + +// DeriveIdentity deterministically derives an Ed25519 identity from a seed string. +// It uses SHA-256 of the seed as the randomness source for Ed25519 key generation, +// so the same seed always produces the same key pair. Call it once on first launch +// and persist PrivateKey in the config's "privateKey" field. +func DeriveIdentity(seed string) (*Identity, error) { + hash := sha256.Sum256([]byte(seed)) + reader := bytes.NewReader(hash[:]) + privKey, _, err := crypto.GenerateKeyPairWithReader(crypto.Ed25519, 256, reader) + if err != nil { + return nil, err + } + keyBytes, err := crypto.MarshalPrivateKey(privKey) + if err != nil { + return nil, err + } + peerID, err := peer.IDFromPrivateKey(privKey) + if err != nil { + return nil, err + } + return &Identity{ + PrivateKey: multibase.MustNewEncoder(multibase.Base58BTC).Encode(keyBytes), + PeerID: peerID.String(), + }, nil +} + // VPNConfig contains the network parameters that Android's VpnService.Builder // needs before calling establish() to create the TUN device. type VPNConfig struct { @@ -283,6 +342,8 @@ func StartNode(fd int, configPath string, events Events) (*Node, error) { } n.host.SetStreamHandler(p2p.PeXProtocol, p2p.NewPeXStreamHandler(n.host, cfg)) + n.host.SetStreamHandler(PingProtocolID, n.pingHandler) + currentNode = n for _, p := range cfg.Peers { n.host.ConnManager().Protect(p.ID, "/hyprspace/peer") @@ -335,6 +396,7 @@ func (n *Node) Stop() error { n.emit("stopped", "") logger.With(zap.Duration("elapsed", time.Since(started))).Info("Mobile node stopped") + currentNode = nil return err } @@ -488,3 +550,86 @@ func (passthroughGater) InterceptSecured(_ network.Direction, _ peer.ID, _ netwo func (passthroughGater) InterceptUpgraded(_ network.Conn) (bool, control.DisconnectReason) { return true, 0 } + +// pingHandler echoes back any payload received on the ping protocol. +func (n *Node) pingHandler(stream network.Stream) { + var respLen uint16 + if err := binary.Read(stream, binary.LittleEndian, &respLen); err != nil { + stream.Close() + return + } + respBuf := make([]byte, respLen) + if _, err := io.ReadFull(stream, respBuf); err != nil { + stream.Close() + return + } + + if _, err := stream.Write(respBuf); err != nil { + stream.Close() + return + } + stream.Close() +} + +// Ping sends a payload to a remote peer over a dedicated libp2p stream +// and reads the echo response. Returns the round-trip latency. +func (n *Node) Ping(peerIDString string, payload string) *PingResult { + peerID, err := peer.Decode(peerIDString) + if err != nil { + return &PingResult{ + Error: fmt.Sprintf("invalid peer ID: %v", err), + } + } + + stream, err := n.host.NewStream(n.ctx, peerID, PingProtocolID) + if err != nil { + return &PingResult{ + Error: fmt.Sprintf("failed to open stream: %v", err), + } + } + defer stream.Close() + + start := time.Now() + + payloadBytes := []byte(payload) + + // Write length-prefixed payload + if err := binary.Write(stream, binary.LittleEndian, uint16(len(payloadBytes))); err != nil { + return &PingResult{ + Error: fmt.Sprintf("failed to write payload: %v", err), + } + } + if _, err := stream.Write(payloadBytes); err != nil { + return &PingResult{ + Error: fmt.Sprintf("failed to send payload: %v", err), + } + } + + // Read length-prefixed response + var respLen uint16 + if err := binary.Read(stream, binary.LittleEndian, &respLen); err != nil { + return &PingResult{ + Error: fmt.Sprintf("failed to read response length: %v", err), + } + } + respBuf := make([]byte, respLen) + if _, err := io.ReadFull(stream, respBuf); err != nil { + return &PingResult{ + Error: fmt.Sprintf("failed to read response: %v", err), + } + } + + elapsed := time.Since(start) + success := string(respBuf) == payload + + errStr := "" + if !success { + errStr = fmt.Sprintf("echo mismatch: expected %q, got %q", payload, string(respBuf)) + } + + return &PingResult{ + LatencyMs: float64(elapsed.Microseconds()) / 1000.0, + Success: success, + Error: errStr, + } +}