diff --git a/.env.example b/.env.example index 6c8a8b953..c9d345f6b 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,12 @@ IDENTITY_PATH=/portal-certs # Compose publishes this port. WIREGUARD_PORT=51820 +# Optional embedded IVNP/I2P relay overlay. It carries relay discovery and +# authenticated relay-to-relay hop streams without requiring inbound reachability. +# The generated config and encrypted router state live under IDENTITY_PATH by default. +IVNP_ENABLED=false +# IVNP_CONFIG=/data/portal/ivnp.conf + # Inclusive lease port range shared by the UDP and raw TCP transports. # 0 disables both. Enabling a transport without a range does nothing; the relay # reports that at startup. Publish the same range in docker-compose.yml when set. diff --git a/cmd/relay-server/config.go b/cmd/relay-server/config.go index d658a45c1..3ccc099d6 100644 --- a/cmd/relay-server/config.go +++ b/cmd/relay-server/config.go @@ -53,6 +53,7 @@ func (f feature) needsAttention() bool { func evaluateFeatures(cfg relayServerConfig) []feature { return []feature{ discoveryFeature(cfg), + ivnpFeature(cfg), acmeFeature(cfg), ensGaslessFeature(cfg), leaseTransportFeature("udp-transport", "UDP_ENABLED", cfg.UDPEnabled, cfg), @@ -66,6 +67,26 @@ func evaluateFeatures(cfg relayServerConfig) []feature { } } +func ivnpFeature(cfg relayServerConfig) feature { + f := feature{Name: "ivnp-overlay"} + if !cfg.IVNPEnabled { + f.State, f.By = stateDisabled, "IVNP_ENABLED=false" + return f + } + if !cfg.DiscoveryEnabled { + f.State, f.By = stateBlocked, "IVNP_ENABLED=true" + f.Missing = "DISCOVERY=true is required because IVNP carries the relay discovery and hop protocols" + return f + } + f.State, f.By = stateEnabled, "IVNP_ENABLED=true" + if path := strings.TrimSpace(cfg.IVNPConfigPath); path != "" { + f.Detail = "config=" + path + } else { + f.Detail = "config=IDENTITY_PATH/ivnp.conf" + } + return f +} + func frontendFeature(cfg relayServerConfig) feature { f := feature{Name: "frontend"} dir := strings.TrimSpace(cfg.FrontendDir) diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index 55f089b17..c393aeb3d 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -40,6 +40,8 @@ type relayServerConfig struct { IdentityPath string Bootstraps string DiscoveryEnabled bool + IVNPEnabled bool + IVNPConfigPath string WireGuardPort int APIPort int SNIPort int @@ -104,6 +106,8 @@ func registerRelayServerFlags(fs *flag.FlagSet, cfg *relayServerConfig) { utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, policy state, and keyless materials", "IDENTITY_PATH") utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "bootstrap relay API URLs; merged with bootstrap relays when discovery is enabled", "BOOTSTRAPS") utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY") + utils.BoolFlagEnv(fs, &cfg.IVNPEnabled, "ivnp-enabled", false, "enable the embedded IVNP relay overlay", "IVNP_ENABLED") + utils.StringFlagEnv(fs, &cfg.IVNPConfigPath, "ivnp-config", "", "IVNP configuration path; defaults to IDENTITY_PATH/ivnp.conf", "IVNP_CONFIG") utils.IntFlagEnv(fs, &cfg.WireGuardPort, "wireguard-port", overlay.DefaultListenPort, utils.ParsePortNumber, "public and listen UDP port for relay overlay", "WIREGUARD_PORT") utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT") @@ -179,6 +183,8 @@ func runServer(ctx context.Context, cfg relayServerConfig) error { IdentityPath: cfg.IdentityPath, Bootstraps: utils.SplitCSV(cfg.Bootstraps), DiscoveryEnabled: cfg.DiscoveryEnabled, + IVNPEnabled: cfg.IVNPEnabled, + IVNPConfigPath: cfg.IVNPConfigPath, WireGuardPort: cfg.WireGuardPort, APIPort: cfg.APIPort, SNIPort: cfg.SNIPort, diff --git a/config.toml b/config.toml index cc3fcefcf..8e1b7d609 100644 --- a/config.toml +++ b/config.toml @@ -7,4 +7,4 @@ base_url = "https://github.com/gosuda/portal-tunnel/releases" [protocol] tunnel = "8" -discovery = "8" +discovery = "9" diff --git a/docker-compose.yml b/docker-compose.yml index 51bc4d57c..a07a12669 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: PORTAL_FRONTEND_DIR: ${PORTAL_FRONTEND_DIR:-} BOOTSTRAPS: ${BOOTSTRAPS:-} DISCOVERY: ${DISCOVERY:-false} + IVNP_ENABLED: ${IVNP_ENABLED:-false} + IVNP_CONFIG: ${IVNP_CONFIG:-} IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs} API_PORT: 4017 diff --git a/docs/src/routes/architecture/+page.md b/docs/src/routes/architecture/+page.md index 1d061d127..24824592f 100644 --- a/docs/src/routes/architecture/+page.md +++ b/docs/src/routes/architecture/+page.md @@ -174,7 +174,7 @@ UDP client - `/sdk/register` is authenticated by a SIWE challenge/response flow using the SDK identity secp256k1 key. On success, the relay issues a lease-scoped ES256K JWT access token signed by the relay identity key and used for the rest of the lease lifecycle. - Relay URLs must use `https://`. - HTTP/2 stays disabled on the admin/API TLS listener. Keyless TLS certificate sharing and `/sdk/connect` both depend on the current HTTP/1.1-only transport contract. -- WireGuard, when enabled, is relay-to-relay overlay transport only. It carries multi-hop relay forwarding and overlay discovery, but it is not used for direct tenant TLS termination, public UDP ingress, or `/sdk/*` control-plane traffic. +- IVNP, when enabled, is the preferred relay-to-relay overlay. It owns I2P peer reachability and internal path construction while Portal authenticates discovery descriptors and hop route tokens. WireGuard remains a direct relay fallback during migration. Neither overlay is used for direct tenant TLS termination, public UDP ingress, or tunnel-client reverse backhaul. ### Reverse Session Protocol @@ -291,14 +291,16 @@ Result: raw public UDP exposure with an internal QUIC datagram backhaul. UDP and -## WireGuard Overlay and Discovery +## Relay Overlay and Discovery - Discovery bootstraps from public HTTPS relay URLs, then expands through relay-to-relay `/discovery` polling and periodic self-announces to bootstrap relays through `/discovery/announce`. - SDK exposures consume relay discovery results to choose relays, but they do not announce themselves and do not serve `/discovery`. -- Discovery descriptors are signed relay self-descriptions. They bind relay routing metadata such as `api_https_addr`, `supports_overlay`, `wireguard_public_key`, and `wireguard_port` to the relay identity. Lease access tokens remain separate and authorize tenant lease operations only. +- Discovery descriptors are signed relay self-descriptions. They bind relay routing metadata such as `api_https_addr`, `supports_overlay`, `ivnp_destination`, and optional WireGuard metadata to the relay identity. Lease access tokens remain separate and authorize tenant lease operations only. - `/discovery/announce` accepts only signed relay descriptors. Loopback or localhost relay descriptors are rejected because they cannot join the public discovery mesh. -- The overlay peer API is plain HTTP on the WireGuard network, not public Internet HTTP. It serves the same discovery payload shape used by public `/discovery`. -- Overlay failure affects inter-relay discovery, mesh synchronization, and multi-hop relay forwarding. Direct tenant TLS routing, keyless TLS, register/renew/connect, and public UDP ingress do not depend on the WireGuard transport path. +- The overlay peer API serves the same Portal-owned discovery payload as public `/discovery`. With IVNP it is carried on the relay's persistent I2P application destination; Portal does not put relay descriptors into I2P NetDB records. +- IVNP discovery runs at a slower cadence than public HTTPS polling, and its latency is not recorded as public relay ingress RTT for MOLS ranking. +- Authenticated hop streams prefer IVNP when both relays advertise destinations and fall back to the direct WireGuard path while migration is in progress. +- Overlay failure affects inter-relay discovery and multi-hop relay forwarding. Direct tenant TLS routing, keyless TLS, register/renew/connect, and public UDP ingress do not depend on the relay overlay. ## Control Plane Flow @@ -361,7 +363,7 @@ The relay signs handshake digests via `/v1/sign` but never receives tenant TLS t - One canonical raw TCP reverse transport - Dedicated TCP port allocation for non-TLS services with raw TCP bridging - Raw public UDP exposure with an internal QUIC datagram backhaul -- Optional WireGuard relay overlay for relay discovery, peer synchronization, and multi-hop relay forwarding +- Optional IVNP relay overlay for NAT-independent relay discovery and authenticated relay-to-relay forwarding, with WireGuard direct fallback during migration - SNI-based routing with root-host fallback - End-to-end tenant TLS with relay-backed keyless signing - Traffic-triggered detect-only MITM self-probing for probable relay-side TLS termination diff --git a/docs/src/routes/configuration/+page.md b/docs/src/routes/configuration/+page.md index bb91d0a46..11dd5067c 100644 --- a/docs/src/routes/configuration/+page.md +++ b/docs/src/routes/configuration/+page.md @@ -55,6 +55,7 @@ A value that cannot be parsed is a startup error rather than a silent fallback: | `API_PORT` | `4017` | int | Admin/API server listen port | | `SNI_PORT` | `443` | int | TCP SNI router listen port; non-standard values are intended for local testing, while the bundled public deployment requires `443` | | `WIREGUARD_PORT` | `51820` | int | Public and listen UDP port for relay discovery overlay | +| `IVNP_CONFIG` | `IDENTITY_PATH/ivnp.conf` | string | Embedded IVNP router configuration path when the IVNP overlay is enabled | ### Transport @@ -70,6 +71,7 @@ A value that cannot be parsed is a startup error rather than a silent fallback: | Variable | Default | Type | Description | |----------|---------|------|-------------| | `DISCOVERY` | `false` | bool | Serve relay discovery endpoints and poll discovery peers | +| `IVNP_ENABLED` | `false` | bool | Carry relay discovery and authenticated relay-to-relay hop streams over embedded IVNP/I2P; requires `DISCOVERY=true` | | `BOOTSTRAPS` | `""` | string | Additional bootstrap relay API URLs used for discovery expansion (comma-separated) | | `LANDING_PAGE_ENABLED` | `false` | bool | Initial dashboard landing-page visibility; admin changes are persisted in the relay policy state | diff --git a/go.mod b/go.mod index b71563100..a0abaf85c 100644 --- a/go.mod +++ b/go.mod @@ -32,20 +32,21 @@ require ( github.com/spruceid/siwe-go v0.2.1 github.com/tyler-smith/go-bip39 v1.1.0 github.com/vultr/govultr/v3 v3.30.0 - golang.org/x/crypto v0.53.0 - golang.org/x/mod v0.37.0 - golang.org/x/net v0.56.0 + golang.org/x/crypto v0.55.0 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb google.golang.org/api v0.275.0 + gosuda.org/ivnp v0.0.0-20260831152821-ff6b4ad3e203 ) require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - filippo.io/edwards25519 v1.0.0-rc.1 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect @@ -150,9 +151,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/go.sum b/go.sum index baecb8fcd..17624f499 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= -filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -452,22 +452,22 @@ golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/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-20180909124046-d0be0721c37e/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= @@ -481,19 +481,19 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.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/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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= 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= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -529,5 +529,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gosuda.org/ivnp v0.0.0-20260831152821-ff6b4ad3e203 h1:d1GZREjq3rxUEiEMTJdnUonV5iX2REoFgvS17ZKBw6g= +gosuda.org/ivnp v0.0.0-20260831152821-ff6b4ad3e203/go.mod h1:0sh2RIj/K0RuIoDLYRuCHvpzKUzkCc8W3jj5gJKJPdU= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/portal/api_server.go b/portal/api_server.go index b1f8d4b4d..94f45fc94 100644 --- a/portal/api_server.go +++ b/portal/api_server.go @@ -433,7 +433,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) { utils.WriteAPIError(w, http.StatusTooManyRequests, types.APIErrorCodeRateLimited, "hop route rate limit exceeded") return } - if s.overlay == nil || s.relaySet == nil { + if (s.overlay == nil && s.ivnpOverlay == nil) || s.relaySet == nil { utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error()) return } @@ -480,7 +480,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) { return } if !forwardRelay.HasOverlayPeer() { - utils.InvalidRequestError(errors.New("forward relay wireguard overlay metadata is required")).Write(w) + utils.InvalidRequestError(errors.New("forward relay overlay metadata is required")).Write(w) return } route.ForwardRelay = forwardRelay @@ -488,9 +488,11 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) { utils.InvalidRequestError(fmt.Errorf("forward relay: %w", err)).Write(w) return } - if err := s.overlay.Sync(s.relaySet.OverlayPeerDescriptor()); err != nil { - utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error()) - return + if s.overlay != nil { + if err := s.overlay.Sync(s.relaySet.OverlayPeerDescriptor()); err != nil { + utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error()) + return + } } record, err := s.registry.RegisterHopRoute(&route, now) if err != nil { diff --git a/portal/discovery/refresher.go b/portal/discovery/refresher.go index 87bf58e9a..72a55ca69 100644 --- a/portal/discovery/refresher.go +++ b/portal/discovery/refresher.go @@ -37,6 +37,7 @@ type Refresher struct { directRecoveryFailures int lastAnnounceSuccess map[string]bool lastAnnounceMu sync.Mutex + nextOverlayRefreshAt time.Time } func NewRefresher(relaySet *RelaySet, overlay OverlayRuntime) *Refresher { @@ -184,6 +185,9 @@ func (r *Refresher) refreshOneHTTPS(ctx context.Context, state RelayState) error if state.Bootstrap { recoveryFailures = 0 } + if policy, ok := r.overlay.(interface{ RecordDiscoveryFailures() bool }); ok && !policy.RecordDiscoveryFailures() { + recoveryFailures = 0 + } baseURL, err := url.Parse(relayURL) if err != nil { @@ -237,6 +241,14 @@ func (r *Refresher) refreshOneHTTPS(ctx context.Context, state RelayState) error func (r *Refresher) refreshOverlay(ctx context.Context) error { now := time.Now().UTC() + if !r.nextOverlayRefreshAt.IsZero() && now.Before(r.nextOverlayRefreshAt) { + return nil + } + if cadence, ok := r.overlay.(interface{ DiscoveryInterval() time.Duration }); ok { + if interval := cadence.DiscoveryInterval(); interval > 0 { + r.nextOverlayRefreshAt = now.Add(interval) + } + } states := r.relaySet.overlayPeerRelayStates(now) if len(states) == 0 { return nil @@ -250,6 +262,13 @@ func (r *Refresher) refreshOverlay(ctx context.Context) error { } candidates := r.relaySet.overlayRefreshCandidates(now) + if checker, ok := r.overlay.(interface { + CanDiscover(types.RelayDescriptor) bool + }); ok { + candidates = slices.DeleteFunc(candidates, func(state RelayState) bool { + return !checker.CanDiscover(state.Descriptor) + }) + } if len(candidates) == 0 { return nil } @@ -328,7 +347,13 @@ func (r *Refresher) refreshOneOverlay(ctx context.Context, state RelayState) (bo } return false, nil } - r.relaySet.RecordDiscoveryRTT(relay.APIHTTPSAddr, time.Since(startedAt), measuredAt) + measureRTT := true + if policy, ok := r.overlay.(interface{ MeasureDiscoveryRTT() bool }); ok { + measureRTT = policy.MeasureDiscoveryRTT() + } + if measureRTT { + r.relaySet.RecordDiscoveryRTT(relay.APIHTTPSAddr, time.Since(startedAt), measuredAt) + } return changed, nil } diff --git a/portal/identity/store.go b/portal/identity/store.go index d708c2e67..0af7056dc 100644 --- a/portal/identity/store.go +++ b/portal/identity/store.go @@ -35,6 +35,7 @@ func NormalizeRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor desc.Address = strings.TrimSpace(desc.Address) desc.Version = strings.TrimSpace(desc.Version) desc.APIHTTPSAddr = strings.TrimSpace(desc.APIHTTPSAddr) + desc.IVNPDestination = strings.ToLower(strings.TrimSpace(desc.IVNPDestination)) desc.WireGuardPublicKey = strings.TrimSpace(desc.WireGuardPublicKey) if desc.Version == "" { desc.Version = types.DiscoveryVersion @@ -68,6 +69,17 @@ func NormalizeRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor if desc.WireGuardPort < 0 || desc.WireGuardPort > 65535 { return types.RelayDescriptor{}, errors.New("wireguard_port is invalid") } + if desc.IVNPDestination != "" { + label, ok := strings.CutSuffix(desc.IVNPDestination, ".b32.i2p") + if !ok || len(label) != 52 { + return types.RelayDescriptor{}, errors.New("ivnp_destination is invalid") + } + for _, character := range label { + if (character < 'a' || character > 'z') && (character < '2' || character > '7') { + return types.RelayDescriptor{}, errors.New("ivnp_destination is invalid") + } + } + } if desc.ActiveConnections < 0 { return types.RelayDescriptor{}, errors.New("active_connections is invalid") } @@ -82,12 +94,12 @@ func NormalizeRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor return types.RelayDescriptor{}, fmt.Errorf("unsupported relay descriptor version %q", desc.Version) case desc.APIHTTPSAddr == "": return types.RelayDescriptor{}, errors.New("api_https_addr is required") - case desc.SupportsOverlay && desc.WireGuardPublicKey == "": - return types.RelayDescriptor{}, errors.New("wireguard_public_key is required when supports_overlay is set") - case desc.SupportsOverlay && desc.WireGuardPort == 0: - return types.RelayDescriptor{}, errors.New("wireguard_port is required when supports_overlay is set") - case !desc.SupportsOverlay && (desc.WireGuardPublicKey != "" || desc.WireGuardPort != 0): - return types.RelayDescriptor{}, errors.New("supports_overlay is required when wireguard metadata is set") + case desc.SupportsOverlay && !desc.HasOverlayPeer(): + return types.RelayDescriptor{}, errors.New("ivnp or wireguard metadata is required when supports_overlay is set") + case (desc.WireGuardPublicKey == "") != (desc.WireGuardPort == 0): + return types.RelayDescriptor{}, errors.New("wireguard_public_key and wireguard_port must be provided together") + case !desc.SupportsOverlay && (desc.IVNPDestination != "" || desc.WireGuardPublicKey != "" || desc.WireGuardPort != 0): + return types.RelayDescriptor{}, errors.New("supports_overlay is required when overlay metadata is set") case desc.ExpiresAt.IsZero(): return types.RelayDescriptor{}, errors.New("expires_at is required") case desc.IssuedAt.After(desc.ExpiresAt): diff --git a/portal/identity/store_test.go b/portal/identity/store_test.go new file mode 100644 index 000000000..d94ac0764 --- /dev/null +++ b/portal/identity/store_test.go @@ -0,0 +1,47 @@ +package identity + +import ( + "strings" + "testing" + "time" + + "github.com/gosuda/portal-tunnel/v2/types" +) + +func TestNormalizeRelayDescriptorAcceptsIVNPOverlay(t *testing.T) { + now := time.Now().UTC() + descriptor, err := NormalizeRelayDescriptor(types.RelayDescriptor{ + Address: "0x0000000000000000000000000000000000000001", + Version: types.DiscoveryVersion, + IssuedAt: now, + ExpiresAt: now.Add(time.Minute), + APIHTTPSAddr: "https://relay.example", + IVNPDestination: strings.Repeat("a", 52) + ".B32.I2P", + SupportsOverlay: true, + }) + if err != nil { + t.Fatalf("NormalizeRelayDescriptor() error = %v", err) + } + if !descriptor.HasIVNPPeer() || !descriptor.HasOverlayPeer() { + t.Fatal("normalized descriptor does not expose its IVNP overlay capability") + } + if descriptor.IVNPDestination != strings.Repeat("a", 52)+".b32.i2p" { + t.Fatalf("IVNPDestination = %q", descriptor.IVNPDestination) + } +} + +func TestNormalizeRelayDescriptorRejectsInvalidIVNPDestination(t *testing.T) { + now := time.Now().UTC() + _, err := NormalizeRelayDescriptor(types.RelayDescriptor{ + Address: "0x0000000000000000000000000000000000000001", + Version: types.DiscoveryVersion, + IssuedAt: now, + ExpiresAt: now.Add(time.Minute), + APIHTTPSAddr: "https://relay.example", + IVNPDestination: "not-a-destination.b32.i2p", + SupportsOverlay: true, + }) + if err == nil { + t.Fatal("NormalizeRelayDescriptor() error = nil") + } +} diff --git a/portal/lease.go b/portal/lease.go index c5eee3e18..e52f5754f 100644 --- a/portal/lease.go +++ b/portal/lease.go @@ -499,7 +499,6 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) ( echConfigList := bytes.Clone(route.ECHConfigList) publicHostname := utils.NormalizeHostname(route.PublicHostname) matchToken := route.MatchToken - overlayIPv4, overlayErr := identity.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey) forwardToken := route.ForwardToken expiresAt := route.ExpiresAt.UTC() hasPublicMatcher := routeHostname != "" || hostnameHash != "" @@ -513,8 +512,8 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) ( return nil, errors.New("route and token matchers are mutually exclusive") case matchToken == "" && routeHostname == "": return nil, errors.New("route hostname or token matcher is required") - case overlayErr != nil: - return nil, fmt.Errorf("forward relay overlay ipv4: %w", overlayErr) + case !route.ForwardRelay.HasOverlayPeer(): + return nil, errors.New("forward relay overlay metadata is required") case forwardToken == "": return nil, errors.New("forward token is required") } @@ -558,16 +557,16 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) ( Name: name, Address: ownerKey, }, - Hostname: routeHostname, - HostnameHash: hostnameHash, - ECHConfigList: echConfigList, - ECHDNSHostname: publicHostname, - Metadata: route.Metadata.Copy(), - FirstSeenAt: route.FirstSeenAt.UTC(), - ExpiresAt: expiresAt, - hopToken: matchToken, - hopNextOverlayIPv4: overlayIPv4, - hopNextToken: forwardToken, + Hostname: routeHostname, + HostnameHash: hostnameHash, + ECHConfigList: echConfigList, + ECHDNSHostname: publicHostname, + Metadata: route.Metadata.Copy(), + FirstSeenAt: route.FirstSeenAt.UTC(), + ExpiresAt: expiresAt, + hopToken: matchToken, + hopNextRelay: route.ForwardRelay, + hopNextToken: forwardToken, } switch { case record.isPublicEntry(): diff --git a/portal/overlay/ivnp.go b/portal/overlay/ivnp.go new file mode 100644 index 000000000..697072471 --- /dev/null +++ b/portal/overlay/ivnp.go @@ -0,0 +1,327 @@ +//go:build !windows + +package overlay + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "gosuda.org/ivnp" + + "github.com/gosuda/portal-tunnel/v2/types" + "github.com/gosuda/portal-tunnel/v2/utils" +) + +const ( + DefaultIVNPDiscoveryPort = 7777 + DefaultIVNPHopPort = 7778 + defaultIVNPRequestTimeout = 30 * time.Second +) + +// IVNP carries Portal discovery and authenticated hop streams over one I2P +// application destination. Portal continues to own descriptor and route-token +// verification; IVNP owns peer reachability and the internal overlay path. +type IVNP struct { + node *ivnp.Node + local *ivnp.LocalDestination + destination string + handler http.Handler + hopHandler StreamHandler + + mu sync.Mutex + endpoint ivnp.DestinationEndpoint + network ivnp.StreamNetwork + discoveryListener net.Listener + hopListener net.Listener + discoveryServer *http.Server + client *http.Client + ready atomic.Bool + closed atomic.Bool +} + +func NewIVNP(configPath string, handler http.Handler, hopHandler StreamHandler) (*IVNP, error) { + configPath = strings.TrimSpace(configPath) + if configPath == "" { + return nil, errors.New("ivnp config path is required") + } + cfg, err := ivnp.LoadOrCreateConfig(configPath) + if err != nil { + return nil, fmt.Errorf("load ivnp config: %w", err) + } + // Portal embeds IVNP directly and does not need the external SAM listener. + cfg.SAM.Enabled = false + node, err := ivnp.New(cfg, ivnp.Options{}) + if err != nil { + return nil, fmt.Errorf("create ivnp node: %w", err) + } + local, err := loadOrCreateIVNPDestination(filepath.Join(filepath.Dir(configPath), "ivnp.destination")) + if err != nil { + _ = node.Close() + return nil, fmt.Errorf("generate ivnp destination: %w", err) + } + return &IVNP{ + node: node, + local: local, + destination: local.B32(), + handler: handler, + hopHandler: hopHandler, + }, nil +} + +func loadOrCreateIVNPDestination(path string) (*ivnp.LocalDestination, error) { + encoded, err := os.ReadFile(path) + if err == nil { + local, importErr := ivnp.ImportLocalDestination(encoded) + clear(encoded) + if importErr != nil { + return nil, fmt.Errorf("import ivnp destination: %w", importErr) + } + return local, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read ivnp destination: %w", err) + } + local, err := ivnp.GenerateLegacyLocalDestination() + if err != nil { + return nil, err + } + encoded = make([]byte, local.PrivateEncodedLen()) + n, err := local.MarshalPrivateTo(encoded) + if err != nil { + local.ReleaseSensitive() + clear(encoded) + return nil, err + } + if err := utils.EnsureParentDir(path); err != nil { + local.ReleaseSensitive() + clear(encoded) + return nil, err + } + err = utils.WriteFileAtomic(path, encoded[:n], 0o600) + clear(encoded) + if err != nil { + local.ReleaseSensitive() + return nil, fmt.Errorf("persist ivnp destination: %w", err) + } + return local, nil +} + +func (o *IVNP) Destination() string { + if o == nil || !o.ready.Load() { + return "" + } + return o.destination +} + +func (o *IVNP) Serve(ctx context.Context) error { + if o == nil || o.node == nil { + return errors.New("ivnp overlay is not initialized") + } + o.mu.Lock() + if o.closed.Load() || o.local == nil { + o.mu.Unlock() + return net.ErrClosed + } + local := o.local + o.local = nil + o.mu.Unlock() + defer local.ReleaseSensitive() + if err := o.node.Start(ctx); err != nil { + return fmt.Errorf("start ivnp node: %w", err) + } + endpoint, err := o.node.DestinationController().CreateDestination(ctx, ivnp.DestinationSpec{Local: local}) + if err != nil { + return fmt.Errorf("create ivnp application destination: %w", err) + } + discoveryListener, err := endpoint.ListenI2P(ctx, fmt.Sprintf(":%d", DefaultIVNPDiscoveryPort)) + if err != nil { + _ = endpoint.Close() + return fmt.Errorf("listen for ivnp discovery: %w", err) + } + hopListener, err := endpoint.ListenI2P(ctx, fmt.Sprintf(":%d", DefaultIVNPHopPort)) + if err != nil { + _ = discoveryListener.Close() + _ = endpoint.Close() + return fmt.Errorf("listen for ivnp hop streams: %w", err) + } + discoveryServer := &http.Server{Handler: o.handler, ReadHeaderTimeout: 10 * time.Second} + client := utils.NewHTTPClient( + utils.WithHTTPDialContext(func(dialCtx context.Context, _, address string) (net.Conn, error) { + return endpoint.DialI2P(dialCtx, address) + }), + utils.WithoutHTTP2(), + utils.WithHTTPTimeout(defaultIVNPRequestTimeout), + ) + o.mu.Lock() + if o.closed.Load() { + o.mu.Unlock() + _ = hopListener.Close() + _ = discoveryListener.Close() + _ = endpoint.Close() + return nil + } + o.endpoint = endpoint + o.network = endpoint + o.discoveryListener = discoveryListener + o.hopListener = hopListener + o.discoveryServer = discoveryServer + o.client = client + o.mu.Unlock() + + go func() { + if serveErr := discoveryServer.Serve(discoveryListener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) && !errors.Is(serveErr, net.ErrClosed) { + log.Error().Err(serveErr).Msg("ivnp discovery server exited") + } + }() + go func() { + <-ctx.Done() + _ = hopListener.Close() + }() + + ready, ok := endpoint.(ivnp.ReadyDestinationEndpoint) + if !ok { + return errors.New("ivnp destination does not report readiness") + } + if err := ready.WaitReady(ctx); err != nil { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("wait for ivnp destination: %w", err) + } + if o.closed.Load() { + return nil + } + o.ready.Store(true) + log.Info().Str("destination", o.destination).Msg("ivnp relay overlay ready") + + for { + conn, err := hopListener.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return nil + } + return fmt.Errorf("accept ivnp hop stream: %w", err) + } + go o.handleHopStream(ctx, conn) + } +} + +func (o *IVNP) handleHopStream(ctx context.Context, conn net.Conn) { + stream, err := readHopStream(conn) + if err != nil || o.hopHandler == nil { + _ = conn.Close() + return + } + o.hopHandler(ctx, stream) +} + +func (o *IVNP) OpenHopStream(ctx context.Context, destination, token string) (net.Conn, error) { + if o == nil || !o.ready.Load() { + return nil, errors.New("ivnp overlay is not ready") + } + destination = strings.ToLower(strings.TrimSpace(destination)) + if destination == "" { + return nil, errors.New("next hop ivnp destination is required") + } + o.mu.Lock() + network := o.network + o.mu.Unlock() + if network == nil { + return nil, net.ErrClosed + } + conn, err := network.DialI2P(ctx, net.JoinHostPort(destination, fmt.Sprintf("%d", DefaultIVNPHopPort))) + if err != nil { + return nil, err + } + if err := writeHopToken(conn, token); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func (o *IVNP) DiscoverRelay(ctx context.Context, relay types.RelayDescriptor) (types.DiscoveryResponse, error) { + if o == nil || !o.ready.Load() { + return types.DiscoveryResponse{}, errors.New("ivnp overlay is not ready") + } + if !relay.HasIVNPPeer() { + return types.DiscoveryResponse{}, errors.New("relay ivnp destination is required") + } + o.mu.Lock() + client := o.client + local := o.local + o.local = nil + o.network = nil + o.mu.Unlock() + if client == nil { + return types.DiscoveryResponse{}, net.ErrClosed + } + var response types.DiscoveryResponse + baseURL := &url.URL{Scheme: "http", Host: net.JoinHostPort(relay.IVNPDestination, fmt.Sprintf("%d", DefaultIVNPDiscoveryPort))} + if err := utils.HTTPDoAPIPath(ctx, client, baseURL, http.MethodGet, types.PathDiscovery, nil, nil, &response); err != nil { + return types.DiscoveryResponse{}, err + } + return response, nil +} + +func (o *IVNP) Sync([]types.RelayDescriptor) error { return nil } + +func (o *IVNP) CanDiscover(relay types.RelayDescriptor) bool { return relay.HasIVNPPeer() } + +func (o *IVNP) DiscoveryInterval() time.Duration { return 2 * time.Minute } + +func (o *IVNP) MeasureDiscoveryRTT() bool { return false } + +func (o *IVNP) RecordDiscoveryFailures() bool { return false } + +func (o *IVNP) Shutdown(ctx context.Context) error { + if o == nil || o.closed.Swap(true) { + return nil + } + o.ready.Store(false) + o.mu.Lock() + endpoint := o.endpoint + discoveryListener := o.discoveryListener + hopListener := o.hopListener + discoveryServer := o.discoveryServer + client := o.client + o.mu.Unlock() + var shutdownErr error + if discoveryServer != nil { + shutdownErr = errors.Join(shutdownErr, discoveryServer.Shutdown(ctx)) + } + if client != nil { + client.CloseIdleConnections() + } + if discoveryListener != nil { + if err := discoveryListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + shutdownErr = errors.Join(shutdownErr, err) + } + } + if hopListener != nil { + if err := hopListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + shutdownErr = errors.Join(shutdownErr, err) + } + } + if endpoint != nil { + shutdownErr = errors.Join(shutdownErr, endpoint.Close()) + } + if local != nil { + local.ReleaseSensitive() + } + if o.node != nil { + shutdownErr = errors.Join(shutdownErr, o.node.Close(), o.node.Wait()) + } + return shutdownErr +} diff --git a/portal/overlay/ivnp_test.go b/portal/overlay/ivnp_test.go new file mode 100644 index 000000000..3d1b71632 --- /dev/null +++ b/portal/overlay/ivnp_test.go @@ -0,0 +1,71 @@ +//go:build !windows + +package overlay + +import ( + "context" + "net" + "path/filepath" + "strings" + "testing" + + "gosuda.org/ivnp" +) + +func TestLoadOrCreateIVNPDestinationPersistsIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "ivnp.destination") + first, err := loadOrCreateIVNPDestination(path) + if err != nil { + t.Fatalf("loadOrCreateIVNPDestination(first) error = %v", err) + } + firstAddress := first.B32() + first.ReleaseSensitive() + + second, err := loadOrCreateIVNPDestination(path) + if err != nil { + t.Fatalf("loadOrCreateIVNPDestination(second) error = %v", err) + } + t.Cleanup(second.ReleaseSensitive) + if second.B32() != firstAddress { + t.Fatalf("second destination = %q, want %q", second.B32(), firstAddress) + } +} + +func TestIVNPOpenHopStreamUsesI2PStreamNetwork(t *testing.T) { + network := ivnp.NewLocalStreamNetwork() + destination := strings.Repeat("a", 52) + ".b32.i2p" + listener, err := network.ListenI2P(context.Background(), net.JoinHostPort(destination, "7778")) + if err != nil { + t.Fatalf("ListenI2P() error = %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + + overlay := &IVNP{network: network} + overlay.ready.Store(true) + type result struct { + stream HopStream + err error + } + accepted := make(chan result, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + accepted <- result{err: acceptErr} + return + } + stream, readErr := readHopStream(conn) + accepted <- result{stream: stream, err: readErr} + }() + conn, err := overlay.OpenHopStream(context.Background(), destination, "hop-token") + if err != nil { + t.Fatalf("OpenHopStream() error = %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + result := <-accepted + if result.err != nil { + t.Fatalf("readHopStream() error = %v", result.err) + } + if result.stream.Token != "hop-token" { + t.Fatalf("received token = %q", result.stream.Token) + } +} diff --git a/portal/overlay/ivnp_unsupported.go b/portal/overlay/ivnp_unsupported.go new file mode 100644 index 000000000..1e306806e --- /dev/null +++ b/portal/overlay/ivnp_unsupported.go @@ -0,0 +1,50 @@ +//go:build windows + +package overlay + +import ( + "context" + "errors" + "net" + "net/http" + "time" + + "github.com/gosuda/portal-tunnel/v2/types" +) + +const ( + DefaultIVNPDiscoveryPort = 7777 + DefaultIVNPHopPort = 7778 +) + +type IVNP struct{} + +func NewIVNP(string, http.Handler, StreamHandler) (*IVNP, error) { + return nil, errors.New("ivnp overlay is not supported on windows") +} + +func (*IVNP) Destination() string { return "" } + +func (*IVNP) Serve(context.Context) error { + return errors.New("ivnp overlay is not supported on windows") +} + +func (*IVNP) OpenHopStream(context.Context, string, string) (net.Conn, error) { + return nil, errors.New("ivnp overlay is not supported on windows") +} + +func (*IVNP) DiscoverRelay(context.Context, types.RelayDescriptor) (types.DiscoveryResponse, error) { + return types.DiscoveryResponse{}, errors.New("ivnp overlay is not supported on windows") +} + +func (*IVNP) Sync([]types.RelayDescriptor) error { return nil } + +func (*IVNP) CanDiscover(types.RelayDescriptor) bool { return false } + +func (*IVNP) DiscoveryInterval() time.Duration { return 2 * time.Minute } + +func (*IVNP) MeasureDiscoveryRTT() bool { return false } + +func (*IVNP) RecordDiscoveryFailures() bool { return false } + +func (*IVNP) Shutdown(context.Context) error { return nil } diff --git a/portal/overlay/overlay.go b/portal/overlay/overlay.go index 81ffcfa18..b133ca192 100644 --- a/portal/overlay/overlay.go +++ b/portal/overlay/overlay.go @@ -2,10 +2,8 @@ package overlay import ( "context" - "encoding/binary" "errors" "fmt" - "io" "net" "net/http" "net/url" @@ -265,15 +263,7 @@ func (o *Overlay) OpenHopStream(ctx context.Context, overlayIPv4, token string) } } - payload := []byte(token) - if len(payload) > maxHopTokenBytes { - _ = next.Close() - return nil, errors.New("next hop token is too large") - } - frame := make([]byte, 4+len(payload)) - binary.BigEndian.PutUint32(frame[:4], uint32(len(payload))) - copy(frame[4:], payload) - if _, err := next.Write(frame); err != nil { + if err := writeHopToken(next, token); err != nil { _ = next.Close() return nil, err } @@ -365,38 +355,11 @@ func (o *Overlay) serveHopSession(ctx context.Context, conn net.Conn) { return } go func(stream *yamux.Stream) { - _ = stream.SetReadDeadline(time.Now().Add(defaultTokenTimeout)) - var size [4]byte - if _, err := io.ReadFull(stream, size[:]); err != nil { - _ = stream.Close() - return - } - n := binary.BigEndian.Uint32(size[:]) - if n == 0 || n > uint32(maxHopTokenBytes) { - _ = stream.Close() - return - } - payload := make([]byte, n) - if _, err := io.ReadFull(stream, payload); err != nil { - _ = stream.Close() - return - } - _ = stream.SetReadDeadline(time.Time{}) - - token := strings.TrimSpace(string(payload)) - if token == "" { + hopStream, err := readHopStream(stream) + if err != nil { _ = stream.Close() return } - remoteAddr := "" - if stream.RemoteAddr() != nil { - remoteAddr = stream.RemoteAddr().String() - } - hopStream := HopStream{ - Conn: stream, - Token: token, - RemoteAddr: remoteAddr, - } handlerPtr := o.streamHandler.Load() if handlerPtr != nil && *handlerPtr != nil { (*handlerPtr)(ctx, hopStream) @@ -465,7 +428,7 @@ func (o *Overlay) DiscoverRelay(ctx context.Context, relay types.RelayDescriptor if o == nil || o.stack == nil { return types.DiscoveryResponse{}, errors.New("overlay is not initialized") } - if !relay.HasOverlayPeer() { + if !relay.HasWireGuardPeer() { return types.DiscoveryResponse{}, errors.New("relay wireguard overlay metadata is required") } overlayIPv4, err := identity.DeriveWireGuardOverlayIPv4(relay.WireGuardPublicKey) @@ -491,7 +454,7 @@ func (o *Overlay) Sync(relays []types.RelayDescriptor) error { peers := make([]types.RelayDescriptor, 0, len(relays)) for _, desc := range relays { - if !desc.HasOverlayPeer() { + if !desc.HasWireGuardPeer() { continue } if desc.WireGuardPublicKey == o.cfg.PublicKey { @@ -505,6 +468,10 @@ func (o *Overlay) Sync(relays []types.RelayDescriptor) error { return o.stack.ApplyPeers(peers) } +func (o *Overlay) CanDiscover(relay types.RelayDescriptor) bool { return relay.HasWireGuardPeer() } + +func (o *Overlay) MeasureDiscoveryRTT() bool { return true } + func hopYamuxConfig() *yamux.Config { cfg := yamux.DefaultConfig() cfg.Logger = nil diff --git a/portal/overlay/stream.go b/portal/overlay/stream.go new file mode 100644 index 000000000..a02c12461 --- /dev/null +++ b/portal/overlay/stream.go @@ -0,0 +1,56 @@ +package overlay + +import ( + "encoding/binary" + "errors" + "io" + "net" + "strings" + "time" +) + +func writeHopToken(conn net.Conn, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return errors.New("next hop token is required") + } + payload := []byte(token) + if len(payload) > maxHopTokenBytes { + return errors.New("next hop token is too large") + } + frame := make([]byte, 4+len(payload)) + binary.BigEndian.PutUint32(frame[:4], uint32(len(payload))) + copy(frame[4:], payload) + _, err := conn.Write(frame) + return err +} + +func readHopStream(conn net.Conn) (HopStream, error) { + if err := conn.SetReadDeadline(time.Now().Add(defaultTokenTimeout)); err != nil { + return HopStream{}, err + } + var size [4]byte + if _, err := io.ReadFull(conn, size[:]); err != nil { + return HopStream{}, err + } + n := binary.BigEndian.Uint32(size[:]) + if n == 0 || n > uint32(maxHopTokenBytes) { + return HopStream{}, errors.New("invalid hop token size") + } + payload := make([]byte, n) + if _, err := io.ReadFull(conn, payload); err != nil { + return HopStream{}, err + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return HopStream{}, err + } + token := strings.TrimSpace(string(payload)) + if token == "" { + return HopStream{}, errors.New("next hop token is required") + } + remoteAddr := "" + if conn.RemoteAddr() != nil { + remoteAddr = conn.RemoteAddr().String() + } + return HopStream{Conn: conn, Token: token, RemoteAddr: remoteAddr}, nil +} diff --git a/portal/overlay/stream_test.go b/portal/overlay/stream_test.go new file mode 100644 index 000000000..0b3d8c577 --- /dev/null +++ b/portal/overlay/stream_test.go @@ -0,0 +1,27 @@ +package overlay + +import ( + "net" + "testing" +) + +func TestHopTokenFrameRoundTrip(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + + writeResult := make(chan error, 1) + go func() { writeResult <- writeHopToken(client, "hop-token") }() + stream, err := readHopStream(server) + if err != nil { + t.Fatalf("readHopStream() error = %v", err) + } + if err := <-writeResult; err != nil { + t.Fatalf("writeHopToken() error = %v", err) + } + if stream.Token != "hop-token" || stream.Conn != server { + t.Fatalf("readHopStream() = %#v", stream) + } +} diff --git a/portal/record.go b/portal/record.go index 653d2f9e2..ae5261425 100644 --- a/portal/record.go +++ b/portal/record.go @@ -26,10 +26,10 @@ type leaseRecord struct { ECHDNSHostname string Metadata types.LeaseMetadata - hopToken string - hopNextOverlayIPv4 string - hopNextToken string - registerChallenge *auth.RegisterChallenge + hopToken string + hopNextRelay types.RelayDescriptor + hopNextToken string + registerChallenge *auth.RegisterChallenge datagram *transport.RelayDatagram udpPorts *transport.PortAllocator @@ -85,13 +85,12 @@ func (r *leaseRecord) routesOverlap(other *leaseRecord) bool { return other.Hostname != "" && r.HostnameHash != "" && utils.HostnameHash(other.Hostname) == r.HostnameHash } -func (r *leaseRecord) nextHop() (string, string, bool) { +func (r *leaseRecord) nextHop() (types.RelayDescriptor, string, bool) { if r == nil { - return "", "", false + return types.RelayDescriptor{}, "", false } - overlayIPv4 := r.hopNextOverlayIPv4 forwardToken := r.hopNextToken - return overlayIPv4, forwardToken, overlayIPv4 != "" || forwardToken != "" + return r.hopNextRelay, forwardToken, r.hopNextRelay.HasOverlayPeer() || forwardToken != "" } func (r *leaseRecord) isExpired(now time.Time) bool { diff --git a/portal/server.go b/portal/server.go index d5424f8ff..135c1f283 100644 --- a/portal/server.go +++ b/portal/server.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/http/pprof" + "path/filepath" "strings" "sync" "time" @@ -35,6 +36,7 @@ const ( defaultClientHelloWait = 2 * time.Second defaultControlBodyLimit = 4 << 20 defaultHopOpenRetryWait = 250 * time.Millisecond + defaultIVNPHopAttempt = 4 * time.Second DefaultPProfListenAddr = "127.0.0.1:6060" ) @@ -43,6 +45,8 @@ type ServerConfig struct { IdentityPath string Bootstraps []string DiscoveryEnabled bool + IVNPEnabled bool + IVNPConfigPath string WireGuardPort int APIPort int SNIPort int @@ -68,6 +72,15 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) { if cfg.IdentityPath == "" { return ServerConfig{}, errors.New("identity path is required") } + if cfg.IVNPEnabled { + if !cfg.DiscoveryEnabled { + return ServerConfig{}, errors.New("ivnp overlay requires relay discovery") + } + cfg.IVNPConfigPath = strings.TrimSpace(cfg.IVNPConfigPath) + if cfg.IVNPConfigPath == "" { + cfg.IVNPConfigPath = filepath.Join(cfg.IdentityPath, "ivnp.conf") + } + } selfRelayURL, err := utils.NormalizeRelayURL(cfg.PortalURL) if err != nil { @@ -140,6 +153,7 @@ type Server struct { quicBackhaul *quic.Listener overlay *overlay.Overlay + ivnpOverlay *overlay.IVNP relaySet *discovery.RelaySet announceLimiter *discovery.AnnounceLimiter registry *leaseRegistry @@ -245,6 +259,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { var pprofListener net.Listener var pprofServer *http.Server var ov *overlay.Overlay + var ivnpOverlay *overlay.IVNP var quicBackhaul *quic.Listener defer func() { if started { @@ -254,6 +269,9 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { if ov != nil { _ = ov.Shutdown(context.Background()) } + if ivnpOverlay != nil { + _ = ivnpOverlay.Shutdown(context.Background()) + } if apiServer != nil { _ = apiServer.Close() } @@ -313,6 +331,12 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { return err } } + if s.relaySet != nil && cfg.IVNPEnabled { + ivnpOverlay, err = s.startIVNPOverlay() + if err != nil { + return err + } + } if cfg.UDPEnabled { quicBackhaul, err = s.newQUICBackhaulListener(apiTLS) if err != nil { @@ -331,6 +355,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { s.cancel = cancel s.group = group s.overlay = ov + s.ivnpOverlay = ivnpOverlay s.quicBackhaul = quicBackhaul started = true @@ -342,6 +367,9 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { if s.overlay != nil { group.Go(func() error { return s.overlay.Serve(groupCtx) }) } + if s.ivnpOverlay != nil { + group.Go(func() error { return s.ivnpOverlay.Serve(groupCtx) }) + } if s.quicBackhaul != nil { group.Go(s.runQUICBackhaulListener) } @@ -366,7 +394,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error { Int("max_port", cfg.MaxPort). Bool("discovery_enabled", cfg.DiscoveryEnabled). Bool("wireguard_enabled", s.overlay != nil). - Bool("multihop_enabled", s.overlay != nil). + Bool("ivnp_enabled", s.ivnpOverlay != nil). + Bool("multihop_enabled", s.overlay != nil || s.ivnpOverlay != nil). Bool("udp_enabled", s.quicBackhaul != nil). Bool("tcp_enabled", s.supportsTCP()). Bool("api_ech_enabled", len(apiTLS.EncryptedClientHelloKeys) > 0). @@ -463,6 +492,11 @@ func (s *Server) Shutdown(ctx context.Context) error { shutdownErr = err } } + if s.ivnpOverlay != nil { + if err := s.ivnpOverlay.Shutdown(ctx); err != nil && shutdownErr == nil { + shutdownErr = err + } + } if s.apiTLSClose != nil { _ = s.apiTLSClose.Close() } @@ -612,12 +646,12 @@ func (s *Server) bridgeLeaseConn(ctx context.Context, conn net.Conn, record *lea if record.isExpired(time.Now()) { return errLeaseNotFound } - if overlayIPv4, forwardToken, hasNextHop := record.nextHop(); hasNextHop { + if forwardRelay, forwardToken, hasNextHop := record.nextHop(); hasNextHop { switch { - case s.overlay == nil: + case s.overlay == nil && s.ivnpOverlay == nil: return errors.New("relay overlay is unavailable") - case overlayIPv4 == "": - return errors.New("next hop overlay ipv4 is required") + case !forwardRelay.HasOverlayPeer(): + return errors.New("next hop overlay metadata is required") case forwardToken == "": return errors.New("next hop token is required") } @@ -628,7 +662,7 @@ func (s *Server) bridgeLeaseConn(ctx context.Context, conn net.Conn, record *lea var lastErr error for { var err error - next, err = s.overlay.OpenHopStream(openCtx, overlayIPv4, forwardToken) + next, err = s.openHopStream(openCtx, forwardRelay, forwardToken) if err == nil { break } @@ -659,6 +693,34 @@ func (s *Server) bridgeLeaseConn(ctx context.Context, conn net.Conn, record *lea return nil } +func (s *Server) openHopStream(ctx context.Context, relay types.RelayDescriptor, token string) (net.Conn, error) { + var errs []error + if s.ivnpOverlay != nil && relay.HasIVNPPeer() { + ivnpCtx, cancel := context.WithTimeout(ctx, defaultIVNPHopAttempt) + conn, err := s.ivnpOverlay.OpenHopStream(ivnpCtx, relay.IVNPDestination, token) + cancel() + if err == nil { + return conn, nil + } + errs = append(errs, fmt.Errorf("ivnp: %w", err)) + } + if s.overlay != nil && relay.HasWireGuardPeer() { + overlayIPv4, err := identity.DeriveWireGuardOverlayIPv4(relay.WireGuardPublicKey) + if err == nil { + var conn net.Conn + conn, err = s.overlay.OpenHopStream(ctx, overlayIPv4, token) + if err == nil { + return conn, nil + } + } + errs = append(errs, fmt.Errorf("wireguard: %w", err)) + } + if len(errs) == 0 { + return nil, errors.New("next hop has no locally available overlay transport") + } + return nil, errors.Join(errs...) +} + func (s *Server) runRegistryJanitor(ctx context.Context, interval time.Duration) error { if interval <= 0 { return errors.New("janitor interval must be positive") @@ -759,32 +821,11 @@ func (s *Server) startOverlay() (*overlay.Overlay, error) { PrivateKey: s.identity.WireGuardPrivateKey, PublicKey: s.identity.WireGuardPublicKey, ListenPort: cfg.WireGuardPort, - }, peerMux, nil) + }, peerMux, s.handleHopStream) if err != nil { return nil, fmt.Errorf("start wireguard overlay: %w", err) } - ov.SetStreamHandler(func(ctx context.Context, stream overlay.HopStream) { - s.registry.mu.RLock() - record := s.registry.recordByHopToken(stream.Token, time.Now()) - s.registry.mu.RUnlock() - if record == nil { - log.Warn().Str("remote_addr", stream.RemoteAddr).Msg("hop stream rejected") - _ = stream.Conn.Close() - return - } - hopRole := "exit" - if record.isHopMiddle() { - hopRole = "middle" - } - log.Info().Str("remote_addr", stream.RemoteAddr).Str("hop_role", hopRole).Msg("hop stream received") - - if err := s.bridgeLeaseConn(ctx, stream.Conn, record); err != nil { - log.Warn().Err(err).Str("remote_addr", stream.RemoteAddr).Msg("hop stream bridge failed") - _ = stream.Conn.Close() - } - }) - if err := ov.Sync(s.relaySet.OverlayPeerDescriptor()); err != nil { _ = ov.Shutdown(context.Background()) return nil, fmt.Errorf("sync wireguard peers: %w", err) @@ -793,12 +834,49 @@ func (s *Server) startOverlay() (*overlay.Overlay, error) { return ov, nil } +func (s *Server) startIVNPOverlay() (*overlay.IVNP, error) { + cfg := s.config() + peerMux := http.NewServeMux() + peerMux.HandleFunc(types.PathRoot, s.handleRoot) + peerMux.HandleFunc(types.PathHealthz, s.handleHealthz) + peerMux.HandleFunc(types.PathDiscovery, s.handleRelayDiscovery) + ivnpOverlay, err := overlay.NewIVNP(cfg.IVNPConfigPath, peerMux, s.handleHopStream) + if err != nil { + return nil, fmt.Errorf("start ivnp overlay: %w", err) + } + return ivnpOverlay, nil +} + +func (s *Server) handleHopStream(ctx context.Context, stream overlay.HopStream) { + s.registry.mu.RLock() + record := s.registry.recordByHopToken(stream.Token, time.Now()) + s.registry.mu.RUnlock() + if record == nil { + log.Warn().Str("remote_addr", stream.RemoteAddr).Msg("hop stream rejected") + _ = stream.Conn.Close() + return + } + hopRole := "exit" + if record.isHopMiddle() { + hopRole = "middle" + } + log.Info().Str("remote_addr", stream.RemoteAddr).Str("hop_role", hopRole).Msg("hop stream received") + if err := s.bridgeLeaseConn(ctx, stream.Conn, record); err != nil { + log.Warn().Err(err).Str("remote_addr", stream.RemoteAddr).Msg("hop stream bridge failed") + _ = stream.Conn.Close() + } +} + func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error { if s.relaySet == nil { <-ctx.Done() return nil } - refresher := discovery.NewRefresher(s.relaySet, s.overlay) + var discoveryOverlay discovery.OverlayRuntime = s.overlay + if s.ivnpOverlay != nil { + discoveryOverlay = s.ivnpOverlay + } + refresher := discovery.NewRefresher(s.relaySet, discoveryOverlay) ticker := time.NewTicker(discovery.DiscoveryPollInterval) defer ticker.Stop() @@ -843,6 +921,11 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error) wireGuardPort = cfg.ListenPort supportsOverlay = true } + ivnpDestination := "" + if s.ivnpOverlay != nil { + ivnpDestination = s.ivnpOverlay.Destination() + supportsOverlay = supportsOverlay || ivnpDestination != "" + } return auth.SignRelayDescriptor(types.RelayDescriptor{ Address: s.identity.Address, @@ -850,6 +933,7 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error) IssuedAt: now, ExpiresAt: now.Add(discovery.DiscoveryDescriptorTTL), APIHTTPSAddr: cfg.PortalURL, + IVNPDestination: ivnpDestination, WireGuardPublicKey: wireGuardPublicKey, WireGuardPort: wireGuardPort, SupportsOverlay: supportsOverlay, diff --git a/types/identity.go b/types/identity.go index 911fa6777..dde7d64b8 100644 --- a/types/identity.go +++ b/types/identity.go @@ -110,6 +110,7 @@ type RelayDescriptor struct { IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` APIHTTPSAddr string `json:"api_https_addr"` + IVNPDestination string `json:"ivnp_destination,omitempty"` WireGuardPublicKey string `json:"wireguard_public_key,omitempty"` WireGuardPort int `json:"wireguard_port,omitempty"` SupportsOverlay bool `json:"supports_overlay,omitempty"` @@ -121,6 +122,14 @@ type RelayDescriptor struct { } func (desc RelayDescriptor) HasOverlayPeer() bool { + return desc.SupportsOverlay && (desc.HasIVNPPeer() || desc.HasWireGuardPeer()) +} + +func (desc RelayDescriptor) HasIVNPPeer() bool { + return strings.TrimSpace(desc.IVNPDestination) != "" +} + +func (desc RelayDescriptor) HasWireGuardPeer() bool { return desc.SupportsOverlay && strings.TrimSpace(desc.WireGuardPublicKey) != "" && desc.WireGuardPort > 0 && @@ -139,6 +148,7 @@ func CanonicalBytes(desc RelayDescriptor) ([]byte, error) { IssuedAtUnixNano int64 `json:"issued_at_unix_nano"` ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"` APIHTTPSAddr string `json:"api_https_addr"` + IVNPDestination string `json:"ivnp_destination"` WireGuardPublicKey string `json:"wireguard_public_key"` WireGuardPort int `json:"wireguard_port"` SupportsOverlay bool `json:"supports_overlay"` @@ -152,6 +162,7 @@ func CanonicalBytes(desc RelayDescriptor) ([]byte, error) { IssuedAtUnixNano: desc.IssuedAt.UTC().UnixNano(), ExpiresAtUnixNano: desc.ExpiresAt.UTC().UnixNano(), APIHTTPSAddr: desc.APIHTTPSAddr, + IVNPDestination: desc.IVNPDestination, WireGuardPublicKey: desc.WireGuardPublicKey, WireGuardPort: desc.WireGuardPort, SupportsOverlay: desc.SupportsOverlay,