This commit is contained in:
Roland Urbano 2026-09-01 02:20:17 +05:30 committed by GitHub
commit 4c69a75b33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 722 additions and 12 deletions

View File

@ -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.

19
go.mod
View File

@ -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

18
go.sum
View File

@ -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=

635
mobile/mobile.go Normal file
View File

@ -0,0 +1,635 @@
// 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 -ldflags "-checklinkname=0" -javapkg hyprspace -target=android -androidapi 26 -o hyprspace.aar ./mobile
package mobile
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"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"
)
// 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.
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
}
// 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 {
// 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,
cfg.BootstrapPeers,
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))
n.host.SetStreamHandler(PingProtocolID, n.pingHandler)
currentNode = n
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")
currentNode = nil
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.ProtocolV1)
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
}
// 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,
}
}