mirror of
https://github.com/hyprspace/hyprspace.git
synced 2026-09-12 19:51:07 +05:00
Merge cdca400689 into a0d0788b59
This commit is contained in:
commit
4c69a75b33
@ -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
19
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
|
||||
|
||||
18
go.sum
18
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=
|
||||
|
||||
635
mobile/mobile.go
Normal file
635
mobile/mobile.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user