рабочий вариант, но скороть 10 МБит
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Working Baseline: VPN/Fabric 2026-05-22 21:43 MSK
|
||||
|
||||
Purpose: known working reference after Android IPv4 ingress traffic started opening sites and the throughput baseline fixes were applied.
|
||||
|
||||
Repository base:
|
||||
- branch: main
|
||||
- HEAD: 469fa0e86032d3de152227c3999532cc33860429
|
||||
|
||||
Captured files:
|
||||
- tracked-working-tree.patch: binary git patch for tracked modifications and deletions.
|
||||
- untracked-files.zip: archive of untracked files present at the baseline time.
|
||||
- untracked-files.txt: relative list of untracked files included in the archive.
|
||||
- status-short.txt: git short status at capture time.
|
||||
|
||||
Runtime baseline:
|
||||
- Android APK: 0.2.270 debug
|
||||
- APK path: dist/downloads/rap-android-vpn-latest-debug.apk
|
||||
- APK SHA256: 2ed0ace422bb7a11d90d6660ad080d4116025483e023b848c0650bdd75d2edaa
|
||||
- home-1 agent image: rap-node-agent:0.2.378-vpn-throughput-quiet2
|
||||
- home-1 rapvpn0 MTU: 1280
|
||||
- home-1 RAP_VPN_FABRIC_SESSION_STREAM_SHARDS: 8
|
||||
- home-1 RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN: 256
|
||||
|
||||
Validation already done:
|
||||
- go test ./cmd/rap-node-agent ./internal/vpnruntime ./mobile/fabricvpn ./internal/mesh
|
||||
- Android debug APK build/publish succeeded
|
||||
- fabric-loadtest local all-mode: 256 streams, 268 MB, ~1.65 Gbit/s, verdict pass
|
||||
- home-1 direct cachefly 100MB: ~84 MB/s (~674 Mbit/s)
|
||||
|
||||
Restore/use as reference:
|
||||
1. Start from HEAD above.
|
||||
2. Apply tracked-working-tree.patch with `git apply --index` or `git apply` depending on whether the index should be restored.
|
||||
3. Extract untracked-files.zip into the repository root if untracked files are needed.
|
||||
4. Rebuild/redeploy APK and home-1 agent from this state.
|
||||
|
||||
Note: this is intentionally a working-tree baseline, not only a git tag, because the working state contains many uncommitted tracked and untracked files.
|
||||
@@ -0,0 +1 @@
|
||||
main
|
||||
@@ -0,0 +1 @@
|
||||
469fa0e86032d3de152227c3999532cc33860429
|
||||
@@ -0,0 +1,259 @@
|
||||
M CODEX_CONTEXT.md
|
||||
M README.md
|
||||
D _tmp_android_build/.gradle/9.5.0/checksums/checksums.lock
|
||||
D _tmp_android_build/.gradle/9.5.0/checksums/md5-checksums.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/checksums/sha1-checksums.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/executionHistory/executionHistory.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/executionHistory/executionHistory.lock
|
||||
D _tmp_android_build/.gradle/9.5.0/fileChanges/last-build.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/fileHashes/fileHashes.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/fileHashes/fileHashes.lock
|
||||
D _tmp_android_build/.gradle/9.5.0/fileHashes/resourceHashesCache.bin
|
||||
D _tmp_android_build/.gradle/9.5.0/gc.properties
|
||||
D _tmp_android_build/.gradle/buildOutputCleanup/buildOutputCleanup.lock
|
||||
D _tmp_android_build/.gradle/buildOutputCleanup/cache.properties
|
||||
D _tmp_android_build/.gradle/buildOutputCleanup/outputFiles.bin
|
||||
D _tmp_android_build/.gradle/vcs-1/gc.properties
|
||||
D _tmp_android_build/README.md
|
||||
D _tmp_android_build/app/build.gradle
|
||||
D _tmp_android_build/app/src/main/AndroidManifest.xml
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/MainActivity.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/RapApiClient.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/RapVpnService.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/RdpActivity.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/SecureTokenStore.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/TestTrafficActivity.java
|
||||
D _tmp_android_build/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java
|
||||
D _tmp_android_build/app/src/main/res/values/styles.xml
|
||||
D _tmp_android_build/build.gradle
|
||||
D _tmp_android_build/local.properties
|
||||
D _tmp_android_build/settings.gradle
|
||||
M agents/rap-node-agent/README.md
|
||||
M agents/rap-node-agent/cmd/fabric-loadtest/main.go
|
||||
M agents/rap-node-agent/cmd/fabric-loadtest/main_test.go
|
||||
M agents/rap-node-agent/cmd/mesh-live-smoke/main.go
|
||||
M agents/rap-node-agent/cmd/rap-host-agent/main.go
|
||||
M agents/rap-node-agent/cmd/rap-node-agent/main.go
|
||||
M agents/rap-node-agent/cmd/rap-node-agent/main_test.go
|
||||
M agents/rap-node-agent/internal/agent/payload.go
|
||||
M agents/rap-node-agent/internal/client/client.go
|
||||
M agents/rap-node-agent/internal/config/config.go
|
||||
M agents/rap-node-agent/internal/config/config_test.go
|
||||
M agents/rap-node-agent/internal/fabricproto/frame.go
|
||||
M agents/rap-node-agent/internal/fabricproto/frame_test.go
|
||||
M agents/rap-node-agent/internal/fabricproto/session.go
|
||||
M agents/rap-node-agent/internal/fabricproto/session_test.go
|
||||
M agents/rap-node-agent/internal/hostagent/config.go
|
||||
M agents/rap-node-agent/internal/hostagent/docker.go
|
||||
M agents/rap-node-agent/internal/hostagent/docker_test.go
|
||||
M agents/rap-node-agent/internal/hostagent/linux.go
|
||||
M agents/rap-node-agent/internal/hostagent/monitor.go
|
||||
M agents/rap-node-agent/internal/hostagent/profile.go
|
||||
M agents/rap-node-agent/internal/hostagent/self_update.go
|
||||
M agents/rap-node-agent/internal/hostagent/service.go
|
||||
M agents/rap-node-agent/internal/hostagent/service_test.go
|
||||
M agents/rap-node-agent/internal/hostagent/update.go
|
||||
M agents/rap-node-agent/internal/hostagent/update_test.go
|
||||
M agents/rap-node-agent/internal/hostagent/windows.go
|
||||
M agents/rap-node-agent/internal/hostagent/windows_update.go
|
||||
D agents/rap-node-agent/internal/mesh/client.go
|
||||
M agents/rap-node-agent/internal/mesh/contracts.go
|
||||
M agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring.go
|
||||
M agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring_test.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_control_transport.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_core.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_quic_server.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_quic_transport.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_route_planner.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go
|
||||
M agents/rap-node-agent/internal/mesh/fabric_transport.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_cache.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_cache_test.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_connection_intent.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_connection_intent_test.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_connection_manager.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_connection_manager_test.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_connection_state.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_recovery_plan.go
|
||||
M agents/rap-node-agent/internal/mesh/peer_recovery_plan_test.go
|
||||
M agents/rap-node-agent/internal/mesh/production_transport.go
|
||||
M agents/rap-node-agent/internal/mesh/registry_gossip.go
|
||||
M agents/rap-node-agent/internal/mesh/registry_gossip_test.go
|
||||
M agents/rap-node-agent/internal/mesh/remote_workspace_sink.go
|
||||
M agents/rap-node-agent/internal/mesh/scoped_config.go
|
||||
M agents/rap-node-agent/internal/mesh/scoped_config_test.go
|
||||
M agents/rap-node-agent/internal/mesh/server.go
|
||||
D agents/rap-node-agent/internal/mesh/server_test.go
|
||||
M agents/rap-node-agent/internal/mesh/vpn_packet.go
|
||||
M agents/rap-node-agent/internal/supervisor/supervisor.go
|
||||
M agents/rap-node-agent/internal/supervisor/supervisor_test.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/fabric_session_packet.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/fabric_session_registry.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/fabric_session_transport.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/fabric_transport.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/gateway.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/gateway_test.go
|
||||
M agents/rap-node-agent/internal/vpnruntime/tun_linux.go
|
||||
M agents/rap-node-agent/internal/webingress/admin_runtime.go
|
||||
M agents/rap-node-agent/internal/webingress/admin_runtime_test.go
|
||||
M agents/rap-node-agent/internal/webingress/manager.go
|
||||
M agents/rap-node-agent/internal/webingress/manager_test.go
|
||||
M agents/rap-node-agent/internal/webingress/runtime.go
|
||||
M agents/rap-node-agent/internal/webingress/runtime_test.go
|
||||
M agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go
|
||||
M agents/rap-node-agent/mobile/fabricvpn/fabricvpn_test.go
|
||||
M backend/README.md
|
||||
M backend/cmd/ws-smoke-client/main.go
|
||||
M backend/go.mod
|
||||
M backend/go.sum
|
||||
M backend/internal/modules/auth/module.go
|
||||
M backend/internal/modules/auth/service.go
|
||||
M backend/internal/modules/cluster/models.go
|
||||
M backend/internal/modules/cluster/module.go
|
||||
M backend/internal/modules/cluster/module_admin_runtime_test.go
|
||||
M backend/internal/modules/cluster/module_error_test.go
|
||||
M backend/internal/modules/cluster/postgres_store.go
|
||||
M backend/internal/modules/cluster/postgres_store_test.go
|
||||
M backend/internal/modules/cluster/repository.go
|
||||
M backend/internal/modules/cluster/service.go
|
||||
M backend/internal/modules/cluster/service_test.go
|
||||
M backend/internal/modules/nodeagent/module.go
|
||||
M backend/internal/platform/authority/authority.go
|
||||
M backend/internal/platform/config/config.go
|
||||
M backend/internal/platform/runtime/app.go
|
||||
M clients/android/.gradle/9.5.0/executionHistory/executionHistory.bin
|
||||
M clients/android/.gradle/9.5.0/executionHistory/executionHistory.lock
|
||||
M clients/android/.gradle/9.5.0/fileHashes/fileHashes.bin
|
||||
M clients/android/.gradle/9.5.0/fileHashes/fileHashes.lock
|
||||
M clients/android/.gradle/9.5.0/fileHashes/resourceHashesCache.bin
|
||||
M clients/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
|
||||
M clients/android/README.md
|
||||
M clients/android/app/build.gradle
|
||||
M clients/android/app/libs/rap-fabricvpn-sources.jar
|
||||
M clients/android/app/libs/rap-fabricvpn.aar
|
||||
M clients/android/app/src/main/java/su/cin/rapvpn/MainActivity.java
|
||||
M clients/android/app/src/main/java/su/cin/rapvpn/RapApiClient.java
|
||||
M clients/android/app/src/main/java/su/cin/rapvpn/RapAutostartReceiver.java
|
||||
M clients/android/app/src/main/java/su/cin/rapvpn/RapVpnService.java
|
||||
M clients/android/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java
|
||||
M clients/windows/README.md
|
||||
M clients/windows/src/RemoteAccessPlatform.Windows.Application/ViewModels/SessionWindowViewModel.cs
|
||||
M clients/windows/src/RemoteAccessPlatform.Windows.Transport/BackendApiClient.cs
|
||||
M clients/windows/src/RemoteAccessPlatform.Windows.Transport/SessionGatewayClient.cs
|
||||
D docs/_legacy_v1/02_specs/technical_specification.md
|
||||
D docs/_legacy_v1/03_codex_prompts/00_master_prompt.md
|
||||
D docs/_legacy_v1/04_repo_bootstrap/what_to_do_in_codex_project_folder.md
|
||||
D docs/_legacy_v1/05_decisions/technology_stack_review_2026.md
|
||||
D docs/_legacy_v1/README_START_HERE.md
|
||||
M docs/architecture/ARCHITECTURE_GUARDRAILS.md
|
||||
M docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md
|
||||
M docs/architecture/DATA_PLANE_V1.md
|
||||
M docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md
|
||||
M docs/architecture/FABRIC_AREA_AND_PEER_STABILITY_MODEL.md
|
||||
M docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md
|
||||
M docs/architecture/FABRIC_LIVE_AUDIT_2026-05-18.md
|
||||
M docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md
|
||||
M docs/architecture/FABRIC_SERVICE_CHANNEL_RUNTIME.md
|
||||
M docs/architecture/MESH_ROUTING_RUNTIME_IMPLEMENTATION_PLAN.md
|
||||
M docs/architecture/RDP_ADAPTER_RUNTIME.md
|
||||
M docs/architecture/RDP_SERVICE_CSHARP_TARGET.md
|
||||
M docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md
|
||||
M docs/architecture/SECURE_NODE_TO_NODE_CHANNEL_LIFECYCLE.md
|
||||
M docs/architecture/SECURITY_SECRETS_READINESS.md
|
||||
M docs/architecture/SERVICE_ADAPTER_PROTOCOL.md
|
||||
M docs/architecture/SIGNED_SCOPED_CLUSTER_SNAPSHOT_MODEL.md
|
||||
M docs/architecture/VPN_IP_TUNNEL_SERVICE_TARGET.md
|
||||
M docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md
|
||||
M docs/audits/CURRENT_BASELINE_MATRIX.md
|
||||
M docs/audits/PROJECT_AUDIT_2026-04-26.md
|
||||
M docs/codex/CURRENT_STATUS.md
|
||||
M docs/codex/NEXT_STEP_PROMPT.md
|
||||
M docs/operations/VPN_BASELINE_0.2.87.md
|
||||
M docs/ops/RAP_HOST_AGENT_MONITOR.md
|
||||
M scripts/android/build-android-apk.ps1
|
||||
M scripts/android/fast-release-android-apk.ps1
|
||||
M scripts/android/rebuild-and-publish-android-apk.ps1
|
||||
M scripts/android/release-android-apk.ps1
|
||||
D scripts/fabric/c17h-multi-agent-synthetic-smoke-ssh.ps1
|
||||
D scripts/fabric/c17h-multi-agent-synthetic-smoke.ps1
|
||||
D scripts/fabric/c17z12-rendezvous-relay-smoke-ssh.ps1
|
||||
M scripts/fabric/c17z19-route-health-feedback-smoke-ssh.ps1
|
||||
M scripts/fabric/c18z1-live-service-channel-ingress-smoke.ps1
|
||||
M scripts/fabric/c18z10-live-service-channel-exit-pool-smoke.ps1
|
||||
M scripts/fabric/c18z100-rebuild-health-feedback-breakdown-smoke.ps1
|
||||
M scripts/fabric/c18z102-rebuild-health-feedback-drilldown-audit-smoke.ps1
|
||||
M scripts/fabric/c18z104-focused-fabric-audit-smoke.ps1
|
||||
M scripts/fabric/c18z11-live-service-channel-entry-pool-smoke.ps1
|
||||
M scripts/fabric/c18z13-live-service-channel-route-quality-smoke.ps1
|
||||
M scripts/fabric/c18z14-live-service-channel-active-quality-shift-smoke.ps1
|
||||
M scripts/fabric/c18z17-live-service-channel-quality-cleanup-smoke.ps1
|
||||
M scripts/fabric/c18z18-service-channel-session-scoped-fairness-smoke.ps1
|
||||
M scripts/fabric/c18z19-service-channel-parallel-flow-window-smoke.ps1
|
||||
M scripts/fabric/c18z2-live-service-channel-soak-smoke.ps1
|
||||
M scripts/fabric/c18z20-service-channel-adaptive-window-telemetry-smoke.ps1
|
||||
M scripts/fabric/c18z21-service-channel-rolling-quality-window-smoke.ps1
|
||||
M scripts/fabric/c18z23-service-channel-recovery-hysteresis-smoke.ps1
|
||||
M scripts/fabric/c18z3-live-service-channel-entry-ws-fallback-smoke.ps1
|
||||
M scripts/fabric/c18z4-live-service-channel-session-pressure-smoke.ps1
|
||||
M scripts/fabric/c18z5-live-service-channel-exit-restart-smoke.ps1
|
||||
M scripts/fabric/c18z52-service-channel-access-telemetry-smoke.ps1
|
||||
M scripts/fabric/c18z54-service-channel-normal-route-access-smoke.ps1
|
||||
M scripts/fabric/c18z55-service-channel-degraded-route-access-smoke.ps1
|
||||
M scripts/fabric/c18z56-service-channel-alternate-remediation-smoke.ps1
|
||||
M scripts/fabric/c18z57-service-channel-remediation-command-smoke.ps1
|
||||
M scripts/fabric/c18z58-service-channel-remediation-apply-smoke.ps1
|
||||
M scripts/fabric/c18z59-service-channel-remediation-traffic-smoke.ps1
|
||||
M scripts/fabric/c18z6-live-service-channel-active-rebuild-smoke.ps1
|
||||
M scripts/fabric/c18z60-service-channel-remediation-multiflow-smoke.ps1
|
||||
M scripts/fabric/c18z61-service-channel-remediation-pressure-smoke.ps1
|
||||
M scripts/fabric/c18z62-service-channel-remediation-qos-smoke.ps1
|
||||
M scripts/fabric/c18z67-service-channel-concurrent-qos-live-smoke.ps1
|
||||
M scripts/fabric/c18z7-live-service-channel-concurrent-isolation-smoke.ps1
|
||||
M scripts/fabric/c18z72-service-channel-pool-policy-smoke.ps1
|
||||
M scripts/fabric/c18z73-service-channel-pool-policy-remediation-guard-smoke.ps1
|
||||
M scripts/fabric/c18z8-live-service-channel-backpressure-isolation-smoke.ps1
|
||||
M scripts/fabric/c18z9-live-service-channel-route-pool-smoke.ps1
|
||||
M scripts/fabric/c18z91-node-agent-data-plane-contract-enforcement-smoke.ps1
|
||||
D scripts/fabric/c18z92-node-agent-disabled-backend-fallback-smoke.ps1
|
||||
M scripts/fabric/c18z93-access-telemetry-data-plane-contract-smoke.ps1
|
||||
M scripts/fabric/c18z94-data-plane-contract-incident-smoke.ps1
|
||||
M scripts/fabric/c18z95-node-agent-blocked-fallback-telemetry-smoke.ps1
|
||||
M scripts/fabric/c18z96-blocked-fallback-rebuild-feedback-smoke.ps1
|
||||
M scripts/fabric/c18z97-blocked-fallback-feedback-dedup-smoke.ps1
|
||||
M scripts/fabric/c18z98-blocked-fallback-rebuild-correlation-smoke.ps1
|
||||
M scripts/fabric/c18z99-rebuild-correlation-filter-smoke.ps1
|
||||
M scripts/fabric/c19a-service-workload-supervision-smoke.ps1
|
||||
D scripts/fabric/deploy-test-nodes.ps1
|
||||
D scripts/fabric/dev-cluster-enrollment-bootstrap-smoke-ssh.ps1
|
||||
M web-admin/README.md
|
||||
D web-admin/deploy/html/assets/index-CiNvRobk.js
|
||||
D web-admin/deploy/html/assets/index-Cur_BAkX.css
|
||||
M web-admin/deploy/html/index.html
|
||||
M web-admin/index.html
|
||||
M web-admin/src/App.tsx
|
||||
M web-admin/src/api/client.ts
|
||||
M web-admin/src/styles.css
|
||||
M web-admin/src/types.ts
|
||||
M workers/rdp-worker/src/dataplane/direct_wss_server.cpp
|
||||
?? .codex-baselines/
|
||||
?? _tmp_release_0.2.355-rescue-hold/
|
||||
?? agents/rap-node-agent/$tmp/
|
||||
?? agents/rap-node-agent/internal/mesh/fabric_live_probe.go
|
||||
?? agents/rap-node-agent/internal/vpnruntime/service_stream_registry.go
|
||||
?? agents/rap-node-agent/internal/vpnruntime/service_stream_registry_test.go
|
||||
?? agents/rap-node-agent/internal/vpnruntime/service_tunnel.go
|
||||
?? agents/rap-node-agent/internal/vpnruntime/service_tunnel_test.go
|
||||
?? agents/rap-node-agent/rap-node-agent-linux-test
|
||||
?? backend/internal/modules/auth/module_html.go
|
||||
?? backend/internal/modules/cluster/module_console_html.go
|
||||
?? backend/internal/modules/cluster/module_nodes_html.go
|
||||
?? backend/internal/platform/fabriccontrol/
|
||||
?? docs/_archive_v1/
|
||||
?? docs/architecture/FABRIC_EXECUTION_PLAN_2026-05-19.md
|
||||
?? docs/architecture/FABRIC_SERVICE_OVER_TRANSPORT_MODEL.md
|
||||
?? docs/architecture/FABRIC_TRANSPORT_SCALE_PLAN.md
|
||||
?? scripts/check-fabric-standard-boundary.ps1
|
||||
?? scripts/check-live-farm-fabric-standard.ps1
|
||||
?? scripts/fabric/c18z92-node-agent-disabled-compat-fallback-smoke.ps1
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\.codex-baselines\vpn-working-20260522-214333\branch.txt
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\.codex-baselines\vpn-working-20260522-214333\head.txt
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\.codex-baselines\vpn-working-20260522-214333\status-short.txt
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\.codex-baselines\vpn-working-20260522-214333\tracked-working-tree.patch
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\_tmp_release_0.2.355-rescue-hold\rap-host-agent-0.2.355-rescue-hold-linux-amd64
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\_tmp_release_0.2.355-rescue-hold\rap-host-agent-0.2.355-rescue-hold-linux-amd64-fixed
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\_tmp_release_0.2.355-rescue-hold\rap-node-agent-0.2.355-rescue-hold-docker-amd64.tar
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\_tmp_release_0.2.355-rescue-hold\rap-node-agent-0.2.355-rescue-hold-linux-amd64
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\go\Seq.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\go\Universe.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\go\error.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\su\cin\rapvpn\fabric\fabricvpn\Fabricvpn.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\su\cin\rapvpn\fabric\fabricvpn\Manager.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\java\su\cin\rapvpn\fabric\fabricvpn\SocketProtector.java
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\fabricvpn_android.c
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\fabricvpn_android.h
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\seq_android.c
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\seq_android.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\seq_android.h
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\universe_android.c
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\$tmp\src\gobind\universe_android.h
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\internal\mesh\fabric_live_probe.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\internal\vpnruntime\service_stream_registry.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\internal\vpnruntime\service_stream_registry_test.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\internal\vpnruntime\service_tunnel.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\internal\vpnruntime\service_tunnel_test.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\agents\rap-node-agent\rap-node-agent-linux-test
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\backend\internal\modules\auth\module_html.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\backend\internal\modules\cluster\module_console_html.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\backend\internal\modules\cluster\module_nodes_html.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\backend\internal\platform\fabriccontrol\frame.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\backend\internal\platform\fabriccontrol\server.go
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\_archive_v1\02_specs\technical_specification.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\_archive_v1\03_codex_prompts\00_master_prompt.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\_archive_v1\04_repo_bootstrap\what_to_do_in_codex_project_folder.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\_archive_v1\05_decisions\technology_stack_review_2026.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\_archive_v1\README_START_HERE.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\architecture\FABRIC_EXECUTION_PLAN_2026-05-19.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\architecture\FABRIC_SERVICE_OVER_TRANSPORT_MODEL.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\docs\architecture\FABRIC_TRANSPORT_SCALE_PLAN.md
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\scripts\check-fabric-standard-boundary.ps1
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\scripts\check-live-farm-fabric-standard.ps1
|
||||
Microsoft.PowerShell.Core\FileSystem::\\nas\MST\codex\rdp-proxy\scripts\fabric\c18z92-node-agent-disabled-compat-fallback-smoke.ps1
|
||||
@@ -0,0 +1,43 @@
|
||||
.codex-baselines/vpn-working-20260522-214333/branch.txt
|
||||
.codex-baselines/vpn-working-20260522-214333/head.txt
|
||||
.codex-baselines/vpn-working-20260522-214333/status-short.txt
|
||||
.codex-baselines/vpn-working-20260522-214333/tracked-working-tree.patch
|
||||
_tmp_release_0.2.355-rescue-hold/rap-host-agent-0.2.355-rescue-hold-linux-amd64
|
||||
_tmp_release_0.2.355-rescue-hold/rap-host-agent-0.2.355-rescue-hold-linux-amd64-fixed
|
||||
_tmp_release_0.2.355-rescue-hold/rap-node-agent-0.2.355-rescue-hold-docker-amd64.tar
|
||||
_tmp_release_0.2.355-rescue-hold/rap-node-agent-0.2.355-rescue-hold-linux-amd64
|
||||
agents/rap-node-agent/$tmp/java/go/Seq.java
|
||||
agents/rap-node-agent/$tmp/java/go/Universe.java
|
||||
agents/rap-node-agent/$tmp/java/go/error.java
|
||||
agents/rap-node-agent/$tmp/java/su/cin/rapvpn/fabric/fabricvpn/Fabricvpn.java
|
||||
agents/rap-node-agent/$tmp/java/su/cin/rapvpn/fabric/fabricvpn/Manager.java
|
||||
agents/rap-node-agent/$tmp/java/su/cin/rapvpn/fabric/fabricvpn/SocketProtector.java
|
||||
agents/rap-node-agent/$tmp/src/gobind/fabricvpn_android.c
|
||||
agents/rap-node-agent/$tmp/src/gobind/fabricvpn_android.h
|
||||
agents/rap-node-agent/$tmp/src/gobind/seq_android.c
|
||||
agents/rap-node-agent/$tmp/src/gobind/seq_android.go
|
||||
agents/rap-node-agent/$tmp/src/gobind/seq_android.h
|
||||
agents/rap-node-agent/$tmp/src/gobind/universe_android.c
|
||||
agents/rap-node-agent/$tmp/src/gobind/universe_android.h
|
||||
agents/rap-node-agent/internal/mesh/fabric_live_probe.go
|
||||
agents/rap-node-agent/internal/vpnruntime/service_stream_registry.go
|
||||
agents/rap-node-agent/internal/vpnruntime/service_stream_registry_test.go
|
||||
agents/rap-node-agent/internal/vpnruntime/service_tunnel.go
|
||||
agents/rap-node-agent/internal/vpnruntime/service_tunnel_test.go
|
||||
agents/rap-node-agent/rap-node-agent-linux-test
|
||||
backend/internal/modules/auth/module_html.go
|
||||
backend/internal/modules/cluster/module_console_html.go
|
||||
backend/internal/modules/cluster/module_nodes_html.go
|
||||
backend/internal/platform/fabriccontrol/frame.go
|
||||
backend/internal/platform/fabriccontrol/server.go
|
||||
docs/_archive_v1/02_specs/technical_specification.md
|
||||
docs/_archive_v1/03_codex_prompts/00_master_prompt.md
|
||||
docs/_archive_v1/04_repo_bootstrap/what_to_do_in_codex_project_folder.md
|
||||
docs/_archive_v1/05_decisions/technology_stack_review_2026.md
|
||||
docs/_archive_v1/README_START_HERE.md
|
||||
docs/architecture/FABRIC_EXECUTION_PLAN_2026-05-19.md
|
||||
docs/architecture/FABRIC_SERVICE_OVER_TRANSPORT_MODEL.md
|
||||
docs/architecture/FABRIC_TRANSPORT_SCALE_PLAN.md
|
||||
scripts/check-fabric-standard-boundary.ps1
|
||||
scripts/check-live-farm-fabric-standard.ps1
|
||||
scripts/fabric/c18z92-node-agent-disabled-compat-fallback-smoke.ps1
|
||||
Binary file not shown.
+76
-76
@@ -95,11 +95,11 @@ Current audit and baseline snapshot:
|
||||
- Canonical test Docker host: `192.168.200.61`
|
||||
- Canonical Docker context: `test-ubuntu`
|
||||
- Canonical SSH alias: `docker-test`
|
||||
- Current external control-plane endpoint for remote/offsite node enrollment:
|
||||
- Current external fabric control endpoint for remote/offsite node enrollment:
|
||||
`http://94.141.118.222:19191` / `http://vpn.cin.su:19191`.
|
||||
- Current port forward: `94.141.118.222:19191` -> `192.168.200.61:18080`.
|
||||
- For offsite Windows/Linux nodes, install profiles should use:
|
||||
`http://vpn.cin.su:19191/api/v1` as control-plane endpoint and
|
||||
`http://vpn.cin.su:19191/api/v1` as fabric control endpoint and
|
||||
`http://vpn.cin.su:19191/downloads` as artifact endpoint unless the user
|
||||
explicitly chooses the raw IP endpoint.
|
||||
- Backend API for local/client smoke runs: `http://192.168.200.61:8080/api/v1`
|
||||
@@ -699,7 +699,7 @@ Current implementation focus remains:
|
||||
replace their local wrapper, after which automatic polling should continue.
|
||||
- Admin UI now marks missing host-agent updater reports as `repair updater` in
|
||||
the node list and explains in node details / Updates when to run the Windows
|
||||
repair command. The command uses the external control-plane endpoint and does
|
||||
repair command. The command uses the external fabric control endpoint and does
|
||||
not require a join token for already enrolled Windows nodes.
|
||||
- Admin UI node details / Updates also provides a ready downloadable
|
||||
`rap-repair-updater-<node>.cmd` plus copy-command action for Windows repair,
|
||||
@@ -716,29 +716,28 @@ Current implementation focus remains:
|
||||
Docker-test nodes `test-1/2/3` updated to `0.1.6`; existing Windows and
|
||||
off-host Docker nodes still need their local updater wrappers to pick up the
|
||||
0.1.6 host-agent repair path.
|
||||
- C17Z30 operator-configured public mesh endpoints are implemented and
|
||||
docker-test-deployed: desired `mesh-listener.advertise_endpoint` is now
|
||||
projected into peer endpoint candidates for other nodes and preferred over
|
||||
auto-discovered private heartbeat endpoints. `home-1`
|
||||
(`8ad04829-cd30-4290-913d-1ce5c7ef7bb3`) is configured with
|
||||
`listen_addr=0.0.0.0:19131`, `advertise_endpoint=http://94.141.118.222:19199`,
|
||||
`connectivity_mode=direct`, `nat_type=port_restricted`, `region=home`.
|
||||
`test-1` synthetic config now receives `home-1` peer endpoint
|
||||
`http://94.141.118.222:19199`; internal `192.168.200.85:19131` responds with
|
||||
HTTP 405 on GET, while external `94.141.118.222:19199` currently refuses TCP,
|
||||
so router/firewall forwarding still needs correction outside the platform.
|
||||
- C17Z31 offsite bootstrap peer selection is implemented and docker-test
|
||||
deployed: operator-configured public/direct desired mesh-listener endpoints
|
||||
are kept in core-mesh bootstrap even after the default warm-peer target is
|
||||
reached. This fixes the case where remote Windows node
|
||||
`ifcm-rufms-s-mo1cr` received only `test-*` warm peers and no `home-1`.
|
||||
Its synthetic config now includes `home-1` endpoint
|
||||
`http://94.141.118.222:19199` and candidates ordered as operator public,
|
||||
heartbeat advertised public, then private LAN converted to relay-required for
|
||||
offsite. External TCP to `94.141.118.222:19199` still failed from Codex and
|
||||
docker-test checks while internal `192.168.200.85:19131` succeeds, so a real
|
||||
offsite `Test-NetConnection 94.141.118.222 -Port 19199` is the next network
|
||||
validation.
|
||||
- C17Z30 operator-configured public mesh endpoints are implemented and
|
||||
docker-test-deployed: desired `fabric-listener.advertise_endpoint` is now
|
||||
projected into peer endpoint candidates for other nodes and preferred over
|
||||
auto-discovered private heartbeat endpoints. `home-1`
|
||||
(`8ad04829-cd30-4290-913d-1ce5c7ef7bb3`) is configured with
|
||||
`listen_addr=0.0.0.0:19131`, `advertise_endpoint=quic://94.141.118.222:19199`,
|
||||
`connectivity_mode=direct`, `nat_type=port_restricted`, `region=home`.
|
||||
`test-1` synthetic config now receives `home-1` peer endpoint
|
||||
`quic://94.141.118.222:19199`; internal `192.168.200.85:19131` responds on
|
||||
the fabric listener while external `94.141.118.222:19199` still needs UDP
|
||||
forwarding, so router/firewall correction remains outside the platform.
|
||||
- C17Z31 offsite bootstrap peer selection is implemented and docker-test
|
||||
deployed: operator-configured public/direct desired fabric-listener endpoints
|
||||
are kept in core-mesh bootstrap even after the default warm-peer target is
|
||||
reached. This fixes the case where remote Windows node
|
||||
`ifcm-rufms-s-mo1cr` received only `test-*` warm peers and no `home-1`.
|
||||
Its synthetic config now includes `home-1` endpoint
|
||||
`quic://94.141.118.222:19199` and candidates ordered as operator public,
|
||||
heartbeat advertised public, then private LAN converted to relay-required for
|
||||
offsite. External UDP reachability to `94.141.118.222:19199` still needs
|
||||
verification while internal `192.168.200.85:19131` succeeds, so the next
|
||||
network validation is an offsite QUIC/UDP probe against port `19199`.
|
||||
- C17Z32 native Ubuntu/Linux service install is implemented and docker-test
|
||||
deployed: backend exposes `/node-agents/linux-install-profile`, host-agent
|
||||
supports `install-linux`, installs `rap-node-agent` under
|
||||
@@ -751,7 +750,7 @@ Current implementation focus remains:
|
||||
install profile and generates profile-based `install-linux` commands.
|
||||
A one-use token for `vps-ubuntu-1` is active until 2026-05-02T08:41:41Z:
|
||||
`rap_join_a23Xhz63YstshWUBAPGPz5fzQ8YpHDP05RXaaYa4DoA`; scope roles are
|
||||
`core-mesh` and `relay-node`, control-plane endpoint is
|
||||
`core-mesh` and `relay-node`, fabric control endpoint is
|
||||
`http://vpn.cin.su:19191/api/v1`, artifact endpoint is
|
||||
`http://vpn.cin.su:19191/downloads`.
|
||||
- Admin UI and docs now cover the full Windows updater operational workflow:
|
||||
@@ -813,19 +812,19 @@ Current implementation focus remains:
|
||||
`usa-los-1` (`linux_binary`) and `ifcm-rufms-s-mo1cr` (`windows_service`) now
|
||||
return `action=update`, `target_version=0.2.40` instead of
|
||||
`no_matching_artifact`.
|
||||
- C18F production-forwarding gate work is partially live: backend
|
||||
`rap-backend:test-vpn-fabric-route-0.2.42` signs node synthetic configs with
|
||||
`production_forwarding=true` / `control_plane_only=false` when the node's
|
||||
desired `mesh-listener` workload has `production_forwarding_enabled=true`.
|
||||
`home-1` and `usa-los-1` desired mesh-listener configs have this flag enabled.
|
||||
- C18F production-forwarding gate work is partially live: backend
|
||||
`rap-backend:test-vpn-fabric-route-0.2.42` signs node synthetic configs with
|
||||
`production_forwarding=true` / `control_plane_only=false` when the node's
|
||||
desired `fabric-listener` workload has `production_forwarding_enabled=true`.
|
||||
`home-1` and `usa-los-1` desired fabric-listener configs have this flag enabled.
|
||||
Node-agent `0.2.44` accepts signed production-forwarding mesh configs and
|
||||
host-agent `0.2.44` fixes Docker updater behavior so synthetic mesh runtime is
|
||||
not disabled on Docker updates. Runtime status: `usa-los-1` reports
|
||||
`mesh_production_forwarding=true`; `home-1` reports `0.2.44` and synthetic
|
||||
runtime enabled, but its listener report is still `disabled/listen_addr_empty`,
|
||||
so `home-1` is not yet a usable production fabric endpoint. Next action is to
|
||||
repair why `home-1` is not applying the signed mesh-listener config
|
||||
(`listen_addr=0.0.0.0:19131`) after Docker updater restart.
|
||||
runtime enabled, but its listener report is still `disabled/listen_addr_empty`,
|
||||
so `home-1` is not yet a usable production fabric endpoint. Next action is to
|
||||
repair why `home-1` is not applying the signed fabric-listener config
|
||||
(`listen_addr=0.0.0.0:19131`) after Docker updater restart.
|
||||
- C18G VPN-over-fabric runtime path is live-tested on docker-test. Backend is
|
||||
deployed as `rap-backend:test-vpn-fabric-route-0.2.43`; VPN route intents now
|
||||
allow both `vpn_packet` data and `fabric_control` health probes. Node-agent
|
||||
@@ -923,7 +922,7 @@ Current implementation focus remains:
|
||||
`route_rebuild_recommended`, `degraded_fallback_recommended`, or repeated
|
||||
consecutive failures. Fenced routes are not selected as primary or alternate;
|
||||
if all selected entry/exit routes are fenced, the lease uses explicit
|
||||
degraded backend fallback with reason
|
||||
degraded compat fallback with reason
|
||||
`fabric_routes_fenced_by_service_channel_feedback`. Live smoke created two
|
||||
short-lived `test-1 -> test-2` route intents, injected a fresh
|
||||
service-channel flow feedback heartbeat marking the higher-priority route as
|
||||
@@ -1122,7 +1121,7 @@ Current implementation focus remains:
|
||||
smoke-passed on 2026-05-07. Node-agent/host-agent `0.2.182` artifacts,
|
||||
Docker image `rap-node-agent:0.2.182`, release manifests, and update
|
||||
policies are published. Backend `rap-backend:fabric-service-channel-0.2.182`
|
||||
is deployed on docker-test. The runtime fix is a dynamic mesh listener
|
||||
is deployed on docker-test. The runtime fix is a dynamic fabric listener
|
||||
handler: synthetic config refreshes now update `/mesh/v1/forward`,
|
||||
service-channel ingress, production routes, delivery inbox, and forward
|
||||
transport without requiring a port/listener restart. Backend route-feedback
|
||||
@@ -1157,7 +1156,7 @@ Current implementation focus remains:
|
||||
`rolling` target `0.2.183`, and the test containers run that image. The
|
||||
runtime fix makes the entry node honor the signed service-channel lease
|
||||
authority: leases with `status=degraded_fallback` or
|
||||
`primary_route.status=missing_route_intent` now force backend fallback instead
|
||||
`primary_route.status=missing_route_intent` now force compat fallback instead
|
||||
of reusing stale generic route candidates. The same fallback rule is applied
|
||||
to HTTP and WebSocket packet ingress. Script
|
||||
`scripts/fabric/c18z3-live-service-channel-entry-ws-fallback-smoke.ps1`
|
||||
@@ -1167,7 +1166,7 @@ Current implementation focus remains:
|
||||
expiry. Result:
|
||||
`artifacts/c18z3-live-service-channel-entry-ws-fallback-smoke-result.json`
|
||||
run `c18z3-20260507-211402`: warm `4/4`, WebSocket packets `8`, recovery
|
||||
`4/4`, backend fallback queue `0 -> 8`, route failures `0`, and all checks
|
||||
`4/4`, compat fallback queue `0 -> 8`, route failures `0`, and all checks
|
||||
passed. During publication the first `0.2.183` Docker tar had a malformed
|
||||
entrypoint and stale size/hash metadata; it was rebuilt, the latest tar alias
|
||||
was replaced, and the release artifact row was corrected to sha256
|
||||
@@ -1182,7 +1181,7 @@ Current implementation focus remains:
|
||||
refresh, and verifies the remaining packets use the alternate route. Result:
|
||||
`artifacts/c18z4-live-service-channel-session-pressure-smoke-result.json`
|
||||
run `c18z4-20260507-212748`: exit inbox depth `0 -> 384`, route failure delta
|
||||
`0`, flow drop delta `0`, backend fallback queue `0 -> 0`, primary route
|
||||
`0`, flow drop delta `0`, compat fallback queue `0 -> 0`, primary route
|
||||
removed from entry/exit configs, alternate route selected after the switch,
|
||||
and both route intents expired. This proves the shared Fabric Service Channel
|
||||
can keep a service session alive while Control Plane changes the live route
|
||||
@@ -1196,7 +1195,7 @@ Current implementation focus remains:
|
||||
traffic over the same WebSocket. Result:
|
||||
`artifacts/c18z5-live-service-channel-exit-restart-smoke-result.json` run
|
||||
`c18z5-20260507-213745`: pre/outage/recovery batches `12/24/24`, total
|
||||
packets `480`, route failure delta `48`, backend fallback queue `0 -> 192`,
|
||||
packets `480`, route failure delta `48`, compat fallback queue `0 -> 192`,
|
||||
flow drop delta `0`, and recovery exit inbox `0 -> 192`. This proves real
|
||||
exit-node failure is visible as fallback/failure telemetry while the
|
||||
long-lived service channel remains usable and fabric delivery resumes after
|
||||
@@ -1215,7 +1214,7 @@ Current implementation focus remains:
|
||||
`c18z6-20260507-214900`: pre/post batches `16/32`, total packets `384`,
|
||||
exit inbox depth `0 -> 384`, Control Plane replacement route
|
||||
`b2f3c510-46d2-4dce-8389-3952a99d0311`, route failure delta `0`, flow drop
|
||||
delta `0`, backend fallback queue `0 -> 0`, all checks passed, and all
|
||||
delta `0`, compat fallback queue `0 -> 0`, all checks passed, and all
|
||||
active nodes remained healthy/current on `0.2.183`. This proves a live
|
||||
service channel can apply a route-manager rebuild decision without rebuilding
|
||||
the service WebSocket.
|
||||
@@ -1230,7 +1229,7 @@ Current implementation focus remains:
|
||||
`artifacts/c18z7-live-service-channel-concurrent-isolation-smoke-result.json`
|
||||
run `c18z7-20260507-215727`: 3 sessions, 36 rounds, 288 packets per session,
|
||||
864 packets total, each session exit inbox depth `288`, total exit depth
|
||||
`864`, backend fallback delta `0`, route failure delta `0`, flow drop delta
|
||||
`864`, compat fallback delta `0`, route failure delta `0`, flow drop delta
|
||||
`0`, and all active nodes healthy/current on `0.2.183`. This proves rebuild
|
||||
and route-manager state are shared correctly without one active service
|
||||
session starving or poisoning the other concurrent sessions.
|
||||
@@ -1246,7 +1245,7 @@ Current implementation focus remains:
|
||||
run `c18z8-20260507-221347`: both interactive sessions delivered 192 packets
|
||||
each, the abusive flow reached scheduler high watermark `1024`, scheduled
|
||||
`1030` packets on the hottest channel, dropped `282` packets on that channel,
|
||||
produced backend fallback delta `0`, route failure delta `0`, and all active
|
||||
produced compat fallback delta `0`, route failure delta `0`, and all active
|
||||
nodes stayed healthy/current on `0.2.183`. This proves bounded backpressure is
|
||||
visible and isolated to the overloaded logical flow without starving other
|
||||
active service sessions.
|
||||
@@ -1267,7 +1266,7 @@ Current implementation focus remains:
|
||||
direct replacement. Result:
|
||||
`artifacts/c18z9-live-service-channel-route-pool-smoke-result.json` run
|
||||
`c18z9-20260507-224901`: 54 batches / 432 packets sent and delivered to exit,
|
||||
backend fallback delta `0`, route failure delta `0`, flow drop delta `0`, and
|
||||
compat fallback delta `0`, route failure delta `0`, flow drop delta `0`, and
|
||||
temporary route intents expired. Test containers `test-1/2/3` run
|
||||
`rap-node-agent:0.2.184`; `usa-los-1`, `home-1`, and
|
||||
`ifcm-rufms-s-mo1cr` remain healthy on `0.2.183` until their rollout policy is
|
||||
@@ -1292,7 +1291,7 @@ Current implementation focus remains:
|
||||
post-rebuild traffic reaches the alternate exit. Result:
|
||||
`artifacts/c18z10-live-service-channel-exit-pool-smoke-result.json` run
|
||||
`c18z10-20260507-232645`: 54 batches / 432 packets sent, primary exit queue
|
||||
`144`, alternate exit queue `288`, backend fallback `0`, route failure delta
|
||||
`144`, alternate exit queue `288`, compat fallback `0`, route failure delta
|
||||
`0`, flow drop delta `0`, decision source
|
||||
`service_channel_feedback_exit_pool_replacement`, and temporary route intents
|
||||
expired. Backend and `test-1/2/3` are running `0.2.185`; update plans now
|
||||
@@ -1317,7 +1316,7 @@ Current implementation focus remains:
|
||||
verifies a refreshed lease selects `test-3`, then sends 288 more packets
|
||||
through the alternate entry to the same exit. Result:
|
||||
`artifacts/c18z11-live-service-channel-entry-pool-smoke-result.json` run
|
||||
`c18z11-20260507-235341`: exit queue `432`, backend fallback `0`, route
|
||||
`c18z11-20260507-235341`: exit queue `432`, compat fallback `0`, route
|
||||
failure deltas `0/0`, flow drop deltas `0/0`, and temporary route intents
|
||||
expired. This is a lease refresh/reconnect contract for entry replacement;
|
||||
preserving a broken client-to-entry socket across an entry node outage is not
|
||||
@@ -1355,7 +1354,7 @@ Current implementation focus remains:
|
||||
`score_adjustment=90`), and a refreshed lease prefers that fast route over a
|
||||
newly introduced higher-priority relay candidate. Result:
|
||||
`artifacts/c18z13-live-service-channel-route-quality-smoke-result.json` run
|
||||
`c18z13-20260508-001610`; backend fallback `0`, flow drops `0`, temporary
|
||||
`c18z13-20260508-001610`; compat fallback `0`, flow drops `0`, temporary
|
||||
route intents expired. Published release id:
|
||||
`64effc62-18b6-4eeb-a1c9-f5fb8e251491`.
|
||||
- C18Z14 active-session route-quality preference is implemented. Backend
|
||||
@@ -1431,7 +1430,7 @@ Current implementation focus remains:
|
||||
`artifacts/c18z17-live-service-channel-quality-cleanup-smoke-result.json`
|
||||
run `c18z14-20260508-075750`; 60 batches / 480 packets delivered, active
|
||||
quality markers `32`, stale quality markers `0`, visible preferences `3`,
|
||||
backend fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
compat fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
- C18Z18 service-session-scoped flow scheduler memory is implemented.
|
||||
Node-agent `0.2.193` is built, published to docker-test downloads,
|
||||
registered in the stable update channel, and deployed to `test-1/2/3`;
|
||||
@@ -1448,12 +1447,12 @@ Current implementation focus remains:
|
||||
`scripts/fabric/c18z18-service-channel-session-scoped-fairness-smoke.ps1`
|
||||
wraps the live C18Z17 quality path and verifies served live channels are
|
||||
session-scoped, unscoped served `flow-NN` channels are absent, quality
|
||||
markers are session-scoped, backend fallback is `0`, and flow drops are `0`.
|
||||
markers are session-scoped, compat fallback is `0`, and flow drops are `0`.
|
||||
Result:
|
||||
`artifacts/c18z18-service-channel-session-scoped-fairness-smoke-result.json`
|
||||
run `c18z14-20260508-082520`; 60 batches / 480 packets delivered, served
|
||||
channels `32`, session-scoped served channels `32`, session-scoped quality
|
||||
channels `32`, unscoped served channels `0`, backend fallback `0`, flow drops
|
||||
channels `32`, unscoped served channels `0`, compat fallback `0`, flow drops
|
||||
`0`, temporary route intents expired.
|
||||
- C18Z19 bounded parallel logical-flow send window is implemented. Node-agent
|
||||
`0.2.194` is built, published to docker-test downloads, registered in the
|
||||
@@ -1469,12 +1468,12 @@ Current implementation focus remains:
|
||||
Live script
|
||||
`scripts/fabric/c18z19-service-channel-parallel-flow-window-smoke.ps1` wraps
|
||||
the C18Z18 live route-quality/session-scoped path and verifies the parallel
|
||||
window is enabled and observed while backend fallback and flow drops stay at
|
||||
window is enabled and observed while compat fallback and flow drops stay at
|
||||
zero. Result:
|
||||
`artifacts/c18z19-service-channel-parallel-flow-window-smoke-result.json`
|
||||
run `c18z14-20260508-084133`; 60 batches / 480 packets delivered,
|
||||
`max_parallel_flow_sends=4`, `send_flow_parallel_batches=60`, served
|
||||
channels `32`, session-scoped quality channels `32`, backend fallback `0`,
|
||||
channels `32`, session-scoped quality channels `32`, compat fallback `0`,
|
||||
flow drops `0`, temporary route intents expired.
|
||||
- C18Z20 per-channel latency/retry/in-flight telemetry and adaptive recommended
|
||||
send-window telemetry are implemented. Node-agent `0.2.195` is built,
|
||||
@@ -1498,7 +1497,7 @@ Current implementation focus remains:
|
||||
run `c18z14-20260508-085635`; 60 batches / 480 packets delivered,
|
||||
`max_parallel_flow_sends=4`, `recommended_parallel_flow_sends=4`,
|
||||
`scheduler_max_in_flight=4`, attempts/success/latency visible on 32 channels,
|
||||
backend fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
compat fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
- C18Z21 rolling per-channel/session quality windows are implemented.
|
||||
Node-agent `0.2.196` is built, published to docker-test downloads,
|
||||
registered in the stable update channel, and deployed to `test-1/2/3`;
|
||||
@@ -1521,7 +1520,7 @@ Current implementation focus remains:
|
||||
run `c18z14-20260508-091952`; 60 batches / 480 packets delivered,
|
||||
scheduler quality-window samples `480`, failures `0`, drops `0`, window
|
||||
samples/success/latency visible on 32 channels, `recommended_parallel_flow_sends=4`,
|
||||
backend fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
compat fallback `0`, flow drops `0`, temporary route intents expired.
|
||||
- C18Z22 backend durable route feedback now consumes the rolling quality
|
||||
window from node-agent heartbeat metadata. Backend
|
||||
`rap-backend:fabric-service-channel-0.2.197` is built and deployed on
|
||||
@@ -1542,7 +1541,7 @@ Current implementation focus remains:
|
||||
`artifacts/c18z22-service-channel-rolling-feedback-smoke-result.json` run
|
||||
`c18z14-20260508-093100`; 60 batches / 480 packets delivered, route feedback
|
||||
count `1`, rolling feedback count `1`, healthy rolling feedback count `1`,
|
||||
rolling payload count `1`, backend fallback `0`, flow drops `0`.
|
||||
rolling payload count `1`, compat fallback `0`, flow drops `0`.
|
||||
- C18Z23 recovery hysteresis is implemented for recovered service-channel
|
||||
routes. Backend `rap-backend:fabric-service-channel-0.2.198` is built and
|
||||
deployed on docker-test; node-agent remains `0.2.196` on `test-1/2/3`.
|
||||
@@ -1845,7 +1844,7 @@ Current implementation focus remains:
|
||||
update. When a cluster authority public key is pinned, the node-agent now
|
||||
rejects unsigned `rap_fsc_*` service-channel requests and requires the
|
||||
signed `rap.fabric_service_channel_lease_authority.v1` payload/signature
|
||||
headers. Legacy unsigned tokens remain accepted only in unpinned test mode.
|
||||
headers. Compat-unsigned tokens remain accepted only in unpinned test mode.
|
||||
Live smoke proved unsigned POST is rejected with 403 while signed lease POST
|
||||
is accepted with 202:
|
||||
`artifacts/c18z47-service-channel-signed-lease-enforcement-smoke-result.json`.
|
||||
@@ -1860,7 +1859,7 @@ Current implementation focus remains:
|
||||
`artifacts/c18z48-service-channel-introspection-smoke-result.json`.
|
||||
- C18Z49 service-channel acceptance telemetry is implemented in node-agent
|
||||
`0.2.232`. Each accepted Fabric Service Channel ingress records
|
||||
`accepted_by=signed|introspection|legacy_unsigned`, route preference, and
|
||||
`accepted_by=signed|introspection|compat_unsigned`, route preference, and
|
||||
backend-fallback state in structured node logs. HTTP packet ingress also
|
||||
returns `X-RAP-Service-Channel-Accepted-By` for smoke/diagnostics.
|
||||
- C18Z50 durable service-channel lease introspection is implemented. Migration
|
||||
@@ -1889,7 +1888,7 @@ Current implementation focus remains:
|
||||
docker-test; node-agent/host-agent `0.2.235` artifacts are published under
|
||||
`/downloads`, registered as active dev releases, and deployed on
|
||||
`test-1/2/3`. Node-agent now reports accepted service-channel ingress
|
||||
counters by `signed`, `introspection`, and `legacy_unsigned`, including
|
||||
counters by `signed`, `introspection`, and `compat_unsigned`, including
|
||||
backend-fallback count and last accepted timestamp. Backend exposes
|
||||
`GET /clusters/{clusterID}/fabric/service-channels/access-telemetry`,
|
||||
reading telemetry observations with heartbeat metadata fallback. Web-admin
|
||||
@@ -1906,7 +1905,7 @@ Current implementation focus remains:
|
||||
fallback, and latest route-quality feedback when a route exists. Web-admin's
|
||||
`Service-channel access` panel now shows active channel rows before per-node
|
||||
counters, so operators can see whether a live service channel is using normal
|
||||
route quality feedback or degraded backend fallback. Live smoke created an
|
||||
route quality feedback or degraded compat fallback. Live smoke created an
|
||||
active lease, sent ingress traffic through test-1, and verified active
|
||||
channel correlation plus fallback visibility:
|
||||
`artifacts/c18z53-service-channel-access-correlation-smoke-result.json`.
|
||||
@@ -1915,14 +1914,14 @@ Current implementation focus remains:
|
||||
`vpn_packets` route intent, injects healthy route-quality heartbeat
|
||||
telemetry, issues a service-channel lease that selects the normal primary
|
||||
route, sends ingress traffic, and verifies the access telemetry active
|
||||
channel row is `ready`, not backend fallback, with `route_feedback_status`
|
||||
channel row is `ready`, not compat fallback, with `route_feedback_status`
|
||||
`healthy`, rolling quality counters, and last send duration:
|
||||
`artifacts/c18z54-service-channel-normal-route-access-smoke-result.json`.
|
||||
- C18Z55 degraded normal-route access correlation is smoke-proven on the same
|
||||
backend/admin surface. The smoke first issues a lease on a normal primary
|
||||
`vpn_packets` route, then injects degraded/fenced route-quality heartbeat
|
||||
feedback for that already-selected route. Access telemetry correctly reports
|
||||
the active channel as `ready` and `force_backend_fallback=false`, while route
|
||||
the active channel as `ready` and `force_compat_fallback=false`, while route
|
||||
feedback is `fenced`, rolling failure/drop/slow counters are visible, and the
|
||||
aggregate access status becomes `degraded` because `degraded_route_count > 0`:
|
||||
`artifacts/c18z55-service-channel-degraded-route-access-smoke-result.json`.
|
||||
@@ -1931,7 +1930,7 @@ Current implementation focus remains:
|
||||
docker-test; node-agent remains `0.2.235`. Active access telemetry channel
|
||||
rows now include `remediation_action`, `remediation_reason`,
|
||||
`remediation_route_id`, `remediation_route_status`, and an operator hint.
|
||||
Decisions distinguish explicit backend fallback, degraded/fenced normal
|
||||
Decisions distinguish explicit compat fallback, degraded/fenced normal
|
||||
route with an authorized alternate (`prefer_alternate_route`), degraded/fenced
|
||||
route needing rebuild (`rebuild_route`), and healthy route (`none`).
|
||||
Web-admin shows the remediation action in the `Service-channel access`
|
||||
@@ -1942,7 +1941,7 @@ Current implementation focus remains:
|
||||
creates primary and authorized alternate `vpn_packets` routes, issues a lease
|
||||
while primary is still healthy/selected, then injects fenced feedback for the
|
||||
selected primary. Access telemetry keeps the active channel on the normal
|
||||
route with `force_backend_fallback=false`, reports `route_feedback_status`
|
||||
route with `force_compat_fallback=false`, reports `route_feedback_status`
|
||||
`fenced`, and recommends `remediation_action=prefer_alternate_route` with the
|
||||
alternate route id/status; `degraded_fallback_channel_count` stays zero:
|
||||
`artifacts/c18z56-service-channel-alternate-remediation-smoke-result.json`.
|
||||
@@ -1976,14 +1975,14 @@ Current implementation focus remains:
|
||||
remediation command is consumed, then verifies runtime heartbeat evidence:
|
||||
`last_selected_route_id` and flow-scheduler `last_route_id` move to the
|
||||
replacement route, `send_successes=1`, `send_failures=0`,
|
||||
`send_fallback_local=0`, and no degraded backend fallback is recommended.
|
||||
`send_fallback_local=0`, and no degraded compat fallback is recommended.
|
||||
Result:
|
||||
`artifacts/c18z59-service-channel-remediation-traffic-smoke-result.json`.
|
||||
- C18Z60 multi-flow remediation traffic proof is smoke-proven. The smoke sends
|
||||
a batch of twelve IPv4/TCP-like packets that classify into multiple
|
||||
independent VPN flow channels after the remediation command is consumed.
|
||||
Runtime heartbeat evidence shows the replacement route selected, at least two
|
||||
flow-scheduler channels on that route, no local/backend fallback, no flow
|
||||
flow-scheduler channels on that route, no local/compat fallback, no flow
|
||||
drops, and no route send failures. Result:
|
||||
`artifacts/c18z60-service-channel-remediation-multiflow-smoke-result.json`.
|
||||
- C18Z61 pressure remediation traffic proof is smoke-proven. The smoke sends a
|
||||
@@ -2000,7 +1999,7 @@ Current implementation focus remains:
|
||||
old default bulk channel ids. Unit tests prove priority ordering
|
||||
`control > interactive > reliable > bulk > droppable`; live smoke proves a
|
||||
bulk 128-packet pressure batch plus an interactive packet both move through
|
||||
the remediation replacement route with no local/backend fallback, drops, or
|
||||
the remediation replacement route with no local/compat fallback, drops, or
|
||||
route failures. Result:
|
||||
`artifacts/c18z62-service-channel-remediation-qos-smoke-result.json`.
|
||||
- C18Z63 concurrent QoS isolation is implemented and unit-proven. A controlled
|
||||
@@ -2046,7 +2045,7 @@ Current implementation focus remains:
|
||||
remediation. Run `c18z67-20260508-213452` accepted all 6 bulk requests,
|
||||
forwarded 3072 post-remediation packets, completed the interactive request in
|
||||
132 ms, observed 32 bulk and 12 interactive replacement-route flow stats, and
|
||||
kept local/backend fallback, route failures, flow drops, and scheduler drops
|
||||
kept local/compat fallback, route failures, flow drops, and scheduler drops
|
||||
at 0. Artifact:
|
||||
`artifacts/c18z67-service-channel-concurrent-qos-live-smoke-result.json`.
|
||||
- C18Z68 service-channel flow-health guard is implemented and deployed on
|
||||
@@ -2054,7 +2053,7 @@ Current implementation focus remains:
|
||||
web-admin rebuilt/deployed. Access telemetry now projects
|
||||
`flow_health_status` and `flow_health_reason` at cluster, node, and
|
||||
active-channel levels from traffic-class counts, queue pressure, flow drops,
|
||||
backend fallback, route-quality failures/drops/slow samples, and route send
|
||||
compat fallback, route-quality failures/drops/slow samples, and route send
|
||||
latency. Web-admin shows explicit flow-health chips beside flow QoS so
|
||||
sustained bulk pressure, degraded latency, fallback, and drops are visible
|
||||
before adding user services. Verification passed:
|
||||
@@ -2114,7 +2113,7 @@ Current implementation focus remains:
|
||||
`rap-node-agent:0.2.245-c18z71` on `test-1/2/3`. Backend exposes audited
|
||||
`GET/PUT /clusters/{clusterID}/fabric/service-channels/pool-policy` for
|
||||
entry/exit pool constraints, preferred entry/exit, selection strategy,
|
||||
route/entry/exit failover modes, backend fallback allowance, and sticky
|
||||
route/entry/exit failover modes, compat fallback allowance, and sticky
|
||||
session mode. Lease issuance now applies the effective policy before route
|
||||
selection, constrains `entry_pool`/`exit_pool`, chooses policy preferred
|
||||
nodes when present, embeds `pool_policy` provenance in the lease, and signs
|
||||
@@ -2258,7 +2257,7 @@ Current implementation focus remains:
|
||||
`rebuild_route` command to `applied` / `replacement_selected`, the entry node
|
||||
reports a route-manager decision for the same `rebuild_request_id`, reports
|
||||
transition `applied_rebuild`, and live service-channel packet ingress selects
|
||||
the replacement route with no local/backend fallback, route failures, or flow
|
||||
the replacement route with no local/compat fallback, route failures, or flow
|
||||
drops. Verification passed:
|
||||
`go test ./internal/modules/cluster ./internal/platform/runtime ./internal/modules/nodeagent`,
|
||||
`go test ./cmd/rap-node-agent ./internal/agent ./internal/mesh ./internal/vpnruntime ./internal/config`,
|
||||
@@ -2455,12 +2454,12 @@ Current implementation focus remains:
|
||||
If a signed data-plane contract has `backend_relay_policy=disabled`, the
|
||||
service-channel runtime no longer proxies failed/missing fabric-route working
|
||||
data through backend relay; it returns a visible service unavailable result.
|
||||
The live smoke temporarily disables backend fallback in pool policy, issues a
|
||||
The live smoke temporarily disables compat fallback in pool policy, issues a
|
||||
no-route lease, verifies `backend_relay_policy=disabled`, posts to test-1,
|
||||
and proves the node rejects with 503 instead of backend relay. Verification
|
||||
passed: node-agent tests, C18Z92 live smoke, and C18Z91 regression smoke.
|
||||
Artifact:
|
||||
`artifacts/c18z92-node-agent-disabled-backend-fallback-smoke-result.json`.
|
||||
`artifacts/c18z92-node-agent-disabled-compat-fallback-smoke-result.json`.
|
||||
- C18Z93 access-telemetry data-plane projection is implemented and deployed on
|
||||
docker-test as `rap-backend:fabric-service-channel-0.2.268-c18z93`;
|
||||
node-agent remains `rap-node-agent:0.2.267-c18z92` on `test-1/2/3`, and
|
||||
@@ -2493,7 +2492,7 @@ Current implementation focus remains:
|
||||
docker-test as backend `rap-backend:fabric-service-channel-0.2.270-c18z95`
|
||||
and node-agent `rap-node-agent:0.2.270-c18z95` on `test-1/2/3`; web-admin is
|
||||
rebuilt/deployed to `rap_web_admin`. Node-agent now reports
|
||||
`backend_fallback_blocked`, `fabric_route_send_failure`, and last data-plane
|
||||
`compat_fallback_blocked`, `fabric_route_send_failure`, and last data-plane
|
||||
violation status/reason in `fabric_service_channel_access_report`. Backend
|
||||
access telemetry projects those fields to cluster, node, and active-channel
|
||||
rows, and `data_plane_contract` incidents distinguish policy-blocked fallback
|
||||
@@ -2505,7 +2504,7 @@ Current implementation focus remains:
|
||||
docker-test as backend `rap-backend:fabric-service-channel-0.2.281-c18z109`;
|
||||
node-agent remains `rap-node-agent:0.2.270-c18z95` on `test-1/2/3`, and
|
||||
web-admin remains deployed. Backend now converts heartbeat access reports
|
||||
with `fabric_route_send_failed_backend_fallback_blocked` into durable fenced
|
||||
with `fabric_route_send_failed_compat_fallback_blocked` into durable fenced
|
||||
`fabric_service_channel_route_feedback` for the active channel primary route.
|
||||
The existing route rebuild planner then selects an authorized replacement
|
||||
route when one exists. Verification passed: backend tests, node-agent tests,
|
||||
@@ -5402,3 +5401,4 @@ The current phase is NOT:
|
||||
Future mesh, VPN, multi-cluster, node-agent updater, and production realtime data-plane work must be introduced only through explicit, narrow, staged implementation prompts.
|
||||
|
||||
Always keep the project production-oriented. Do not simplify it into a toy app.
|
||||
|
||||
|
||||
@@ -124,9 +124,21 @@ SDK (флаг `-InstallMissing`) и соберет APK. После этого а
|
||||
9. `docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md`
|
||||
10. `docs/codex/NEXT_STEP_PROMPT.md`
|
||||
|
||||
Do not use `docs/_legacy_v1` for implementation decisions. Legacy files are
|
||||
Do not use `docs/_archive_v1` for implementation decisions. Archived files are
|
||||
historical reference only.
|
||||
|
||||
## Fabric Standard Boundary Check
|
||||
|
||||
When changing farm/runtime/update code, run the fabric standard audit before
|
||||
finishing:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File scripts\check-fabric-standard-boundary.ps1
|
||||
```
|
||||
|
||||
It verifies that removed pre-fabric keys and updater flags do not leak back
|
||||
into the repository.
|
||||
|
||||
## Current Next Step
|
||||
|
||||
RDP work is paused. Platform-core stages C1-C9 are implemented and verified:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Fri May 01 13:10:35 MSK 2026
|
||||
gradle.version=9.5.0
|
||||
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
# RAP Android VPN
|
||||
|
||||
This is the Android client for the experimental RAP VPN service.
|
||||
|
||||
Implemented now:
|
||||
|
||||
- login through `/auth/login`;
|
||||
- trusted-device reconnect through `/auth/refresh` without retyping the password
|
||||
while the device session is valid;
|
||||
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
|
||||
- request Android VPN permission and create a `VpnService` TUN interface;
|
||||
- relay TUN packets through the Control Plane HTTP packet relay to the active
|
||||
`home-1` gateway lease.
|
||||
- user-facing HOME-first screen: connect/disconnect is primary, while backend,
|
||||
cluster, organization, login, and password are kept in the settings dialog;
|
||||
- saved connection settings in app preferences so repeat connects do not require
|
||||
retyping the profile.
|
||||
- encrypted refresh-token storage through Android Keystore. If the trusted
|
||||
device session is revoked or expires, the app asks for the password once and
|
||||
then rotates the device keys/profile again.
|
||||
|
||||
This is still a lab runtime, not a production WireGuard/IPsec implementation.
|
||||
The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`,
|
||||
`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`.
|
||||
|
||||
Build from this repository on Windows:
|
||||
|
||||
```powershell
|
||||
$env:ANDROID_HOME="C:\Android\Sdk"
|
||||
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
|
||||
pwsh -ExecutionPolicy Bypass -File ..\..\scripts\android\build-android-apk.ps1
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Or run directly from the project:
|
||||
|
||||
```powershell
|
||||
$env:ANDROID_HOME="C:\Android\Sdk"
|
||||
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
|
||||
gradle assembleDebug
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "su.cin.rapvpn"
|
||||
compileSdk 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 64
|
||||
versionName "0.2.64"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="RAP VPN"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".RdpActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TestVpnActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".TestTrafficActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<service
|
||||
android:name=".RapVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RapDiagnosticService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn-diagnostics" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,650 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
private static final int VPN_PREPARE_REQUEST = 42;
|
||||
private static final String PREFS = "rap-vpn";
|
||||
private static final String PREF_DEVICE_FINGERPRINT = "device_fingerprint";
|
||||
private static final String PREF_REFRESH_TOKEN = "refresh_token";
|
||||
private static final String PREF_REFRESH_EXPIRES_AT = "refresh_expires_at";
|
||||
private static final String PREF_USER_ID = "user_id";
|
||||
private static final String PREF_DEVICE_ID = "device_id";
|
||||
private static final String PREF_PROFILE_JSON = "profile_json";
|
||||
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private EditText backendUrl;
|
||||
private EditText clusterId;
|
||||
private EditText organizationId;
|
||||
private EditText email;
|
||||
private EditText password;
|
||||
private TextView status;
|
||||
private TextView profileSummary;
|
||||
private TextView serverDirectory;
|
||||
private TextView runtimeStatus;
|
||||
private String profileJson = "";
|
||||
private String vpnConnectionId = "";
|
||||
private JSONArray lastResources = new JSONArray();
|
||||
private RapApiClient.AuthContext authContext = null;
|
||||
private SharedPreferences prefs;
|
||||
private SharedPreferences runtimePrefs;
|
||||
private SecureTokenStore secureTokens;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
runtimePrefs = getSharedPreferences("rap-vpn-runtime", MODE_PRIVATE);
|
||||
secureTokens = new SecureTokenStore(this);
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(0xff101820);
|
||||
int pad = dp(20);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
|
||||
backendUrl = field("Backend URL", preferredBackendUrl());
|
||||
clusterId = field("Cluster ID", prefs.getString("cluster_id", "cfc0743d-d960-49fb-9de8-96e063d5e4aa"));
|
||||
organizationId = field("Organization ID", prefs.getString("organization_id", "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"));
|
||||
email = field("Email", prefs.getString("email", "m"));
|
||||
password = field("Password", "");
|
||||
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
profileJson = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
restoreAuthContext();
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("RAP HOME VPN " + APP_VERSION);
|
||||
title.setTextColor(0xffffffff);
|
||||
title.setTextSize(26);
|
||||
title.setPadding(0, 0, 0, dp(8));
|
||||
|
||||
profileSummary = new TextView(this);
|
||||
profileSummary.setTextColor(0xffc8d6df);
|
||||
profileSummary.setTextSize(14);
|
||||
profileSummary.setText(summaryText());
|
||||
|
||||
serverDirectory = new TextView(this);
|
||||
serverDirectory.setTextColor(0xffe8eef2);
|
||||
serverDirectory.setTextSize(15);
|
||||
serverDirectory.setPadding(0, dp(14), 0, dp(14));
|
||||
serverDirectory.setText("");
|
||||
|
||||
status = new TextView(this);
|
||||
status.setTextColor(0xffd8eadf);
|
||||
status.setPadding(0, dp(14), 0, dp(14));
|
||||
status.setText("Готово. Версия " + APP_VERSION + ".");
|
||||
|
||||
runtimeStatus = new TextView(this);
|
||||
runtimeStatus.setTextColor(0xff9fb6c2);
|
||||
runtimeStatus.setTextSize(13);
|
||||
runtimeStatus.setPadding(0, 0, 0, dp(10));
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
|
||||
Button load = new Button(this);
|
||||
load.setText("Войти / обновить профиль");
|
||||
load.setOnClickListener(v -> loadProfile(false));
|
||||
|
||||
Button start = new Button(this);
|
||||
start.setText("Включить HOME VPN");
|
||||
start.setOnClickListener(v -> prepareVpn());
|
||||
|
||||
Button stop = new Button(this);
|
||||
stop.setText("Отключить VPN");
|
||||
stop.setOnClickListener(v -> {
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
startService(stopIntent);
|
||||
status.setText("VPN остановлен.");
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
});
|
||||
|
||||
Button settings = new Button(this);
|
||||
settings.setText("Настройки");
|
||||
settings.setOnClickListener(v -> showSettingsDialog());
|
||||
|
||||
Button servers = new Button(this);
|
||||
servers.setText("Открыть удаленный сервер");
|
||||
servers.setOnClickListener(v -> showServerPicker());
|
||||
|
||||
root.addView(title);
|
||||
root.addView(profileSummary);
|
||||
root.addView(load);
|
||||
root.addView(servers);
|
||||
root.addView(start);
|
||||
root.addView(stop);
|
||||
root.addView(settings);
|
||||
root.addView(status);
|
||||
root.addView(runtimeStatus);
|
||||
setContentView(root);
|
||||
scheduleRuntimeStatusRefresh();
|
||||
if (authContext != null && !authContext.deviceId.isEmpty()) {
|
||||
startDiagnosticChannel();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private EditText field(String hint, String value) {
|
||||
EditText input = new EditText(this);
|
||||
input.setHint(hint);
|
||||
input.setText(value);
|
||||
input.setSingleLine(true);
|
||||
return input;
|
||||
}
|
||||
|
||||
private void loadProfile() {
|
||||
loadProfile(false);
|
||||
}
|
||||
|
||||
private void loadProfile(boolean startAfterLoad) {
|
||||
status.setText("Загрузка...");
|
||||
saveSettings();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
|
||||
authContext = authenticate(client);
|
||||
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
|
||||
profileJson = client.vpnClientProfile(clusterId.getText().toString(), activeOrganizationId, authContext.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
vpnConnectionId = firstConnectionId(profileJson);
|
||||
saveProfileState();
|
||||
JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId);
|
||||
lastResources = resourcePayload.optJSONArray("resources");
|
||||
if (lastResources == null) {
|
||||
lastResources = new JSONArray();
|
||||
}
|
||||
String resourcesText = resourcesText(resourcePayload);
|
||||
runOnUiThread(() -> {
|
||||
profileSummary.setText(summaryText());
|
||||
serverDirectory.setText(resourcesText);
|
||||
status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены.");
|
||||
startDiagnosticChannel();
|
||||
if (startAfterLoad) {
|
||||
requestVpnPermission();
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> {
|
||||
String message = friendlyError(ex);
|
||||
status.setText("Ошибка: " + message);
|
||||
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
|
||||
clearSavedAuth(false);
|
||||
showSettingsDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void prepareVpn() {
|
||||
loadProfile(true);
|
||||
status.setText("Обновляю сессию устройства и VPN-профиль...");
|
||||
}
|
||||
|
||||
private void requestVpnPermission() {
|
||||
if (profileJson.isEmpty()) {
|
||||
status.setText("VPN-профиль не загружен.");
|
||||
return;
|
||||
}
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
if (prepare != null) {
|
||||
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
|
||||
return;
|
||||
}
|
||||
startVpn();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
|
||||
startVpn();
|
||||
}
|
||||
}
|
||||
|
||||
private void startVpn() {
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
|
||||
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl.getText().toString());
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
startForegroundService(intent);
|
||||
status.setText("VPN включен. Версия " + APP_VERSION + ". Backend: " + backendUrl.getText() + ". Connection: " + vpnConnectionId);
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
}
|
||||
|
||||
private void scheduleRuntimeStatusRefresh() {
|
||||
runtimeStatus.postDelayed(() -> {
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
scheduleRuntimeStatusRefresh();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
private String runtimeStatusText() {
|
||||
String state = runtimePrefs.getString("state", "нет данных");
|
||||
String message = runtimePrefs.getString("message", "");
|
||||
long updatedAt = runtimePrefs.getLong("updated_at", 0);
|
||||
long read = runtimePrefs.getLong("uplink_read", 0);
|
||||
long sent = runtimePrefs.getLong("uplink_sent", 0);
|
||||
long down = runtimePrefs.getLong("downlink_received", 0);
|
||||
long errors = runtimePrefs.getLong("errors", 0);
|
||||
long readBytes = runtimePrefs.getLong("uplink_read_bytes", 0);
|
||||
long sentBytes = runtimePrefs.getLong("uplink_sent_bytes", 0);
|
||||
long downBytes = runtimePrefs.getLong("downlink_received_bytes", 0);
|
||||
long droppedRead = runtimePrefs.getLong("uplink_dropped_packets", 0);
|
||||
long droppedDown = runtimePrefs.getLong("downlink_dropped_packets", 0);
|
||||
float uplinkReadMbps = runtimePrefs.getFloat("uplink_read_mbps", 0f);
|
||||
float uplinkSentMbps = runtimePrefs.getFloat("uplink_sent_mbps", 0f);
|
||||
float downlinkMbps = runtimePrefs.getFloat("downlink_received_mbps", 0f);
|
||||
float uplinkReadPps = runtimePrefs.getFloat("uplink_read_pps", 0f);
|
||||
float uplinkSentPps = runtimePrefs.getFloat("uplink_sent_pps", 0f);
|
||||
float downlinkPps = runtimePrefs.getFloat("downlink_received_pps", 0f);
|
||||
int workerCount = runtimePrefs.getInt("uplink_worker_count", 0);
|
||||
int queueDepthTotal = runtimePrefs.getInt("uplink_queue_depth_total", 0);
|
||||
int queueDepthMax = runtimePrefs.getInt("uplink_queue_depth_max", 0);
|
||||
String queueDepths = runtimePrefs.getString("uplink_queue_depths", "");
|
||||
long queue0Drops = runtimePrefs.getLong("uplink_queue_0_drops", 0);
|
||||
long queue1Drops = runtimePrefs.getLong("uplink_queue_1_drops", 0);
|
||||
long queue2Drops = runtimePrefs.getLong("uplink_queue_2_drops", 0);
|
||||
long queue3Drops = runtimePrefs.getLong("uplink_queue_3_drops", 0);
|
||||
long queue0Offers = runtimePrefs.getLong("uplink_queue_0_offers", 0);
|
||||
long queue1Offers = runtimePrefs.getLong("uplink_queue_1_offers", 0);
|
||||
long queue2Offers = runtimePrefs.getLong("uplink_queue_2_offers", 0);
|
||||
long queue3Offers = runtimePrefs.getLong("uplink_queue_3_offers", 0);
|
||||
long sender0Packets = runtimePrefs.getLong("uplink_sender_worker_packets_0", 0);
|
||||
long sender1Packets = runtimePrefs.getLong("uplink_sender_worker_packets_1", 0);
|
||||
long sender2Packets = runtimePrefs.getLong("uplink_sender_worker_packets_2", 0);
|
||||
long sender3Packets = runtimePrefs.getLong("uplink_sender_worker_packets_3", 0);
|
||||
long sender0Errors = runtimePrefs.getLong("uplink_sender_worker_errors_0", 0);
|
||||
long sender1Errors = runtimePrefs.getLong("uplink_sender_worker_errors_1", 0);
|
||||
long sender2Errors = runtimePrefs.getLong("uplink_sender_worker_errors_2", 0);
|
||||
long sender3Errors = runtimePrefs.getLong("uplink_sender_worker_errors_3", 0);
|
||||
String age = updatedAt <= 0 ? "никогда" : ((System.currentTimeMillis() - updatedAt) / 1000) + " сек назад";
|
||||
return "Диагностика: " + state
|
||||
+ "\n" + message
|
||||
+ "\nread/sent/down: " + read + "/" + sent + "/" + down
|
||||
+ "\nerrors/drops: " + errors + "/" + (droppedRead + droppedDown)
|
||||
+ "\nthroughput Mbps: up " + String.format(Locale.US, "%.2f", uplinkSentMbps)
|
||||
+ " / down " + String.format(Locale.US, "%.2f", downlinkMbps)
|
||||
+ "\npps: up " + String.format(Locale.US, "%.1f", uplinkSentPps)
|
||||
+ " / down " + String.format(Locale.US, "%.1f", downlinkPps)
|
||||
+ "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes
|
||||
+ "\nworkers: " + workerCount
|
||||
+ "\nqueue depth total/max: " + queueDepthTotal + " / " + queueDepthMax
|
||||
+ "\nqueue depths: " + (queueDepths.isEmpty() ? "-" : queueDepths)
|
||||
+ "\nqueue0 q/s: " + queue0Offers + "/" + queue0Drops
|
||||
+ " q1 " + queue1Offers + "/" + queue1Drops
|
||||
+ " q2 " + queue2Offers + "/" + queue2Drops
|
||||
+ " q3 " + queue3Offers + "/" + queue3Drops
|
||||
+ "\nsender pkt/err: w0 " + sender0Packets + "/" + sender0Errors
|
||||
+ " w1 " + sender1Packets + "/" + sender1Errors
|
||||
+ " w2 " + sender2Packets + "/" + sender2Errors
|
||||
+ " w3 " + sender3Packets + "/" + sender3Errors
|
||||
+ "\nобновлено: " + age;
|
||||
}
|
||||
|
||||
private void startDiagnosticChannel() {
|
||||
if (authContext == null || authContext.deviceId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
RapDiagnosticService.start(this);
|
||||
}
|
||||
|
||||
private String firstConnectionId(String profile) throws Exception {
|
||||
JSONObject root = new JSONObject(profile);
|
||||
JSONObject vpnProfile = root.getJSONObject("vpn_client_profile");
|
||||
JSONArray connections = vpnProfile.getJSONArray("connections");
|
||||
if (connections.length() == 0) {
|
||||
throw new IllegalStateException("VPN profile has no connections");
|
||||
}
|
||||
return connections.getJSONObject(0).getString("id");
|
||||
}
|
||||
|
||||
private String resourcesText(JSONObject payload) throws Exception {
|
||||
JSONArray resources = payload.optJSONArray("resources");
|
||||
if (resources == null || resources.length() == 0) {
|
||||
return "Серверы: доступных ресурсов нет.";
|
||||
}
|
||||
StringBuilder text = new StringBuilder("Серверы:\n");
|
||||
int limit = Math.min(resources.length(), 6);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
JSONObject resource = resources.getJSONObject(i);
|
||||
text.append("• ")
|
||||
.append(resource.optString("name", "server"))
|
||||
.append(" ")
|
||||
.append(resource.optString("protocol", "rdp"))
|
||||
.append(" ")
|
||||
.append(resource.optString("address", ""))
|
||||
.append('\n');
|
||||
}
|
||||
if (resources.length() > limit) {
|
||||
text.append("и еще ").append(resources.length() - limit).append("...");
|
||||
}
|
||||
return text.toString().trim();
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return (int) (value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
|
||||
private String summaryText() {
|
||||
String deviceId = prefs == null ? "" : prefs.getString(PREF_DEVICE_ID, "");
|
||||
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
|
||||
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
|
||||
: vpnConnectionId;
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nВход: usa-los-1"
|
||||
+ "\nОрганизация: HOME"
|
||||
+ "\nВыход: home-1"
|
||||
+ "\nBackend: " + backendUrl.getText()
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
|
||||
}
|
||||
|
||||
private String preferredBackendUrl() {
|
||||
String saved = prefs.getString("backend_url", DEFAULT_BACKEND_URL);
|
||||
String normalized = normalizeBackendUrl(saved);
|
||||
if (!normalized.equals(saved == null ? "" : saved.trim())) {
|
||||
prefs.edit().putString("backend_url", normalized).apply();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
|
||||
if (!normalizedBackend.equals(backendUrl.getText().toString().trim())) {
|
||||
backendUrl.setText(normalizedBackend);
|
||||
}
|
||||
prefs.edit()
|
||||
.putString("backend_url", normalizedBackend)
|
||||
.putString("cluster_id", clusterId.getText().toString())
|
||||
.putString("organization_id", organizationId.getText().toString())
|
||||
.putString("email", email.getText().toString())
|
||||
.apply();
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception {
|
||||
String savedRefresh = savedRefreshToken();
|
||||
if (!savedRefresh.isEmpty()) {
|
||||
try {
|
||||
RapApiClient.AuthContext refreshed = client.refresh(savedRefresh);
|
||||
saveAuthContext(refreshed);
|
||||
return refreshed;
|
||||
} catch (Exception ignored) {
|
||||
clearSavedAuth(false);
|
||||
}
|
||||
}
|
||||
String passwordValue = password.getText().toString().trim();
|
||||
if (passwordValue.isEmpty()) {
|
||||
throw new IllegalStateException("Сессия устройства истекла или отозвана. Введите пароль один раз, дальше ключи обновятся автоматически.");
|
||||
}
|
||||
RapApiClient.AuthContext loggedIn = client.login(email.getText().toString().trim(), passwordValue, deviceFingerprint());
|
||||
saveAuthContext(loggedIn);
|
||||
return loggedIn;
|
||||
}
|
||||
|
||||
private String resolveOrganizationId(RapApiClient client, String userId) throws Exception {
|
||||
JSONObject payload = client.organizations(userId);
|
||||
JSONArray organizations = payload.optJSONArray("organizations");
|
||||
if (organizations == null || organizations.length() == 0) {
|
||||
throw new IllegalStateException("У пользователя нет активной организации.");
|
||||
}
|
||||
String configured = organizationId.getText().toString().trim();
|
||||
JSONObject fallback = null;
|
||||
for (int i = 0; i < organizations.length(); i++) {
|
||||
JSONObject item = organizations.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String id = item.optString("id", "");
|
||||
String name = item.optString("name", "");
|
||||
String slug = item.optString("slug", "");
|
||||
if (!configured.isEmpty() && configured.equals(id)) {
|
||||
return configured;
|
||||
}
|
||||
if (fallback == null || "HOME".equalsIgnoreCase(name) || "home".equalsIgnoreCase(slug)) {
|
||||
fallback = item;
|
||||
}
|
||||
}
|
||||
String selected = fallback != null ? fallback.optString("id", "") : "";
|
||||
if (selected.isEmpty()) {
|
||||
throw new IllegalStateException("Не удалось выбрать организацию пользователя.");
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
organizationId.setText(selected);
|
||||
saveSettings();
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void saveAuthContext(RapApiClient.AuthContext context) throws Exception {
|
||||
secureTokens.put(PREF_REFRESH_TOKEN, context.refreshToken);
|
||||
prefs.edit()
|
||||
.putString(PREF_USER_ID, context.userId)
|
||||
.putString(PREF_DEVICE_ID, context.deviceId)
|
||||
.putString(PREF_REFRESH_EXPIRES_AT, context.refreshTokenExpiresAt)
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void saveProfileState() {
|
||||
prefs.edit()
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void restoreAuthContext() {
|
||||
String userId = prefs.getString(PREF_USER_ID, "");
|
||||
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
|
||||
if (!userId.isEmpty() && !deviceId.isEmpty()) {
|
||||
authContext = new RapApiClient.AuthContext(
|
||||
userId,
|
||||
deviceId,
|
||||
"",
|
||||
"",
|
||||
secureTokens.get(PREF_REFRESH_TOKEN),
|
||||
prefs.getString(PREF_REFRESH_EXPIRES_AT, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void clearSavedAuth(boolean clearProfile) {
|
||||
secureTokens.remove(PREF_REFRESH_TOKEN);
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.remove(PREF_REFRESH_EXPIRES_AT)
|
||||
.remove(PREF_USER_ID)
|
||||
.remove(PREF_DEVICE_ID);
|
||||
if (clearProfile) {
|
||||
editor.remove(PREF_PROFILE_JSON).remove(PREF_VPN_CONNECTION_ID);
|
||||
profileJson = "";
|
||||
vpnConnectionId = "";
|
||||
}
|
||||
editor.apply();
|
||||
authContext = null;
|
||||
}
|
||||
|
||||
private String savedRefreshToken() {
|
||||
String token = secureTokens.get(PREF_REFRESH_TOKEN);
|
||||
if (!token.isEmpty()) {
|
||||
return token;
|
||||
}
|
||||
String legacyToken = prefs.getString(PREF_REFRESH_TOKEN, "");
|
||||
if (!legacyToken.isEmpty()) {
|
||||
try {
|
||||
secureTokens.put(PREF_REFRESH_TOKEN, legacyToken);
|
||||
prefs.edit().remove(PREF_REFRESH_TOKEN).apply();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return legacyToken;
|
||||
}
|
||||
|
||||
private String deviceFingerprint() {
|
||||
String existing = prefs.getString(PREF_DEVICE_FINGERPRINT, "");
|
||||
if (!existing.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
String generated = "android-" + java.util.UUID.randomUUID();
|
||||
prefs.edit().putString(PREF_DEVICE_FINGERPRINT, generated).apply();
|
||||
return generated;
|
||||
}
|
||||
|
||||
private void showSettingsDialog() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
int pad = dp(12);
|
||||
form.setPadding(pad, pad, pad, pad);
|
||||
EditText backendDraft = field("Backend URL", backendUrl.getText().toString());
|
||||
EditText clusterDraft = field("Cluster ID", clusterId.getText().toString());
|
||||
EditText organizationDraft = field("Organization ID", organizationId.getText().toString());
|
||||
EditText emailDraft = field("Email", email.getText().toString());
|
||||
EditText passwordDraft = field("Password", password.getText().toString());
|
||||
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
passwordDraft.setHint("Password (не сохраняется)");
|
||||
CheckBox showPassword = new CheckBox(this);
|
||||
showPassword.setText("Показать пароль");
|
||||
showPassword.setTextColor(0xff111111);
|
||||
showPassword.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | (isChecked
|
||||
? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||
: InputType.TYPE_TEXT_VARIATION_PASSWORD));
|
||||
passwordDraft.setSelection(passwordDraft.getText().length());
|
||||
});
|
||||
form.addView(backendDraft);
|
||||
form.addView(clusterDraft);
|
||||
form.addView(organizationDraft);
|
||||
form.addView(emailDraft);
|
||||
form.addView(passwordDraft);
|
||||
form.addView(showPassword);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Настройки подключения")
|
||||
.setView(form)
|
||||
.setPositiveButton("Сохранить", (dialog, which) -> {
|
||||
backendUrl.setText(backendDraft.getText().toString());
|
||||
clusterId.setText(clusterDraft.getText().toString());
|
||||
organizationId.setText(organizationDraft.getText().toString());
|
||||
email.setText(emailDraft.getText().toString());
|
||||
password.setText(passwordDraft.getText().toString());
|
||||
saveSettings();
|
||||
profileSummary.setText(summaryText());
|
||||
})
|
||||
.setNeutralButton("Забыть устройство", (dialog, which) -> {
|
||||
clearSavedAuth(true);
|
||||
status.setText("Устройство забыто. Для следующего входа нужен пароль.");
|
||||
})
|
||||
.setNegativeButton("Отмена", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private String friendlyError(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
if (message == null || message.trim().isEmpty()) {
|
||||
return "неизвестная ошибка";
|
||||
}
|
||||
if (message.contains("auth.invalid_credentials") || message.contains("Неверный логин")) {
|
||||
int passwordLength = password.getText() == null ? 0 : password.getText().toString().length();
|
||||
return "Неверный логин или пароль. Проверьте раскладку и спецсимволы. Длина введенного пароля: " + passwordLength + ".";
|
||||
}
|
||||
if (message.contains("auth.invalid_refresh_token") || message.contains("invalid refresh token")) {
|
||||
return "Сессия устройства истекла. Введите пароль один раз, дальше ключи обновятся автоматически.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private void showServerPicker() {
|
||||
if (lastResources.length() == 0) {
|
||||
loadProfile();
|
||||
status.setText("Загружаю список серверов...");
|
||||
return;
|
||||
}
|
||||
String[] labels = new String[lastResources.length()];
|
||||
for (int i = 0; i < lastResources.length(); i++) {
|
||||
JSONObject resource = lastResources.optJSONObject(i);
|
||||
labels[i] = resource == null
|
||||
? "server"
|
||||
: resource.optString("name", "server") + " " + resource.optString("address", "");
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Удаленный сервер")
|
||||
.setItems(labels, (dialog, which) -> startRemoteDesktop(which))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void startRemoteDesktop(int index) {
|
||||
JSONObject resource = lastResources.optJSONObject(index);
|
||||
if (resource == null) {
|
||||
return;
|
||||
}
|
||||
if (authContext == null || authContext.userId.isEmpty() || authContext.deviceId.isEmpty()) {
|
||||
loadProfile();
|
||||
status.setText("Профиль обновляется. Повторите открытие сервера.");
|
||||
return;
|
||||
}
|
||||
status.setText("Открываю " + resource.optString("name", "сервер") + "...");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
|
||||
JSONObject result = client.startSession(resource.getString("id"), authContext.userId, authContext.deviceId);
|
||||
Intent intent = new Intent(this, RdpActivity.class);
|
||||
intent.putExtra(RdpActivity.EXTRA_SESSION_RESULT, result.toString());
|
||||
intent.putExtra(RdpActivity.EXTRA_GATEWAY_URL, gatewayUrl());
|
||||
intent.putExtra(RdpActivity.EXTRA_RESOURCE_NAME, resource.optString("name", "Remote Desktop"));
|
||||
runOnUiThread(() -> {
|
||||
status.setText("Сессия создана.");
|
||||
startActivity(intent);
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> status.setText("Ошибка RDP: " + ex.getMessage()));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String gatewayUrl() {
|
||||
String api = backendUrl.getText().toString().trim();
|
||||
String gateway = api.replace("https://", "wss://").replace("http://", "ws://");
|
||||
if (gateway.endsWith("/")) {
|
||||
gateway = gateway.substring(0, gateway.length() - 1);
|
||||
}
|
||||
return gateway + "/gateway/ws";
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.VpnService;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.SocketFactory;
|
||||
|
||||
final class RapApiClient {
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream");
|
||||
private static final int MAX_PACKET_BATCH_PACKETS = 128;
|
||||
private static final int MAX_PACKET_BATCH_BYTES = 128 * 1024;
|
||||
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
|
||||
private static final int MAX_BATCH_HEADER_BYTES = 4;
|
||||
private static final int BATCH_RETRY_THRESHOLD = 2;
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final String networkMode;
|
||||
private volatile boolean batchModeEnabled = true;
|
||||
private volatile int batchModeFailures = 0;
|
||||
|
||||
RapApiClient(String baseUrl) {
|
||||
this(baseUrl, null);
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Context context) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
SocketFactory socketFactory = context == null ? null : underlyingSocketFactory(context);
|
||||
if (socketFactory != null) {
|
||||
builder.socketFactory(socketFactory);
|
||||
this.networkMode = "direct_network";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(true);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(64);
|
||||
dispatcher.setMaxRequestsPerHost(32);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, VpnService vpnService) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (vpnService != null) {
|
||||
SocketFactory socketFactory = underlyingSocketFactory(vpnService);
|
||||
builder.socketFactory(socketFactory != null ? socketFactory : new ProtectedSocketFactory(vpnService));
|
||||
this.networkMode = socketFactory != null ? "direct_network" : "protected_socket";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, TimeUnit.SECONDS);
|
||||
builder.retryOnConnectionFailure(true);
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
dispatcher.setMaxRequests(64);
|
||||
dispatcher.setMaxRequestsPerHost(32);
|
||||
builder.dispatcher(dispatcher);
|
||||
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
|
||||
this.httpClient = builder.build();
|
||||
}
|
||||
|
||||
String networkMode() {
|
||||
return networkMode;
|
||||
}
|
||||
|
||||
private SocketFactory underlyingSocketFactory(Context context) {
|
||||
ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (connectivity == null) {
|
||||
return null;
|
||||
}
|
||||
for (Network network : connectivity.getAllNetworks()) {
|
||||
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
|
||||
if (capabilities == null) {
|
||||
continue;
|
||||
}
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
continue;
|
||||
}
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
continue;
|
||||
}
|
||||
return network.getSocketFactory();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
AuthContext login(String email, String password, String deviceFingerprint) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("email", email);
|
||||
body.put("password", password);
|
||||
body.put("device_fingerprint", deviceFingerprint);
|
||||
body.put("device_label", "RAP Android VPN");
|
||||
body.put("trust_device", true);
|
||||
JSONObject response = post("/auth/login", body);
|
||||
return parseAuthContext(response);
|
||||
}
|
||||
|
||||
AuthContext refresh(String refreshToken) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("refresh_token", refreshToken);
|
||||
return parseAuthContext(post("/auth/refresh", body));
|
||||
}
|
||||
|
||||
String vpnClientProfile(String clusterId, String organizationId, String userId, String entryNodeId) throws Exception {
|
||||
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
if (entryNodeId != null && !entryNodeId.trim().isEmpty()) {
|
||||
path += "&entry_node_id=" + entryNodeId.trim();
|
||||
}
|
||||
return get(path).toString();
|
||||
}
|
||||
|
||||
JSONObject organizations(String userId) throws Exception {
|
||||
return get("/organizations/?user_id=" + userId);
|
||||
}
|
||||
|
||||
JSONObject resources(String organizationId, String userId) throws Exception {
|
||||
String path = "/resources/?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
return get(path);
|
||||
}
|
||||
|
||||
JSONObject startSession(String resourceId, String userId, String deviceId) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("resource_id", resourceId);
|
||||
body.put("user_id", userId);
|
||||
body.put("device_id", deviceId);
|
||||
return post("/sessions/", body);
|
||||
}
|
||||
|
||||
JSONObject reportVPNDiagnosticStatus(String clusterId, String deviceId, JSONObject payload) throws Exception {
|
||||
return post("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/status", payload);
|
||||
}
|
||||
|
||||
JSONObject nextVPNDiagnosticCommand(String clusterId, String deviceId, int timeoutMs) throws Exception {
|
||||
byte[] payload = getBytes("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/commands?timeout_ms=" + timeoutMs);
|
||||
if (payload.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
JSONObject vpnPacketStats(String clusterId, String vpnConnectionId) throws Exception {
|
||||
return get("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/stats");
|
||||
}
|
||||
|
||||
JSONObject resetVPNPacketQueues(String clusterId, String vpnConnectionId) throws Exception {
|
||||
return post("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/reset", new JSONObject());
|
||||
}
|
||||
|
||||
void sendClientPacket(String clusterId, String vpnConnectionId, byte[] packet, int length) throws Exception {
|
||||
postBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets", packet, length);
|
||||
}
|
||||
|
||||
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
|
||||
if (!batchModeEnabled) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packets == null || packets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
|
||||
if (chunks.isEmpty()) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (List<byte[]> chunk : chunks) {
|
||||
postBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?batch=true", encodePacketBatch(chunk));
|
||||
}
|
||||
resetBatchMode();
|
||||
} catch (Exception e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
try {
|
||||
return getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?timeout_ms=" + timeoutMs);
|
||||
} catch (InterruptedIOException e) {
|
||||
return new byte[0];
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
|
||||
return new byte[0];
|
||||
}
|
||||
throw e;
|
||||
} catch (IllegalStateException e) {
|
||||
String message = e.getMessage();
|
||||
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
|
||||
return new byte[0];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
if (!batchModeEnabled) {
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
byte[] payload;
|
||||
try {
|
||||
payload = getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?batch=true&timeout_ms=" + timeoutMs);
|
||||
if (payload == null || payload.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (!isLikelyPacketBatch(payload)) {
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
return decodePacketBatch(payload);
|
||||
} catch (InterruptedIOException e) {
|
||||
return new ArrayList<>();
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
} catch (IllegalStateException e) {
|
||||
String message = e.getMessage();
|
||||
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject get(String path) throws Exception {
|
||||
Request request = new Request.Builder().url(baseUrl + path).get().build();
|
||||
return read(request);
|
||||
}
|
||||
|
||||
private JSONObject post(String path, JSONObject body) throws Exception {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON))
|
||||
.build();
|
||||
return read(request);
|
||||
}
|
||||
|
||||
private byte[] getBytes(String path) throws Exception {
|
||||
Request request = new Request.Builder().url(baseUrl + path).get().build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (response.code() == 204) {
|
||||
return new byte[0];
|
||||
}
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException("HTTP " + response.code());
|
||||
}
|
||||
ResponseBody body = response.body();
|
||||
return body == null ? new byte[0] : body.bytes();
|
||||
}
|
||||
}
|
||||
|
||||
private void postBytes(String path, byte[] packet, int length) throws Exception {
|
||||
byte[] bodyBytes = new byte[length];
|
||||
System.arraycopy(packet, 0, bodyBytes, 0, length);
|
||||
postBytes(path, bodyBytes);
|
||||
}
|
||||
|
||||
private void postBytes(String path, byte[] bodyBytes) throws Exception {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(bodyBytes, OCTET_STREAM))
|
||||
.build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException("HTTP " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] encodePacketBatch(List<byte[]> packets) {
|
||||
int total = 0;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet != null && packet.length > 0) {
|
||||
total += 4 + packet.length;
|
||||
}
|
||||
}
|
||||
byte[] out = new byte[total];
|
||||
int offset = 0;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
int length = packet.length;
|
||||
out[offset] = (byte) ((length >> 24) & 0xff);
|
||||
out[offset + 1] = (byte) ((length >> 16) & 0xff);
|
||||
out[offset + 2] = (byte) ((length >> 8) & 0xff);
|
||||
out[offset + 3] = (byte) (length & 0xff);
|
||||
offset += 4;
|
||||
System.arraycopy(packet, 0, out, offset, length);
|
||||
offset += length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private JSONObject read(Request request) throws Exception {
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
String text = body == null ? "" : body.string();
|
||||
if (!response.isSuccessful()) {
|
||||
if (response.code() == 401 && text.contains("auth.invalid_credentials")) {
|
||||
throw new IllegalStateException("Неверный логин или пароль.");
|
||||
}
|
||||
if (response.code() == 401 && text.contains("auth.invalid_refresh_token")) {
|
||||
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
|
||||
}
|
||||
throw new IllegalStateException("HTTP " + response.code() + ": " + text);
|
||||
}
|
||||
return new JSONObject(text);
|
||||
}
|
||||
}
|
||||
|
||||
private List<byte[]> decodePacketBatch(byte[] payload) {
|
||||
List<byte[]> packets = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (payload != null && offset + 4 <= payload.length) {
|
||||
int length = ((payload[offset] & 0xff) << 24)
|
||||
| ((payload[offset + 1] & 0xff) << 16)
|
||||
| ((payload[offset + 2] & 0xff) << 8)
|
||||
| (payload[offset + 3] & 0xff);
|
||||
offset += 4;
|
||||
if (length <= 0 || offset + length > payload.length) {
|
||||
break;
|
||||
}
|
||||
byte[] packet = new byte[length];
|
||||
System.arraycopy(payload, offset, packet, 0, length);
|
||||
packets.add(packet);
|
||||
offset += length;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
private List<List<byte[]>> chunkPacketsForBatch(List<byte[]> packets) {
|
||||
List<List<byte[]>> chunks = new ArrayList<>();
|
||||
List<byte[]> current = new ArrayList<>();
|
||||
int currentBytes = 0;
|
||||
boolean hasData = false;
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
if (packet.length > MAX_SINGLE_PACKET_BYTES) {
|
||||
continue;
|
||||
}
|
||||
hasData = true;
|
||||
|
||||
int projected = currentBytes + MAX_BATCH_HEADER_BYTES + packet.length;
|
||||
if (!current.isEmpty() && (current.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES)) {
|
||||
chunks.add(current);
|
||||
current = new ArrayList<>();
|
||||
currentBytes = 0;
|
||||
}
|
||||
current.add(packet);
|
||||
currentBytes = projected;
|
||||
}
|
||||
if (!hasData) {
|
||||
return chunks;
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
chunks.add(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private boolean isLikelyPacketBatch(byte[] payload) {
|
||||
if (payload == null || payload.length < MAX_BATCH_HEADER_BYTES) {
|
||||
return false;
|
||||
}
|
||||
int offset = 0;
|
||||
int consumed = 0;
|
||||
while (offset + MAX_BATCH_HEADER_BYTES <= payload.length) {
|
||||
int length = ((payload[offset] & 0xff) << 24)
|
||||
| ((payload[offset + 1] & 0xff) << 16)
|
||||
| ((payload[offset + 2] & 0xff) << 8)
|
||||
| (payload[offset + 3] & 0xff);
|
||||
offset += MAX_BATCH_HEADER_BYTES;
|
||||
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES) {
|
||||
return false;
|
||||
}
|
||||
if (offset + length > payload.length) {
|
||||
return false;
|
||||
}
|
||||
offset += length;
|
||||
consumed++;
|
||||
if (consumed > MAX_PACKET_BATCH_PACKETS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return offset == payload.length && consumed > 0;
|
||||
}
|
||||
|
||||
private List<byte[]> receiveSinglePacketAsBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
byte[] payload = receiveClientPacket(clusterId, vpnConnectionId, timeoutMs);
|
||||
if (payload == null || payload.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return new ArrayList<>(Collections.singletonList(payload));
|
||||
}
|
||||
|
||||
private boolean shouldDisableBatchMode(Throwable error) {
|
||||
return error != null;
|
||||
}
|
||||
|
||||
private synchronized void disableBatchMode() {
|
||||
batchModeFailures++;
|
||||
if (batchModeFailures >= BATCH_RETRY_THRESHOLD) {
|
||||
batchModeEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void resetBatchMode() {
|
||||
batchModeFailures = 0;
|
||||
batchModeEnabled = true;
|
||||
}
|
||||
|
||||
private AuthContext parseAuthContext(JSONObject response) throws Exception {
|
||||
JSONObject user = response.getJSONObject("user");
|
||||
String userId = user.optString("id", "");
|
||||
if (userId.isEmpty()) {
|
||||
userId = user.optString("ID", "");
|
||||
}
|
||||
JSONObject device = response.optJSONObject("device");
|
||||
String deviceId = device != null ? device.optString("id", "") : "";
|
||||
if (deviceId.isEmpty() && device != null) {
|
||||
deviceId = device.optString("ID", "");
|
||||
}
|
||||
JSONObject tokens = response.optJSONObject("tokens");
|
||||
String accessToken = tokens != null ? tokens.optString("access_token", "") : "";
|
||||
String accessExpiresAt = tokens != null ? tokens.optString("access_token_expires_at", "") : "";
|
||||
String refreshToken = tokens != null ? tokens.optString("refresh_token", "") : "";
|
||||
String refreshExpiresAt = tokens != null ? tokens.optString("refresh_token_expires_at", "") : "";
|
||||
return new AuthContext(userId, deviceId, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt);
|
||||
}
|
||||
|
||||
private String trimRight(String value) {
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static final class ProtectedSocketFactory extends SocketFactory {
|
||||
private final SocketFactory delegate = SocketFactory.getDefault();
|
||||
private final VpnService vpnService;
|
||||
|
||||
ProtectedSocketFactory(VpnService vpnService) {
|
||||
this.vpnService = vpnService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket() throws IOException {
|
||||
return protect(delegate.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.createSocket());
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
|
||||
Socket socket = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localHost, localPort));
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.createSocket());
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket socket = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localAddress, localPort));
|
||||
socket.connect(new InetSocketAddress(address, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
private Socket protect(Socket socket) throws IOException {
|
||||
if (!vpnService.protect(socket)) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw new IOException("protect control-plane socket failed");
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
|
||||
static final class AuthContext {
|
||||
final String userId;
|
||||
final String deviceId;
|
||||
final String accessToken;
|
||||
final String accessTokenExpiresAt;
|
||||
final String refreshToken;
|
||||
final String refreshTokenExpiresAt;
|
||||
|
||||
AuthContext(String userId, String deviceId, String accessToken, String accessTokenExpiresAt, String refreshToken, String refreshTokenExpiresAt) {
|
||||
this.userId = userId;
|
||||
this.deviceId = deviceId;
|
||||
this.accessToken = accessToken;
|
||||
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
||||
this.refreshToken = refreshToken;
|
||||
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class RapDiagnosticService extends Service {
|
||||
static final String ACTION_START = "su.cin.rapvpn.DIAGNOSTIC_START";
|
||||
static final String ACTION_STOP = "su.cin.rapvpn.DIAGNOSTIC_STOP";
|
||||
private static final String CHANNEL_ID = "rap-vpn-diagnostics";
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
private static final String PREFS = "rap-vpn";
|
||||
private static final String RUNTIME_PREFS = "rap-vpn-runtime";
|
||||
private static final String PREF_REFRESH_TOKEN = "refresh_token";
|
||||
private static final String PREF_USER_ID = "user_id";
|
||||
private static final String PREF_DEVICE_ID = "device_id";
|
||||
private static final String PREF_PROFILE_JSON = "profile_json";
|
||||
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private volatile boolean running;
|
||||
private Thread worker;
|
||||
private String serviceState = "";
|
||||
private String lastCommandType = "";
|
||||
private String lastCommandResult = "";
|
||||
private long lastCommandAt = 0;
|
||||
private long lastHeartbeatAt = 0;
|
||||
private long lastCommandPollAt = 0;
|
||||
private String controlNetworkMode = "";
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startForeground(1002, notification());
|
||||
startWorker();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
static void start(android.content.Context context) {
|
||||
Intent intent = new Intent(context, RapDiagnosticService.class);
|
||||
intent.setAction(ACTION_START);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
context.startForegroundService(intent);
|
||||
} else {
|
||||
context.startService(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private void startWorker() {
|
||||
if (worker != null && worker.isAlive()) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
worker = new Thread(this::runLoop, "rap-vpn-diagnostic-service");
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", ""));
|
||||
if (!backendUrl.equals(prefs.getString("backend_url", ""))) {
|
||||
prefs.edit().putString("backend_url", backendUrl).apply();
|
||||
}
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
|
||||
if (backendUrl.isEmpty() || clusterId.isEmpty() || deviceId.isEmpty()) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
RapApiClient client = new RapApiClient(backendUrl, this);
|
||||
controlNetworkMode = client.networkMode();
|
||||
lastHeartbeatAt = System.currentTimeMillis();
|
||||
serviceState = "online " + new SimpleDateFormat("HH:mm:ss").format(new Date());
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, statusPayload("heartbeat"));
|
||||
lastCommandPollAt = System.currentTimeMillis();
|
||||
JSONObject commandEnvelope = client.nextVPNDiagnosticCommand(clusterId, deviceId, 5000);
|
||||
if (commandEnvelope != null) {
|
||||
handleCommand(client, clusterId, deviceId, commandEnvelope);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
serviceState = "error: " + e.getMessage();
|
||||
try {
|
||||
Thread.sleep(3000);
|
||||
} catch (InterruptedException interrupted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(RapApiClient client, String clusterId, String deviceId, JSONObject envelope) throws Exception {
|
||||
JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command");
|
||||
JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload");
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String type = payload.optString("type", "");
|
||||
String result;
|
||||
if ("start_vpn".equals(type)) {
|
||||
result = startVPNFromSavedProfile();
|
||||
} else if ("stop_vpn".equals(type)) {
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
startService(stopIntent);
|
||||
result = "stop_vpn accepted";
|
||||
} else if ("http_get".equals(type)) {
|
||||
result = runHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_http_get".equals(type)) {
|
||||
result = runVPNHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_dns_lookup".equals(type)) {
|
||||
result = runVPNDNSLookup(payload.optString("host", "2ip.ru"));
|
||||
} else if ("open_url".equals(type)) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result = "open_url accepted " + url;
|
||||
} else if ("vpn_stats".equals(type)) {
|
||||
result = collectVPNStats(client, clusterId);
|
||||
} else if ("full_vpn_test".equals(type)) {
|
||||
result = runFullVPNTest(client, clusterId, payload);
|
||||
} else if ("refresh_profile".equals(type)) {
|
||||
result = refreshProfile();
|
||||
} else {
|
||||
result = "unknown command " + type;
|
||||
}
|
||||
lastCommandType = type;
|
||||
lastCommandResult = result;
|
||||
lastCommandAt = System.currentTimeMillis();
|
||||
JSONObject report = statusPayload("command_result");
|
||||
report.put("command_type", type);
|
||||
report.put("command_result", result);
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, report);
|
||||
}
|
||||
|
||||
private String startVPNFromSavedProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String profileJson = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
String backendUrl = prefs.getString("backend_url", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (profileJson.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
|
||||
return "start_vpn skipped: profile/backend/cluster/connection missing";
|
||||
}
|
||||
if (VpnService.prepare(this) != null) {
|
||||
Intent launcher = new Intent(this, TestVpnActivity.class);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_PROFILE_JSON, profileJson);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_BACKEND_URL, backendUrl);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_CLUSTER_ID, clusterId);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
launcher.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(launcher);
|
||||
return "start_vpn permission required: opened vpn launcher " + vpnConnectionId;
|
||||
}
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
|
||||
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl);
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId);
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
startForegroundService(intent);
|
||||
} else {
|
||||
startService(intent);
|
||||
}
|
||||
return "start_vpn accepted " + vpnConnectionId;
|
||||
}
|
||||
|
||||
private String refreshProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
try {
|
||||
String refreshToken = new SecureTokenStore(this).get(PREF_REFRESH_TOKEN);
|
||||
if (refreshToken.isEmpty()) {
|
||||
return "refresh_profile skipped: refresh token missing";
|
||||
}
|
||||
RapApiClient client = new RapApiClient(normalizeBackendUrl(prefs.getString("backend_url", "")), this);
|
||||
RapApiClient.AuthContext auth = client.refresh(refreshToken);
|
||||
String organizationId = prefs.getString("organization_id", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String profileJson = client.vpnClientProfile(clusterId, organizationId, auth.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject profile = root.getJSONObject("vpn_client_profile");
|
||||
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
|
||||
prefs.edit()
|
||||
.putString(PREF_USER_ID, auth.userId)
|
||||
.putString(PREF_DEVICE_ID, auth.deviceId)
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, connectionId)
|
||||
.apply();
|
||||
new SecureTokenStore(this).put(PREF_REFRESH_TOKEN, auth.refreshToken);
|
||||
return "refresh_profile ok " + connectionId;
|
||||
} catch (Exception e) {
|
||||
return "refresh_profile failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject statusPayload(String event) throws Exception {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("event", event);
|
||||
payload.put("app_version", APP_VERSION);
|
||||
payload.put("service", "diagnostic");
|
||||
payload.put("user_id", prefs.getString(PREF_USER_ID, ""));
|
||||
payload.put("device_id", prefs.getString(PREF_DEVICE_ID, ""));
|
||||
payload.put("organization_id", prefs.getString("organization_id", ""));
|
||||
payload.put("vpn_connection_id", prefs.getString(PREF_VPN_CONNECTION_ID, ""));
|
||||
payload.put("backend_url", prefs.getString("backend_url", ""));
|
||||
payload.put("control_network_mode", controlNetworkMode);
|
||||
payload.put("profile_loaded", !prefs.getString(PREF_PROFILE_JSON, "").isEmpty());
|
||||
payload.put("runtime", runtimeSnapshot());
|
||||
payload.put("vpn_config", vpnConfigSnapshot());
|
||||
payload.put("service_state", serviceState);
|
||||
payload.put("last_result", lastCommandResult);
|
||||
payload.put("last_command_type", lastCommandType);
|
||||
payload.put("last_command_result", lastCommandResult);
|
||||
payload.put("last_command_at", lastCommandAt);
|
||||
payload.put("last_heartbeat_at", lastHeartbeatAt);
|
||||
payload.put("last_command_poll_at", lastCommandPollAt);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
private String collectVPNStats(RapApiClient client, String clusterId) {
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (connectionId.isEmpty()) {
|
||||
return "vpn_stats skipped: connection missing";
|
||||
}
|
||||
try {
|
||||
JSONObject stats = client.vpnPacketStats(clusterId, connectionId);
|
||||
return "vpn_stats " + compact(stats.toString(), 900);
|
||||
} catch (Exception e) {
|
||||
return "vpn_stats failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runFullVPNTest(RapApiClient client, String clusterId, JSONObject payload) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
int watchSeconds = payload.optInt("watch_seconds", 30);
|
||||
if (watchSeconds < 5) {
|
||||
watchSeconds = 5;
|
||||
}
|
||||
if (watchSeconds > 120) {
|
||||
watchSeconds = 120;
|
||||
}
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
result.append(refreshProfile()).append(" | ");
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append("reset=").append(compact(client.resetVPNPacketQueues(clusterId, connectionId).toString(), 240)).append(" | ");
|
||||
}
|
||||
result.append(startVPNFromSavedProfile()).append(" | ");
|
||||
Thread.sleep(3000);
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result.append("open_url=").append(url);
|
||||
long deadline = System.currentTimeMillis() + watchSeconds * 1000L;
|
||||
while (running && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(5000);
|
||||
JSONObject report = statusPayload("full_vpn_test_watch");
|
||||
report.put("test_url", url);
|
||||
if (!connectionId.isEmpty()) {
|
||||
report.put("packet_stats", client.vpnPacketStats(clusterId, connectionId));
|
||||
}
|
||||
client.reportVPNDiagnosticStatus(clusterId, getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_DEVICE_ID, ""), report);
|
||||
}
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append(" | stats=").append(compact(client.vpnPacketStats(clusterId, connectionId).toString(), 900));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.append(" | full_vpn_test failed: ").append(e.getClass().getSimpleName()).append(": ").append(e.getMessage());
|
||||
}
|
||||
return compact(result.toString(), 1200);
|
||||
}
|
||||
|
||||
private String compact(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String compacted = value.replace('\n', ' ').replace('\r', ' ');
|
||||
if (compacted.length() <= maxLength) {
|
||||
return compacted;
|
||||
}
|
||||
return compacted.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||
}
|
||||
|
||||
private JSONObject runtimeSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString("state", ""));
|
||||
payload.put("message", runtime.getString("message", ""));
|
||||
payload.put("updated_at", runtime.getLong("updated_at", 0));
|
||||
payload.put("runtime_started_at", runtime.getLong("runtime_started_at", 0));
|
||||
payload.put("uplink_read", runtime.getLong("uplink_read", 0));
|
||||
payload.put("uplink_sent", runtime.getLong("uplink_sent", 0));
|
||||
payload.put("downlink_received", runtime.getLong("downlink_received", 0));
|
||||
payload.put("uplink_read_total", runtime.getLong("uplink_read_total", 0));
|
||||
payload.put("uplink_read_bytes", runtime.getLong("uplink_read_bytes", 0));
|
||||
payload.put("uplink_sent_total", runtime.getLong("uplink_sent_total", 0));
|
||||
payload.put("uplink_sent_bytes", runtime.getLong("uplink_sent_bytes", 0));
|
||||
payload.put("downlink_received_total", runtime.getLong("downlink_received_total", 0));
|
||||
payload.put("downlink_received_bytes", runtime.getLong("downlink_received_bytes", 0));
|
||||
payload.put("uplink_read_mbps", runtime.getFloat("uplink_read_mbps", 0f));
|
||||
payload.put("uplink_sent_mbps", runtime.getFloat("uplink_sent_mbps", 0f));
|
||||
payload.put("downlink_received_mbps", runtime.getFloat("downlink_received_mbps", 0f));
|
||||
payload.put("uplink_read_pps", runtime.getFloat("uplink_read_pps", 0f));
|
||||
payload.put("uplink_sent_pps", runtime.getFloat("uplink_sent_pps", 0f));
|
||||
payload.put("downlink_received_pps", runtime.getFloat("downlink_received_pps", 0f));
|
||||
payload.put("uplink_dropped_packets", runtime.getLong("uplink_dropped_packets", 0));
|
||||
payload.put("uplink_dropped_bytes", runtime.getLong("uplink_dropped_bytes", 0));
|
||||
payload.put("downlink_dropped_packets", runtime.getLong("downlink_dropped_packets", 0));
|
||||
payload.put("downlink_dropped_bytes", runtime.getLong("downlink_dropped_bytes", 0));
|
||||
payload.put("errors", runtime.getLong("errors", 0));
|
||||
payload.put("uplink", runtimePrefix(runtime, "uplink"));
|
||||
payload.put("uplink_sender", runtimePrefix(runtime, "uplink_sender"));
|
||||
payload.put("downlink", runtimePrefix(runtime, "downlink"));
|
||||
payload.put("relay", runtimePrefix(runtime, "relay"));
|
||||
payload.put("uplink_worker_count", runtime.getInt("uplink_worker_count", 0));
|
||||
payload.put("uplink_queue_depth_total", runtime.getInt("uplink_queue_depth_total", 0));
|
||||
payload.put("uplink_queue_depth_max", runtime.getInt("uplink_queue_depth_max", 0));
|
||||
payload.put("uplink_queue_depths", runtime.getString("uplink_queue_depths", ""));
|
||||
payload.put("uplink_queue_0_offers", runtime.getLong("uplink_queue_0_offers", 0));
|
||||
payload.put("uplink_queue_1_offers", runtime.getLong("uplink_queue_1_offers", 0));
|
||||
payload.put("uplink_queue_2_offers", runtime.getLong("uplink_queue_2_offers", 0));
|
||||
payload.put("uplink_queue_3_offers", runtime.getLong("uplink_queue_3_offers", 0));
|
||||
payload.put("uplink_queue_0_drops", runtime.getLong("uplink_queue_0_drops", 0));
|
||||
payload.put("uplink_queue_1_drops", runtime.getLong("uplink_queue_1_drops", 0));
|
||||
payload.put("uplink_queue_2_drops", runtime.getLong("uplink_queue_2_drops", 0));
|
||||
payload.put("uplink_queue_3_drops", runtime.getLong("uplink_queue_3_drops", 0));
|
||||
payload.put("uplink_sender_worker_packets_0", runtime.getLong("uplink_sender_worker_packets_0", 0));
|
||||
payload.put("uplink_sender_worker_packets_1", runtime.getLong("uplink_sender_worker_packets_1", 0));
|
||||
payload.put("uplink_sender_worker_packets_2", runtime.getLong("uplink_sender_worker_packets_2", 0));
|
||||
payload.put("uplink_sender_worker_packets_3", runtime.getLong("uplink_sender_worker_packets_3", 0));
|
||||
payload.put("uplink_sender_worker_errors_0", runtime.getLong("uplink_sender_worker_errors_0", 0));
|
||||
payload.put("uplink_sender_worker_errors_1", runtime.getLong("uplink_sender_worker_errors_1", 0));
|
||||
payload.put("uplink_sender_worker_errors_2", runtime.getLong("uplink_sender_worker_errors_2", 0));
|
||||
payload.put("uplink_sender_worker_errors_3", runtime.getLong("uplink_sender_worker_errors_3", 0));
|
||||
payload.put("uplink_queue_depth", runtime.getInt("uplink_queue_depth", 0));
|
||||
payload.put("downlink_restarts", runtime.getLong("downlink_restarts", 0));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject vpnConfigSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("vpn_address", runtime.getString("vpn_address", ""));
|
||||
payload.put("dns_servers", runtime.getString("dns_servers", ""));
|
||||
payload.put("routes", runtime.getString("routes", ""));
|
||||
payload.put("full_tunnel", runtime.getBoolean("full_tunnel", false));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject runtimePrefix(SharedPreferences runtime, String prefix) throws Exception {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString(prefix + "_state", ""));
|
||||
payload.put("message", runtime.getString(prefix + "_message", ""));
|
||||
payload.put("updated_at", runtime.getLong(prefix + "_updated_at", 0));
|
||||
payload.put("packets", runtime.getLong(prefix + "_packets", 0));
|
||||
payload.put("bytes", runtime.getLong(prefix + "_bytes", 0));
|
||||
payload.put("errors", runtime.getLong(prefix + "_errors", 0));
|
||||
payload.put("error_type", runtime.getString(prefix + "_error_type", ""));
|
||||
payload.put("thread_alive", runtime.getBoolean(prefix + "_thread_alive", false));
|
||||
payload.put("rate_mbps", runtime.getFloat(prefix + "_rate_mbps", 0f));
|
||||
payload.put("rate_pps", runtime.getFloat(prefix + "_rate_pps", 0f));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String runHttpGet(String target) {
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "http_get " + target + " -> HTTP " + code;
|
||||
} catch (Exception e) {
|
||||
return "http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNHttpGet(String target) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_http_get " + target + " -> vpn network not found";
|
||||
}
|
||||
URL url = new URL(target);
|
||||
HttpURLConnection connection;
|
||||
String resolved = "";
|
||||
if ("http".equalsIgnoreCase(url.getProtocol()) && !isIPv4Literal(url.getHost())) {
|
||||
resolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
}
|
||||
if (!resolved.isEmpty()) {
|
||||
URL resolvedURL = new URL(url.getProtocol(), resolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
} else {
|
||||
connection = (HttpURLConnection) vpn.openConnection(url);
|
||||
}
|
||||
try {
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
} catch (UnknownHostException e) {
|
||||
String fallbackResolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
if (fallbackResolved.isEmpty() || !"http".equalsIgnoreCase(url.getProtocol())) {
|
||||
throw e;
|
||||
}
|
||||
URL resolvedURL = new URL(url.getProtocol(), fallbackResolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return "vpn_http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIPv4Literal(String host) {
|
||||
if (host == null) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
for (String part : parts) {
|
||||
int value = Integer.parseInt(part);
|
||||
if (value < 0 || value > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNDNSLookup(String host) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_dns_lookup " + host + " -> vpn network not found";
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
InetAddress[] system = vpn.getAllByName(host);
|
||||
result.append("system=");
|
||||
appendAddresses(result, system);
|
||||
} catch (Exception e) {
|
||||
result.append("system=").append(e.getClass().getSimpleName()).append(":").append(e.getMessage());
|
||||
}
|
||||
String manual = manualVPNDNSLookup(vpn, host);
|
||||
result.append(" manual=").append(manual);
|
||||
return "vpn_dns_lookup " + host + " -> " + result;
|
||||
} catch (Exception e) {
|
||||
return "vpn_dns_lookup " + host + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String firstManualVPNAddress(Network vpn, String host) {
|
||||
String result = manualVPNDNSLookup(vpn, host);
|
||||
if (result.startsWith("ok:")) {
|
||||
String addresses = result.substring(3);
|
||||
int comma = addresses.indexOf(',');
|
||||
return comma >= 0 ? addresses.substring(0, comma) : addresses;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String manualVPNDNSLookup(Network vpn, String host) {
|
||||
String dnsServers = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE).getString("dns_servers", "");
|
||||
if (dnsServers.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
String dnsServer = dnsServers.split(",", 2)[0].trim();
|
||||
if (dnsServer.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
vpn.bindSocket(socket);
|
||||
socket.setSoTimeout(5000);
|
||||
byte[] query = buildDNSQuery(host);
|
||||
DatagramPacket packet = new DatagramPacket(query, query.length, InetAddress.getByName(dnsServer), 53);
|
||||
socket.send(packet);
|
||||
byte[] response = new byte[512];
|
||||
DatagramPacket answer = new DatagramPacket(response, response.length);
|
||||
socket.receive(answer);
|
||||
List<String> addresses = parseDNSAResponse(response, answer.getLength());
|
||||
if (addresses.isEmpty()) {
|
||||
return "empty:" + dnsServer;
|
||||
}
|
||||
return "ok:" + String.join(",", addresses);
|
||||
} catch (SocketTimeoutException e) {
|
||||
return "timeout:" + dnsServer;
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ":" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildDNSQuery(String host) throws Exception {
|
||||
byte[] out = new byte[512];
|
||||
int id = new Random().nextInt(0xffff);
|
||||
out[0] = (byte) ((id >> 8) & 0xff);
|
||||
out[1] = (byte) (id & 0xff);
|
||||
out[2] = 0x01;
|
||||
out[5] = 0x01;
|
||||
int offset = 12;
|
||||
for (String label : host.split("\\.")) {
|
||||
byte[] bytes = label.getBytes("UTF-8");
|
||||
out[offset++] = (byte) bytes.length;
|
||||
System.arraycopy(bytes, 0, out, offset, bytes.length);
|
||||
offset += bytes.length;
|
||||
}
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
byte[] query = new byte[offset];
|
||||
System.arraycopy(out, 0, query, 0, offset);
|
||||
return query;
|
||||
}
|
||||
|
||||
private List<String> parseDNSAResponse(byte[] packet, int length) {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
if (length < 12) {
|
||||
return addresses;
|
||||
}
|
||||
int qd = u16(packet, 4);
|
||||
int an = u16(packet, 6);
|
||||
int offset = 12;
|
||||
for (int i = 0; i < qd; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
offset += 4;
|
||||
if (offset > length) {
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < an && offset < length; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
if (offset + 10 > length) {
|
||||
return addresses;
|
||||
}
|
||||
int type = u16(packet, offset);
|
||||
int cls = u16(packet, offset + 2);
|
||||
int rdLen = u16(packet, offset + 8);
|
||||
offset += 10;
|
||||
if (type == 1 && cls == 1 && rdLen == 4 && offset + 4 <= length) {
|
||||
addresses.add((packet[offset] & 0xff) + "." + (packet[offset + 1] & 0xff) + "." + (packet[offset + 2] & 0xff) + "." + (packet[offset + 3] & 0xff));
|
||||
}
|
||||
offset += rdLen;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private int skipDNSName(byte[] packet, int length, int offset) {
|
||||
while (offset < length) {
|
||||
int value = packet[offset] & 0xff;
|
||||
offset++;
|
||||
if (value == 0) {
|
||||
break;
|
||||
}
|
||||
if ((value & 0xc0) == 0xc0) {
|
||||
offset++;
|
||||
break;
|
||||
}
|
||||
offset += value;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private int u16(byte[] packet, int offset) {
|
||||
if (packet == null || offset + 1 >= packet.length) {
|
||||
return 0;
|
||||
}
|
||||
return ((packet[offset] & 0xff) << 8) | (packet[offset + 1] & 0xff);
|
||||
}
|
||||
|
||||
private void appendAddresses(StringBuilder result, InetAddress[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
result.append("empty");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) {
|
||||
result.append(",");
|
||||
}
|
||||
result.append(addresses[i].getHostAddress());
|
||||
}
|
||||
}
|
||||
|
||||
private String hostHeader(URL url) {
|
||||
if (url.getPort() > 0) {
|
||||
return url.getHost() + ":" + url.getPort();
|
||||
}
|
||||
return url.getHost();
|
||||
}
|
||||
|
||||
private Network vpnNetwork() {
|
||||
ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
|
||||
if (connectivity == null) {
|
||||
return null;
|
||||
}
|
||||
for (Network network : connectivity.getAllNetworks()) {
|
||||
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
|
||||
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
return network;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Notification notification() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "RAP VPN diagnostics", NotificationManager.IMPORTANCE_LOW);
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(this, CHANNEL_ID) : new Notification.Builder(this);
|
||||
return builder
|
||||
.setContentTitle("RAP VPN diagnostics")
|
||||
.setContentText("Diagnostic channel is active")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
|
||||
public class RdpActivity extends Activity {
|
||||
static final String EXTRA_SESSION_RESULT = "session_result";
|
||||
static final String EXTRA_GATEWAY_URL = "gateway_url";
|
||||
static final String EXTRA_RESOURCE_NAME = "resource_name";
|
||||
|
||||
private final OkHttpClient http = new OkHttpClient();
|
||||
private ImageView desktop;
|
||||
private TextView overlay;
|
||||
private WebSocket webSocket;
|
||||
private int desktopWidth = 1;
|
||||
private int desktopHeight = 1;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
|
||||
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
root.setBackgroundColor(0xff05090c);
|
||||
desktop = new ImageView(this);
|
||||
desktop.setScaleType(ImageView.ScaleType.FIT_CENTER);
|
||||
desktop.setBackgroundColor(0xff05090c);
|
||||
desktop.setOnTouchListener((view, event) -> {
|
||||
sendTouch(event);
|
||||
return true;
|
||||
});
|
||||
overlay = new TextView(this);
|
||||
overlay.setTextColor(0xffffffff);
|
||||
overlay.setTextSize(14);
|
||||
overlay.setBackgroundColor(0x66000000);
|
||||
overlay.setPadding(14, 10, 14, 10);
|
||||
overlay.setText("Подключение...");
|
||||
root.addView(desktop, new FrameLayout.LayoutParams(-1, -1));
|
||||
root.addView(overlay, new FrameLayout.LayoutParams(-2, -2));
|
||||
setContentView(root);
|
||||
connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "activity closed");
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
try {
|
||||
JSONObject result = new JSONObject(getIntent().getStringExtra(EXTRA_SESSION_RESULT));
|
||||
JSONObject token = result.getJSONObject("attach_token");
|
||||
String attachToken = token.getString("token");
|
||||
String gatewayUrl = getIntent().getStringExtra(EXTRA_GATEWAY_URL);
|
||||
String url = gatewayUrl + "?attach_token=" + attachToken;
|
||||
runOnUiThread(() -> overlay.setText(getIntent().getStringExtra(EXTRA_RESOURCE_NAME)));
|
||||
Request request = new Request.Builder().url(url).build();
|
||||
webSocket = http.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
runOnUiThread(() -> overlay.setText("Подключено"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
handleEnvelope(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
runOnUiThread(() -> overlay.setText("Ошибка: " + t.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
runOnUiThread(() -> overlay.setText("Отключено"));
|
||||
}
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
overlay.setText("Ошибка запуска: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleEnvelope(String text) {
|
||||
try {
|
||||
JSONObject envelope = new JSONObject(text);
|
||||
String type = envelope.optString("type");
|
||||
if ("session.state".equals(type)) {
|
||||
JSONObject payload = envelope.optJSONObject("payload");
|
||||
String state = payload == null ? "" : payload.optString("state", "");
|
||||
if (!state.isEmpty() && !"active".equals(state)) {
|
||||
runOnUiThread(() -> overlay.setText("Сессия: " + state));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!"session.frame".equals(type)) {
|
||||
return;
|
||||
}
|
||||
JSONObject payload = envelope.optJSONObject("payload");
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String frameData = payload.optString("frame_data", "");
|
||||
int width = payload.optInt("frame_width", payload.optInt("desktop_width", 0));
|
||||
int height = payload.optInt("frame_height", payload.optInt("desktop_height", 0));
|
||||
byte[] bytes = Base64.decode(frameData, Base64.DEFAULT);
|
||||
Bitmap bitmap = decodeFrame(bytes, width, height, payload.optString("frame_format", ""));
|
||||
if (bitmap != null) {
|
||||
desktopWidth = Math.max(1, width);
|
||||
desktopHeight = Math.max(1, height);
|
||||
runOnUiThread(() -> {
|
||||
desktop.setImageBitmap(bitmap);
|
||||
overlay.setText("");
|
||||
});
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
runOnUiThread(() -> overlay.setText("Кадр: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap decodeFrame(byte[] bytes, int width, int height, String format) {
|
||||
Bitmap compressed = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (compressed != null) {
|
||||
return compressed;
|
||||
}
|
||||
if (width <= 0 || height <= 0 || bytes.length < width * height * 4) {
|
||||
return null;
|
||||
}
|
||||
int[] colors = new int[width * height];
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (int i = 0; i < colors.length; i++) {
|
||||
int b = buffer.get() & 0xff;
|
||||
int g = buffer.get() & 0xff;
|
||||
int r = buffer.get() & 0xff;
|
||||
int a = buffer.get() & 0xff;
|
||||
colors[i] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
|
||||
}
|
||||
|
||||
private void sendTouch(MotionEvent event) {
|
||||
if (webSocket == null || desktop.getWidth() <= 0 || desktop.getHeight() <= 0) {
|
||||
return;
|
||||
}
|
||||
String action;
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
action = "down";
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
action = "up";
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
action = "move";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
double x = Math.max(0, Math.min(1, event.getX() / Math.max(1f, desktop.getWidth())));
|
||||
double y = Math.max(0, Math.min(1, event.getY() / Math.max(1f, desktop.getHeight())));
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("correlation_id", UUID.randomUUID().toString());
|
||||
payload.put("client_captured_at", java.time.Instant.now().toString());
|
||||
payload.put("kind", "mouse");
|
||||
payload.put("action", action);
|
||||
payload.put("button", "left");
|
||||
payload.put("normalized_x", x);
|
||||
payload.put("normalized_y", y);
|
||||
payload.put("surface_width", desktopWidth);
|
||||
payload.put("surface_height", desktopHeight);
|
||||
JSONObject envelope = new JSONObject();
|
||||
envelope.put("type", "input");
|
||||
envelope.put("payload", payload);
|
||||
webSocket.send(envelope.toString().getBytes(StandardCharsets.UTF_8).length > 0 ? envelope.toString() : "{}");
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.util.Base64;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
|
||||
final class SecureTokenStore {
|
||||
private static final String PREFS = "rap-vpn-secure";
|
||||
private static final String KEY_ALIAS = "rap-vpn-refresh-token";
|
||||
private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
|
||||
private static final int IV_LENGTH = 12;
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
SecureTokenStore(Context context) {
|
||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
void put(String name, String value) throws Exception {
|
||||
if (value == null || value.isEmpty()) {
|
||||
remove(name);
|
||||
return;
|
||||
}
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key());
|
||||
byte[] ciphertext = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] iv = cipher.getIV();
|
||||
if (iv == null || iv.length == 0) {
|
||||
throw new IllegalStateException("Android Keystore did not provide encryption IV");
|
||||
}
|
||||
byte[] payload = new byte[iv.length + ciphertext.length];
|
||||
System.arraycopy(iv, 0, payload, 0, iv.length);
|
||||
System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
|
||||
prefs.edit().putString(name, Base64.encodeToString(payload, Base64.NO_WRAP)).apply();
|
||||
}
|
||||
|
||||
String get(String name) {
|
||||
String encoded = prefs.getString(name, "");
|
||||
if (encoded.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
byte[] payload = Base64.decode(encoded, Base64.NO_WRAP);
|
||||
if (payload.length <= IV_LENGTH) {
|
||||
return "";
|
||||
}
|
||||
byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH);
|
||||
byte[] ciphertext = Arrays.copyOfRange(payload, IV_LENGTH, payload.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
void remove(String name) {
|
||||
prefs.edit().remove(name).apply();
|
||||
}
|
||||
|
||||
private SecretKey key() throws Exception {
|
||||
KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
|
||||
keyStore.load(null);
|
||||
KeyStore.Entry entry = keyStore.getEntry(KEY_ALIAS, null);
|
||||
if (entry instanceof KeyStore.SecretKeyEntry) {
|
||||
return ((KeyStore.SecretKeyEntry) entry).getSecretKey();
|
||||
}
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
|
||||
generator.init(new KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build());
|
||||
return generator.generateKey();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class TestTrafficActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
TextView text = new TextView(this);
|
||||
text.setText("traffic test starting");
|
||||
setContentView(text);
|
||||
String url = getIntent().getStringExtra("url");
|
||||
if (url == null || url.isEmpty()) {
|
||||
url = "http://192.168.200.61:18080/";
|
||||
}
|
||||
String target = url;
|
||||
new Thread(() -> runRequest(text, target), "rap-test-traffic").start();
|
||||
}
|
||||
|
||||
private void runRequest(TextView text, String target) {
|
||||
String result;
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
result = "HTTP " + connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
} catch (Exception e) {
|
||||
result = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
String finalResult = result;
|
||||
runOnUiThread(() -> text.setText(finalResult));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.widget.TextView;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class TestVpnActivity extends Activity {
|
||||
public static final String EXTRA_PROFILE_JSON = "profile_json";
|
||||
public static final String EXTRA_PROFILE_BASE64 = "profile_base64";
|
||||
public static final String EXTRA_BACKEND_URL = "backend_url";
|
||||
public static final String EXTRA_CLUSTER_ID = "cluster_id";
|
||||
public static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id";
|
||||
private static final int VPN_PREPARE_REQUEST = 77;
|
||||
|
||||
private Intent serviceIntent;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
TextView text = new TextView(this);
|
||||
text.setText("RAP VPN test launcher");
|
||||
setContentView(text);
|
||||
serviceIntent = buildServiceIntent(getIntent());
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
if (prepare != null) {
|
||||
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
|
||||
return;
|
||||
}
|
||||
startVpn();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
|
||||
startVpn();
|
||||
}
|
||||
}
|
||||
|
||||
private Intent buildServiceIntent(Intent source) {
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson(source));
|
||||
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, source.getStringExtra(EXTRA_BACKEND_URL));
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, source.getStringExtra(EXTRA_CLUSTER_ID));
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, source.getStringExtra(EXTRA_VPN_CONNECTION_ID));
|
||||
return intent;
|
||||
}
|
||||
|
||||
private String profileJson(Intent source) {
|
||||
String direct = source.getStringExtra(EXTRA_PROFILE_JSON);
|
||||
if (direct != null && !direct.isEmpty()) {
|
||||
return direct;
|
||||
}
|
||||
String encoded = source.getStringExtra(EXTRA_PROFILE_BASE64);
|
||||
if (encoded == null || encoded.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
byte[] raw = Base64.decode(encoded, Base64.DEFAULT);
|
||||
return new String(raw, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private void startVpn() {
|
||||
startForegroundService(serviceIntent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:colorAccent">#2f6f50</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application" version "8.7.3" apply false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
sdk.dir=C:\Android\sdk
|
||||
@@ -1,18 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "RapAndroidVpn"
|
||||
include ":app"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,400 @@
|
||||
// Copyright 2014 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package go;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.lang.ref.PhantomReference;
|
||||
import java.lang.ref.Reference;
|
||||
import java.lang.ref.ReferenceQueue;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import go.Universe;
|
||||
|
||||
// Seq is a sequence of machine-dependent encoded values.
|
||||
// Used by automatically generated language bindings to talk to Go.
|
||||
public class Seq {
|
||||
private static Logger log = Logger.getLogger("GoSeq");
|
||||
|
||||
// also known to bind/seq/ref.go and bind/objc/seq_darwin.m
|
||||
private static final int NULL_REFNUM = 41;
|
||||
|
||||
// use single Ref for null Object
|
||||
public static final Ref nullRef = new Ref(NULL_REFNUM, null);
|
||||
|
||||
// The singleton GoRefQueue
|
||||
private static final GoRefQueue goRefQueue = new GoRefQueue();
|
||||
|
||||
static {
|
||||
System.loadLibrary("gojni");
|
||||
init();
|
||||
Universe.touch();
|
||||
}
|
||||
|
||||
// setContext sets the context in the go-library to be used in RunOnJvm.
|
||||
public static void setContext(Context context) {
|
||||
setContext((java.lang.Object)context);
|
||||
}
|
||||
|
||||
private static native void init();
|
||||
|
||||
// Empty method to run class initializer
|
||||
public static void touch() {}
|
||||
|
||||
private Seq() {
|
||||
}
|
||||
|
||||
// ctx is an android.context.Context.
|
||||
static native void setContext(java.lang.Object ctx);
|
||||
|
||||
public static void incRefnum(int refnum) {
|
||||
tracker.incRefnum(refnum);
|
||||
}
|
||||
|
||||
// incRef increments the reference count of Java objects.
|
||||
// For proxies for Go objects, it calls into the Proxy method
|
||||
// incRefnum() to make sure the Go reference count is positive
|
||||
// even if the Proxy is garbage collected and its Ref is finalized.
|
||||
public static int incRef(Object o) {
|
||||
return tracker.inc(o);
|
||||
}
|
||||
|
||||
public static int incGoObjectRef(GoObject o) {
|
||||
return o.incRefnum();
|
||||
}
|
||||
|
||||
// trackGoRef tracks a Go reference and decrements its refcount
|
||||
// when the given GoObject wrapper is garbage collected.
|
||||
//
|
||||
// TODO(crawshaw): We could cut down allocations for frequently
|
||||
// sent Go objects by maintaining a map to weak references. This
|
||||
// however, would require allocating two objects per reference
|
||||
// instead of one. It also introduces weak references, the bane
|
||||
// of any Java debugging session.
|
||||
//
|
||||
// When we have real code, examine the tradeoffs.
|
||||
public static void trackGoRef(int refnum, GoObject obj) {
|
||||
if (refnum > 0) {
|
||||
throw new RuntimeException("trackGoRef called with Java refnum " + refnum);
|
||||
}
|
||||
goRefQueue.track(refnum, obj);
|
||||
}
|
||||
|
||||
public static Ref getRef(int refnum) {
|
||||
return tracker.get(refnum);
|
||||
}
|
||||
|
||||
// Increment the Go reference count before sending over a refnum.
|
||||
// The ref parameter is only used to make sure the referenced
|
||||
// object is not garbage collected before Go increments the
|
||||
// count. It's the equivalent of Go's runtime.KeepAlive.
|
||||
public static native void incGoRef(int refnum, GoObject ref);
|
||||
|
||||
// Informs the Go ref tracker that Java is done with this refnum.
|
||||
static native void destroyRef(int refnum);
|
||||
|
||||
// decRef is called from seq.FinalizeRef
|
||||
static void decRef(int refnum) {
|
||||
tracker.dec(refnum);
|
||||
}
|
||||
|
||||
// A GoObject is a Java class implemented in Go. When a GoObject
|
||||
// is passed to Go, it is wrapped in a Go proxy, to make it behave
|
||||
// the same as passing a regular Java class.
|
||||
public interface GoObject {
|
||||
// Increment refcount and return the refnum of the proxy.
|
||||
//
|
||||
// The Go reference count need to be bumped while the
|
||||
// refnum is passed to Go, to avoid finalizing and
|
||||
// invalidating it before being translated on the Go side.
|
||||
int incRefnum();
|
||||
}
|
||||
// A Proxy is a Java object that proxies a Go object. Proxies, unlike
|
||||
// GoObjects, are unwrapped to their Go counterpart when deserialized
|
||||
// in Go.
|
||||
public interface Proxy extends GoObject {}
|
||||
|
||||
// A Ref represents an instance of a Java object passed back and forth
|
||||
// across the language boundary.
|
||||
public static final class Ref {
|
||||
public final int refnum;
|
||||
|
||||
private int refcnt; // Track how many times sent to Go.
|
||||
|
||||
public final Object obj; // The referenced Java obj.
|
||||
|
||||
Ref(int refnum, Object o) {
|
||||
if (refnum < 0) {
|
||||
throw new RuntimeException("Ref instantiated with a Go refnum " + refnum);
|
||||
}
|
||||
this.refnum = refnum;
|
||||
this.refcnt = 0;
|
||||
this.obj = o;
|
||||
}
|
||||
|
||||
void inc() {
|
||||
// Count how many times this ref's Java object is passed to Go.
|
||||
if (refcnt == Integer.MAX_VALUE) {
|
||||
throw new RuntimeException("refnum " + refnum + " overflow");
|
||||
}
|
||||
refcnt++;
|
||||
}
|
||||
}
|
||||
|
||||
static final RefTracker tracker = new RefTracker();
|
||||
|
||||
static final class RefTracker {
|
||||
private static final int REF_OFFSET = 42;
|
||||
|
||||
// Next Java object reference number.
|
||||
//
|
||||
// Reference numbers are positive for Java objects,
|
||||
// and start, arbitrarily at a different offset to Go
|
||||
// to make debugging by reading Seq hex a little easier.
|
||||
private int next = REF_OFFSET; // next Java object ref
|
||||
|
||||
// Java objects that have been passed to Go. refnum -> Ref
|
||||
// The Ref obj field is non-null.
|
||||
// This map pins Java objects so they don't get GCed while the
|
||||
// only reference to them is held by Go code.
|
||||
private final RefMap javaObjs = new RefMap();
|
||||
|
||||
// Java objects to refnum
|
||||
private final IdentityHashMap<Object, Integer> javaRefs = new IdentityHashMap<>();
|
||||
|
||||
// inc increments the reference count of a Java object when it
|
||||
// is sent to Go. inc returns the refnum for the object.
|
||||
synchronized int inc(Object o) {
|
||||
if (o == null) {
|
||||
return NULL_REFNUM;
|
||||
}
|
||||
if (o instanceof Proxy) {
|
||||
return ((Proxy)o).incRefnum();
|
||||
}
|
||||
Integer refnumObj = javaRefs.get(o);
|
||||
if (refnumObj == null) {
|
||||
if (next == Integer.MAX_VALUE) {
|
||||
throw new RuntimeException("createRef overflow for " + o);
|
||||
}
|
||||
refnumObj = next++;
|
||||
javaRefs.put(o, refnumObj);
|
||||
}
|
||||
int refnum = refnumObj;
|
||||
Ref ref = javaObjs.get(refnum);
|
||||
if (ref == null) {
|
||||
ref = new Ref(refnum, o);
|
||||
javaObjs.put(refnum, ref);
|
||||
}
|
||||
ref.inc();
|
||||
return refnum;
|
||||
}
|
||||
|
||||
synchronized void incRefnum(int refnum) {
|
||||
Ref ref = javaObjs.get(refnum);
|
||||
if (ref == null) {
|
||||
throw new RuntimeException("referenced Java object is not found: refnum="+refnum);
|
||||
}
|
||||
ref.inc();
|
||||
}
|
||||
|
||||
// dec decrements the reference count of a Java object when
|
||||
// Go signals a corresponding proxy object is finalized.
|
||||
// If the count reaches zero, the Java object is removed
|
||||
// from the javaObjs map.
|
||||
synchronized void dec(int refnum) {
|
||||
if (refnum <= 0) {
|
||||
// We don't keep track of the Go object.
|
||||
// This must not happen.
|
||||
log.severe("dec request for Go object "+ refnum);
|
||||
return;
|
||||
}
|
||||
if (refnum == Seq.nullRef.refnum) {
|
||||
return;
|
||||
}
|
||||
// Java objects are removed on request of Go.
|
||||
Ref obj = javaObjs.get(refnum);
|
||||
if (obj == null) {
|
||||
throw new RuntimeException("referenced Java object is not found: refnum="+refnum);
|
||||
}
|
||||
obj.refcnt--;
|
||||
if (obj.refcnt <= 0) {
|
||||
javaObjs.remove(refnum);
|
||||
javaRefs.remove(obj.obj);
|
||||
}
|
||||
}
|
||||
|
||||
// get returns an existing Ref to a Java object.
|
||||
synchronized Ref get(int refnum) {
|
||||
if (refnum < 0) {
|
||||
throw new RuntimeException("ref called with Go refnum " + refnum);
|
||||
}
|
||||
if (refnum == NULL_REFNUM) {
|
||||
return nullRef;
|
||||
}
|
||||
Ref ref = javaObjs.get(refnum);
|
||||
if (ref == null) {
|
||||
throw new RuntimeException("unknown java Ref: "+refnum);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
|
||||
// GoRefQueue is a queue of GoRefs that are no longer live. An internal thread
|
||||
// processes the queue and decrement the reference count on the Go side.
|
||||
static class GoRefQueue extends ReferenceQueue<GoObject> {
|
||||
// The set of tracked GoRefs. If we don't hold on to the GoRef instances, the Java GC
|
||||
// will not add them to the queue when their referents are reclaimed.
|
||||
private final Collection<GoRef> refs = Collections.synchronizedCollection(new HashSet<GoRef>());
|
||||
|
||||
void track(int refnum, GoObject obj) {
|
||||
refs.add(new GoRef(refnum, obj, this));
|
||||
}
|
||||
|
||||
GoRefQueue() {
|
||||
Thread daemon = new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
while (true) {
|
||||
try {
|
||||
GoRef ref = (GoRef)remove();
|
||||
refs.remove(ref);
|
||||
destroyRef(ref.refnum);
|
||||
ref.clear();
|
||||
} catch (InterruptedException e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
daemon.setDaemon(true);
|
||||
daemon.setName("GoRefQueue Finalizer Thread");
|
||||
daemon.start();
|
||||
}
|
||||
}
|
||||
|
||||
// A GoRef is a PhantomReference to a Java proxy for a Go object.
|
||||
// GoRefs are enqueued to the singleton GoRefQueue when no longer live,
|
||||
// so the corresponding reference count can be decremented.
|
||||
static class GoRef extends PhantomReference<GoObject> {
|
||||
final int refnum;
|
||||
|
||||
GoRef(int refnum, GoObject obj, GoRefQueue q) {
|
||||
super(obj, q);
|
||||
if (refnum > 0) {
|
||||
throw new RuntimeException("GoRef instantiated with a Java refnum " + refnum);
|
||||
}
|
||||
this.refnum = refnum;
|
||||
}
|
||||
}
|
||||
|
||||
// RefMap is a mapping of integers to Ref objects.
|
||||
//
|
||||
// The integers can be sparse. In Go this would be a map[int]*Ref.
|
||||
static final class RefMap {
|
||||
private int next = 0;
|
||||
private int live = 0;
|
||||
private int[] keys = new int[16];
|
||||
private Ref[] objs = new Ref[16];
|
||||
|
||||
RefMap() {}
|
||||
|
||||
Ref get(int key) {
|
||||
int i = Arrays.binarySearch(keys, 0, next, key);
|
||||
if (i >= 0) {
|
||||
return objs[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void remove(int key) {
|
||||
int i = Arrays.binarySearch(keys, 0, next, key);
|
||||
if (i >= 0) {
|
||||
if (objs[i] != null) {
|
||||
objs[i] = null;
|
||||
live--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void put(int key, Ref obj) {
|
||||
if (obj == null) {
|
||||
throw new RuntimeException("put a null ref (with key "+key+")");
|
||||
}
|
||||
int i = Arrays.binarySearch(keys, 0, next, key);
|
||||
if (i >= 0) {
|
||||
if (objs[i] == null) {
|
||||
objs[i] = obj;
|
||||
live++;
|
||||
}
|
||||
if (objs[i] != obj) {
|
||||
throw new RuntimeException("replacing an existing ref (with key "+key+")");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (next >= keys.length) {
|
||||
grow();
|
||||
i = Arrays.binarySearch(keys, 0, next, key);
|
||||
}
|
||||
i = ~i;
|
||||
if (i < next) {
|
||||
// Insert, shift everything afterwards down.
|
||||
System.arraycopy(keys, i, keys, i+1, next-i);
|
||||
System.arraycopy(objs, i, objs, i+1, next-i);
|
||||
}
|
||||
keys[i] = key;
|
||||
objs[i] = obj;
|
||||
live++;
|
||||
next++;
|
||||
}
|
||||
|
||||
private void grow() {
|
||||
// Compact and (if necessary) grow backing store.
|
||||
int[] newKeys;
|
||||
Ref[] newObjs;
|
||||
int len = 2*roundPow2(live);
|
||||
if (len > keys.length) {
|
||||
newKeys = new int[keys.length*2];
|
||||
newObjs = new Ref[objs.length*2];
|
||||
} else {
|
||||
newKeys = keys;
|
||||
newObjs = objs;
|
||||
}
|
||||
|
||||
int j = 0;
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (objs[i] != null) {
|
||||
newKeys[j] = keys[i];
|
||||
newObjs[j] = objs[i];
|
||||
j++;
|
||||
}
|
||||
}
|
||||
for (int i = j; i < newKeys.length; i++) {
|
||||
newKeys[i] = 0;
|
||||
newObjs[i] = null;
|
||||
}
|
||||
|
||||
keys = newKeys;
|
||||
objs = newObjs;
|
||||
next = j;
|
||||
|
||||
if (live != next) {
|
||||
throw new RuntimeException("bad state: live="+live+", next="+next);
|
||||
}
|
||||
}
|
||||
|
||||
private static int roundPow2(int x) {
|
||||
int p = 1;
|
||||
while (p < x) {
|
||||
p *= 2;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// Java class go.Universe is a proxy for talking to a Go program.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric
|
||||
package go;
|
||||
|
||||
import go.Seq;
|
||||
|
||||
public abstract class Universe {
|
||||
static {
|
||||
Seq.touch(); // for loading the native library
|
||||
_init();
|
||||
}
|
||||
|
||||
private Universe() {} // uninstantiable
|
||||
|
||||
// touch is called from other bound packages to initialize this package
|
||||
public static void touch() {}
|
||||
|
||||
private static native void _init();
|
||||
|
||||
private static final class proxyerror extends Exception implements Seq.Proxy, error {
|
||||
private final int refnum;
|
||||
|
||||
@Override public final int incRefnum() {
|
||||
Seq.incGoRef(refnum, this);
|
||||
return refnum;
|
||||
}
|
||||
|
||||
proxyerror(int refnum) { this.refnum = refnum; Seq.trackGoRef(refnum, this); }
|
||||
|
||||
@Override public String getMessage() { return error(); }
|
||||
|
||||
public native String error();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// Java class go.error is a proxy for talking to a Go program.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric
|
||||
package go;
|
||||
|
||||
import go.Seq;
|
||||
|
||||
public interface error {
|
||||
public String error();
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// Java class su.cin.rapvpn.fabric.fabricvpn.Fabricvpn is a proxy for talking to a Go program.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric github.com/example/remote-access-platform/agents/rap-node-agent/mobile/fabricvpn
|
||||
package su.cin.rapvpn.fabric.fabricvpn;
|
||||
|
||||
import go.Seq;
|
||||
|
||||
public abstract class Fabricvpn {
|
||||
static {
|
||||
Seq.touch(); // for loading the native library
|
||||
_init();
|
||||
}
|
||||
|
||||
private Fabricvpn() {} // uninstantiable
|
||||
|
||||
// touch is called from other bound packages to initialize this package
|
||||
public static void touch() {}
|
||||
|
||||
private static native void _init();
|
||||
|
||||
private static final class proxySocketProtector implements Seq.Proxy, SocketProtector {
|
||||
private final int refnum;
|
||||
|
||||
@Override public final int incRefnum() {
|
||||
Seq.incGoRef(refnum, this);
|
||||
return refnum;
|
||||
}
|
||||
|
||||
proxySocketProtector(int refnum) { this.refnum = refnum; Seq.trackGoRef(refnum, this); }
|
||||
|
||||
public native boolean protect(long fd);
|
||||
}
|
||||
|
||||
|
||||
public static native Manager newManager();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// Java class su.cin.rapvpn.fabric.fabricvpn.Manager is a proxy for talking to a Go program.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric github.com/example/remote-access-platform/agents/rap-node-agent/mobile/fabricvpn
|
||||
package su.cin.rapvpn.fabric.fabricvpn;
|
||||
|
||||
import go.Seq;
|
||||
|
||||
public final class Manager implements Seq.Proxy {
|
||||
static { Fabricvpn.touch(); }
|
||||
|
||||
private final int refnum;
|
||||
|
||||
@Override public final int incRefnum() {
|
||||
Seq.incGoRef(refnum, this);
|
||||
return refnum;
|
||||
}
|
||||
|
||||
public Manager() {
|
||||
this.refnum = __NewManager();
|
||||
Seq.trackGoRef(refnum, this);
|
||||
}
|
||||
|
||||
private static native int __NewManager();
|
||||
|
||||
Manager(int refnum) { this.refnum = refnum; Seq.trackGoRef(refnum, this); }
|
||||
|
||||
public native String controlRequest(String payloadJSON) throws Exception;
|
||||
public native byte[] receivePacket(long timeoutMillis) throws Exception;
|
||||
public native byte[] receivePacketBatchPayload(long timeoutMillis) throws Exception;
|
||||
public native void reconnect() throws Exception;
|
||||
public native void sendPacket(byte[] packet) throws Exception;
|
||||
public native void sendPacketBatchPayload(byte[] payload) throws Exception;
|
||||
public native void setSocketProtector(SocketProtector protector);
|
||||
public native String snapshotJSON();
|
||||
public native void start(String configJSON) throws Exception;
|
||||
public native void stop();
|
||||
public native void updateRuntimeConfig(String configJSON) throws Exception;
|
||||
@Override public boolean equals(Object o) {
|
||||
if (o == null || !(o instanceof Manager)) {
|
||||
return false;
|
||||
}
|
||||
Manager that = (Manager)o;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public int hashCode() {
|
||||
return java.util.Arrays.hashCode(new Object[] {});
|
||||
}
|
||||
|
||||
@Override public String toString() {
|
||||
StringBuilder b = new StringBuilder();
|
||||
b.append("Manager").append("{");
|
||||
return b.append("}").toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// Java class su.cin.rapvpn.fabric.fabricvpn.SocketProtector is a proxy for talking to a Go program.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric github.com/example/remote-access-platform/agents/rap-node-agent/mobile/fabricvpn
|
||||
package su.cin.rapvpn.fabric.fabricvpn;
|
||||
|
||||
import go.Seq;
|
||||
|
||||
public interface SocketProtector {
|
||||
public boolean protect(long fd);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// JNI functions for the Go <=> Java bridge.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric github.com/example/remote-access-platform/agents/rap-node-agent/mobile/fabricvpn
|
||||
|
||||
#include <android/log.h>
|
||||
#include <stdint.h>
|
||||
#include "seq.h"
|
||||
#include "_cgo_export.h"
|
||||
#include "fabricvpn.h"
|
||||
|
||||
jclass proxy_class_fabricvpn_SocketProtector;
|
||||
jmethodID proxy_class_fabricvpn_SocketProtector_cons;
|
||||
static jmethodID mid_SocketProtector_Protect;
|
||||
jclass proxy_class_fabricvpn_Manager;
|
||||
jmethodID proxy_class_fabricvpn_Manager_cons;
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Fabricvpn__1init(JNIEnv *env, jclass _unused) {
|
||||
jclass clazz;
|
||||
clazz = (*env)->FindClass(env, "su/cin/rapvpn/fabric/fabricvpn/Manager");
|
||||
proxy_class_fabricvpn_Manager = (*env)->NewGlobalRef(env, clazz);
|
||||
proxy_class_fabricvpn_Manager_cons = (*env)->GetMethodID(env, clazz, "<init>", "(I)V");
|
||||
clazz = (*env)->FindClass(env, "su/cin/rapvpn/fabric/fabricvpn/Fabricvpn$proxySocketProtector");
|
||||
proxy_class_fabricvpn_SocketProtector = (*env)->NewGlobalRef(env, clazz);
|
||||
proxy_class_fabricvpn_SocketProtector_cons = (*env)->GetMethodID(env, clazz, "<init>", "(I)V");
|
||||
clazz = (*env)->FindClass(env, "su/cin/rapvpn/fabric/fabricvpn/SocketProtector");
|
||||
mid_SocketProtector_Protect = (*env)->GetMethodID(env, clazz, "protect", "(J)Z");
|
||||
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Fabricvpn_newManager(JNIEnv* env, jclass _clazz) {
|
||||
int32_t r0 = proxyfabricvpn__NewManager();
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class_fabricvpn_Manager, proxy_class_fabricvpn_Manager_cons);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager__1_1NewManager(JNIEnv *env, jclass clazz) {
|
||||
int32_t refnum = proxyfabricvpn__NewManager();
|
||||
return refnum;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_controlRequest(JNIEnv* env, jobject __this__, jstring payloadJSON) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nstring _payloadJSON = go_seq_from_java_string(env, payloadJSON);
|
||||
struct proxyfabricvpn_Manager_ControlRequest_return res = proxyfabricvpn_Manager_ControlRequest(o, _payloadJSON);
|
||||
jstring _r0 = go_seq_to_java_string(env, res.r0);
|
||||
jobject _r1 = go_seq_from_refnum(env, res.r1, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r1);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_receivePacket(JNIEnv* env, jobject __this__, jlong timeoutMillis) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nint _timeoutMillis = (nint)timeoutMillis;
|
||||
struct proxyfabricvpn_Manager_ReceivePacket_return res = proxyfabricvpn_Manager_ReceivePacket(o, _timeoutMillis);
|
||||
jbyteArray _r0 = go_seq_to_java_bytearray(env, res.r0, 1);
|
||||
jobject _r1 = go_seq_from_refnum(env, res.r1, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r1);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_receivePacketBatchPayload(JNIEnv* env, jobject __this__, jlong timeoutMillis) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nint _timeoutMillis = (nint)timeoutMillis;
|
||||
struct proxyfabricvpn_Manager_ReceivePacketBatchPayload_return res = proxyfabricvpn_Manager_ReceivePacketBatchPayload(o, _timeoutMillis);
|
||||
jbyteArray _r0 = go_seq_to_java_bytearray(env, res.r0, 1);
|
||||
jobject _r1 = go_seq_from_refnum(env, res.r1, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r1);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_reconnect(JNIEnv* env, jobject __this__) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
int32_t r0 = proxyfabricvpn_Manager_Reconnect(o);
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r0);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_sendPacket(JNIEnv* env, jobject __this__, jbyteArray packet) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nbyteslice _packet = go_seq_from_java_bytearray(env, packet, 0);
|
||||
int32_t r0 = proxyfabricvpn_Manager_SendPacket(o, _packet);
|
||||
go_seq_release_byte_array(env, packet, _packet.ptr);
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r0);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_sendPacketBatchPayload(JNIEnv* env, jobject __this__, jbyteArray payload) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nbyteslice _payload = go_seq_from_java_bytearray(env, payload, 0);
|
||||
int32_t r0 = proxyfabricvpn_Manager_SendPacketBatchPayload(o, _payload);
|
||||
go_seq_release_byte_array(env, payload, _payload.ptr);
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r0);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_setSocketProtector(JNIEnv* env, jobject __this__, jobject protector) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
int32_t _protector = go_seq_to_refnum(env, protector);
|
||||
proxyfabricvpn_Manager_SetSocketProtector(o, _protector);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_snapshotJSON(JNIEnv* env, jobject __this__) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nstring r0 = proxyfabricvpn_Manager_SnapshotJSON(o);
|
||||
jstring _r0 = go_seq_to_java_string(env, r0);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_start(JNIEnv* env, jobject __this__, jstring configJSON) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nstring _configJSON = go_seq_from_java_string(env, configJSON);
|
||||
int32_t r0 = proxyfabricvpn_Manager_Start(o, _configJSON);
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r0);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_stop(JNIEnv* env, jobject __this__) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
proxyfabricvpn_Manager_Stop(o);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Manager_updateRuntimeConfig(JNIEnv* env, jobject __this__, jstring configJSON) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nstring _configJSON = go_seq_from_java_string(env, configJSON);
|
||||
int32_t r0 = proxyfabricvpn_Manager_UpdateRuntimeConfig(o, _configJSON);
|
||||
jobject _r0 = go_seq_from_refnum(env, r0, proxy_class__error, proxy_class__error_cons);
|
||||
go_seq_maybe_throw_exception(env, _r0);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_su_cin_rapvpn_fabric_fabricvpn_Fabricvpn_00024proxySocketProtector_protect(JNIEnv* env, jobject __this__, jlong fd) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
int64_t _fd = (int64_t)fd;
|
||||
char r0 = proxyfabricvpn_SocketProtector_Protect(o, _fd);
|
||||
jboolean _r0 = r0 ? JNI_TRUE : JNI_FALSE;
|
||||
return _r0;
|
||||
}
|
||||
|
||||
char cproxyfabricvpn_SocketProtector_Protect(int32_t refnum, int64_t fd) {
|
||||
JNIEnv *env = go_seq_push_local_frame(1);
|
||||
jobject o = go_seq_from_refnum(env, refnum, proxy_class_fabricvpn_SocketProtector, proxy_class_fabricvpn_SocketProtector_cons);
|
||||
jlong _fd = (jlong)fd;
|
||||
jboolean res = (*env)->CallBooleanMethod(env, o, mid_SocketProtector_Protect, _fd);
|
||||
char _res = (char)res;
|
||||
go_seq_pop_local_frame(env);
|
||||
return _res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// JNI function headers for the Go <=> Java bridge.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric github.com/example/remote-access-platform/agents/rap-node-agent/mobile/fabricvpn
|
||||
|
||||
#ifndef __Fabricvpn_H__
|
||||
#define __Fabricvpn_H__
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
extern jclass proxy_class_fabricvpn_SocketProtector;
|
||||
extern jmethodID proxy_class_fabricvpn_SocketProtector_cons;
|
||||
|
||||
char cproxyfabricvpn_SocketProtector_Protect(int32_t refnum, int64_t fd);
|
||||
|
||||
extern jclass proxy_class_fabricvpn_Manager;
|
||||
extern jmethodID proxy_class_fabricvpn_Manager_cons;
|
||||
#endif
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright 2016 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// C support functions for bindings. This file is copied into the
|
||||
// generated gomobile_bind package and compiled along with the
|
||||
// generated binding files.
|
||||
|
||||
#include <android/log.h>
|
||||
#include <errno.h>
|
||||
#include <jni.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <pthread.h>
|
||||
#include "seq.h"
|
||||
#include "_cgo_export.h"
|
||||
|
||||
#define NULL_REFNUM 41
|
||||
|
||||
// initClasses are only exported from Go if reverse bindings are used.
|
||||
// If they're not, weakly define a no-op function.
|
||||
__attribute__((weak)) void initClasses(void) { }
|
||||
|
||||
static JavaVM *jvm;
|
||||
// jnienvs holds the per-thread JNIEnv* for Go threads where we called AttachCurrentThread.
|
||||
// A pthread key destructor is supplied to call DetachCurrentThread on exit. This trick is
|
||||
// documented in http://developer.android.com/training/articles/perf-jni.html under "Threads".
|
||||
static pthread_key_t jnienvs;
|
||||
|
||||
static jclass seq_class;
|
||||
static jmethodID seq_getRef;
|
||||
static jmethodID seq_decRef;
|
||||
static jmethodID seq_incRef;
|
||||
static jmethodID seq_incGoObjectRef;
|
||||
static jmethodID seq_incRefnum;
|
||||
|
||||
static jfieldID ref_objField;
|
||||
|
||||
static jclass throwable_class;
|
||||
|
||||
// env_destructor is registered as a thread data key destructor to
|
||||
// clean up a Go thread that is attached to the JVM.
|
||||
static void env_destructor(void *env) {
|
||||
if ((*jvm)->DetachCurrentThread(jvm) != JNI_OK) {
|
||||
LOG_INFO("failed to detach current thread");
|
||||
}
|
||||
}
|
||||
|
||||
static JNIEnv *go_seq_get_thread_env(void) {
|
||||
JNIEnv *env;
|
||||
jint ret = (*jvm)->GetEnv(jvm, (void **)&env, JNI_VERSION_1_6);
|
||||
if (ret != JNI_OK) {
|
||||
if (ret != JNI_EDETACHED) {
|
||||
LOG_FATAL("failed to get thread env");
|
||||
}
|
||||
if ((*jvm)->AttachCurrentThread(jvm, &env, NULL) != JNI_OK) {
|
||||
LOG_FATAL("failed to attach current thread");
|
||||
}
|
||||
pthread_setspecific(jnienvs, env);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
void go_seq_maybe_throw_exception(JNIEnv *env, jobject exc) {
|
||||
if (exc != NULL) {
|
||||
(*env)->Throw(env, exc);
|
||||
}
|
||||
}
|
||||
|
||||
jobject go_seq_get_exception(JNIEnv *env) {
|
||||
jthrowable exc = (*env)->ExceptionOccurred(env);
|
||||
if (!exc) {
|
||||
return NULL;
|
||||
}
|
||||
(*env)->ExceptionClear(env);
|
||||
return exc;
|
||||
}
|
||||
|
||||
jbyteArray go_seq_to_java_bytearray(JNIEnv *env, nbyteslice s, int copy) {
|
||||
if (s.ptr == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
jbyteArray res = (*env)->NewByteArray(env, s.len);
|
||||
if (res == NULL) {
|
||||
LOG_FATAL("NewByteArray failed");
|
||||
}
|
||||
(*env)->SetByteArrayRegion(env, res, 0, s.len, s.ptr);
|
||||
if (copy) {
|
||||
free(s.ptr);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
#define surr1 0xd800
|
||||
#define surr2 0xdc00
|
||||
#define surr3 0xe000
|
||||
|
||||
// Unicode replacement character
|
||||
#define replacementChar 0xFFFD
|
||||
|
||||
#define rune1Max ((1<<7) - 1)
|
||||
#define rune2Max ((1<<11) - 1)
|
||||
#define rune3Max ((1<<16) - 1)
|
||||
// Maximum valid Unicode code point.
|
||||
#define MaxRune 0x0010FFFF
|
||||
|
||||
#define surrogateMin 0xD800
|
||||
#define surrogateMax 0xDFFF
|
||||
// 0011 1111
|
||||
#define maskx 0x3F
|
||||
// 1000 0000
|
||||
#define tx 0x80
|
||||
// 1100 0000
|
||||
#define t2 0xC0
|
||||
// 1110 0000
|
||||
#define t3 0xE0
|
||||
// 1111 0000
|
||||
#define t4 0xF0
|
||||
|
||||
// encode_rune writes into p (which must be large enough) the UTF-8 encoding
|
||||
// of the rune. It returns the number of bytes written.
|
||||
static int encode_rune(uint8_t *p, uint32_t r) {
|
||||
if (r <= rune1Max) {
|
||||
p[0] = (uint8_t)r;
|
||||
return 1;
|
||||
} else if (r <= rune2Max) {
|
||||
p[0] = t2 | (uint8_t)(r>>6);
|
||||
p[1] = tx | (((uint8_t)(r))&maskx);
|
||||
return 2;
|
||||
} else {
|
||||
if (r > MaxRune || (surrogateMin <= r && r <= surrogateMax)) {
|
||||
r = replacementChar;
|
||||
}
|
||||
if (r <= rune3Max) {
|
||||
p[0] = t3 | (uint8_t)(r>>12);
|
||||
p[1] = tx | (((uint8_t)(r>>6))&maskx);
|
||||
p[2] = tx | (((uint8_t)(r))&maskx);
|
||||
return 3;
|
||||
} else {
|
||||
p[0] = t4 | (uint8_t)(r>>18);
|
||||
p[1] = tx | (((uint8_t)(r>>12))&maskx);
|
||||
p[2] = tx | (((uint8_t)(r>>6))&maskx);
|
||||
p[3] = tx | (((uint8_t)(r))&maskx);
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// utf16_decode decodes an array of UTF16 characters to a UTF-8 encoded
|
||||
// nstring copy. The support functions and utf16_decode itself are heavily
|
||||
// based on the unicode/utf8 and unicode/utf16 Go packages.
|
||||
static nstring utf16_decode(jchar *chars, jsize len) {
|
||||
jsize worstCaseLen = 4*len;
|
||||
uint8_t *buf = malloc(worstCaseLen);
|
||||
if (buf == NULL) {
|
||||
LOG_FATAL("utf16Decode: malloc failed");
|
||||
}
|
||||
jsize nsrc = 0;
|
||||
jsize ndst = 0;
|
||||
while (nsrc < len) {
|
||||
uint32_t r = chars[nsrc];
|
||||
nsrc++;
|
||||
if (surr1 <= r && r < surr2 && nsrc < len) {
|
||||
uint32_t r2 = chars[nsrc];
|
||||
if (surr2 <= r2 && r2 < surr3) {
|
||||
nsrc++;
|
||||
r = (((r-surr1)<<10) | (r2 - surr2)) + 0x10000;
|
||||
}
|
||||
}
|
||||
if (ndst + 4 > worstCaseLen) {
|
||||
LOG_FATAL("utf16Decode: buffer overflow");
|
||||
}
|
||||
ndst += encode_rune(buf + ndst, r);
|
||||
}
|
||||
struct nstring res = {.chars = buf, .len = ndst};
|
||||
return res;
|
||||
}
|
||||
|
||||
nstring go_seq_from_java_string(JNIEnv *env, jstring str) {
|
||||
struct nstring res = {NULL, 0};
|
||||
if (str == NULL) {
|
||||
return res;
|
||||
}
|
||||
jsize nchars = (*env)->GetStringLength(env, str);
|
||||
if (nchars == 0) {
|
||||
return res;
|
||||
}
|
||||
jchar *chars = (jchar *)(*env)->GetStringChars(env, str, NULL);
|
||||
if (chars == NULL) {
|
||||
LOG_FATAL("GetStringChars failed");
|
||||
}
|
||||
nstring nstr = utf16_decode(chars, nchars);
|
||||
(*env)->ReleaseStringChars(env, str, chars);
|
||||
return nstr;
|
||||
}
|
||||
|
||||
nbyteslice go_seq_from_java_bytearray(JNIEnv *env, jbyteArray arr, int copy) {
|
||||
struct nbyteslice res = {NULL, 0};
|
||||
if (arr == NULL) {
|
||||
return res;
|
||||
}
|
||||
|
||||
jsize len = (*env)->GetArrayLength(env, arr);
|
||||
if (len == 0) {
|
||||
return res;
|
||||
}
|
||||
jbyte *ptr = (*env)->GetByteArrayElements(env, arr, NULL);
|
||||
if (ptr == NULL) {
|
||||
LOG_FATAL("GetByteArrayElements failed");
|
||||
}
|
||||
if (copy) {
|
||||
void *ptr_copy = (void *)malloc(len);
|
||||
if (ptr_copy == NULL) {
|
||||
LOG_FATAL("malloc failed");
|
||||
}
|
||||
memcpy(ptr_copy, ptr, len);
|
||||
(*env)->ReleaseByteArrayElements(env, arr, ptr, JNI_ABORT);
|
||||
ptr = (jbyte *)ptr_copy;
|
||||
}
|
||||
res.ptr = ptr;
|
||||
res.len = len;
|
||||
return res;
|
||||
}
|
||||
|
||||
int32_t go_seq_to_refnum_go(JNIEnv *env, jobject o) {
|
||||
if (o == NULL) {
|
||||
return NULL_REFNUM;
|
||||
}
|
||||
return (int32_t)(*env)->CallStaticIntMethod(env, seq_class, seq_incGoObjectRef, o);
|
||||
}
|
||||
|
||||
int32_t go_seq_to_refnum(JNIEnv *env, jobject o) {
|
||||
if (o == NULL) {
|
||||
return NULL_REFNUM;
|
||||
}
|
||||
return (int32_t)(*env)->CallStaticIntMethod(env, seq_class, seq_incRef, o);
|
||||
}
|
||||
|
||||
int32_t go_seq_unwrap(jint refnum) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jobject jobj = go_seq_from_refnum(env, refnum, NULL, NULL);
|
||||
int32_t goref = go_seq_to_refnum_go(env, jobj);
|
||||
go_seq_pop_local_frame(env);
|
||||
return goref;
|
||||
}
|
||||
|
||||
jobject go_seq_from_refnum(JNIEnv *env, int32_t refnum, jclass proxy_class, jmethodID proxy_cons) {
|
||||
if (refnum == NULL_REFNUM) {
|
||||
return NULL;
|
||||
}
|
||||
if (refnum < 0) { // Go object
|
||||
// return new <Proxy>(refnum)
|
||||
return (*env)->NewObject(env, proxy_class, proxy_cons, refnum);
|
||||
}
|
||||
// Seq.Ref ref = Seq.getRef(refnum)
|
||||
jobject ref = (*env)->CallStaticObjectMethod(env, seq_class, seq_getRef, (jint)refnum);
|
||||
if (ref == NULL) {
|
||||
LOG_FATAL("Unknown reference: %d", refnum);
|
||||
}
|
||||
// Go incremented the reference count just before passing the refnum. Decrement it here.
|
||||
(*env)->CallStaticVoidMethod(env, seq_class, seq_decRef, (jint)refnum);
|
||||
// return ref.obj
|
||||
return (*env)->GetObjectField(env, ref, ref_objField);
|
||||
}
|
||||
|
||||
// go_seq_to_java_string converts a nstring to a jstring.
|
||||
jstring go_seq_to_java_string(JNIEnv *env, nstring str) {
|
||||
jstring s = (*env)->NewString(env, str.chars, str.len/2);
|
||||
if (str.chars != NULL) {
|
||||
free(str.chars);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// go_seq_push_local_frame retrieves or creates the JNIEnv* for the current thread
|
||||
// and pushes a JNI reference frame. Must be matched with call to go_seq_pop_local_frame.
|
||||
JNIEnv *go_seq_push_local_frame(jint nargs) {
|
||||
JNIEnv *env = go_seq_get_thread_env();
|
||||
// Given the number of function arguments, compute a conservative bound for the minimal frame size.
|
||||
// Assume two slots for each per parameter (Seq.Ref and Seq.Object) and add extra
|
||||
// extra space for the receiver, the return value, and exception (if any).
|
||||
jint frameSize = 2*nargs + 10;
|
||||
if ((*env)->PushLocalFrame(env, frameSize) < 0) {
|
||||
LOG_FATAL("PushLocalFrame failed");
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// Pop the current local frame, freeing all JNI local references in it
|
||||
void go_seq_pop_local_frame(JNIEnv *env) {
|
||||
(*env)->PopLocalFrame(env, NULL);
|
||||
}
|
||||
|
||||
void go_seq_inc_ref(int32_t ref) {
|
||||
JNIEnv *env = go_seq_get_thread_env();
|
||||
(*env)->CallStaticVoidMethod(env, seq_class, seq_incRefnum, (jint)ref);
|
||||
}
|
||||
|
||||
void go_seq_dec_ref(int32_t ref) {
|
||||
JNIEnv *env = go_seq_get_thread_env();
|
||||
(*env)->CallStaticVoidMethod(env, seq_class, seq_decRef, (jint)ref);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_go_Seq_init(JNIEnv *env, jclass clazz) {
|
||||
if ((*env)->GetJavaVM(env, &jvm) != 0) {
|
||||
LOG_FATAL("failed to get JVM");
|
||||
}
|
||||
if (pthread_key_create(&jnienvs, env_destructor) != 0) {
|
||||
LOG_FATAL("failed to initialize jnienvs thread local storage");
|
||||
}
|
||||
|
||||
seq_class = (*env)->NewGlobalRef(env, clazz);
|
||||
seq_getRef = (*env)->GetStaticMethodID(env, seq_class, "getRef", "(I)Lgo/Seq$Ref;");
|
||||
if (seq_getRef == NULL) {
|
||||
LOG_FATAL("failed to find method Seq.getRef");
|
||||
}
|
||||
seq_decRef = (*env)->GetStaticMethodID(env, seq_class, "decRef", "(I)V");
|
||||
if (seq_decRef == NULL) {
|
||||
LOG_FATAL("failed to find method Seq.decRef");
|
||||
}
|
||||
seq_incRefnum = (*env)->GetStaticMethodID(env, seq_class, "incRefnum", "(I)V");
|
||||
if (seq_incRefnum == NULL) {
|
||||
LOG_FATAL("failed to find method Seq.incRefnum");
|
||||
}
|
||||
seq_incRef = (*env)->GetStaticMethodID(env, seq_class, "incRef", "(Ljava/lang/Object;)I");
|
||||
if (seq_incRef == NULL) {
|
||||
LOG_FATAL("failed to find method Seq.incRef");
|
||||
}
|
||||
seq_incGoObjectRef = (*env)->GetStaticMethodID(env, seq_class, "incGoObjectRef", "(Lgo/Seq$GoObject;)I");
|
||||
if (seq_incGoObjectRef == NULL) {
|
||||
LOG_FATAL("failed to find method Seq.incGoObjectRef");
|
||||
}
|
||||
jclass ref_class = (*env)->FindClass(env, "go/Seq$Ref");
|
||||
if (ref_class == NULL) {
|
||||
LOG_FATAL("failed to find the Seq.Ref class");
|
||||
}
|
||||
ref_objField = (*env)->GetFieldID(env, ref_class, "obj", "Ljava/lang/Object;");
|
||||
if (ref_objField == NULL) {
|
||||
LOG_FATAL("failed to find the Seq.Ref.obj field");
|
||||
}
|
||||
initClasses();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_go_Seq_destroyRef(JNIEnv *env, jclass clazz, jint refnum) {
|
||||
DestroyRef(refnum);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_go_Seq_incGoRef(JNIEnv *env, jclass clazz, jint refnum, jobject ref) {
|
||||
IncGoRef(refnum);
|
||||
}
|
||||
|
||||
jclass go_seq_find_class(const char *name) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jclass clazz = (*env)->FindClass(env, name);
|
||||
if (clazz == NULL) {
|
||||
(*env)->ExceptionClear(env);
|
||||
} else {
|
||||
clazz = (*env)->NewGlobalRef(env, clazz);
|
||||
}
|
||||
go_seq_pop_local_frame(env);
|
||||
return clazz;
|
||||
}
|
||||
|
||||
jmethodID go_seq_get_static_method_id(jclass clazz, const char *name, const char *sig) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jmethodID m = (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||
if (m == NULL) {
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
go_seq_pop_local_frame(env);
|
||||
return m;
|
||||
}
|
||||
|
||||
jmethodID go_seq_get_method_id(jclass clazz, const char *name, const char *sig) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jmethodID m = (*env)->GetMethodID(env, clazz, name, sig);
|
||||
if (m == NULL) {
|
||||
(*env)->ExceptionClear(env);
|
||||
}
|
||||
go_seq_pop_local_frame(env);
|
||||
return m;
|
||||
}
|
||||
|
||||
void go_seq_release_byte_array(JNIEnv *env, jbyteArray arr, jbyte* ptr) {
|
||||
if (ptr != NULL) {
|
||||
(*env)->ReleaseByteArrayElements(env, arr, ptr, 0);
|
||||
}
|
||||
}
|
||||
|
||||
int go_seq_isinstanceof(jint refnum, jclass clazz) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jobject obj = go_seq_from_refnum(env, refnum, NULL, NULL);
|
||||
jboolean isinst = (*env)->IsInstanceOf(env, obj, clazz);
|
||||
go_seq_pop_local_frame(env);
|
||||
return isinst;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2016 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
// Go support functions for bindings. This file is copied into the
|
||||
// generated main package and compiled along with the generated binding
|
||||
// files.
|
||||
|
||||
//#cgo CFLAGS: -Werror
|
||||
//#cgo LDFLAGS: -llog
|
||||
//#include <jni.h>
|
||||
//#include <stdint.h>
|
||||
//#include <stdlib.h>
|
||||
//#include "seq_android.h"
|
||||
import "C"
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/mobile/bind/seq"
|
||||
)
|
||||
|
||||
// DestroyRef is called by Java to inform Go it is done with a reference.
|
||||
//export DestroyRef
|
||||
func DestroyRef(refnum C.int32_t) {
|
||||
seq.Delete(int32(refnum))
|
||||
}
|
||||
|
||||
// encodeString returns a copy of a Go string as a UTF16 encoded nstring.
|
||||
// The returned data is freed in go_seq_to_java_string.
|
||||
//
|
||||
// encodeString uses UTF16 as the intermediate format. Note that UTF8 is an obvious
|
||||
// alternative, but JNI only supports a C-safe variant of UTF8 (modified UTF8).
|
||||
func encodeString(s string) C.nstring {
|
||||
n := C.int(len(s))
|
||||
if n == 0 {
|
||||
return C.nstring{}
|
||||
}
|
||||
// Allocate enough for the worst case estimate, every character is a surrogate pair
|
||||
worstCaseLen := 4 * len(s)
|
||||
utf16buf := C.malloc(C.size_t(worstCaseLen))
|
||||
if utf16buf == nil {
|
||||
panic("encodeString: malloc failed")
|
||||
}
|
||||
chars := (*[1<<30 - 1]uint16)(unsafe.Pointer(utf16buf))[:worstCaseLen/2 : worstCaseLen/2]
|
||||
nchars := seq.UTF16Encode(s, chars)
|
||||
return C.nstring{chars: unsafe.Pointer(utf16buf), len: C.jsize(nchars*2)}
|
||||
}
|
||||
|
||||
// decodeString decodes a UTF8 encoded nstring to a Go string. The data
|
||||
// in str is freed after use.
|
||||
func decodeString(str C.nstring) string {
|
||||
if str.chars == nil {
|
||||
return ""
|
||||
}
|
||||
chars := (*[1<<31 - 1]byte)(str.chars)[:str.len]
|
||||
s := string(chars)
|
||||
C.free(str.chars)
|
||||
return s
|
||||
}
|
||||
|
||||
// fromSlice converts a slice to a jbyteArray cast as a nbyteslice. If cpy
|
||||
// is set, the returned slice is a copy to be free by go_seq_to_java_bytearray.
|
||||
func fromSlice(s []byte, cpy bool) C.nbyteslice {
|
||||
if s == nil || len(s) == 0 {
|
||||
return C.nbyteslice{}
|
||||
}
|
||||
var ptr *C.jbyte
|
||||
n := C.jsize(len(s))
|
||||
if cpy {
|
||||
ptr = (*C.jbyte)(C.malloc(C.size_t(n)))
|
||||
if ptr == nil {
|
||||
panic("fromSlice: malloc failed")
|
||||
}
|
||||
copy((*[1<<31 - 1]byte)(unsafe.Pointer(ptr))[:n], s)
|
||||
} else {
|
||||
ptr = (*C.jbyte)(unsafe.Pointer(&s[0]))
|
||||
}
|
||||
return C.nbyteslice{ptr: unsafe.Pointer(ptr), len: n}
|
||||
}
|
||||
|
||||
// toSlice takes a nbyteslice (jbyteArray) and returns a byte slice
|
||||
// with the data. If cpy is set, the slice contains a copy of the data and is
|
||||
// freed.
|
||||
func toSlice(s C.nbyteslice, cpy bool) []byte {
|
||||
if s.ptr == nil || s.len == 0 {
|
||||
return nil
|
||||
}
|
||||
var b []byte
|
||||
if cpy {
|
||||
b = C.GoBytes(s.ptr, C.int(s.len))
|
||||
C.free(s.ptr)
|
||||
} else {
|
||||
b = (*[1<<31 - 1]byte)(unsafe.Pointer(s.ptr))[:s.len:s.len]
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2016 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
#ifndef __GO_SEQ_ANDROID_HDR__
|
||||
#define __GO_SEQ_ANDROID_HDR__
|
||||
|
||||
#include <stdint.h>
|
||||
#include <android/log.h>
|
||||
// For abort()
|
||||
#include <stdlib.h>
|
||||
#include <jni.h>
|
||||
|
||||
#define LOG_INFO(...) __android_log_print(ANDROID_LOG_INFO, "go/Seq", __VA_ARGS__)
|
||||
#define LOG_FATAL(...) \
|
||||
{ \
|
||||
__android_log_print(ANDROID_LOG_FATAL, "go/Seq", __VA_ARGS__); \
|
||||
abort(); \
|
||||
}
|
||||
|
||||
// Platform specific types
|
||||
typedef struct nstring {
|
||||
// UTF16 or UTF8 Encoded string. When converting from Java string to Go
|
||||
// string, UTF16. When converting from Go to Java, UTF8.
|
||||
void *chars;
|
||||
// length in bytes, regardless of encoding
|
||||
jsize len;
|
||||
} nstring;
|
||||
typedef struct nbyteslice {
|
||||
void *ptr;
|
||||
jsize len;
|
||||
} nbyteslice;
|
||||
typedef jlong nint;
|
||||
|
||||
extern void go_seq_dec_ref(int32_t ref);
|
||||
extern void go_seq_inc_ref(int32_t ref);
|
||||
// go_seq_unwrap takes a reference number to a Java wrapper and returns
|
||||
// a reference number to its wrapped Go object.
|
||||
extern int32_t go_seq_unwrap(jint refnum);
|
||||
extern int32_t go_seq_to_refnum(JNIEnv *env, jobject o);
|
||||
extern int32_t go_seq_to_refnum_go(JNIEnv *env, jobject o);
|
||||
extern jobject go_seq_from_refnum(JNIEnv *env, int32_t refnum, jclass proxy_class, jmethodID proxy_cons);
|
||||
|
||||
extern void go_seq_maybe_throw_exception(JNIEnv *env, jobject msg);
|
||||
// go_seq_get_exception returns any pending exception and clears the exception status.
|
||||
extern jobject go_seq_get_exception(JNIEnv *env);
|
||||
|
||||
extern jbyteArray go_seq_to_java_bytearray(JNIEnv *env, nbyteslice s, int copy);
|
||||
extern nbyteslice go_seq_from_java_bytearray(JNIEnv *env, jbyteArray s, int copy);
|
||||
extern void go_seq_release_byte_array(JNIEnv *env, jbyteArray arr, jbyte* ptr);
|
||||
|
||||
extern jstring go_seq_to_java_string(JNIEnv *env, nstring str);
|
||||
extern nstring go_seq_from_java_string(JNIEnv *env, jstring s);
|
||||
|
||||
// push_local_frame retrieves or creates the JNIEnv* for the current thread
|
||||
// and pushes a JNI reference frame. Must be matched with call to pop_local_frame.
|
||||
extern JNIEnv *go_seq_push_local_frame(jint cap);
|
||||
// Pop the current local frame, releasing all JNI local references in it
|
||||
extern void go_seq_pop_local_frame(JNIEnv *env);
|
||||
|
||||
// Return a global reference to the given class. Return NULL and clear exception if not found.
|
||||
extern jclass go_seq_find_class(const char *name);
|
||||
extern jmethodID go_seq_get_static_method_id(jclass clazz, const char *name, const char *sig);
|
||||
extern jmethodID go_seq_get_method_id(jclass clazz, const char *name, const char *sig);
|
||||
extern int go_seq_isinstanceof(jint refnum, jclass clazz);
|
||||
|
||||
#endif // __GO_SEQ_ANDROID_HDR__
|
||||
@@ -0,0 +1,43 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// JNI functions for the Go <=> Java bridge.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric
|
||||
|
||||
#include <android/log.h>
|
||||
#include <stdint.h>
|
||||
#include "seq.h"
|
||||
#include "_cgo_export.h"
|
||||
#include "universe.h"
|
||||
|
||||
jclass proxy_class__error;
|
||||
jmethodID proxy_class__error_cons;
|
||||
static jmethodID mid_error_Error;
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_go_Universe__1init(JNIEnv *env, jclass _unused) {
|
||||
jclass clazz;
|
||||
clazz = (*env)->FindClass(env, "go/Universe$proxyerror");
|
||||
proxy_class__error = (*env)->NewGlobalRef(env, clazz);
|
||||
proxy_class__error_cons = (*env)->GetMethodID(env, clazz, "<init>", "(I)V");
|
||||
clazz = (*env)->FindClass(env, "java/lang/Throwable");
|
||||
mid_error_Error = (*env)->GetMethodID(env, clazz, "getMessage", "()Ljava/lang/String;");
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_go_Universe_00024proxyerror_error(JNIEnv* env, jobject __this__) {
|
||||
int32_t o = go_seq_to_refnum_go(env, __this__);
|
||||
nstring r0 = proxy_error_Error(o);
|
||||
jstring _r0 = go_seq_to_java_string(env, r0);
|
||||
return _r0;
|
||||
}
|
||||
|
||||
nstring cproxy_error_Error(int32_t refnum) {
|
||||
JNIEnv *env = go_seq_push_local_frame(0);
|
||||
jobject o = go_seq_from_refnum(env, refnum, proxy_class__error, proxy_class__error_cons);
|
||||
jstring res = (*env)->CallObjectMethod(env, o, mid_error_Error);
|
||||
nstring _res = go_seq_from_java_string(env, res);
|
||||
go_seq_pop_local_frame(env);
|
||||
return _res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Code generated by gobind. DO NOT EDIT.
|
||||
|
||||
// JNI function headers for the Go <=> Java bridge.
|
||||
//
|
||||
// autogenerated by gobind -lang=java -javapkg=su.cin.rapvpn.fabric
|
||||
|
||||
#ifndef __Universe_H__
|
||||
#define __Universe_H__
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
extern jclass proxy_class__error;
|
||||
extern jmethodID proxy_class__error_cons;
|
||||
|
||||
nstring cproxy_error_Error(int32_t refnum);
|
||||
|
||||
#endif
|
||||
@@ -34,10 +34,10 @@ Implemented:
|
||||
- reliable fabric/control queue rejection when full
|
||||
- bounded non-production `synthetic.echo` test-service path
|
||||
- direct, single-relay, and forced-fallback test-service proofs
|
||||
- live HTTP peer transport for synthetic mesh envelopes
|
||||
- disabled-by-default synthetic mesh HTTP endpoint in `rap-node-agent`
|
||||
- live QUIC peer transport for synthetic mesh envelopes
|
||||
- disabled-by-default synthetic mesh QUIC endpoint in `rap-node-agent`
|
||||
- `mesh-live-smoke` harness proving direct and single-relay synthetic traffic
|
||||
over real local HTTP endpoints
|
||||
over real local QUIC endpoints
|
||||
- scoped synthetic mesh config file loading for peer endpoints and routes
|
||||
- Control Plane synthetic mesh config read fallback when no local scoped config
|
||||
file is set
|
||||
@@ -46,7 +46,7 @@ Implemented:
|
||||
- explicit production mesh forwarding gate config; production forwarding still
|
||||
has no runtime implementation and remains unavailable
|
||||
- route-bound production mesh envelope contract and fail-closed validation on
|
||||
`/mesh/v1/forward`
|
||||
the QUIC production-forward path
|
||||
- metadata-only production envelope observation hook for valid envelopes, still
|
||||
without forwarding payloads
|
||||
- bounded metadata-only production envelope observation sink for accepted
|
||||
@@ -93,7 +93,7 @@ Implemented:
|
||||
- bounded peer recovery planner over peer cache and connection states
|
||||
- peer connection intent planner with transport readiness classification
|
||||
- peer connection manager for real control-plane health over reusable
|
||||
HTTP keep-alive transport
|
||||
QUIC fabric transport
|
||||
- route-health effective-path runtime through replacement relay control paths
|
||||
|
||||
Not implemented yet:
|
||||
@@ -125,35 +125,30 @@ state directory. On Linux it also installs a systemd `update-loop` service by
|
||||
default, so nodes continue to update from Control Plane policy without operator
|
||||
commands on each host.
|
||||
|
||||
Preferred profile-based install:
|
||||
Preferred fabric-native install:
|
||||
|
||||
```bash
|
||||
rap-host-agent install \
|
||||
--profile-url https://control.example.com/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--install-token <one_time_install_token> \
|
||||
--node-name docker-node-1
|
||||
--bootstrap-bundle ./docker-node-1.bootstrap.json
|
||||
```
|
||||
|
||||
The host-agent exchanges the install token for a signed control-plane install
|
||||
profile, then applies Docker image, container, state-dir, mesh listen,
|
||||
advertise, NAT/connectivity, and region settings from that profile. The same
|
||||
token is then used by the node-agent for first enrollment, so the operator does
|
||||
not need to manually pass cluster/runtime flags.
|
||||
Offline/import bootstrap is also supported:
|
||||
|
||||
```bash
|
||||
rap-host-agent install \
|
||||
--bootstrap-bundle ./docker-node-1.bootstrap.json
|
||||
```
|
||||
|
||||
The bootstrap bundle carries the signed install profile, pinned cluster
|
||||
authority key, and QUIC fabric registry seeds. The host-agent applies Docker
|
||||
image, container, state-dir, mesh listen, advertise, NAT/connectivity, and
|
||||
region settings locally, then the node-agent enrolls through QUIC fabric.
|
||||
|
||||
Manual install is still supported:
|
||||
|
||||
```bash
|
||||
rap-host-agent install \
|
||||
--backend-url http://192.168.200.61:18080/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--join-token <raw_join_token> \
|
||||
--node-name docker-node-1 \
|
||||
--image rap-node-agent:dev-enrollment-bootstrap-smoke \
|
||||
--container-name rap-node-agent-docker-node-1 \
|
||||
--state-dir /var/lib/rap/nodes/docker-node-1 \
|
||||
--network host \
|
||||
--replace
|
||||
--bootstrap-bundle ./docker-node-1.bootstrap.json
|
||||
```
|
||||
|
||||
The command creates or replaces only the local Docker container. The running
|
||||
@@ -175,8 +170,6 @@ local updater service without recreating the node-agent container:
|
||||
|
||||
```bash
|
||||
rap-host-agent install-updater \
|
||||
--backend-url http://192.168.200.61:18080/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--state-dir /var/lib/rap/nodes/docker-node-1 \
|
||||
--container-name rap-node-agent-docker-node-1
|
||||
```
|
||||
@@ -191,7 +184,6 @@ container is running, and reports update phases back to the Control Plane.
|
||||
|
||||
```bash
|
||||
rap-host-agent update \
|
||||
--backend-url http://192.168.200.61:18080/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--node-id <node_id> \
|
||||
--container-name rap-node-agent-docker-node-1 \
|
||||
@@ -215,7 +207,6 @@ already-installed release.
|
||||
|
||||
```bash
|
||||
rap-host-agent update-loop \
|
||||
--backend-url http://192.168.200.61:18080/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--node-id <node_id> \
|
||||
--container-name rap-node-agent-docker-node-1 \
|
||||
@@ -241,7 +232,6 @@ the new binary.
|
||||
|
||||
```bash
|
||||
rap-host-agent update-host-agent-loop \
|
||||
--backend-url http://192.168.200.61:18080/api/v1 \
|
||||
--cluster-id <cluster_id> \
|
||||
--state-dir /var/lib/rap/nodes/docker-node-1 \
|
||||
--binary-path /usr/local/bin/rap-host-agent
|
||||
@@ -249,16 +239,21 @@ rap-host-agent update-host-agent-loop \
|
||||
|
||||
## Windows Host Agent Bootstrap And Updates
|
||||
|
||||
Windows uses the same Control Plane install profile, but the local placement is
|
||||
a Scheduled Task instead of Docker. In `--startup-mode auto` the installer first
|
||||
Windows uses the same bootstrap bundle model, but the local placement is a
|
||||
Scheduled Task instead of Docker. In `--startup-mode auto` the installer first
|
||||
tries an elevated `ONSTART` task running as `SYSTEM`; without admin rights it
|
||||
falls back to a per-user `ONLOGON` task. The `ONSTART` mode starts after reboot
|
||||
without an interactive user session. The `ONLOGON` fallback can only start after
|
||||
that Windows user signs in.
|
||||
|
||||
```cmd
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing 'http://control.example.com/downloads/rap-host-agent-windows-amd64.exe' -OutFile $env:TEMP\rap-host-agent.exe"
|
||||
%TEMP%\rap-host-agent.exe install-windows --profile-url "http://control.example.com/api/v1" --cluster-id "<cluster_id>" --install-token "<one_time_install_token>" --node-name "office-win-1" --startup-mode "auto"
|
||||
%TEMP%\rap-host-agent.exe install-windows --bootstrap-bundle "C:\bootstrap\office-win-1.bootstrap.json" --startup-mode "auto"
|
||||
```
|
||||
|
||||
Offline/import bootstrap is also supported:
|
||||
|
||||
```cmd
|
||||
%TEMP%\rap-host-agent.exe install-windows --bootstrap-bundle "C:\bootstrap\office-win-1.bootstrap.json" --startup-mode "auto"
|
||||
```
|
||||
|
||||
`install-windows` installs two tasks:
|
||||
@@ -275,9 +270,8 @@ independent from the local identity file location and is required for repair of
|
||||
older Windows installs where the node is already heartbeat-healthy but the
|
||||
host-agent updater has no usable identity file.
|
||||
|
||||
```cmd
|
||||
%TEMP%\rap-host-agent.exe install-windows --backend-url "http://control.example.com/api/v1" --cluster-id "<cluster_id>" --node-id "<node_id>" --node-name "office-win-1" --replace --startup-mode "auto" --auto-update-current-version "<current_version>"
|
||||
```
|
||||
The repair path also reuses the local signed bootstrap/runtime state; it does
|
||||
not require any backend URL.
|
||||
|
||||
The admin UI node details page generates a downloadable
|
||||
`rap-repair-updater-<node>.cmd` for this repair path. It performs these steps:
|
||||
@@ -347,14 +341,8 @@ Control Plane release artifacts for Windows must use:
|
||||
|
||||
Create a join token from the platform control plane, then run:
|
||||
|
||||
```powershell
|
||||
.\bin\rap-node-agent.exe `
|
||||
-backend-url http://192.168.200.61:8080/api/v1 `
|
||||
-cluster-id <cluster_id> `
|
||||
-join-token <raw_join_token> `
|
||||
-node-name test-node-1 `
|
||||
-state-dir C:\ProgramData\RapNodeAgent
|
||||
```
|
||||
Use a signed bootstrap bundle plus QUIC fabric registry seeds. The node
|
||||
enrolls only through QUIC fabric inside the farm.
|
||||
|
||||
The agent submits a pending join request and exits. It does not self-activate.
|
||||
A platform admin must approve the join request.
|
||||
@@ -375,19 +363,18 @@ Then run the agent again:
|
||||
|
||||
```powershell
|
||||
.\bin\rap-node-agent.exe `
|
||||
-backend-url http://192.168.200.61:8080/api/v1 `
|
||||
-state-dir C:\ProgramData\RapNodeAgent
|
||||
```
|
||||
|
||||
It sends periodic heartbeats to:
|
||||
It sends periodic heartbeats through the signed `control-api` service over QUIC
|
||||
fabric:
|
||||
|
||||
```text
|
||||
/api/v1/clusters/{clusterID}/nodes/{nodeID}/heartbeats
|
||||
fabric control path /clusters/{clusterID}/nodes/{nodeID}/heartbeats
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `RAP_BACKEND_URL`
|
||||
- `RAP_CLUSTER_ID`
|
||||
- `RAP_CLUSTER_AUTHORITY_PUBLIC_KEY`
|
||||
- `RAP_CLUSTER_AUTHORITY_FINGERPRINT`
|
||||
@@ -398,8 +385,8 @@ It sends periodic heartbeats to:
|
||||
- `RAP_HEARTBEAT_INTERVAL_SECONDS`
|
||||
- `RAP_ENROLLMENT_POLL_INTERVAL_SECONDS`
|
||||
- `RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS`
|
||||
- `RAP_MESH_SYNTHETIC_RUNTIME_ENABLED`
|
||||
- `RAP_MESH_LISTEN_ADDR`
|
||||
- `RAP_FABRIC_RUNTIME_ENABLED`
|
||||
- `RAP_FABRIC_LISTEN_ADDR`
|
||||
- `RAP_MESH_ADVERTISE_ENDPOINT`
|
||||
- `RAP_MESH_ADVERTISE_ENDPOINTS_JSON`
|
||||
- `RAP_MESH_ADVERTISE_TRANSPORT`
|
||||
@@ -412,15 +399,15 @@ It sends periodic heartbeats to:
|
||||
- `RAP_MESH_PRODUCTION_FORWARDING_ENABLED`
|
||||
- `RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY`
|
||||
|
||||
`RAP_MESH_SYNTHETIC_RUNTIME_ENABLED` defaults to `false`. It gates only the
|
||||
`RAP_FABRIC_RUNTIME_ENABLED` defaults to `false`. It gates only the
|
||||
C17A/C17B/C17C/C17D/C17E synthetic probe, route-health, relay scheduling,
|
||||
bounded `synthetic.echo` test-service runtime, and live synthetic HTTP endpoint.
|
||||
bounded `synthetic.echo` test-service runtime, and live synthetic QUIC endpoint.
|
||||
It must not be used for RDP, VPN, file, video, or other production service
|
||||
traffic.
|
||||
|
||||
`RAP_WORKLOAD_SUPERVISION_ENABLED` defaults to `false`. When enabled, the agent
|
||||
polls node-scoped desired workloads and reports status. The current bounded
|
||||
runtime reports built-in `core-mesh` and `mesh-listener` services as running
|
||||
runtime reports built-in `core-mesh` and `fabric-listener` services as running
|
||||
when enabled, supports the native built-in `synthetic.echo` test workload, and
|
||||
keeps unsupported production workloads such as RDP workers degraded until their
|
||||
supervisors are implemented.
|
||||
@@ -431,8 +418,9 @@ reports the remote-workspace adapter channel contract and requires Fabric
|
||||
Service Channel as the future data plane; it does not start FreeRDP, create a
|
||||
remote session, or carry production RDP payloads.
|
||||
|
||||
`RAP_MESH_LISTEN_ADDR` starts the C17E/C17F/C17G synthetic HTTP endpoint only when
|
||||
`RAP_MESH_SYNTHETIC_RUNTIME_ENABLED=true`. `RAP_MESH_SYNTHETIC_CONFIG` points to
|
||||
`RAP_FABRIC_LISTEN_ADDR` names the historical synthetic listener address, but the
|
||||
current runtime is QUIC-fabric-only and does not start an HTTP listener.
|
||||
`RAP_MESH_SYNTHETIC_CONFIG` points to
|
||||
a scoped synthetic mesh config snapshot and is preferred over debug JSON.
|
||||
`RAP_MESH_PEER_ENDPOINTS_JSON` is a JSON object mapping peer node IDs to
|
||||
endpoint URLs. `RAP_MESH_SYNTHETIC_ROUTES_JSON` is a JSON array of synthetic
|
||||
@@ -454,10 +442,9 @@ same fields in `identity.json` are set.
|
||||
|
||||
`RAP_MESH_PRODUCTION_FORWARDING_ENABLED` defaults to `false`. It is a future
|
||||
production-forwarding gate only. Turning it on does not enable production mesh
|
||||
payload forwarding; `/mesh/v1/forward` still returns an unavailable runtime
|
||||
response after validating the route-bound production envelope contract, until
|
||||
a later approved production mesh stage implements route-bound, policy-bound
|
||||
forwarding.
|
||||
payload forwarding; the runtime still refuses service traffic after validating
|
||||
the route-bound production envelope contract, until a later approved
|
||||
production mesh stage implements route-bound, policy-bound forwarding.
|
||||
|
||||
The production envelope contract requires route, hop, TTL, expiry, payload
|
||||
length, and SHA-256 payload hash fields. C17J accepts only the
|
||||
@@ -522,11 +509,11 @@ recent failure reason, reliability score, and freshness/staleness signals.
|
||||
The score remains advisory only and is not wired into production forwarding.
|
||||
|
||||
C17Z adds the first narrow production forwarding runtime. When
|
||||
`RAP_MESH_PRODUCTION_FORWARDING_ENABLED=true`, `/mesh/v1/forward` can deliver
|
||||
route-bound `fabric.control` envelopes at the local destination or forward them
|
||||
to a direct next hop from explicit peer endpoint config. Service channels,
|
||||
RDP/VPN/file/video payloads, arbitrary relay forwarding, and multi-hop
|
||||
production route execution remain unavailable.
|
||||
`RAP_MESH_PRODUCTION_FORWARDING_ENABLED=true`, the QUIC production-forward
|
||||
handler can deliver route-bound `fabric.control` envelopes at the local
|
||||
destination or forward them to a direct next hop from explicit peer endpoint
|
||||
config. Service channels, RDP/VPN/file/video payloads, arbitrary relay
|
||||
forwarding, and multi-hop production route execution remain unavailable.
|
||||
|
||||
C17Z1 adds route-path-bound multi-hop forwarding for production
|
||||
`fabric.control` only. Envelopes may carry `route_path` and
|
||||
@@ -559,7 +546,7 @@ C17Z5 turns scoped peer directory and recovery seed config into node-local
|
||||
runtime `PeerCache` state. The cache builds a bounded warm peer set from
|
||||
route-adjacent peers, recovery seeds, peer endpoints, and endpoint candidates.
|
||||
When synthetic mesh testing is enabled, the node-agent probes warm peers with
|
||||
`/mesh/v1/health` and reports metadata-only mesh-link observations. This is not
|
||||
QUIC fabric live probes and reports metadata-only mesh-link observations. This is not
|
||||
a persistent connection manager and does not forward service payloads.
|
||||
|
||||
C17Z6 adds advertised mesh endpoint reporting. When
|
||||
@@ -602,7 +589,7 @@ persistent connection transport, STUN/TURN/ICE, NAT traversal, relay runtime,
|
||||
or service payload forwarding.
|
||||
|
||||
C17Z11 adds the first real node-local peer connection manager for mesh
|
||||
control-plane health. It uses a reusable HTTP keep-alive client to probe
|
||||
control-plane health. It uses a reusable QUIC fabric transport to probe
|
||||
direct/private/corporate peer endpoints selected by C17Z10 intents, updates
|
||||
the shared peer connection tracker, and records `waiting_rendezvous` for
|
||||
outbound-only or relay-required peers. Heartbeats include metadata-only
|
||||
@@ -612,8 +599,8 @@ payload forwarding.
|
||||
|
||||
C17Z12 adds a node-scoped rendezvous/relay control-plane lease contract for
|
||||
peers that would otherwise remain `waiting_rendezvous`. The agent consumes
|
||||
`rendezvous_leases`, resolves matching intents into `relay_control`, probes the
|
||||
relay node `/mesh/v1/health`, and records `relay_ready` for the peer control
|
||||
`rendezvous_leases`, resolves matching intents into `relay_quic`, probes the
|
||||
relay node over QUIC fabric live probe, and records `relay_ready` for the peer control
|
||||
path. This remains control-plane health only and does not enable RDP/VPN/file/
|
||||
video/service payload forwarding, arbitrary relay packet forwarding,
|
||||
STUN/TURN/ICE, or host networking changes.
|
||||
@@ -668,17 +655,17 @@ enable service payload forwarding.
|
||||
C17Z21 defines the portable inbound listener contract for Docker, Linux
|
||||
service, Windows service, and future OS-specific node packages. The node-agent
|
||||
does not stop when the mesh listen port cannot be bound. It keeps the outbound
|
||||
Control Plane session alive and emits `c17z21.mesh_listener_report.v1` in
|
||||
Control Plane session alive and emits `c17z21.fabric_listener_report.v1` in
|
||||
heartbeat metadata with configured address, effective address, listen mode,
|
||||
listener status, inbound reachability, one-way connectivity, failure reason,
|
||||
and port-conflict diagnostics.
|
||||
|
||||
`RAP_MESH_LISTEN_PORT_MODE` controls behavior:
|
||||
`RAP_FABRIC_LISTEN_PORT_MODE` controls behavior:
|
||||
|
||||
- `manual`: bind exactly `RAP_MESH_LISTEN_ADDR`; on conflict report
|
||||
- `manual`: bind exactly `RAP_FABRIC_LISTEN_ADDR`; on conflict report
|
||||
`listen_failed` and wait for an operator/config change.
|
||||
- `auto`: try `RAP_MESH_LISTEN_ADDR`; on conflict scan
|
||||
`RAP_MESH_LISTEN_AUTO_PORT_START..RAP_MESH_LISTEN_AUTO_PORT_END` and report
|
||||
- `auto`: try `RAP_FABRIC_LISTEN_ADDR`; on conflict scan
|
||||
`RAP_FABRIC_LISTEN_AUTO_PORT_START..RAP_FABRIC_LISTEN_AUTO_PORT_END` and report
|
||||
`auto_rebound` when a free port is selected.
|
||||
- `disabled`: do not open an inbound listener; the node is expected to be
|
||||
outbound-only, relay/rendezvous, or Control Plane only.
|
||||
@@ -694,7 +681,7 @@ C17Z22 separates outbound Control Plane presence from inbound mesh
|
||||
reachability. When synthetic mesh testing is enabled, every heartbeat includes
|
||||
`c17z22.mesh_outbound_session_report.v1` with node-to-control-plane direction,
|
||||
keepalive transport, listener conflict state, rendezvous/relay counters, and a
|
||||
flag showing whether the current outbound session can be used as a reverse
|
||||
`fabric_control_endpoint` plus a flag showing whether the current outbound session can be used as a reverse
|
||||
control-channel contract. This is the portable basis for Docker, Linux service,
|
||||
Windows service, and future packages where a node may be behind NAT or have no
|
||||
stable inbound address. It is still control-plane telemetry only and does not
|
||||
@@ -715,7 +702,7 @@ and is ranked ahead of auto-discovered addresses.
|
||||
C17Z25 adds per-peer endpoint fallback probing to the control-plane mesh
|
||||
manager. A node no longer treats the top-ranked endpoint candidate as the only
|
||||
possible address for a peer. For each warm direct/private/corporate peer, the
|
||||
manager probes the ranked candidate list until one `/mesh/v1/health` endpoint
|
||||
manager probes the ranked candidate list until one QUIC fabric endpoint
|
||||
responds or all direct candidates fail. Heartbeat metadata includes
|
||||
`c17z25.mesh_peer_connection_manager_report.v1` with `probe_results`,
|
||||
`selected_candidate_id`, `selected_endpoint`, and per-candidate success/failure
|
||||
@@ -733,14 +720,14 @@ Scoped synthetic config shape:
|
||||
"peer_directory_version": "peers-v1",
|
||||
"policy_version": "policy-v1",
|
||||
"peer_endpoints": {
|
||||
"node-b": "http://127.0.0.1:19002"
|
||||
"node-b": "quic://127.0.0.1:19002"
|
||||
},
|
||||
"peer_endpoint_candidates": {
|
||||
"node-b": [
|
||||
{
|
||||
"endpoint_id": "node-b-public",
|
||||
"node_id": "node-b",
|
||||
"transport": "direct_tcp_tls",
|
||||
"transport": "direct_quic",
|
||||
"address": "203.0.113.20:443",
|
||||
"reachability": "public",
|
||||
"nat_type": "restricted",
|
||||
@@ -784,3 +771,4 @@ Expected:
|
||||
- Production forwarding remains disabled by default and limited to
|
||||
`fabric.control` when explicitly enabled.
|
||||
- No privileged operations are performed by the current agent.
|
||||
|
||||
|
||||
@@ -1621,7 +1621,7 @@ func verdict(report loadtestReport) (string, []string) {
|
||||
reasons = append(reasons, targetAckVerdictReasons(report)...)
|
||||
reasons = append(reasons, routePressureDistributionVerdictReasons(report)...)
|
||||
reasons = append(reasons, targetEndpointPolicyVerdictReasons(report)...)
|
||||
reasons = append(reasons, legacyRouteModeVerdictReasons(report)...)
|
||||
reasons = append(reasons, disallowedRouteModeVerdictReasons(report)...)
|
||||
reasons = append(reasons, routeModeCoverageVerdictReasons(report)...)
|
||||
if len(reasons) > 0 {
|
||||
return "fail", reasons
|
||||
@@ -1846,25 +1846,22 @@ func targetEndpointPolicyVerdictReasons(report loadtestReport) []string {
|
||||
return []string{fmt.Sprintf("non_quic_targets=%s", strings.Join(invalid, ","))}
|
||||
}
|
||||
|
||||
func legacyRouteModeVerdictReasons(report loadtestReport) []string {
|
||||
func disallowedRouteModeVerdictReasons(report loadtestReport) []string {
|
||||
if len(report.TargetStats) == 0 {
|
||||
return nil
|
||||
}
|
||||
legacyModes := map[string]struct{}{
|
||||
"relay": {},
|
||||
"outbound_reverse": {},
|
||||
"websocket": {},
|
||||
"ws": {},
|
||||
"wss": {},
|
||||
"direct_http": {},
|
||||
"direct_https": {},
|
||||
"direct_tcp_tls": {},
|
||||
supportedModes := map[string]struct{}{
|
||||
string(mesh.FabricRouteDirect): {},
|
||||
string(mesh.FabricRouteLAN): {},
|
||||
string(mesh.FabricRouteICE): {},
|
||||
string(mesh.FabricRouteReverse): {},
|
||||
string(mesh.FabricRouteRelay): {},
|
||||
}
|
||||
found := map[string]int{}
|
||||
for _, stats := range report.TargetStats {
|
||||
for mode, count := range stats.RouteModes {
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
if _, legacy := legacyModes[mode]; legacy && count > 0 {
|
||||
if _, supported := supportedModes[mode]; !supported && count > 0 {
|
||||
found[mode] += count
|
||||
}
|
||||
}
|
||||
@@ -1877,7 +1874,7 @@ func legacyRouteModeVerdictReasons(report loadtestReport) []string {
|
||||
modes = append(modes, fmt.Sprintf("%s:%d", mode, count))
|
||||
}
|
||||
sort.Strings(modes)
|
||||
return []string{fmt.Sprintf("legacy_route_modes_observed=%s", strings.Join(modes, ","))}
|
||||
return []string{fmt.Sprintf("compat_route_modes_observed=%s", strings.Join(modes, ","))}
|
||||
}
|
||||
|
||||
func routeModeCoverageVerdictReasons(report loadtestReport) []string {
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestRouteModeCoverageVerdictRequiresMixedModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRouteModeVerdictRejectsNonQUICModes(t *testing.T) {
|
||||
func TestDisallowedRouteModeVerdictRejectsNonQUICModes(t *testing.T) {
|
||||
report := loadtestReport{
|
||||
TargetStats: map[string]targetStats{
|
||||
"a": {RouteModes: map[string]int{
|
||||
@@ -49,12 +49,12 @@ func TestLegacyRouteModeVerdictRejectsNonQUICModes(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
}
|
||||
reasons := legacyRouteModeVerdictReasons(report)
|
||||
reasons := disallowedRouteModeVerdictReasons(report)
|
||||
if len(reasons) != 1 ||
|
||||
!strings.Contains(reasons[0], "relay:1") ||
|
||||
!strings.Contains(reasons[0], "outbound_reverse:2") ||
|
||||
!strings.Contains(reasons[0], "wss:3") {
|
||||
t.Fatalf("reasons = %v, want legacy route mode failure", reasons)
|
||||
t.Fatalf("reasons = %v, want compat route mode failure", reasons)
|
||||
}
|
||||
|
||||
report.TargetStats["a"] = targetStats{RouteModes: map[string]int{
|
||||
@@ -64,7 +64,7 @@ func TestLegacyRouteModeVerdictRejectsNonQUICModes(t *testing.T) {
|
||||
string(mesh.FabricRouteReverse): 1,
|
||||
string(mesh.FabricRouteRelay): 1,
|
||||
}}
|
||||
if reasons := legacyRouteModeVerdictReasons(report); len(reasons) != 0 {
|
||||
if reasons := disallowedRouteModeVerdictReasons(report); len(reasons) != 0 {
|
||||
t.Fatalf("reasons = %v, want QUIC modes accepted", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -22,22 +20,21 @@ import (
|
||||
)
|
||||
|
||||
type smokeNode struct {
|
||||
Local mesh.PeerIdentity
|
||||
Runtime *mesh.SyntheticRuntime
|
||||
URL string
|
||||
server *httptest.Server
|
||||
Local mesh.PeerIdentity
|
||||
Runtime *mesh.SyntheticRuntime
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type smokeSyntheticTransport struct {
|
||||
peers map[string]string
|
||||
peers map[string]*mesh.SyntheticRuntime
|
||||
}
|
||||
|
||||
func (t smokeSyntheticTransport) SendSynthetic(ctx context.Context, nextNodeID string, envelope mesh.SyntheticEnvelope) (mesh.SyntheticEnvelope, error) {
|
||||
baseURL := t.peers[nextNodeID]
|
||||
if baseURL == "" {
|
||||
runtime := t.peers[nextNodeID]
|
||||
if runtime == nil {
|
||||
return mesh.SyntheticEnvelope{}, mesh.ErrSyntheticPeerUnavailable
|
||||
}
|
||||
return mesh.NewClient(baseURL).SendSynthetic(ctx, envelope)
|
||||
return runtime.Receive(ctx, envelope)
|
||||
}
|
||||
|
||||
type smokeReport struct {
|
||||
@@ -104,8 +101,8 @@ func run(ctx context.Context) (smokeReport, error) {
|
||||
relayRoute := smokeRoute("route-relay", []string{"node-a", "node-r", "node-b"})
|
||||
routes := []mesh.SyntheticRoute{directRoute, relayRoute}
|
||||
nodeAConfigPath, err := writeSmokeScopedConfig(nodeA.Local, map[string]string{
|
||||
"node-r": nodeR.URL,
|
||||
"node-b": nodeB.URL,
|
||||
"node-r": nodeR.Endpoint,
|
||||
"node-b": nodeB.Endpoint,
|
||||
}, routes)
|
||||
if err != nil {
|
||||
return smokeReport{}, err
|
||||
@@ -117,10 +114,19 @@ func run(ctx context.Context) (smokeReport, error) {
|
||||
|
||||
nodeA.Runtime = smokeRuntime(nodeA.Local, nodeAConfig.Routes, nodeAConfig.PeerEndpoints)
|
||||
nodeR.Runtime = smokeRuntime(nodeR.Local, routes, map[string]string{
|
||||
"node-b": nodeB.URL,
|
||||
"node-b": nodeB.Endpoint,
|
||||
})
|
||||
nodeB.Runtime = smokeRuntime(nodeB.Local, routes, map[string]string{})
|
||||
|
||||
nodeA.Runtime = smokeRuntimeWithPeers(nodeA.Local, nodeAConfig.Routes, map[string]*mesh.SyntheticRuntime{
|
||||
"node-r": nodeR.Runtime,
|
||||
"node-b": nodeB.Runtime,
|
||||
})
|
||||
nodeR.Runtime = smokeRuntimeWithPeers(nodeR.Local, routes, map[string]*mesh.SyntheticRuntime{
|
||||
"node-b": nodeB.Runtime,
|
||||
})
|
||||
nodeB.Runtime = smokeRuntimeWithPeers(nodeB.Local, routes, map[string]*mesh.SyntheticRuntime{})
|
||||
|
||||
directAck, err := nodeA.Runtime.SendProbe(ctx, directRoute.RouteID, mesh.SyntheticChannelFabricControl, "smoke-direct")
|
||||
if err != nil {
|
||||
return smokeReport{}, fmt.Errorf("direct probe: %w", err)
|
||||
@@ -209,9 +215,9 @@ func run(ctx context.Context) (smokeReport, error) {
|
||||
FabricSessionLatencyMS: fabricSessionLatency.Milliseconds(),
|
||||
FabricSessionEndpoint: "quic://" + fabricQUICEndpoint,
|
||||
PeerEndpoints: map[string]any{
|
||||
"node-a": nodeA.URL,
|
||||
"node-r": nodeR.URL,
|
||||
"node-b": nodeB.URL,
|
||||
"node-a": nodeA.Endpoint,
|
||||
"node-r": nodeR.Endpoint,
|
||||
"node-b": nodeB.Endpoint,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -472,21 +478,21 @@ func writeSmokeScopedConfig(local mesh.PeerIdentity, peers map[string]string, ro
|
||||
}
|
||||
|
||||
func newSmokeNode(local mesh.PeerIdentity) *smokeNode {
|
||||
node := &smokeNode{Local: local}
|
||||
node.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mesh.Server{Local: node.Local, SyntheticRuntime: node.Runtime}.Handler().ServeHTTP(w, r)
|
||||
}))
|
||||
node.URL = node.server.URL
|
||||
return node
|
||||
}
|
||||
|
||||
func (n *smokeNode) Close() {
|
||||
if n.server != nil {
|
||||
n.server.Close()
|
||||
return &smokeNode{
|
||||
Local: local,
|
||||
Endpoint: "quic://smoke-" + local.NodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *smokeNode) Close() {
|
||||
}
|
||||
|
||||
func smokeRuntime(local mesh.PeerIdentity, routes []mesh.SyntheticRoute, peers map[string]string) *mesh.SyntheticRuntime {
|
||||
_ = peers
|
||||
return smokeRuntimeWithPeers(local, routes, map[string]*mesh.SyntheticRuntime{})
|
||||
}
|
||||
|
||||
func smokeRuntimeWithPeers(local mesh.PeerIdentity, routes []mesh.SyntheticRoute, peers map[string]*mesh.SyntheticRuntime) *mesh.SyntheticRuntime {
|
||||
return mesh.NewSyntheticRuntime(mesh.SyntheticRuntimeConfig{
|
||||
Enabled: true,
|
||||
Local: local,
|
||||
|
||||
@@ -113,14 +113,13 @@ func applyStagedSelfUpdate() {
|
||||
func runInstallLinux(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("install-linux", flag.ContinueOnError)
|
||||
cfg := hostagent.LinuxInstallConfig{}
|
||||
var profileURL string
|
||||
var installToken string
|
||||
fs.StringVar(&cfg.RuntimeConfig.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
var joinBundle string
|
||||
fs.StringVar(&cfg.RuntimeConfig.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used as bootstrap seeds.")
|
||||
fs.StringVar(&cfg.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID used by updater repair mode.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.JoinToken, "join-token", getenv("RAP_JOIN_TOKEN", ""), "One-time join token for first enrollment.")
|
||||
fs.StringVar(&profileURL, "profile-url", getenv("RAP_INSTALL_PROFILE_URL", ""), "Control Plane API base URL or /node-agents/linux-install-profile URL for profile-based install.")
|
||||
fs.StringVar(&installToken, "install-token", getenv("RAP_INSTALL_TOKEN", ""), "One-time install token used to fetch Linux install profile.")
|
||||
fs.StringVar(&joinBundle, "join-bundle", getenv("RAP_JOIN_BUNDLE", ""), "Preferred local join bundle JSON with Linux install profile and QUIC fabric bootstrap seeds.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.NodeName, "node-name", getenv("RAP_NODE_NAME", ""), "Node display name.")
|
||||
fs.StringVar(&cfg.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Node state directory.")
|
||||
fs.StringVar(&cfg.InstallDir, "install-dir", getenv("RAP_LINUX_INSTALL_DIR", ""), "Directory for rap-node-agent and rap-host-agent.")
|
||||
@@ -131,28 +130,31 @@ func runInstallLinux(ctx context.Context, args []string) error {
|
||||
fs.BoolVar(&cfg.AutoUpdateEnabled, "auto-update-enabled", getenvBool("RAP_AUTO_UPDATE_ENABLED", true), "Install and start the Linux host-agent update service.")
|
||||
fs.StringVar(&cfg.AutoUpdateCurrentVersion, "auto-update-current-version", getenv("RAP_NODE_AGENT_VERSION", agent.Version), "Initial node-agent version used by update-loop before the first successful update.")
|
||||
fs.StringVar(&cfg.AutoUpdateChannel, "auto-update-channel", getenv("RAP_UPDATE_CHANNEL", ""), "Optional update channel override for update-loop.")
|
||||
fs.IntVar(&cfg.AutoUpdateIntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", 21600), "Emergency fallback plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&cfg.AutoUpdateIntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Emergency rescue plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&cfg.AutoUpdateInitialDelaySeconds, "auto-update-initial-delay-seconds", getenvInt("RAP_UPDATE_INITIAL_DELAY_SECONDS", 15), "Update-loop initial delay in seconds.")
|
||||
fs.IntVar(&cfg.AutoUpdateHealthTimeoutSeconds, "auto-update-health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated service health timeout in seconds.")
|
||||
fs.StringVar(&cfg.HostAgentSourcePath, "host-agent-source-path", getenv("RAP_HOST_AGENT_SOURCE_PATH", ""), "Source rap-host-agent path copied to the persistent updater location.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.FabricRuntimeEnabled, "fabric-runtime-enabled", getenvBool("RAP_FABRIC_RUNTIME_ENABLED", false), "Enable node-local synthetic fabric control runtime.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 8), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricListenAddr, "fabric-listen-addr", getenv("RAP_FABRIC_LISTEN_ADDR", ""), "Optional node listener address for QUIC fabric runtime.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricListenPortMode, "fabric-listen-port-mode", getenv("RAP_FABRIC_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.FabricListenAutoPortStart, "fabric-listen-auto-port-start", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.FabricListenAutoPortEnd, "fabric-listen-auto-port-end", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", "outbound_only"), "Connectivity mode hint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshSiteID, "mesh-site-id", getenv("RAP_MESH_SITE_ID", ""), "Physical/logical site identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshLocalityGroupID, "mesh-locality-group-id", getenv("RAP_MESH_LOCALITY_GROUP_ID", ""), "Private locality group identifier used for LAN/private endpoint selection.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshNATGroupID, "mesh-nat-group-id", getenv("RAP_MESH_NAT_GROUP_ID", ""), "Shared NAT/ingress group identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", "linux"), "Region/site hint.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.HeartbeatIntervalSeconds, "heartbeat-interval-seconds", getenvInt("RAP_HEARTBEAT_INTERVAL_SECONDS", 15), "Heartbeat interval seconds.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.EnrollmentPollIntervalSeconds, "enrollment-poll-interval-seconds", getenvInt("RAP_ENROLLMENT_POLL_INTERVAL_SECONDS", 5), "Enrollment poll interval seconds.")
|
||||
@@ -160,7 +162,7 @@ func runInstallLinux(ctx context.Context, args []string) error {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(profileURL) != "" || strings.TrimSpace(installToken) != "" {
|
||||
if strings.TrimSpace(joinBundle) != "" {
|
||||
dryRun := cfg.DryRun
|
||||
startupMode := strings.TrimSpace(cfg.StartupMode)
|
||||
autoUpdateEnabled := cfg.AutoUpdateEnabled
|
||||
@@ -170,7 +172,7 @@ func runInstallLinux(ctx context.Context, args []string) error {
|
||||
autoUpdateInitialDelaySeconds := cfg.AutoUpdateInitialDelaySeconds
|
||||
autoUpdateHealthTimeoutSeconds := cfg.AutoUpdateHealthTimeoutSeconds
|
||||
hostAgentSourcePath := cfg.HostAgentSourcePath
|
||||
profile, err := hostagent.FetchLinuxInstallProfile(ctx, hostagent.ProfileRequest{URL: profileURL, ClusterID: cfg.RuntimeConfig.ClusterID, InstallToken: installToken, NodeName: cfg.RuntimeConfig.NodeName})
|
||||
profile, err := hostagent.LoadLinuxJoinBundle(joinBundle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -201,14 +203,13 @@ func runInstallLinux(ctx context.Context, args []string) error {
|
||||
func runInstallWindows(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("install-windows", flag.ContinueOnError)
|
||||
cfg := hostagent.WindowsInstallConfig{}
|
||||
var profileURL string
|
||||
var installToken string
|
||||
fs.StringVar(&cfg.RuntimeConfig.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
var joinBundle string
|
||||
fs.StringVar(&cfg.RuntimeConfig.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used as bootstrap seeds.")
|
||||
fs.StringVar(&cfg.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID used by updater repair mode.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.JoinToken, "join-token", getenv("RAP_JOIN_TOKEN", ""), "One-time join token for first enrollment.")
|
||||
fs.StringVar(&profileURL, "profile-url", getenv("RAP_INSTALL_PROFILE_URL", ""), "Control Plane API base URL or /node-agents/windows-install-profile URL for profile-based install.")
|
||||
fs.StringVar(&installToken, "install-token", getenv("RAP_INSTALL_TOKEN", ""), "One-time install token used to fetch Windows install profile.")
|
||||
fs.StringVar(&joinBundle, "join-bundle", getenv("RAP_JOIN_BUNDLE", ""), "Preferred local join bundle JSON with Windows install profile and QUIC fabric bootstrap seeds.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.NodeName, "node-name", getenv("RAP_NODE_NAME", ""), "Node display name.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Node state directory.")
|
||||
fs.StringVar(&cfg.InstallDir, "install-dir", getenv("RAP_WINDOWS_INSTALL_DIR", ""), "Directory for rap-node-agent.exe and wrapper scripts.")
|
||||
@@ -218,28 +219,31 @@ func runInstallWindows(ctx context.Context, args []string) error {
|
||||
fs.BoolVar(&cfg.AutoUpdateEnabled, "auto-update-enabled", getenvBool("RAP_AUTO_UPDATE_ENABLED", true), "Install and start the Windows host-agent update task.")
|
||||
fs.StringVar(&cfg.AutoUpdateCurrentVersion, "auto-update-current-version", getenv("RAP_NODE_AGENT_VERSION", agent.Version), "Initial node-agent version used by update-loop before the first successful update.")
|
||||
fs.StringVar(&cfg.AutoUpdateChannel, "auto-update-channel", getenv("RAP_UPDATE_CHANNEL", ""), "Optional update channel override for update-loop.")
|
||||
fs.IntVar(&cfg.AutoUpdateIntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", 21600), "Emergency fallback plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&cfg.AutoUpdateIntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Emergency rescue plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&cfg.AutoUpdateInitialDelaySeconds, "auto-update-initial-delay-seconds", getenvInt("RAP_UPDATE_INITIAL_DELAY_SECONDS", 15), "Update-loop initial delay in seconds.")
|
||||
fs.IntVar(&cfg.AutoUpdateHealthTimeoutSeconds, "auto-update-health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated service health timeout in seconds.")
|
||||
fs.StringVar(&cfg.HostAgentSourcePath, "host-agent-source-path", getenv("RAP_HOST_AGENT_SOURCE_PATH", ""), "Source rap-host-agent.exe path copied to the persistent updater location.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.FabricRuntimeEnabled, "fabric-runtime-enabled", getenvBool("RAP_FABRIC_RUNTIME_ENABLED", false), "Enable node-local synthetic fabric control runtime.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.")
|
||||
fs.BoolVar(&cfg.RuntimeConfig.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 8), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricListenAddr, "fabric-listen-addr", getenv("RAP_FABRIC_LISTEN_ADDR", ""), "Optional node listener address for QUIC fabric runtime.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.FabricListenPortMode, "fabric-listen-port-mode", getenv("RAP_FABRIC_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.FabricListenAutoPortStart, "fabric-listen-auto-port-start", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.FabricListenAutoPortEnd, "fabric-listen-auto-port-end", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", "outbound_only"), "Connectivity mode hint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshSiteID, "mesh-site-id", getenv("RAP_MESH_SITE_ID", ""), "Physical/logical site identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshLocalityGroupID, "mesh-locality-group-id", getenv("RAP_MESH_LOCALITY_GROUP_ID", ""), "Private locality group identifier used for LAN/private endpoint selection.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshNATGroupID, "mesh-nat-group-id", getenv("RAP_MESH_NAT_GROUP_ID", ""), "Shared NAT/ingress group identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.RuntimeConfig.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", "windows"), "Region/site hint.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.HeartbeatIntervalSeconds, "heartbeat-interval-seconds", getenvInt("RAP_HEARTBEAT_INTERVAL_SECONDS", 15), "Heartbeat interval seconds.")
|
||||
fs.IntVar(&cfg.RuntimeConfig.EnrollmentPollIntervalSeconds, "enrollment-poll-interval-seconds", getenvInt("RAP_ENROLLMENT_POLL_INTERVAL_SECONDS", 5), "Enrollment poll interval seconds.")
|
||||
@@ -247,7 +251,7 @@ func runInstallWindows(ctx context.Context, args []string) error {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(profileURL) != "" || strings.TrimSpace(installToken) != "" {
|
||||
if strings.TrimSpace(joinBundle) != "" {
|
||||
dryRun := cfg.DryRun
|
||||
startupMode := strings.TrimSpace(cfg.StartupMode)
|
||||
autoUpdateEnabled := cfg.AutoUpdateEnabled
|
||||
@@ -257,12 +261,7 @@ func runInstallWindows(ctx context.Context, args []string) error {
|
||||
autoUpdateInitialDelaySeconds := cfg.AutoUpdateInitialDelaySeconds
|
||||
autoUpdateHealthTimeoutSeconds := cfg.AutoUpdateHealthTimeoutSeconds
|
||||
hostAgentSourcePath := cfg.HostAgentSourcePath
|
||||
profile, err := hostagent.FetchWindowsInstallProfile(ctx, hostagent.ProfileRequest{
|
||||
URL: profileURL,
|
||||
ClusterID: cfg.RuntimeConfig.ClusterID,
|
||||
InstallToken: installToken,
|
||||
NodeName: cfg.RuntimeConfig.NodeName,
|
||||
})
|
||||
profile, err := hostagent.LoadWindowsJoinBundle(joinBundle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -364,7 +363,7 @@ func runUpdate(ctx context.Context, args []string) error {
|
||||
}
|
||||
fmt.Printf("action=%s reason=%s target=%s production_forwarding=%t\n", plan.Action, plan.Reason, plan.TargetVersion, plan.ProductionForwarding)
|
||||
if plan.Artifact != nil {
|
||||
fmt.Printf("artifact=%s sha256=%s size=%d\n", plan.Artifact.URL, plan.Artifact.SHA256, plan.Artifact.SizeBytes)
|
||||
fmt.Printf("artifact_id=%s sha256=%s size=%d transport=quic_fabric\n", plan.Artifact.ID, plan.Artifact.SHA256, plan.Artifact.SizeBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -407,7 +406,7 @@ func runUpdateLoop(ctx context.Context, args []string) error {
|
||||
var hostAgentVersion string
|
||||
var hostAgentBinaryPath string
|
||||
registerUpdateFlags(fs, &req, &healthTimeoutSeconds)
|
||||
fs.IntVar(&intervalSeconds, "interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", 21600), "Seconds between emergency fallback update plan polls. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&intervalSeconds, "interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Seconds between emergency rescue update plan polls. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&initialDelaySeconds, "initial-delay-seconds", getenvInt("RAP_UPDATE_INITIAL_DELAY_SECONDS", 0), "Seconds to wait before the first poll.")
|
||||
fs.Float64Var(&jitter, "jitter", getenvFloat("RAP_UPDATE_JITTER", 0.15), "Fractional random jitter for interval and initial delay, 0..1.")
|
||||
fs.IntVar(&maxRuns, "max-runs", getenvInt("RAP_UPDATE_MAX_RUNS", 0), "Maximum loop iterations. Use 0 to run until stopped.")
|
||||
@@ -432,7 +431,6 @@ func runUpdateLoop(ctx context.Context, args []string) error {
|
||||
}
|
||||
cfg.HostAgentUpdateEnabled = hostAgentStatusEnabled
|
||||
cfg.HostAgentUpdateRequest = hostagent.HostAgentUpdateRequest{
|
||||
BackendURL: req.BackendURL,
|
||||
ClusterID: req.ClusterID,
|
||||
NodeID: req.NodeID,
|
||||
StateDir: req.StateDir,
|
||||
@@ -487,7 +485,6 @@ func parseMonitor(args []string) (hostagent.MonitorConfig, error) {
|
||||
var staleRestartingSeconds int
|
||||
var tmpMinAgeMinutes int
|
||||
watchContainers := repeatedFlag{}
|
||||
fs.StringVar(&cfg.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL used for monitor status reports.")
|
||||
fs.StringVar(&cfg.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&cfg.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.")
|
||||
fs.StringVar(&cfg.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", hostagent.DefaultStateDir), "Host path containing node-agent identity.json.")
|
||||
@@ -545,13 +542,12 @@ func runInstallUpdater(ctx context.Context, args []string) error {
|
||||
var selfUpdater bool
|
||||
var monitorEnabled bool
|
||||
monitorContainers := repeatedFlag{}
|
||||
fs.StringVar(&runtimeCfg.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
fs.StringVar(&runtimeCfg.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&runtimeCfg.ContainerName, "container-name", getenv("RAP_NODE_AGENT_CONTAINER", hostagent.DefaultContainerName), "Docker container name to update.")
|
||||
fs.StringVar(&runtimeCfg.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", hostagent.DefaultStateDir), "Host path containing node-agent identity.json.")
|
||||
fs.StringVar(&service.CurrentVersion, "current-version", getenv("RAP_NODE_AGENT_VERSION", agent.Version), "Initial node-agent version before first successful update.")
|
||||
fs.StringVar(&service.Channel, "channel", getenv("RAP_UPDATE_CHANNEL", ""), "Optional update channel override.")
|
||||
fs.IntVar(&service.IntervalSeconds, "interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", 21600), "Emergency fallback plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&service.IntervalSeconds, "interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Emergency rescue plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&service.InitialDelaySeconds, "initial-delay-seconds", getenvInt("RAP_UPDATE_INITIAL_DELAY_SECONDS", 15), "Update-loop initial delay in seconds.")
|
||||
fs.Float64Var(&service.Jitter, "jitter", getenvFloat("RAP_UPDATE_JITTER", 0.15), "Update-loop interval jitter, 0..1.")
|
||||
fs.IntVar(&service.HealthTimeoutSec, "health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated container running-state timeout in seconds.")
|
||||
@@ -637,7 +633,6 @@ func parseHostAgentUpdate(args []string) (hostagent.HostAgentUpdateRequest, int,
|
||||
var maxRuns int
|
||||
var jitter float64
|
||||
var stopOnError bool
|
||||
fs.StringVar(&req.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
fs.StringVar(&req.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&req.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.")
|
||||
fs.StringVar(&req.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Host path containing node-agent identity.json.")
|
||||
@@ -651,7 +646,7 @@ func parseHostAgentUpdate(args []string) (hostagent.HostAgentUpdateRequest, int,
|
||||
fs.StringVar(&req.InstallType, "install-type", getenv("RAP_HOST_AGENT_UPDATE_INSTALL_TYPE", hostagent.BinaryUpdateInstallType), "Host-agent artifact install type.")
|
||||
fs.StringVar(&req.BinaryPath, "binary-path", getenv("RAP_HOST_AGENT_BINARY_PATH", hostagent.DefaultHostAgentInstallPath), "rap-host-agent binary path to replace atomically.")
|
||||
fs.BoolVar(&req.DryRun, "dry-run", false, "Fetch and print the update plan without applying it.")
|
||||
fs.IntVar(&intervalSeconds, "interval-seconds", getenvInt("RAP_HOST_AGENT_UPDATE_INTERVAL_SECONDS", 900), "Seconds between host-agent update plan polls.")
|
||||
fs.IntVar(&intervalSeconds, "interval-seconds", getenvInt("RAP_HOST_AGENT_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Seconds between host-agent update plan polls.")
|
||||
fs.IntVar(&initialDelaySeconds, "initial-delay-seconds", getenvInt("RAP_HOST_AGENT_UPDATE_INITIAL_DELAY_SECONDS", 45), "Seconds to wait before the first poll.")
|
||||
fs.Float64Var(&jitter, "jitter", getenvFloat("RAP_UPDATE_JITTER", 0.15), "Fractional random jitter for interval and initial delay, 0..1.")
|
||||
fs.IntVar(&maxRuns, "max-runs", getenvInt("RAP_UPDATE_MAX_RUNS", 0), "Maximum loop iterations. Use 0 to run until stopped.")
|
||||
@@ -663,7 +658,6 @@ func parseHostAgentUpdate(args []string) (hostagent.HostAgentUpdateRequest, int,
|
||||
}
|
||||
|
||||
func registerUpdateFlags(fs *flag.FlagSet, req *hostagent.UpdateRequest, healthTimeoutSeconds *int) {
|
||||
fs.StringVar(&req.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
fs.StringVar(&req.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&req.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.")
|
||||
fs.StringVar(&req.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Host path containing node-agent identity.json; used when node-id is not known yet.")
|
||||
@@ -688,16 +682,15 @@ func parseInstall(args []string) (installCommandConfig, error) {
|
||||
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
||||
cfg := hostagent.RuntimeConfig{}
|
||||
var dryRun bool
|
||||
var profileURL string
|
||||
var installToken string
|
||||
var joinBundle string
|
||||
var autoUpdateEnabled bool
|
||||
autoUpdate := hostagent.UpdateServiceConfig{}
|
||||
monitorContainers := repeatedFlag{}
|
||||
fs.StringVar(&cfg.BackendURL, "backend-url", getenv("RAP_BACKEND_URL", ""), "Control Plane API base URL.")
|
||||
fs.StringVar(&cfg.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&cfg.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.")
|
||||
fs.StringVar(&cfg.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used as bootstrap seeds.")
|
||||
fs.StringVar(&cfg.JoinToken, "join-token", getenv("RAP_JOIN_TOKEN", ""), "One-time join token for first enrollment.")
|
||||
fs.StringVar(&profileURL, "profile-url", getenv("RAP_INSTALL_PROFILE_URL", ""), "Control Plane API base URL or /node-agents/docker-install-profile URL for profile-based install.")
|
||||
fs.StringVar(&installToken, "install-token", getenv("RAP_INSTALL_TOKEN", ""), "One-time install token used to fetch Docker install profile.")
|
||||
fs.StringVar(&joinBundle, "join-bundle", getenv("RAP_JOIN_BUNDLE", ""), "Preferred local join bundle JSON with Docker install profile and QUIC fabric bootstrap seeds.")
|
||||
fs.StringVar(&cfg.NodeName, "node-name", getenv("RAP_NODE_NAME", ""), "Node display name.")
|
||||
fs.StringVar(&cfg.Image, "image", getenv("RAP_NODE_AGENT_IMAGE", hostagent.DefaultImage), "Docker image for rap-node-agent.")
|
||||
fs.StringVar(&cfg.ContainerName, "container-name", getenv("RAP_NODE_AGENT_CONTAINER", hostagent.DefaultContainerName), "Docker container name.")
|
||||
@@ -716,7 +709,7 @@ func parseInstall(args []string) (installCommandConfig, error) {
|
||||
fs.StringVar(&autoUpdate.CurrentVersion, "auto-update-current-version", getenv("RAP_NODE_AGENT_VERSION", agent.Version), "Initial node-agent version used by update-loop before the first successful update.")
|
||||
fs.StringVar(&autoUpdate.SelfUpdateVersion, "host-agent-current-version", getenv("RAP_HOST_AGENT_VERSION", agent.Version), "Initial host-agent binary version used by the self-updater.")
|
||||
fs.StringVar(&autoUpdate.Channel, "auto-update-channel", getenv("RAP_UPDATE_CHANNEL", ""), "Optional update channel override for update-loop.")
|
||||
fs.IntVar(&autoUpdate.IntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", 21600), "Emergency fallback plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&autoUpdate.IntervalSeconds, "auto-update-interval-seconds", getenvInt("RAP_UPDATE_INTERVAL_SECONDS", hostagent.DefaultUpdateIntervalSec), "Emergency rescue plan poll interval in seconds. Update-service/heartbeat hints trigger normal runs.")
|
||||
fs.IntVar(&autoUpdate.InitialDelaySeconds, "auto-update-initial-delay-seconds", getenvInt("RAP_UPDATE_INITIAL_DELAY_SECONDS", 15), "Update-loop initial delay in seconds.")
|
||||
fs.Float64Var(&autoUpdate.Jitter, "auto-update-jitter", getenvFloat("RAP_UPDATE_JITTER", 0.15), "Update-loop interval jitter, 0..1.")
|
||||
fs.IntVar(&autoUpdate.HealthTimeoutSec, "auto-update-health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated container running-state timeout in seconds.")
|
||||
@@ -728,23 +721,26 @@ func parseInstall(args []string) (installCommandConfig, error) {
|
||||
fs.IntVar(&autoUpdate.MonitorDiskCritical, "monitor-disk-critical-percent", getenvInt("RAP_MONITOR_DISK_CRITICAL_PERCENT", hostagent.DefaultMonitorDiskCriticalPercent), "Disk used percent that reports failure after cleanup.")
|
||||
fs.BoolVar(&autoUpdate.MonitorCleanupDocker, "monitor-cleanup-docker", getenvBool("RAP_MONITOR_CLEANUP_DOCKER", true), "Run safe docker prune cleanup when disk is above cleanup threshold.")
|
||||
fs.BoolVar(&cfg.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.")
|
||||
fs.BoolVar(&cfg.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.")
|
||||
fs.BoolVar(&cfg.FabricRuntimeEnabled, "fabric-runtime-enabled", getenvBool("RAP_FABRIC_RUNTIME_ENABLED", false), "Enable node-local synthetic fabric control runtime.")
|
||||
fs.BoolVar(&cfg.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.")
|
||||
fs.BoolVar(&cfg.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.")
|
||||
fs.BoolVar(&cfg.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.")
|
||||
fs.StringVar(&cfg.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.")
|
||||
fs.IntVar(&cfg.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 8), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.")
|
||||
fs.IntVar(&cfg.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.")
|
||||
fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address inside container.")
|
||||
fs.StringVar(&cfg.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", ""), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 0), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 0), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.FabricListenAddr, "fabric-listen-addr", getenv("RAP_FABRIC_LISTEN_ADDR", ""), "Optional node listener address for QUIC fabric runtime inside container.")
|
||||
fs.StringVar(&cfg.FabricListenPortMode, "fabric-listen-port-mode", getenv("RAP_FABRIC_LISTEN_PORT_MODE", ""), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.FabricListenAutoPortStart, "fabric-listen-auto-port-start", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_START", 0), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.FabricListenAutoPortEnd, "fabric-listen-auto-port-end", getenvInt("RAP_FABRIC_LISTEN_AUTO_PORT_END", 0), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.")
|
||||
fs.StringVar(&cfg.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", ""), "Connectivity mode hint.")
|
||||
fs.StringVar(&cfg.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", ""), "NAT type hint.")
|
||||
fs.StringVar(&cfg.MeshSiteID, "mesh-site-id", getenv("RAP_MESH_SITE_ID", ""), "Physical/logical site identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.MeshLocalityGroupID, "mesh-locality-group-id", getenv("RAP_MESH_LOCALITY_GROUP_ID", ""), "Private locality group identifier used for LAN/private endpoint selection.")
|
||||
fs.StringVar(&cfg.MeshNATGroupID, "mesh-nat-group-id", getenv("RAP_MESH_NAT_GROUP_ID", ""), "Shared NAT/ingress group identifier advertised with QUIC endpoints.")
|
||||
fs.StringVar(&cfg.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", ""), "Region/site hint.")
|
||||
fs.IntVar(&cfg.HeartbeatIntervalSeconds, "heartbeat-interval-seconds", getenvInt("RAP_HEARTBEAT_INTERVAL_SECONDS", 15), "Heartbeat interval seconds.")
|
||||
fs.IntVar(&cfg.EnrollmentPollIntervalSeconds, "enrollment-poll-interval-seconds", getenvInt("RAP_ENROLLMENT_POLL_INTERVAL_SECONDS", 5), "Enrollment poll interval seconds.")
|
||||
@@ -752,25 +748,20 @@ func parseInstall(args []string) (installCommandConfig, error) {
|
||||
fs.IntVar(&cfg.ProductionObservationSinkCap, "production-observation-sink-capacity", getenvInt("RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY", 0), "Production observation sink capacity.")
|
||||
extraEnv := repeatedFlag{}
|
||||
extraRunArg := repeatedFlag{}
|
||||
imageArtifactURL := repeatedFlag{}
|
||||
imageArtifactPath := repeatedFlag{}
|
||||
fs.Var(&extraEnv, "env", "Extra KEY=VALUE env passed to node-agent container; may be repeated.")
|
||||
fs.Var(&extraRunArg, "docker-run-arg", "Extra raw docker run argument; may be repeated.")
|
||||
fs.Var(&imageArtifactURL, "image-artifact-url", "Docker image tar artifact URL to docker load before running; may be repeated.")
|
||||
fs.Var(&imageArtifactPath, "image-artifact-path", "Local Docker image tar artifact path to docker load before running; may be repeated.")
|
||||
fs.Var(&monitorContainers, "monitor-container", "Extra Docker container watched by monitor; may be repeated.")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return installCommandConfig{}, err
|
||||
}
|
||||
cfg.ExtraEnv = extraEnv
|
||||
cfg.AdditionalDockerRunArgs = extraRunArg
|
||||
cfg.ImageArtifactURLs = append(cfg.ImageArtifactURLs, imageArtifactURL...)
|
||||
cfg.ImageArtifactURLs = append(cfg.ImageArtifactURLs, imageArtifactPath...)
|
||||
autoUpdate.MonitorContainers = monitorContainers
|
||||
if strings.TrimSpace(profileURL) != "" || strings.TrimSpace(installToken) != "" {
|
||||
profile, err := hostagent.FetchDockerInstallProfile(context.Background(), hostagent.ProfileRequest{
|
||||
URL: profileURL,
|
||||
ClusterID: cfg.ClusterID,
|
||||
InstallToken: installToken,
|
||||
NodeName: cfg.NodeName,
|
||||
})
|
||||
if strings.TrimSpace(joinBundle) != "" {
|
||||
profile, err := hostagent.LoadDockerJoinBundle(joinBundle)
|
||||
if err != nil {
|
||||
return installCommandConfig{}, err
|
||||
}
|
||||
@@ -778,8 +769,8 @@ func parseInstall(args []string) (installCommandConfig, error) {
|
||||
profileCfg.ExtraEnv = cfg.ExtraEnv
|
||||
profileCfg.AdditionalDockerRunArgs = cfg.AdditionalDockerRunArgs
|
||||
profileCfg.DockerVPNGatewayEnabled = profileCfg.DockerVPNGatewayEnabled || cfg.DockerVPNGatewayEnabled
|
||||
if len(imageArtifactURL) > 0 {
|
||||
profileCfg.ImageArtifactURLs = append([]string(nil), imageArtifactURL...)
|
||||
if len(imageArtifactPath) > 0 {
|
||||
profileCfg.ImageArtifactURLs = append([]string(nil), imageArtifactPath...)
|
||||
}
|
||||
if cfg.ImageArtifactSHA256 != "" {
|
||||
profileCfg.ImageArtifactSHA256 = cfg.ImageArtifactSHA256
|
||||
@@ -867,16 +858,15 @@ func shellJoin(args []string) string {
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `usage:
|
||||
rap-host-agent install -profile-url URL -install-token TOKEN [-node-name NAME] [docker options]
|
||||
rap-host-agent install -backend-url URL -cluster-id ID -join-token TOKEN -node-name NAME [docker options]
|
||||
rap-host-agent install-windows -profile-url URL -install-token TOKEN [-node-name NAME] [windows options]
|
||||
rap-host-agent install-linux -profile-url URL -install-token TOKEN [-node-name NAME] [linux/systemd options]
|
||||
rap-host-agent install-updater (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR -container-name NAME
|
||||
rap-host-agent update-host-agent (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR
|
||||
rap-host-agent update-host-agent-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR
|
||||
rap-host-agent monitor-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR --watch-container NAME
|
||||
rap-host-agent monitor-once (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR --watch-container NAME
|
||||
rap-host-agent update (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -node-id ID [-container-name NAME]
|
||||
rap-host-agent update-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -node-id ID [-container-name NAME]
|
||||
rap-host-agent install -join-bundle FILE [docker options]
|
||||
rap-host-agent install-windows -join-bundle FILE [windows options]
|
||||
rap-host-agent install-linux -join-bundle FILE [linux/systemd options]
|
||||
rap-host-agent install-updater -fabric-registry-records-json JSON -cluster-id ID -state-dir DIR -container-name NAME
|
||||
rap-host-agent update-host-agent -fabric-registry-records-json JSON -cluster-id ID -state-dir DIR
|
||||
rap-host-agent update-host-agent-loop -fabric-registry-records-json JSON -cluster-id ID -state-dir DIR
|
||||
rap-host-agent monitor-loop -fabric-registry-records-json JSON -cluster-id ID -state-dir DIR --watch-container NAME
|
||||
rap-host-agent monitor-once -fabric-registry-records-json JSON -cluster-id ID -state-dir DIR --watch-container NAME
|
||||
rap-host-agent update -fabric-registry-records-json JSON -cluster-id ID -node-id ID [-container-name NAME]
|
||||
rap-host-agent update-loop -fabric-registry-records-json JSON -cluster-id ID -node-id ID [-container-name NAME]
|
||||
rap-host-agent status [-container-name NAME]`)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/state"
|
||||
)
|
||||
|
||||
const Version = "0.2.321-directreadytarget"
|
||||
const Version = "0.2.372-vpn-opaque-channel"
|
||||
|
||||
func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) client.EnrollRequest {
|
||||
return client.EnrollRequest{
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
type Client struct{}
|
||||
|
||||
type RawControlRequest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -45,19 +34,19 @@ type EnrollResponse struct {
|
||||
JoinRequest json.RawMessage `json:"join_request"`
|
||||
}
|
||||
|
||||
type EnrollmentBootstrapRequest struct {
|
||||
type EnrollmentJoinRequest struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
NodeFingerprint string `json:"node_fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
type EnrollmentBootstrapResponse struct {
|
||||
Status string `json:"status"`
|
||||
JoinRequest json.RawMessage `json:"join_request"`
|
||||
Bootstrap *NodeBootstrap `json:"node_bootstrap,omitempty"`
|
||||
type EnrollmentJoinResponse struct {
|
||||
Status string `json:"status"`
|
||||
JoinRequest json.RawMessage `json:"join_request"`
|
||||
JoinContract *NodeJoinContract `json:"node_join,omitempty"`
|
||||
}
|
||||
|
||||
type NodeBootstrap struct {
|
||||
type NodeJoinContract struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
IdentityStatus string `json:"identity_status"`
|
||||
@@ -84,15 +73,19 @@ type HeartbeatResponse struct {
|
||||
}
|
||||
|
||||
type NodeUpdateHint struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
CheckNow bool `json:"check_now"`
|
||||
Products []string `json:"products,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
SubscriptionStatus string `json:"subscription_status,omitempty"`
|
||||
UpdateService *NodeUpdateServiceAssignment `json:"update_service,omitempty"`
|
||||
FallbackPollSeconds int `json:"fallback_poll_seconds,omitempty"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
CheckNow bool `json:"check_now"`
|
||||
Products []string `json:"products,omitempty"`
|
||||
TargetVersions map[string]string `json:"target_versions,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
SubscriptionStatus string `json:"subscription_status,omitempty"`
|
||||
UpdateService *NodeUpdateServiceAssignment `json:"update_service,omitempty"`
|
||||
UpdateServiceCandidates []NodeUpdateServiceAssignment `json:"update_service_candidates,omitempty"`
|
||||
RescuePollSeconds int `json:"rescue_poll_seconds,omitempty"`
|
||||
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
|
||||
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
|
||||
}
|
||||
|
||||
type NodeUpdateServiceAssignment struct {
|
||||
@@ -207,6 +200,13 @@ type NodeVPNAssignmentLease struct {
|
||||
}
|
||||
|
||||
type NodeVPNAssignment struct {
|
||||
TunnelID string `json:"tunnel_id,omitempty"`
|
||||
PoolID string `json:"pool_id,omitempty"`
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
LocalServiceID string `json:"local_service_id,omitempty"`
|
||||
RemoteServiceID string `json:"remote_service_id,omitempty"`
|
||||
ServiceKind string `json:"service_kind,omitempty"`
|
||||
ServiceClass string `json:"service_class,omitempty"`
|
||||
VPNConnectionID string `json:"vpn_connection_id"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
@@ -624,6 +624,7 @@ type EndpointCandidateHealthObservation struct {
|
||||
EndpointID string `json:"endpoint_id"`
|
||||
Source string `json:"source,omitempty"`
|
||||
ReporterNodeID string `json:"reporter_node_id,omitempty"`
|
||||
ReporterRegion string `json:"reporter_region,omitempty"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms,omitempty"`
|
||||
SuccessCount uint64 `json:"success_count,omitempty"`
|
||||
FailureCount uint64 `json:"failure_count,omitempty"`
|
||||
@@ -632,343 +633,4 @@ type EndpointCandidateHealthObservation struct {
|
||||
ObservedAt time.Time `json:"observed_at,omitempty"`
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enroll(ctx context.Context, request EnrollRequest) (EnrollResponse, error) {
|
||||
var response EnrollResponse
|
||||
if err := c.postJSON(ctx, "/node-agents/enroll", request, &response); err != nil {
|
||||
return EnrollResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) BootstrapEnrollment(ctx context.Context, joinRequestID string, request EnrollmentBootstrapRequest) (EnrollmentBootstrapResponse, error) {
|
||||
var response EnrollmentBootstrapResponse
|
||||
path := fmt.Sprintf("/node-agents/enrollments/%s/bootstrap", joinRequestID)
|
||||
if err := c.postJSON(ctx, path, request, &response); err != nil {
|
||||
return EnrollmentBootstrapResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) Heartbeat(ctx context.Context, clusterID, nodeID string, request HeartbeatRequest) (HeartbeatResponse, error) {
|
||||
var response HeartbeatResponse
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/heartbeats", clusterID, nodeID)
|
||||
if err := c.postJSON(ctx, path, request, &response); err != nil {
|
||||
return HeartbeatResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) NodeUpdatePlan(ctx context.Context, clusterID, nodeID string, request NodeUpdatePlanRequest) (NodeUpdatePlan, error) {
|
||||
values := url.Values{}
|
||||
values.Set("product", request.Product)
|
||||
values.Set("current_version", request.CurrentVersion)
|
||||
values.Set("os", request.OS)
|
||||
values.Set("arch", request.Arch)
|
||||
values.Set("install_type", request.InstallType)
|
||||
if request.Channel != "" {
|
||||
values.Set("channel", request.Channel)
|
||||
}
|
||||
var response NodeUpdatePlanResponse
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/updates/plan?%s", clusterID, nodeID, values.Encode())
|
||||
if err := c.getJSON(ctx, path, &response); err != nil {
|
||||
return NodeUpdatePlan{}, err
|
||||
}
|
||||
return response.Plan, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportNodeUpdateStatus(ctx context.Context, clusterID, nodeID string, request NodeUpdateStatusRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/updates/status", clusterID, nodeID)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DesiredWorkloads(ctx context.Context, clusterID, nodeID string) ([]DesiredWorkload, error) {
|
||||
var response struct {
|
||||
DesiredWorkloads []DesiredWorkload `json:"desired_workloads"`
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/workloads/desired", clusterID, nodeID)
|
||||
if err := c.getJSON(ctx, path, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.DesiredWorkloads, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportWorkloadStatus(ctx context.Context, clusterID, nodeID, serviceType string, request WorkloadStatusRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/workloads/%s/status", clusterID, nodeID, serviceType)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) NodeVPNAssignments(ctx context.Context, clusterID, nodeID string) ([]NodeVPNAssignment, error) {
|
||||
var response struct {
|
||||
Assignments []NodeVPNAssignment `json:"vpn_assignments"`
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments", clusterID, nodeID)
|
||||
if err := c.getJSON(ctx, path, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Assignments, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportNodeVPNAssignmentStatus(ctx context.Context, clusterID, nodeID, vpnConnectionID string, request NodeVPNAssignmentStatusRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/status", clusterID, nodeID, vpnConnectionID)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) AcquireNodeVPNAssignmentLease(ctx context.Context, clusterID, nodeID, vpnConnectionID string, request NodeVPNAssignmentLeaseAcquireRequest) (*NodeVPNAssignmentLease, error) {
|
||||
var response struct {
|
||||
Lease NodeVPNAssignmentLease `json:"lease"`
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/lease/acquire", clusterID, nodeID, vpnConnectionID)
|
||||
if err := c.postJSON(ctx, path, request, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Lease, nil
|
||||
}
|
||||
|
||||
func (c *Client) RenewNodeVPNAssignmentLease(ctx context.Context, clusterID, nodeID, vpnConnectionID, leaseID string, request NodeVPNAssignmentLeaseRenewRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/lease/%s/renew", clusterID, nodeID, vpnConnectionID, leaseID)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SendVPNGatewayPacket(ctx context.Context, clusterID, vpnConnectionID string, packet []byte) error {
|
||||
if len(packet) == 0 {
|
||||
return nil
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/vpn-connections/%s/tunnel/gateway/packets", clusterID, vpnConnectionID)
|
||||
return c.postBytes(ctx, path, packet)
|
||||
}
|
||||
|
||||
func (c *Client) SendVPNGatewayPacketBatch(ctx context.Context, clusterID, vpnConnectionID string, packets [][]byte) error {
|
||||
packets = cleanVPNPacketBatch(packets)
|
||||
if len(packets) == 0 {
|
||||
return nil
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/vpn-connections/%s/tunnel/gateway/packets?batch=true", clusterID, vpnConnectionID)
|
||||
return c.postBytes(ctx, path, encodeVPNPacketBatch(packets))
|
||||
}
|
||||
|
||||
func (c *Client) ReceiveVPNGatewayPacket(ctx context.Context, clusterID, vpnConnectionID string, timeout time.Duration) ([]byte, bool, error) {
|
||||
path := fmt.Sprintf("/clusters/%s/vpn-connections/%s/tunnel/gateway/packets?timeout_ms=%d", clusterID, vpnConnectionID, timeout.Milliseconds())
|
||||
return c.getBytes(ctx, path)
|
||||
}
|
||||
|
||||
func (c *Client) ReceiveVPNGatewayPacketBatch(ctx context.Context, clusterID, vpnConnectionID string, timeout time.Duration) ([][]byte, error) {
|
||||
path := fmt.Sprintf("/clusters/%s/vpn-connections/%s/tunnel/gateway/packets?batch=true&timeout_ms=%d", clusterID, vpnConnectionID, timeout.Milliseconds())
|
||||
payload, ok, err := c.getBytes(ctx, path)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return decodeVPNPacketBatch(payload)
|
||||
}
|
||||
|
||||
func (c *Client) ReportMeshLink(ctx context.Context, clusterID string, request MeshLinkObservationRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/mesh/links", clusterID)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ReportTelemetry(ctx context.Context, clusterID, nodeID string, request TelemetryRequest) error {
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/telemetry", clusterID, nodeID)
|
||||
return c.postJSON(ctx, path, request, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SyntheticMeshConfig(ctx context.Context, clusterID, nodeID string) (SyntheticMeshConfig, error) {
|
||||
var response struct {
|
||||
Config SyntheticMeshConfig `json:"synthetic_mesh_config"`
|
||||
}
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/mesh/synthetic-config", clusterID, nodeID)
|
||||
if err := c.getJSON(ctx, path, &response); err != nil {
|
||||
return SyntheticMeshConfig{}, err
|
||||
}
|
||||
return response.Config, nil
|
||||
}
|
||||
|
||||
func (c *Client) AdminRuntimeProjection(ctx context.Context, clusterID, nodeID string, request AdminRuntimeProjectionRequest) (AdminRuntimeProjectionResponse, error) {
|
||||
var response AdminRuntimeProjectionResponse
|
||||
path := fmt.Sprintf("/clusters/%s/nodes/%s/admin-runtime/projection", clusterID, nodeID)
|
||||
if err := c.postJSON(ctx, path, request, &response); err != nil {
|
||||
return AdminRuntimeProjectionResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) RawControl(ctx context.Context, request RawControlRequest) (RawControlResponse, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(request.Method))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
path := strings.TrimSpace(request.Path)
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return RawControlResponse{}, fmt.Errorf("control path must be relative")
|
||||
}
|
||||
var body io.Reader
|
||||
if len(request.Body) > 0 && string(request.Body) != "null" {
|
||||
body = bytes.NewReader(request.Body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return RawControlResponse{}, err
|
||||
}
|
||||
if body != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return RawControlResponse{}, err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(httpResp.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return RawControlResponse{}, err
|
||||
}
|
||||
return RawControlResponse{StatusCode: httpResp.StatusCode, Body: json.RawMessage(payload)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(ctx context.Context, path string, response any) error {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return fmt.Errorf("backend returned status %d", httpResp.StatusCode)
|
||||
}
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(httpResp.Body).Decode(response)
|
||||
}
|
||||
|
||||
func (c *Client) getBytes(ctx context.Context, path string) ([]byte, bool, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode == http.StatusNoContent {
|
||||
return nil, false, nil
|
||||
}
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, false, fmt.Errorf("backend returned status %d", httpResp.StatusCode)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(httpResp.Body, vpnPacketBatchMaxBytes))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) postBytes(ctx context.Context, path string, payload []byte) error {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return fmt.Errorf("backend returned status %d", httpResp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, request any, response any) error {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return fmt.Errorf("backend returned status %d", httpResp.StatusCode)
|
||||
}
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(httpResp.Body).Decode(response)
|
||||
}
|
||||
|
||||
const (
|
||||
vpnPacketMaxBytes = 65535
|
||||
vpnPacketBatchMaxBytes = 4 * 1024 * 1024
|
||||
)
|
||||
|
||||
func encodeVPNPacketBatch(packets [][]byte) []byte {
|
||||
packets = cleanVPNPacketBatch(packets)
|
||||
total := 0
|
||||
for _, packet := range packets {
|
||||
total += 4 + len(packet)
|
||||
}
|
||||
out := make([]byte, total)
|
||||
offset := 0
|
||||
for _, packet := range packets {
|
||||
binary.BigEndian.PutUint32(out[offset:offset+4], uint32(len(packet)))
|
||||
offset += 4
|
||||
copy(out[offset:offset+len(packet)], packet)
|
||||
offset += len(packet)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeVPNPacketBatch(payload []byte) ([][]byte, error) {
|
||||
var packets [][]byte
|
||||
for offset := 0; offset < len(payload); {
|
||||
if offset+4 > len(payload) {
|
||||
return nil, fmt.Errorf("truncated vpn packet batch header")
|
||||
}
|
||||
size := int(binary.BigEndian.Uint32(payload[offset : offset+4]))
|
||||
offset += 4
|
||||
if size <= 0 || size > vpnPacketMaxBytes {
|
||||
return nil, fmt.Errorf("invalid vpn packet batch item size")
|
||||
}
|
||||
if offset+size > len(payload) {
|
||||
return nil, fmt.Errorf("truncated vpn packet batch item")
|
||||
}
|
||||
packets = append(packets, append([]byte(nil), payload[offset:offset+size]...))
|
||||
offset += size
|
||||
}
|
||||
return cleanVPNPacketBatch(packets), nil
|
||||
}
|
||||
|
||||
func cleanVPNPacketBatch(packets [][]byte) [][]byte {
|
||||
if len(packets) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleaned := make([][]byte, 0, len(packets))
|
||||
for _, packet := range packets {
|
||||
if len(packet) == 0 {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, append([]byte(nil), packet...))
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
func New(_ string) *Client { return &Client{} }
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
const MaxMeshProductionObservationSinkCapacity = 10000
|
||||
|
||||
type Config struct {
|
||||
BackendURL string
|
||||
ClusterID string
|
||||
ClusterAuthorityPublicKey string
|
||||
ClusterAuthorityFingerprint string
|
||||
@@ -30,7 +29,7 @@ type Config struct {
|
||||
HeartbeatInterval time.Duration
|
||||
EnrollmentPollInterval time.Duration
|
||||
EnrollmentPollTimeout time.Duration
|
||||
MeshSyntheticRuntimeEnabled bool
|
||||
FabricRuntimeEnabled bool
|
||||
MeshProductionForwardingEnabled bool
|
||||
VPNFabricSessionTransportEnabled bool
|
||||
MeshQUICFabricEnabled bool
|
||||
@@ -39,17 +38,18 @@ type Config struct {
|
||||
VPNFabricQUICMaxStreamsPerConn int
|
||||
VPNFabricQUICIdleTTL time.Duration
|
||||
MeshProductionObservationSinkCapacity int
|
||||
MeshListenAddr string
|
||||
MeshListenPortMode string
|
||||
MeshListenAutoPortStart int
|
||||
MeshListenAutoPortEnd int
|
||||
FabricListenAddr string
|
||||
FabricListenPortMode string
|
||||
FabricListenAutoPortStart int
|
||||
FabricListenAutoPortEnd int
|
||||
MeshAdvertiseEndpoint string
|
||||
MeshAdvertiseEndpointsJSON string
|
||||
FabricRegistryRecordsJSON string
|
||||
MeshAdvertiseTransport string
|
||||
MeshConnectivityMode string
|
||||
MeshNATType string
|
||||
MeshLocalSegmentID string
|
||||
MeshSiteID string
|
||||
MeshLocalityGroupID string
|
||||
MeshNATGroupID string
|
||||
MeshSTUNReflexiveEndpoint string
|
||||
MeshSTUNServer string
|
||||
@@ -72,7 +72,6 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
defaultStateDir := filepath.Join(".", ".rap-node-agent")
|
||||
fs := flag.NewFlagSet("rap-node-agent", flag.ContinueOnError)
|
||||
cfg := Config{}
|
||||
fs.StringVar(&cfg.BackendURL, "backend-url", getEnv(env, "RAP_BACKEND_URL", "http://127.0.0.1:8080/api/v1"), "Backend API base URL.")
|
||||
fs.StringVar(&cfg.ClusterID, "cluster-id", getEnv(env, "RAP_CLUSTER_ID", ""), "Cluster ID.")
|
||||
fs.StringVar(&cfg.ClusterAuthorityPublicKey, "cluster-authority-public-key", getEnv(env, "RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned cluster authority Ed25519 public key.")
|
||||
fs.StringVar(&cfg.ClusterAuthorityFingerprint, "cluster-authority-fingerprint", getEnv(env, "RAP_CLUSTER_AUTHORITY_FINGERPRINT", ""), "Pinned cluster authority key fingerprint.")
|
||||
@@ -85,26 +84,27 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
fs.StringVar(&cfg.WebIngressSigningKeyID, "web-ingress-signing-key-id", getEnv(env, "RAP_WEB_INGRESS_SIGNING_KEY_ID", ""), "Optional key id for web ingress envelope signatures.")
|
||||
fs.StringVar(&cfg.WebIngressTrustedKeysJSON, "web-ingress-trusted-keys-json", getEnv(env, "RAP_WEB_INGRESS_TRUSTED_KEYS_JSON", ""), "JSON map or array of trusted Ed25519 public keys for web ingress runtime receiver.")
|
||||
fs.StringVar(&cfg.WebIngressRuntimeServiceClasses, "web-ingress-runtime-service-classes", getEnv(env, "RAP_WEB_INGRESS_RUNTIME_SERVICE_CLASSES", ""), "Optional comma-separated allow-list of web ingress runtime service classes accepted by this node.")
|
||||
fs.BoolVar(&cfg.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getEnvBool(env, "RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable C17A synthetic fabric probe runtime. Disabled by default.")
|
||||
fs.BoolVar(&cfg.FabricRuntimeEnabled, "fabric-runtime-enabled", getEnvBool(env, "RAP_FABRIC_RUNTIME_ENABLED", false), "Enable C17A synthetic fabric probe runtime. Disabled by default.")
|
||||
fs.BoolVar(&cfg.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getEnvBool(env, "RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production fabric-control direct next-hop forwarding gate. Disabled by default.")
|
||||
fs.BoolVar(&cfg.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getEnvBool(env, "RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric session when explicitly enabled. Disabled by default.")
|
||||
fs.BoolVar(&cfg.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getEnvBool(env, "RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener. Disabled by default.")
|
||||
fs.StringVar(&cfg.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getEnv(env, "RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "Listen address for QUIC/UDP fabric endpoint, for example :19443.")
|
||||
fs.IntVar(&cfg.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getEnvInt(env, "RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getEnvInt(env, "RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 8), "VPN fabric-session stream shards per traffic class.")
|
||||
fs.IntVar(&cfg.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getEnvInt(env, "RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.")
|
||||
fs.DurationVar(&cfg.VPNFabricQUICIdleTTL, "vpn-fabric-quic-idle-ttl", time.Duration(getEnvInt(env, "RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300))*time.Second, "Idle TTL for cached VPN QUIC carrier connections.")
|
||||
fs.IntVar(&cfg.MeshProductionObservationSinkCapacity, "mesh-production-observation-sink-capacity", getEnvSignedInt(env, "RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY", 0), "Bounded local metadata-only production envelope observation sink capacity. Disabled when 0.")
|
||||
fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getEnv(env, "RAP_MESH_LISTEN_ADDR", ""), "Listen address for disabled-by-default historical synthetic mesh HTTP endpoint.")
|
||||
fs.StringVar(&cfg.MeshListenPortMode, "mesh-listen-port-mode", getEnv(env, "RAP_MESH_LISTEN_PORT_MODE", "manual"), "Mesh listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getEnvInt(env, "RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.")
|
||||
fs.IntVar(&cfg.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getEnvInt(env, "RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.")
|
||||
fs.StringVar(&cfg.FabricListenAddr, "fabric-listen-addr", getEnv(env, "RAP_FABRIC_LISTEN_ADDR", ""), "Optional node listener address used by the QUIC fabric runtime contract.")
|
||||
fs.StringVar(&cfg.FabricListenPortMode, "fabric-listen-port-mode", getEnv(env, "RAP_FABRIC_LISTEN_PORT_MODE", "manual"), "Fabric listen port behavior: manual, auto, or disabled.")
|
||||
fs.IntVar(&cfg.FabricListenAutoPortStart, "fabric-listen-auto-port-start", getEnvInt(env, "RAP_FABRIC_LISTEN_AUTO_PORT_START", 19131), "First port used when fabric listen port mode is auto.")
|
||||
fs.IntVar(&cfg.FabricListenAutoPortEnd, "fabric-listen-auto-port-end", getEnvInt(env, "RAP_FABRIC_LISTEN_AUTO_PORT_END", 19231), "Last port used when fabric listen port mode is auto.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getEnv(env, "RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint reported to the Control Plane. Empty disables endpoint reporting.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getEnv(env, "RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "JSON array of advertised mesh endpoint candidates, including private/corporate endpoints.")
|
||||
fs.StringVar(&cfg.FabricRegistryRecordsJSON, "fabric-registry-records-json", getEnv(env, "RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry gossip records used as bootstrap discovery seeds.")
|
||||
fs.StringVar(&cfg.MeshAdvertiseTransport, "mesh-advertise-transport", getEnv(env, "RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Transport label for the advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.MeshConnectivityMode, "mesh-connectivity-mode", getEnv(env, "RAP_MESH_CONNECTIVITY_MODE", "direct"), "Connectivity mode reported with the advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.MeshNATType, "mesh-nat-type", getEnv(env, "RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint reported with the advertised mesh endpoint.")
|
||||
fs.StringVar(&cfg.MeshLocalSegmentID, "mesh-local-segment-id", getEnv(env, "RAP_MESH_LOCAL_SEGMENT_ID", ""), "Optional local LAN/site segment ID advertised with QUIC endpoint candidates.")
|
||||
fs.StringVar(&cfg.MeshSiteID, "mesh-site-id", getEnv(env, "RAP_MESH_SITE_ID", ""), "Optional physical or logical site identifier advertised with QUIC endpoint candidates.")
|
||||
fs.StringVar(&cfg.MeshLocalityGroupID, "mesh-locality-group-id", getEnv(env, "RAP_MESH_LOCALITY_GROUP_ID", ""), "Optional locality group identifier used to decide whether private QUIC endpoints are actually local.")
|
||||
fs.StringVar(&cfg.MeshNATGroupID, "mesh-nat-group-id", getEnv(env, "RAP_MESH_NAT_GROUP_ID", ""), "Optional NAT group ID advertised with QUIC endpoint candidates.")
|
||||
fs.StringVar(&cfg.MeshSTUNReflexiveEndpoint, "mesh-stun-reflexive-endpoint", getEnv(env, "RAP_MESH_STUN_REFLEXIVE_ENDPOINT", ""), "Optional STUN-discovered reflexive QUIC endpoint, for example quic://203.0.113.10:19443.")
|
||||
fs.StringVar(&cfg.MeshSTUNServer, "mesh-stun-server", getEnv(env, "RAP_MESH_STUN_SERVER", ""), "Optional STUN server name used to discover the reflexive endpoint.")
|
||||
@@ -127,21 +127,20 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.BackendURL = strings.TrimRight(strings.TrimSpace(cfg.BackendURL), "/")
|
||||
cfg.ClusterID = strings.TrimSpace(cfg.ClusterID)
|
||||
cfg.ClusterAuthorityPublicKey = strings.TrimSpace(cfg.ClusterAuthorityPublicKey)
|
||||
cfg.ClusterAuthorityFingerprint = strings.TrimSpace(cfg.ClusterAuthorityFingerprint)
|
||||
cfg.JoinToken = strings.TrimSpace(cfg.JoinToken)
|
||||
cfg.NodeName = strings.TrimSpace(cfg.NodeName)
|
||||
cfg.StateDir = strings.TrimSpace(cfg.StateDir)
|
||||
cfg.MeshListenAddr = strings.TrimSpace(cfg.MeshListenAddr)
|
||||
cfg.FabricListenAddr = strings.TrimSpace(cfg.FabricListenAddr)
|
||||
cfg.MeshQUICFabricListenAddr = strings.TrimSpace(cfg.MeshQUICFabricListenAddr)
|
||||
cfg.MeshListenPortMode = strings.ToLower(strings.TrimSpace(cfg.MeshListenPortMode))
|
||||
cfg.FabricListenPortMode = strings.ToLower(strings.TrimSpace(cfg.FabricListenPortMode))
|
||||
if cfg.VPNFabricSessionStreamShards <= 0 {
|
||||
cfg.VPNFabricSessionStreamShards = 4
|
||||
cfg.VPNFabricSessionStreamShards = 8
|
||||
}
|
||||
if cfg.VPNFabricSessionStreamShards > 64 {
|
||||
cfg.VPNFabricSessionStreamShards = 64
|
||||
if cfg.VPNFabricSessionStreamShards > 128 {
|
||||
cfg.VPNFabricSessionStreamShards = 128
|
||||
}
|
||||
if cfg.VPNFabricQUICMaxStreamsPerConn <= 0 {
|
||||
cfg.VPNFabricQUICMaxStreamsPerConn = 64
|
||||
@@ -156,16 +155,15 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
if cfg.MeshAdvertiseTransport == "" {
|
||||
cfg.MeshAdvertiseTransport = "quic"
|
||||
}
|
||||
cfg.MeshAdvertiseTransport = normalizeLegacyAdvertiseTransport(cfg.MeshAdvertiseTransport)
|
||||
cfg.MeshAdvertiseEndpoint = normalizeLegacyEndpointSchemeToQUIC(cfg.MeshAdvertiseEndpoint)
|
||||
cfg.MeshConnectivityMode = strings.TrimSpace(cfg.MeshConnectivityMode)
|
||||
cfg.MeshNATType = strings.TrimSpace(cfg.MeshNATType)
|
||||
cfg.MeshLocalSegmentID = strings.TrimSpace(cfg.MeshLocalSegmentID)
|
||||
cfg.MeshSiteID = strings.TrimSpace(cfg.MeshSiteID)
|
||||
cfg.MeshLocalityGroupID = strings.TrimSpace(cfg.MeshLocalityGroupID)
|
||||
cfg.MeshNATGroupID = strings.TrimSpace(cfg.MeshNATGroupID)
|
||||
cfg.MeshSTUNReflexiveEndpoint = normalizeLegacyEndpointSchemeToQUIC(strings.TrimRight(strings.TrimSpace(cfg.MeshSTUNReflexiveEndpoint), "/"))
|
||||
cfg.MeshSTUNReflexiveEndpoint = strings.TrimRight(strings.TrimSpace(cfg.MeshSTUNReflexiveEndpoint), "/")
|
||||
cfg.MeshSTUNServer = strings.TrimSpace(cfg.MeshSTUNServer)
|
||||
cfg.MeshRelayNodeID = strings.TrimSpace(cfg.MeshRelayNodeID)
|
||||
cfg.MeshRelayEndpoint = normalizeLegacyEndpointSchemeToQUIC(strings.TrimRight(strings.TrimSpace(cfg.MeshRelayEndpoint), "/"))
|
||||
cfg.MeshRelayEndpoint = strings.TrimRight(strings.TrimSpace(cfg.MeshRelayEndpoint), "/")
|
||||
cfg.MeshRegion = strings.TrimSpace(cfg.MeshRegion)
|
||||
cfg.MeshSyntheticConfigPath = strings.TrimSpace(cfg.MeshSyntheticConfigPath)
|
||||
cfg.MeshPeerEndpointsJSON = strings.TrimSpace(cfg.MeshPeerEndpointsJSON)
|
||||
@@ -177,8 +175,8 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
cfg.RemoteWorkspaceRealAdapterCommand = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterCommand)
|
||||
cfg.RemoteWorkspaceRealAdapterArgsJSON = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterArgsJSON)
|
||||
cfg.RemoteWorkspaceRealAdapterWorkDir = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterWorkDir)
|
||||
if cfg.BackendURL == "" {
|
||||
return Config{}, errors.New("backend URL is required")
|
||||
if cfg.FabricRegistryRecordsJSON == "" {
|
||||
return Config{}, errors.New("fabric registry records are required")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
return Config{}, errors.New("node name is required")
|
||||
@@ -204,30 +202,30 @@ func Load(args []string, env map[string]string) (Config, error) {
|
||||
if cfg.FabricRegistryRecordsJSON != "" && !isJSONArray(cfg.FabricRegistryRecordsJSON) {
|
||||
return Config{}, errors.New("fabric registry records must be a JSON array")
|
||||
}
|
||||
switch cfg.MeshListenPortMode {
|
||||
switch cfg.FabricListenPortMode {
|
||||
case "", "manual", "auto", "disabled":
|
||||
if cfg.MeshListenPortMode == "" {
|
||||
cfg.MeshListenPortMode = "manual"
|
||||
if cfg.FabricListenPortMode == "" {
|
||||
cfg.FabricListenPortMode = "manual"
|
||||
}
|
||||
default:
|
||||
return Config{}, errors.New("mesh listen port mode must be manual, auto, or disabled")
|
||||
return Config{}, errors.New("fabric listen port mode must be manual, auto, or disabled")
|
||||
}
|
||||
if cfg.MeshListenAutoPortStart <= 0 || cfg.MeshListenAutoPortEnd <= 0 {
|
||||
return Config{}, errors.New("mesh listen auto port range must be positive")
|
||||
if cfg.FabricListenAutoPortStart <= 0 || cfg.FabricListenAutoPortEnd <= 0 {
|
||||
return Config{}, errors.New("fabric listen auto port range must be positive")
|
||||
}
|
||||
if cfg.MeshListenAutoPortStart > cfg.MeshListenAutoPortEnd {
|
||||
return Config{}, errors.New("mesh listen auto port start must be less than or equal to end")
|
||||
if cfg.FabricListenAutoPortStart > cfg.FabricListenAutoPortEnd {
|
||||
return Config{}, errors.New("fabric listen auto port start must be less than or equal to end")
|
||||
}
|
||||
if !isQUICAdvertiseTransport(cfg.MeshAdvertiseTransport) {
|
||||
return Config{}, errors.New("mesh advertise transport must be a QUIC transport label")
|
||||
}
|
||||
if hasLegacyEndpointScheme(cfg.MeshAdvertiseEndpoint) {
|
||||
if hasUnsupportedEndpointScheme(cfg.MeshAdvertiseEndpoint) {
|
||||
return Config{}, errors.New("mesh advertise endpoint must be a QUIC endpoint")
|
||||
}
|
||||
if cfg.MeshSTUNReflexiveEndpoint != "" && hasLegacyEndpointScheme(cfg.MeshSTUNReflexiveEndpoint) {
|
||||
if cfg.MeshSTUNReflexiveEndpoint != "" && hasUnsupportedEndpointScheme(cfg.MeshSTUNReflexiveEndpoint) {
|
||||
return Config{}, errors.New("mesh STUN reflexive endpoint must be a QUIC endpoint")
|
||||
}
|
||||
if cfg.MeshRelayEndpoint != "" && hasLegacyEndpointScheme(cfg.MeshRelayEndpoint) {
|
||||
if cfg.MeshRelayEndpoint != "" && hasUnsupportedEndpointScheme(cfg.MeshRelayEndpoint) {
|
||||
return Config{}, errors.New("mesh relay endpoint must be a QUIC endpoint")
|
||||
}
|
||||
return cfg, nil
|
||||
@@ -242,36 +240,12 @@ func isQUICAdvertiseTransport(label string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyAdvertiseTransport(label string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(label)) {
|
||||
case "direct_http", "direct_https", "direct_tcp_tls", "http", "https", "ws", "wss", "websocket":
|
||||
return "direct_quic"
|
||||
case "outbound_reverse", "reverse", "reverse_outbound":
|
||||
return "reverse_quic"
|
||||
case "relay", "relay_control":
|
||||
return "relay_quic"
|
||||
default:
|
||||
return strings.TrimSpace(label)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyEndpointSchemeToQUIC(endpoint string) string {
|
||||
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||
lower := strings.ToLower(endpoint)
|
||||
for _, prefix := range []string{"http://", "https://", "ws://", "wss://"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return "quic://" + endpoint[len(prefix):]
|
||||
}
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func hasLegacyEndpointScheme(endpoint string) bool {
|
||||
func hasUnsupportedEndpointScheme(endpoint string) bool {
|
||||
endpoint = strings.ToLower(strings.TrimSpace(endpoint))
|
||||
return strings.HasPrefix(endpoint, "http://") ||
|
||||
strings.HasPrefix(endpoint, "https://") ||
|
||||
strings.HasPrefix(endpoint, "ws://") ||
|
||||
strings.HasPrefix(endpoint, "wss://")
|
||||
if endpoint == "" || !strings.Contains(endpoint, "://") {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(endpoint, "quic://")
|
||||
}
|
||||
|
||||
func isJSONArray(value string) bool {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
cfg, err := Load([]string{"-node-name", "node-b"}, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1/",
|
||||
"RAP_CLUSTER_ID": "cluster-1",
|
||||
"RAP_CLUSTER_AUTHORITY_PUBLIC_KEY": "public-key-b64",
|
||||
"RAP_CLUSTER_AUTHORITY_FINGERPRINT": "rap-ca-ed25519-test",
|
||||
@@ -23,7 +23,7 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
"RAP_HEARTBEAT_INTERVAL_SECONDS": "7",
|
||||
"RAP_ENROLLMENT_POLL_INTERVAL_SECONDS": "3",
|
||||
"RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS": "30",
|
||||
"RAP_MESH_SYNTHETIC_RUNTIME_ENABLED": "true",
|
||||
"RAP_FABRIC_RUNTIME_ENABLED": "true",
|
||||
"RAP_MESH_PRODUCTION_FORWARDING_ENABLED": "true",
|
||||
"RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED": "true",
|
||||
"RAP_MESH_QUIC_FABRIC_ENABLED": "true",
|
||||
@@ -32,17 +32,18 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
"RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN": "24",
|
||||
"RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS": "120",
|
||||
"RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY": "5",
|
||||
"RAP_MESH_LISTEN_ADDR": "127.0.0.1:19001",
|
||||
"RAP_MESH_LISTEN_PORT_MODE": "auto",
|
||||
"RAP_MESH_LISTEN_AUTO_PORT_START": "19010",
|
||||
"RAP_MESH_LISTEN_AUTO_PORT_END": "19020",
|
||||
"RAP_FABRIC_LISTEN_ADDR": "127.0.0.1:19001",
|
||||
"RAP_FABRIC_LISTEN_PORT_MODE": "auto",
|
||||
"RAP_FABRIC_LISTEN_AUTO_PORT_START": "19010",
|
||||
"RAP_FABRIC_LISTEN_AUTO_PORT_END": "19020",
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT": "quic://node-a.example.test:19443/",
|
||||
"RAP_MESH_ADVERTISE_ENDPOINTS_JSON": `[{"endpoint_id":"node-a-lan","address":"10.10.0.20:19001"}]`,
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": ` [{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}] `,
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic",
|
||||
"RAP_MESH_CONNECTIVITY_MODE": "outbound_only",
|
||||
"RAP_MESH_NAT_TYPE": "symmetric",
|
||||
"RAP_MESH_LOCAL_SEGMENT_ID": "site-a",
|
||||
"RAP_MESH_SITE_ID": "home",
|
||||
"RAP_MESH_LOCALITY_GROUP_ID": "home-lan",
|
||||
"RAP_MESH_NAT_GROUP_ID": "nat-a",
|
||||
"RAP_MESH_STUN_REFLEXIVE_ENDPOINT": "quic://203.0.113.20:19443/",
|
||||
"RAP_MESH_STUN_SERVER": "stun.example.test:3478",
|
||||
@@ -50,7 +51,7 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
"RAP_MESH_RELAY_ENDPOINT": "quic://node-r.example.test:19443/",
|
||||
"RAP_MESH_REGION": "eu",
|
||||
"RAP_MESH_SYNTHETIC_CONFIG": "/tmp/rap-node/mesh-synthetic.json",
|
||||
"RAP_MESH_PEER_ENDPOINTS_JSON": `{"node-b":"http://127.0.0.1:19002"}`,
|
||||
"RAP_MESH_PEER_ENDPOINTS_JSON": `{"node-b":"quic://127.0.0.1:19002"}`,
|
||||
"RAP_MESH_SYNTHETIC_ROUTES_JSON": `[{"route_id":"route-1"}]`,
|
||||
"RAP_REMOTE_WORKSPACE_REAL_ADAPTER_ENABLED": "true",
|
||||
"RAP_REMOTE_WORKSPACE_REAL_ADAPTER_COMMAND": " /opt/rap/bin/rdp-worker ",
|
||||
@@ -60,9 +61,6 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.BackendURL != "http://backend/api/v1" {
|
||||
t.Fatalf("BackendURL = %q", cfg.BackendURL)
|
||||
}
|
||||
if cfg.NodeName != "node-b" {
|
||||
t.Fatalf("NodeName = %q", cfg.NodeName)
|
||||
}
|
||||
@@ -87,8 +85,8 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
cfg.WebIngressRuntimeServiceClasses != "platform_admin, cluster_admin" {
|
||||
t.Fatalf("unexpected web ingress key config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MeshSyntheticRuntimeEnabled {
|
||||
t.Fatal("MeshSyntheticRuntimeEnabled = false, want true")
|
||||
if !cfg.FabricRuntimeEnabled {
|
||||
t.Fatal("FabricRuntimeEnabled = false, want true")
|
||||
}
|
||||
if !cfg.MeshProductionForwardingEnabled {
|
||||
t.Fatal("MeshProductionForwardingEnabled = false, want true")
|
||||
@@ -111,11 +109,11 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
if cfg.MeshProductionObservationSinkCapacity != 5 {
|
||||
t.Fatalf("MeshProductionObservationSinkCapacity = %d, want 5", cfg.MeshProductionObservationSinkCapacity)
|
||||
}
|
||||
if cfg.MeshListenAddr != "127.0.0.1:19001" {
|
||||
t.Fatalf("MeshListenAddr = %q", cfg.MeshListenAddr)
|
||||
if cfg.FabricListenAddr != "127.0.0.1:19001" {
|
||||
t.Fatalf("FabricListenAddr = %q", cfg.FabricListenAddr)
|
||||
}
|
||||
if cfg.MeshListenPortMode != "auto" || cfg.MeshListenAutoPortStart != 19010 || cfg.MeshListenAutoPortEnd != 19020 {
|
||||
t.Fatalf("unexpected mesh listen port config: %+v", cfg)
|
||||
if cfg.FabricListenPortMode != "auto" || cfg.FabricListenAutoPortStart != 19010 || cfg.FabricListenAutoPortEnd != 19020 {
|
||||
t.Fatalf("unexpected fabric listen port config: %+v", cfg)
|
||||
}
|
||||
if cfg.MeshAdvertiseEndpoint != "quic://node-a.example.test:19443" ||
|
||||
cfg.MeshAdvertiseEndpointsJSON == "" ||
|
||||
@@ -123,7 +121,8 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
cfg.MeshAdvertiseTransport != "direct_quic" ||
|
||||
cfg.MeshConnectivityMode != "outbound_only" ||
|
||||
cfg.MeshNATType != "symmetric" ||
|
||||
cfg.MeshLocalSegmentID != "site-a" ||
|
||||
cfg.MeshSiteID != "home" ||
|
||||
cfg.MeshLocalityGroupID != "home-lan" ||
|
||||
cfg.MeshNATGroupID != "nat-a" ||
|
||||
cfg.MeshSTUNReflexiveEndpoint != "quic://203.0.113.20:19443" ||
|
||||
cfg.MeshSTUNServer != "stun.example.test:3478" ||
|
||||
@@ -146,10 +145,24 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigLoadsLocalityGroup(t *testing.T) {
|
||||
cfg, err := Load(nil, map[string]string{
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
"RAP_MESH_LOCALITY_GROUP_ID": "home-lan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.MeshLocalityGroupID != "home-lan" {
|
||||
t.Fatalf("unexpected locality group: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsEnrollmentPollingToNoTimeout(t *testing.T) {
|
||||
cfg, err := Load(nil, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
@@ -168,10 +181,31 @@ func TestLoadConfigDefaultsEnrollmentPollingToNoTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresFabricBootstrap(t *testing.T) {
|
||||
_, err := Load([]string{
|
||||
"--node-name", "node-a",
|
||||
"--state-dir", t.TempDir(),
|
||||
"--fabric-registry-records-json", `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
}, map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMissingFabricBootstrap(t *testing.T) {
|
||||
_, err := Load([]string{
|
||||
"--node-name", "node-a",
|
||||
"--state-dir", t.TempDir(),
|
||||
}, map[string]string{})
|
||||
if err == nil || !strings.Contains(err.Error(), "fabric registry records are required") {
|
||||
t.Fatalf("expected fabric validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsNegativeProductionObservationSinkCapacity(t *testing.T) {
|
||||
_, err := Load(nil, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
"RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY": "-1",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -181,8 +215,8 @@ func TestLoadConfigRejectsNegativeProductionObservationSinkCapacity(t *testing.T
|
||||
|
||||
func TestLoadConfigRejectsTooLargeProductionObservationSinkCapacity(t *testing.T) {
|
||||
_, err := Load(nil, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
"RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY": "10001",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -190,32 +224,26 @@ func TestLoadConfigRejectsTooLargeProductionObservationSinkCapacity(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigNormalizesLegacyMeshAdvertiseTransport(t *testing.T) {
|
||||
cfg, err := Load(nil, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT": "quic://node-a.example.test:19443",
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT": "wss",
|
||||
func TestLoadConfigRejectsDisallowedMeshAdvertiseTransport(t *testing.T) {
|
||||
_, err := Load(nil, map[string]string{
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT": "quic://node-a.example.test:19443",
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT": "wss",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error for legacy mesh advertise transport migration: %v", err)
|
||||
}
|
||||
if cfg.MeshAdvertiseTransport != "direct_quic" {
|
||||
t.Fatalf("transport = %q, want direct_quic", cfg.MeshAdvertiseTransport)
|
||||
if err == nil || !strings.Contains(err.Error(), "QUIC transport label") {
|
||||
t.Fatalf("expected QUIC transport rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigNormalizesLegacyMeshAdvertiseEndpointScheme(t *testing.T) {
|
||||
cfg, err := Load(nil, map[string]string{
|
||||
"RAP_BACKEND_URL": "http://backend/api/v1",
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT": "https://node-a.example.test:443",
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic",
|
||||
func TestLoadConfigRejectsDisallowedMeshAdvertiseEndpointScheme(t *testing.T) {
|
||||
_, err := Load(nil, map[string]string{
|
||||
"RAP_NODE_NAME": "node-a",
|
||||
"RAP_FABRIC_REGISTRY_RECORDS_JSON": `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT": "https://node-a.example.test:443",
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error for legacy mesh advertise endpoint migration: %v", err)
|
||||
}
|
||||
if cfg.MeshAdvertiseEndpoint != "quic://node-a.example.test:443" {
|
||||
t.Fatalf("endpoint = %q, want quic scheme", cfg.MeshAdvertiseEndpoint)
|
||||
if err == nil || !strings.Contains(err.Error(), "QUIC endpoint") {
|
||||
t.Fatalf("expected QUIC endpoint rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,13 @@ const (
|
||||
Magic uint32 = 0x52415046 // RAPF
|
||||
Version uint8 = 1
|
||||
|
||||
HeaderSize = 32
|
||||
DefaultMaxPayload = 1024 * 1024
|
||||
HeaderSize = 32
|
||||
|
||||
// DefaultMaxPayload is a per-frame guardrail, not a throughput limit.
|
||||
// Fabric services must scale by many QUIC streams and many frames; keeping
|
||||
// this above common VPN/RDP/VNC burst batches avoids a hidden 1 MiB choke
|
||||
// while still bounding memory for a single decoded frame.
|
||||
DefaultMaxPayload = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
type FrameType uint8
|
||||
|
||||
@@ -102,6 +102,26 @@ func TestRejectsOversizedPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPayloadAllowsMultiMegabyteServiceBatches(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("x"), 2*1024*1024)
|
||||
frame := Frame{
|
||||
Type: FrameData,
|
||||
StreamID: 1,
|
||||
Payload: payload,
|
||||
}
|
||||
encoded, err := MarshalFrame(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal multi-megabyte frame: %v", err)
|
||||
}
|
||||
decoded, err := UnmarshalFrame(encoded, DefaultMaxPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal multi-megabyte frame: %v", err)
|
||||
}
|
||||
if len(decoded.Payload) != len(payload) {
|
||||
t.Fatalf("payload length = %d, want %d", len(decoded.Payload), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsUnknownTrafficClass(t *testing.T) {
|
||||
frame := Frame{
|
||||
Type: FrameOpenStream,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultInitialStreamCredit = 32
|
||||
DefaultMaxStreamCredit = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,6 +30,7 @@ const (
|
||||
|
||||
type SessionConfig struct {
|
||||
InitialStreamCredit int
|
||||
MaxStreamCredit int
|
||||
ClassQueueCapacity map[TrafficClass]int
|
||||
}
|
||||
|
||||
@@ -188,6 +190,7 @@ func (s *Session) Ack(streamID uint64, sequence uint64) error {
|
||||
delta := sequence - st.metrics.Acked
|
||||
st.metrics.Acked = sequence
|
||||
s.metrics.FramesAcked += delta
|
||||
st.credit = minInt(st.credit+int(delta), s.cfg.MaxStreamCredit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -205,7 +208,7 @@ func (s *Session) AddCredit(streamID uint64, frames int) error {
|
||||
if st.state != StreamStateOpen {
|
||||
return ErrStreamClosed
|
||||
}
|
||||
st.credit += frames
|
||||
st.credit = minInt(st.credit+frames, s.cfg.MaxStreamCredit)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -311,6 +314,12 @@ func normalizeSessionConfig(cfg SessionConfig) SessionConfig {
|
||||
if cfg.InitialStreamCredit <= 0 {
|
||||
cfg.InitialStreamCredit = DefaultInitialStreamCredit
|
||||
}
|
||||
if cfg.MaxStreamCredit <= 0 {
|
||||
cfg.MaxStreamCredit = maxInt(DefaultMaxStreamCredit, cfg.InitialStreamCredit)
|
||||
}
|
||||
if cfg.InitialStreamCredit > cfg.MaxStreamCredit {
|
||||
cfg.InitialStreamCredit = cfg.MaxStreamCredit
|
||||
}
|
||||
if cfg.ClassQueueCapacity == nil {
|
||||
cfg.ClassQueueCapacity = map[TrafficClass]int{}
|
||||
}
|
||||
@@ -331,14 +340,28 @@ func priorityOrder() []TrafficClass {
|
||||
func defaultClassQueueCapacity(trafficClass TrafficClass) int {
|
||||
switch trafficClass {
|
||||
case TrafficClassControl, TrafficClassDNS, TrafficClassInteractive:
|
||||
return 128
|
||||
return 1024
|
||||
case TrafficClassReliable:
|
||||
return 64
|
||||
return 512
|
||||
case TrafficClassBulk:
|
||||
return 16
|
||||
return 256
|
||||
case TrafficClassDroppable:
|
||||
return 8
|
||||
return 128
|
||||
default:
|
||||
return 32
|
||||
return 256
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -129,20 +129,36 @@ func TestSessionResetDropsOnlySelectedStream(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSessionAckUpdatesMetrics(t *testing.T) {
|
||||
session := NewSession(SessionConfig{})
|
||||
session := NewSession(SessionConfig{InitialStreamCredit: 2})
|
||||
mustOpenStream(t, session, 1, TrafficClassReliable)
|
||||
mustEnqueue(t, session, 1, "one")
|
||||
mustEnqueue(t, session, 1, "two")
|
||||
if _, err := session.EnqueueData(1, []byte("blocked")); !errors.Is(err, ErrStreamCreditExhausted) {
|
||||
t.Fatalf("credit error = %v, want %v", err, ErrStreamCreditExhausted)
|
||||
}
|
||||
|
||||
if err := session.Ack(1, 2); err != nil {
|
||||
t.Fatalf("ack: %v", err)
|
||||
}
|
||||
mustEnqueue(t, session, 1, "three")
|
||||
snapshot := session.Snapshot()
|
||||
if snapshot.FramesAcked != 2 || snapshot.Streams[1].Acked != 2 {
|
||||
if snapshot.FramesAcked != 2 || snapshot.Streams[1].Acked != 2 || snapshot.Streams[1].Credit != 1 {
|
||||
t.Fatalf("ack metrics = %+v stream=%+v", snapshot, snapshot.Streams[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCreditIsCapped(t *testing.T) {
|
||||
session := NewSession(SessionConfig{InitialStreamCredit: 1, MaxStreamCredit: 2})
|
||||
mustOpenStream(t, session, 1, TrafficClassReliable)
|
||||
if err := session.AddCredit(1, 100); err != nil {
|
||||
t.Fatalf("add credit: %v", err)
|
||||
}
|
||||
snapshot := session.Snapshot()
|
||||
if snapshot.Streams[1].Credit != 2 {
|
||||
t.Fatalf("credit = %d, want cap 2", snapshot.Streams[1].Credit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCloseRejectsNewData(t *testing.T) {
|
||||
session := NewSession(SessionConfig{})
|
||||
mustOpenStream(t, session, 1, TrafficClassReliable)
|
||||
|
||||
@@ -15,8 +15,8 @@ const (
|
||||
)
|
||||
|
||||
type RuntimeConfig struct {
|
||||
BackendURL string
|
||||
ClusterID string
|
||||
ClusterAuthorityPublicKey string
|
||||
JoinToken string
|
||||
NodeName string
|
||||
Image string
|
||||
@@ -28,7 +28,7 @@ type RuntimeConfig struct {
|
||||
Replace bool
|
||||
DockerVPNGatewayEnabled bool
|
||||
WorkloadSupervisionEnabled bool
|
||||
MeshSyntheticRuntimeEnabled bool
|
||||
FabricRuntimeEnabled bool
|
||||
MeshProductionForwardingEnabled bool
|
||||
VPNFabricSessionTransportEnabled bool
|
||||
MeshQUICFabricEnabled bool
|
||||
@@ -36,16 +36,19 @@ type RuntimeConfig struct {
|
||||
VPNFabricSessionStreamShards int
|
||||
VPNFabricQUICMaxStreamsPerConn int
|
||||
VPNFabricQUICIdleTTLSeconds int
|
||||
MeshListenAddr string
|
||||
MeshListenPortMode string
|
||||
MeshListenAutoPortStart int
|
||||
MeshListenAutoPortEnd int
|
||||
FabricListenAddr string
|
||||
FabricListenPortMode string
|
||||
FabricListenAutoPortStart int
|
||||
FabricListenAutoPortEnd int
|
||||
MeshAdvertiseEndpoint string
|
||||
MeshAdvertiseEndpointsJSON string
|
||||
FabricRegistryRecordsJSON string
|
||||
MeshAdvertiseTransport string
|
||||
MeshConnectivityMode string
|
||||
MeshNATType string
|
||||
MeshSiteID string
|
||||
MeshLocalityGroupID string
|
||||
MeshNATGroupID string
|
||||
MeshRegion string
|
||||
HeartbeatIntervalSeconds int
|
||||
EnrollmentPollIntervalSeconds int
|
||||
@@ -59,8 +62,8 @@ type RuntimeConfig struct {
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) Normalize() RuntimeConfig {
|
||||
cfg.BackendURL = strings.TrimRight(strings.TrimSpace(cfg.BackendURL), "/")
|
||||
cfg.ClusterID = strings.TrimSpace(cfg.ClusterID)
|
||||
cfg.ClusterAuthorityPublicKey = strings.TrimSpace(cfg.ClusterAuthorityPublicKey)
|
||||
cfg.JoinToken = strings.TrimSpace(cfg.JoinToken)
|
||||
cfg.NodeName = strings.TrimSpace(cfg.NodeName)
|
||||
cfg.Image = firstNonEmpty(cfg.Image, DefaultImage)
|
||||
@@ -68,13 +71,13 @@ func (cfg RuntimeConfig) Normalize() RuntimeConfig {
|
||||
cfg.StateDir = firstNonEmpty(cfg.StateDir, DefaultStateDir)
|
||||
cfg.Network = firstNonEmpty(cfg.Network, DefaultNetwork)
|
||||
cfg.RestartPolicy = firstNonEmpty(cfg.RestartPolicy, "unless-stopped")
|
||||
cfg.MeshListenAddr = strings.TrimSpace(cfg.MeshListenAddr)
|
||||
cfg.FabricListenAddr = strings.TrimSpace(cfg.FabricListenAddr)
|
||||
cfg.MeshQUICFabricListenAddr = strings.TrimSpace(cfg.MeshQUICFabricListenAddr)
|
||||
if cfg.VPNFabricSessionStreamShards <= 0 {
|
||||
cfg.VPNFabricSessionStreamShards = 4
|
||||
cfg.VPNFabricSessionStreamShards = 8
|
||||
}
|
||||
if cfg.VPNFabricSessionStreamShards > 64 {
|
||||
cfg.VPNFabricSessionStreamShards = 64
|
||||
if cfg.VPNFabricSessionStreamShards > 128 {
|
||||
cfg.VPNFabricSessionStreamShards = 128
|
||||
}
|
||||
if cfg.VPNFabricQUICMaxStreamsPerConn <= 0 {
|
||||
cfg.VPNFabricQUICMaxStreamsPerConn = 64
|
||||
@@ -82,13 +85,16 @@ func (cfg RuntimeConfig) Normalize() RuntimeConfig {
|
||||
if cfg.VPNFabricQUICIdleTTLSeconds <= 0 {
|
||||
cfg.VPNFabricQUICIdleTTLSeconds = 300
|
||||
}
|
||||
cfg.MeshListenPortMode = strings.ToLower(strings.TrimSpace(cfg.MeshListenPortMode))
|
||||
cfg.FabricListenPortMode = strings.ToLower(strings.TrimSpace(cfg.FabricListenPortMode))
|
||||
cfg.MeshAdvertiseEndpoint = strings.TrimRight(strings.TrimSpace(cfg.MeshAdvertiseEndpoint), "/")
|
||||
cfg.MeshAdvertiseEndpointsJSON = strings.TrimSpace(cfg.MeshAdvertiseEndpointsJSON)
|
||||
cfg.FabricRegistryRecordsJSON = strings.TrimSpace(cfg.FabricRegistryRecordsJSON)
|
||||
cfg.MeshAdvertiseTransport = strings.TrimSpace(cfg.MeshAdvertiseTransport)
|
||||
cfg.MeshConnectivityMode = strings.TrimSpace(cfg.MeshConnectivityMode)
|
||||
cfg.MeshNATType = strings.TrimSpace(cfg.MeshNATType)
|
||||
cfg.MeshSiteID = strings.TrimSpace(cfg.MeshSiteID)
|
||||
cfg.MeshLocalityGroupID = strings.TrimSpace(cfg.MeshLocalityGroupID)
|
||||
cfg.MeshNATGroupID = strings.TrimSpace(cfg.MeshNATGroupID)
|
||||
cfg.MeshRegion = strings.TrimSpace(cfg.MeshRegion)
|
||||
cfg.ImageArtifactSHA256 = strings.TrimSpace(cfg.ImageArtifactSHA256)
|
||||
if cfg.HeartbeatIntervalSeconds == 0 {
|
||||
@@ -103,12 +109,15 @@ func (cfg RuntimeConfig) Normalize() RuntimeConfig {
|
||||
func (cfg RuntimeConfig) ValidateInstall() error {
|
||||
cfg = cfg.Normalize()
|
||||
var missing []string
|
||||
if cfg.BackendURL == "" {
|
||||
missing = append(missing, "backend-url")
|
||||
if cfg.FabricRegistryRecordsJSON == "" {
|
||||
missing = append(missing, "fabric-registry-records-json")
|
||||
}
|
||||
if cfg.ClusterID == "" {
|
||||
missing = append(missing, "cluster-id")
|
||||
}
|
||||
if cfg.ClusterAuthorityPublicKey == "" && !cfg.Replace {
|
||||
missing = append(missing, "cluster-authority-public-key")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
missing = append(missing, "node-name")
|
||||
}
|
||||
@@ -127,21 +136,21 @@ func (cfg RuntimeConfig) ValidateInstall() error {
|
||||
if cfg.EnrollmentPollTimeoutSeconds < 0 {
|
||||
return errors.New("enrollment poll timeout must not be negative")
|
||||
}
|
||||
switch cfg.MeshListenPortMode {
|
||||
switch cfg.FabricListenPortMode {
|
||||
case "", "manual", "auto", "disabled":
|
||||
default:
|
||||
return errors.New("mesh listen port mode must be manual, auto, or disabled")
|
||||
return errors.New("fabric listen port mode must be manual, auto, or disabled")
|
||||
}
|
||||
if cfg.MeshListenAutoPortStart < 0 || cfg.MeshListenAutoPortEnd < 0 {
|
||||
return errors.New("mesh listen auto port range must not be negative")
|
||||
if cfg.FabricListenAutoPortStart < 0 || cfg.FabricListenAutoPortEnd < 0 {
|
||||
return errors.New("fabric listen auto port range must not be negative")
|
||||
}
|
||||
if cfg.MeshListenAutoPortStart > 0 && cfg.MeshListenAutoPortEnd > 0 && cfg.MeshListenAutoPortStart > cfg.MeshListenAutoPortEnd {
|
||||
return errors.New("mesh listen auto port start must be less than or equal to end")
|
||||
if cfg.FabricListenAutoPortStart > 0 && cfg.FabricListenAutoPortEnd > 0 && cfg.FabricListenAutoPortStart > cfg.FabricListenAutoPortEnd {
|
||||
return errors.New("fabric listen auto port start must be less than or equal to end")
|
||||
}
|
||||
if cfg.MeshAdvertiseTransport != "" && !isQUICAdvertiseTransport(cfg.MeshAdvertiseTransport) {
|
||||
return errors.New("mesh advertise transport must be a QUIC transport label")
|
||||
}
|
||||
if hasLegacyEndpointScheme(cfg.MeshAdvertiseEndpoint) {
|
||||
if hasUnsupportedEndpointScheme(cfg.MeshAdvertiseEndpoint) {
|
||||
return errors.New("mesh advertise endpoint must be a QUIC endpoint")
|
||||
}
|
||||
if cfg.ProductionObservationSinkCap < 0 {
|
||||
@@ -174,12 +183,12 @@ func isQUICAdvertiseTransport(label string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func hasLegacyEndpointScheme(endpoint string) bool {
|
||||
func hasUnsupportedEndpointScheme(endpoint string) bool {
|
||||
endpoint = strings.ToLower(strings.TrimSpace(endpoint))
|
||||
return strings.HasPrefix(endpoint, "http://") ||
|
||||
strings.HasPrefix(endpoint, "https://") ||
|
||||
strings.HasPrefix(endpoint, "ws://") ||
|
||||
strings.HasPrefix(endpoint, "wss://")
|
||||
if endpoint == "" || !strings.Contains(endpoint, "://") {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(endpoint, "quic://")
|
||||
}
|
||||
|
||||
func isJSONArray(value string) bool {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -126,15 +125,15 @@ func (m DockerManager) ensureImageFromArtifact(ctx context.Context, runner Comma
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func downloadFirstArtifact(ctx context.Context, urls []string, expectedSHA256 string, expectedSizeBytes int64) (string, error) {
|
||||
func downloadFirstArtifact(ctx context.Context, paths []string, expectedSHA256 string, expectedSizeBytes int64) (string, error) {
|
||||
var lastErr error
|
||||
for _, rawURL := range urls {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
for _, rawPath := range paths {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
continue
|
||||
}
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
path, err := downloadArtifact(ctx, rawURL, expectedSHA256, expectedSizeBytes)
|
||||
path, err := downloadArtifact(ctx, rawPath, expectedSHA256, expectedSizeBytes)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
@@ -144,29 +143,34 @@ func downloadFirstArtifact(ctx context.Context, urls []string, expectedSHA256 st
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("no artifact URLs configured")
|
||||
return "", fmt.Errorf("no artifact paths configured")
|
||||
}
|
||||
|
||||
func downloadArtifact(ctx context.Context, rawURL, expectedSHA256 string, expectedSizeBytes int64) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
func downloadArtifact(ctx context.Context, rawPath, expectedSHA256 string, expectedSizeBytes int64) (string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
source := strings.TrimSpace(rawPath)
|
||||
if source == "" {
|
||||
return "", fmt.Errorf("artifact path is empty")
|
||||
}
|
||||
if strings.Contains(source, "://") {
|
||||
return "", fmt.Errorf("network artifact reference %q is disabled; update artifacts must arrive via quic fabric", source)
|
||||
}
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download artifact %s: %w", rawURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("download artifact %s: %s", rawURL, resp.Status)
|
||||
return "", fmt.Errorf("open artifact %s: %w", source, err)
|
||||
}
|
||||
defer input.Close()
|
||||
file, err := os.CreateTemp("", "rap-docker-image-*.tar")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := file.Name()
|
||||
hasher := sha256.New()
|
||||
written, copyErr := io.Copy(io.MultiWriter(file, hasher), resp.Body)
|
||||
written, copyErr := io.Copy(io.MultiWriter(file, hasher), input)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
os.Remove(path)
|
||||
@@ -176,21 +180,17 @@ func downloadArtifact(ctx context.Context, rawURL, expectedSHA256 string, expect
|
||||
os.Remove(path)
|
||||
return "", closeErr
|
||||
}
|
||||
if resp.ContentLength >= 0 && written != resp.ContentLength {
|
||||
os.Remove(path)
|
||||
return "", fmt.Errorf("artifact download truncated for %s: got %d bytes want content-length %d", rawURL, written, resp.ContentLength)
|
||||
}
|
||||
if expectedSizeBytes > 0 && written != expectedSizeBytes {
|
||||
if strings.TrimSpace(expectedSHA256) != "" {
|
||||
os.Remove(path)
|
||||
return "", fmt.Errorf("artifact size mismatch for %s: got %d bytes want %d", rawURL, written, expectedSizeBytes)
|
||||
return "", fmt.Errorf("artifact size mismatch for %s: got %d bytes want %d", source, written, expectedSizeBytes)
|
||||
}
|
||||
fmt.Printf("artifact size mismatch for %s: got %d bytes want %d; proceeding without checksum for backward-compatible installs\n", rawURL, written, expectedSizeBytes)
|
||||
fmt.Printf("artifact size mismatch for %s: got %d bytes want %d; proceeding because checksum is absent\n", source, written, expectedSizeBytes)
|
||||
}
|
||||
actual := hex.EncodeToString(hasher.Sum(nil))
|
||||
if expected := strings.TrimSpace(expectedSHA256); expected != "" && !strings.EqualFold(actual, expected) {
|
||||
os.Remove(path)
|
||||
return "", fmt.Errorf("artifact checksum mismatch for %s: got %s want %s", rawURL, actual, expected)
|
||||
return "", fmt.Errorf("artifact checksum mismatch for %s: got %s want %s", source, actual, expected)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -254,7 +254,6 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string {
|
||||
cfg = cfg.Normalize()
|
||||
stateDir = firstNonEmpty(stateDir, cfg.StateDir)
|
||||
env := []string{
|
||||
"RAP_BACKEND_URL=" + cfg.BackendURL,
|
||||
"RAP_CLUSTER_ID=" + cfg.ClusterID,
|
||||
"RAP_NODE_NAME=" + cfg.NodeName,
|
||||
"RAP_NODE_STATE_DIR=" + stateDir,
|
||||
@@ -262,7 +261,7 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string {
|
||||
"RAP_ENROLLMENT_POLL_INTERVAL_SECONDS=" + strconv.Itoa(cfg.EnrollmentPollIntervalSeconds),
|
||||
"RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS=" + strconv.Itoa(cfg.EnrollmentPollTimeoutSeconds),
|
||||
"RAP_WORKLOAD_SUPERVISION_ENABLED=" + boolString(cfg.WorkloadSupervisionEnabled),
|
||||
"RAP_MESH_SYNTHETIC_RUNTIME_ENABLED=" + boolString(cfg.MeshSyntheticRuntimeEnabled),
|
||||
"RAP_FABRIC_RUNTIME_ENABLED=" + boolString(cfg.FabricRuntimeEnabled),
|
||||
"RAP_MESH_PRODUCTION_FORWARDING_ENABLED=" + boolString(cfg.MeshProductionForwardingEnabled),
|
||||
"RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED=" + boolString(cfg.VPNFabricSessionTransportEnabled),
|
||||
"RAP_MESH_QUIC_FABRIC_ENABLED=" + boolString(cfg.MeshQUICFabricEnabled),
|
||||
@@ -270,23 +269,26 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string {
|
||||
"RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN=" + strconv.Itoa(cfg.VPNFabricQUICMaxStreamsPerConn),
|
||||
"RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS=" + strconv.Itoa(cfg.VPNFabricQUICIdleTTLSeconds),
|
||||
}
|
||||
if cfg.ClusterAuthorityPublicKey != "" {
|
||||
env = append(env, "RAP_CLUSTER_AUTHORITY_PUBLIC_KEY="+cfg.ClusterAuthorityPublicKey)
|
||||
}
|
||||
if cfg.JoinToken != "" {
|
||||
env = append(env, "RAP_JOIN_TOKEN="+cfg.JoinToken)
|
||||
}
|
||||
if cfg.MeshListenAddr != "" {
|
||||
env = append(env, "RAP_MESH_LISTEN_ADDR="+cfg.MeshListenAddr)
|
||||
if cfg.FabricListenAddr != "" {
|
||||
env = append(env, "RAP_FABRIC_LISTEN_ADDR="+cfg.FabricListenAddr)
|
||||
}
|
||||
if cfg.MeshQUICFabricListenAddr != "" {
|
||||
env = append(env, "RAP_MESH_QUIC_FABRIC_LISTEN_ADDR="+cfg.MeshQUICFabricListenAddr)
|
||||
}
|
||||
if cfg.MeshListenPortMode != "" {
|
||||
env = append(env, "RAP_MESH_LISTEN_PORT_MODE="+cfg.MeshListenPortMode)
|
||||
if cfg.FabricListenPortMode != "" {
|
||||
env = append(env, "RAP_FABRIC_LISTEN_PORT_MODE="+cfg.FabricListenPortMode)
|
||||
}
|
||||
if cfg.MeshListenAutoPortStart > 0 {
|
||||
env = append(env, "RAP_MESH_LISTEN_AUTO_PORT_START="+strconv.Itoa(cfg.MeshListenAutoPortStart))
|
||||
if cfg.FabricListenAutoPortStart > 0 {
|
||||
env = append(env, "RAP_FABRIC_LISTEN_AUTO_PORT_START="+strconv.Itoa(cfg.FabricListenAutoPortStart))
|
||||
}
|
||||
if cfg.MeshListenAutoPortEnd > 0 {
|
||||
env = append(env, "RAP_MESH_LISTEN_AUTO_PORT_END="+strconv.Itoa(cfg.MeshListenAutoPortEnd))
|
||||
if cfg.FabricListenAutoPortEnd > 0 {
|
||||
env = append(env, "RAP_FABRIC_LISTEN_AUTO_PORT_END="+strconv.Itoa(cfg.FabricListenAutoPortEnd))
|
||||
}
|
||||
if cfg.MeshAdvertiseEndpoint != "" {
|
||||
env = append(env, "RAP_MESH_ADVERTISE_ENDPOINT="+cfg.MeshAdvertiseEndpoint)
|
||||
@@ -306,6 +308,15 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string {
|
||||
if cfg.MeshNATType != "" {
|
||||
env = append(env, "RAP_MESH_NAT_TYPE="+cfg.MeshNATType)
|
||||
}
|
||||
if cfg.MeshSiteID != "" {
|
||||
env = append(env, "RAP_MESH_SITE_ID="+cfg.MeshSiteID)
|
||||
}
|
||||
if cfg.MeshLocalityGroupID != "" {
|
||||
env = append(env, "RAP_MESH_LOCALITY_GROUP_ID="+cfg.MeshLocalityGroupID)
|
||||
}
|
||||
if cfg.MeshNATGroupID != "" {
|
||||
env = append(env, "RAP_MESH_NAT_GROUP_ID="+cfg.MeshNATGroupID)
|
||||
}
|
||||
if cfg.MeshRegion != "" {
|
||||
env = append(env, "RAP_MESH_REGION="+cfg.MeshRegion)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clusterauth "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority"
|
||||
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh"
|
||||
)
|
||||
|
||||
type recordingRunner struct {
|
||||
@@ -48,6 +53,27 @@ type imagePresentRunner struct {
|
||||
calls [][]string
|
||||
}
|
||||
|
||||
type inspectRuntimeRunner struct {
|
||||
output string
|
||||
}
|
||||
|
||||
func (r *inspectRuntimeRunner) Run(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name == "docker" && len(args) >= 2 && args[0] == "inspect" {
|
||||
return r.output, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func testFabricRuntimeConfig() RuntimeConfig {
|
||||
return RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *imagePresentRunner) Run(_ context.Context, name string, args ...string) (string, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if len(args) > 0 && args[0] == "run" {
|
||||
@@ -58,21 +84,21 @@ func (r *imagePresentRunner) Run(_ context.Context, name string, args ...string)
|
||||
|
||||
func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) {
|
||||
args := DockerRunArgs(RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1/",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "/srv/rap/node-a",
|
||||
MeshSyntheticRuntimeEnabled: true,
|
||||
FabricRuntimeEnabled: true,
|
||||
VPNFabricSessionTransportEnabled: true,
|
||||
MeshQUICFabricEnabled: true,
|
||||
MeshQUICFabricListenAddr: ":19443",
|
||||
VPNFabricSessionStreamShards: 6,
|
||||
VPNFabricQUICMaxStreamsPerConn: 24,
|
||||
VPNFabricQUICIdleTTLSeconds: 120,
|
||||
MeshListenAddr: ":19131",
|
||||
FabricListenAddr: ":19131",
|
||||
MeshAdvertiseEndpoint: "quic://10.0.0.11:19443/",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
MeshAdvertiseTransport: "direct_quic",
|
||||
@@ -83,19 +109,19 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"run", "-d", "--name\x00rap-node-agent-node-a", "--network\x00host",
|
||||
"-v\x00/srv/rap/node-a:/var/lib/rap-node-agent",
|
||||
"RAP_BACKEND_URL=http://control/api/v1",
|
||||
"RAP_CLUSTER_ID=cluster-1",
|
||||
"RAP_CLUSTER_AUTHORITY_PUBLIC_KEY=authority-key-b64",
|
||||
"RAP_JOIN_TOKEN=join-secret",
|
||||
"RAP_NODE_STATE_DIR=/var/lib/rap-node-agent",
|
||||
"RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS=0",
|
||||
"RAP_MESH_SYNTHETIC_RUNTIME_ENABLED=true",
|
||||
"RAP_FABRIC_RUNTIME_ENABLED=true",
|
||||
"RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED=true",
|
||||
"RAP_MESH_QUIC_FABRIC_ENABLED=true",
|
||||
"RAP_MESH_QUIC_FABRIC_LISTEN_ADDR=:19443",
|
||||
"RAP_VPN_FABRIC_SESSION_STREAM_SHARDS=6",
|
||||
"RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN=24",
|
||||
"RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS=120",
|
||||
"RAP_MESH_LISTEN_ADDR=:19131",
|
||||
"RAP_FABRIC_LISTEN_ADDR=:19131",
|
||||
"RAP_MESH_ADVERTISE_ENDPOINT=quic://10.0.0.11:19443",
|
||||
`RAP_FABRIC_REGISTRY_RECORDS_JSON=[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
"RAP_MESH_ADVERTISE_TRANSPORT=direct_quic",
|
||||
@@ -110,7 +136,6 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) {
|
||||
|
||||
func TestDockerRunArgsEnableVPNGatewayDevice(t *testing.T) {
|
||||
args := DockerRunArgs(RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
@@ -130,6 +155,40 @@ func TestDockerRunArgsEnableVPNGatewayDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfigFromContainerReadsFabricListenEnv(t *testing.T) {
|
||||
runner := &inspectRuntimeRunner{output: fmt.Sprintf(`[{
|
||||
"Config":{
|
||||
"Image":"rap-node-agent:test",
|
||||
"Env":[
|
||||
"RAP_CLUSTER_ID=cluster-1",
|
||||
"RAP_NODE_NAME=node-a",
|
||||
"RAP_FABRIC_LISTEN_ADDR=:19131",
|
||||
"RAP_FABRIC_LISTEN_PORT_MODE=auto",
|
||||
"RAP_FABRIC_LISTEN_AUTO_PORT_START=19131",
|
||||
"RAP_FABRIC_LISTEN_AUTO_PORT_END=19231"
|
||||
]
|
||||
},
|
||||
"HostConfig":{
|
||||
"NetworkMode":"host",
|
||||
"RestartPolicy":{"Name":"unless-stopped"},
|
||||
"CapAdd":[],
|
||||
"Devices":[],
|
||||
"Privileged":false
|
||||
},
|
||||
"Mounts":[{"Source":"/srv/rap/node-a","Destination":"/var/lib/rap-node-agent"}]
|
||||
}]`)}
|
||||
_, cfg, err := (DockerManager{}).runtimeConfigFromContainer(context.Background(), runner, "docker", "rap-node-agent-node-a")
|
||||
if err != nil {
|
||||
t.Fatalf("runtime config from container: %v", err)
|
||||
}
|
||||
if cfg.FabricListenAddr != ":19131" || cfg.FabricListenPortMode != "auto" {
|
||||
t.Fatalf("fabric listen env was not read: %+v", cfg)
|
||||
}
|
||||
if cfg.FabricListenAutoPortStart != 19131 || cfg.FabricListenAutoPortEnd != 19231 {
|
||||
t.Fatalf("fabric listen auto range was not read: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareStateDirCreatesWritableHostPath(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "node-state")
|
||||
if err := PrepareStateDir(dir); err != nil {
|
||||
@@ -153,92 +212,23 @@ func TestPrepareStateDirSkipsNamedVolume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDockerInstallProfileBuildsRuntimeConfig(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/node-agents/docker-install-profile" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"docker_install_profile": map[string]any{
|
||||
"cluster_id": "cluster-1",
|
||||
"backend_url": "https://control.example.test/api/v1",
|
||||
"join_token": "rap_join_profile",
|
||||
"node_name": "node-a",
|
||||
"image": "rap-node-agent:test",
|
||||
"artifact_endpoints": []string{"https://cache.example.test/artifacts"},
|
||||
"fabric_registry_records": []map[string]any{{
|
||||
"schema": "rap.fabric.registry.gossip_record.v1",
|
||||
"service_class": "control-api",
|
||||
"service_id": "control-a",
|
||||
}},
|
||||
"docker_image_artifact": map[string]any{
|
||||
"kind": "docker_image_tar",
|
||||
"image": "rap-node-agent:test",
|
||||
"file_name": "rap-node-agent-test.tar",
|
||||
"size_bytes": 21,
|
||||
},
|
||||
"container_name": "rap-node-agent-node-a",
|
||||
"state_dir": "/var/lib/rap/nodes/node-a",
|
||||
"network": "host",
|
||||
"restart_policy": "unless-stopped",
|
||||
"replace": true,
|
||||
"mesh_synthetic_runtime_enabled": true,
|
||||
"vpn_fabric_session_transport_enabled": true,
|
||||
"mesh_quic_fabric_enabled": true,
|
||||
"mesh_quic_fabric_listen_addr": ":19443",
|
||||
"vpn_fabric_session_stream_shards": 6,
|
||||
"mesh_connectivity_mode": "outbound_only",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile, err := FetchDockerInstallProfile(context.Background(), ProfileRequest{
|
||||
URL: server.URL + "/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
InstallToken: "rap_join_profile",
|
||||
NodeName: "node-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("fetch profile: %v", err)
|
||||
}
|
||||
cfg := RuntimeConfigFromProfile(profile).Normalize()
|
||||
if cfg.BackendURL != "https://control.example.test/api/v1" ||
|
||||
cfg.ClusterID != "cluster-1" ||
|
||||
cfg.JoinToken != "rap_join_profile" ||
|
||||
cfg.ContainerName != "rap-node-agent-node-a" ||
|
||||
len(cfg.ImageArtifactURLs) != 1 ||
|
||||
cfg.ImageArtifactSizeBytes != 21 ||
|
||||
!cfg.MeshSyntheticRuntimeEnabled ||
|
||||
!cfg.VPNFabricSessionTransportEnabled ||
|
||||
!cfg.MeshQUICFabricEnabled ||
|
||||
cfg.MeshQUICFabricListenAddr != ":19443" ||
|
||||
cfg.VPNFabricSessionStreamShards != 6 ||
|
||||
cfg.FabricRegistryRecordsJSON != `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api","service_id":"control-a"}]` ||
|
||||
cfg.MeshConnectivityMode != "outbound_only" {
|
||||
t.Fatalf("unexpected cfg: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallLoadsImageArtifactWhenImageMissing(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("fake docker image tar"))
|
||||
}))
|
||||
defer server.Close()
|
||||
artifactPath := writeDockerImageArtifact(t, "fake docker image tar")
|
||||
runner := &imageMissingRunner{}
|
||||
|
||||
result, err := (DockerManager{Runner: runner}).Install(context.Background(), RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{server.URL + "/rap-node-agent-test.tar"},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{artifactPath},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
@@ -255,24 +245,22 @@ func TestInstallLoadsImageArtifactWhenImageMissing(t *testing.T) {
|
||||
func TestInstallAcceptsSizeMismatchWhenChecksumMissing(t *testing.T) {
|
||||
const payload = "fake docker image tar"
|
||||
const wrongSize = 999
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
defer server.Close()
|
||||
artifactPath := writeDockerImageArtifact(t, payload)
|
||||
runner := &imageMissingRunner{}
|
||||
|
||||
result, err := (DockerManager{Runner: runner}).Install(context.Background(), RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{server.URL + "/rap-node-agent-test.tar"},
|
||||
ImageArtifactSHA256: "", // intentionally absent -> size mismatch should not block install
|
||||
ImageArtifactSizeBytes: wrongSize,
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{artifactPath},
|
||||
ImageArtifactSHA256: "", // intentionally absent -> size mismatch should not block install
|
||||
ImageArtifactSizeBytes: wrongSize,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
@@ -283,24 +271,22 @@ func TestInstallAcceptsSizeMismatchWhenChecksumMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInstallReloadsImageArtifactWhenReplacingMutableTag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("fake docker image tar"))
|
||||
}))
|
||||
defer server.Close()
|
||||
artifactPath := writeDockerImageArtifact(t, "fake docker image tar")
|
||||
runner := &imagePresentRunner{}
|
||||
|
||||
result, err := (DockerManager{Runner: runner}).Install(context.Background(), RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{server.URL + "/rap-node-agent-test.tar"},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{artifactPath},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
@@ -315,27 +301,22 @@ func TestInstallReloadsImageArtifactWhenReplacingMutableTag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDockerInstallLoadsExplicitArtifactBeforeReplace(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/rap-node-agent-test.tar" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("fake docker image tar"))
|
||||
}))
|
||||
defer server.Close()
|
||||
artifactPath := writeDockerImageArtifact(t, "fake docker image tar")
|
||||
|
||||
runner := &imageMissingRunner{}
|
||||
result, err := (DockerManager{Runner: runner}).Install(context.Background(), RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{server.URL + "/rap-node-agent-test.tar"},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
Image: "rap-node-agent:test",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
Replace: true,
|
||||
ImageArtifactURLs: []string{artifactPath},
|
||||
ImageArtifactSHA256: "5c2fbd41c87e83dc372690e8e1244b98baf8aded64870b369c28c4b313e15cc2",
|
||||
ImageArtifactSizeBytes: 21,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
@@ -349,6 +330,15 @@ func TestDockerInstallLoadsExplicitArtifactBeforeReplace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeDockerImageArtifact(t *testing.T, payload string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "rap-node-agent-test.tar")
|
||||
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write artifact: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flattenCalls(calls [][]string) []string {
|
||||
out := []string{}
|
||||
for _, call := range calls {
|
||||
@@ -360,14 +350,15 @@ func flattenCalls(calls [][]string) []string {
|
||||
func TestInstallCanPullReplaceAndRedactsJoinToken(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
result, err := (DockerManager{Runner: runner}).Install(context.Background(), RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
PullImage: true,
|
||||
Replace: true,
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
PullImage: true,
|
||||
Replace: true,
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "rap-node-state",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
@@ -385,44 +376,350 @@ func TestInstallCanPullReplaceAndRedactsJoinToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateRequiresJoinTokenUnlessReplacingExistingState(t *testing.T) {
|
||||
err := RuntimeConfig{BackendURL: "http://control/api/v1", ClusterID: "cluster-1", NodeName: "node-a"}.ValidateInstall()
|
||||
err := RuntimeConfig{ClusterID: "cluster-1", ClusterAuthorityPublicKey: "authority-key-b64", FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`, NodeName: "node-a"}.ValidateInstall()
|
||||
if err == nil || !strings.Contains(err.Error(), "join-token") {
|
||||
t.Fatalf("expected join token validation error, got %v", err)
|
||||
}
|
||||
err = RuntimeConfig{BackendURL: "http://control/api/v1", ClusterID: "cluster-1", NodeName: "node-a", Replace: true}.ValidateInstall()
|
||||
err = RuntimeConfig{ClusterID: "cluster-1", ClusterAuthorityPublicKey: "authority-key-b64", FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`, NodeName: "node-a", Replace: true}.ValidateInstall()
|
||||
if err != nil {
|
||||
t.Fatalf("replace update should allow missing join token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsLegacyMeshAdvertiseTransport(t *testing.T) {
|
||||
func TestValidateAllowsFabricBootstrapWithoutBackendURL(t *testing.T) {
|
||||
err := RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
MeshAdvertiseEndpoint: "quic://10.0.0.11:19443",
|
||||
MeshAdvertiseTransport: "wss",
|
||||
MeshQUICFabricEnabled: true,
|
||||
MeshQUICFabricListenAddr: ":19443",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
}.ValidateInstall()
|
||||
if err != nil {
|
||||
t.Fatalf("fabric-native install should validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresAuthorityKeyForFabricBootstrap(t *testing.T) {
|
||||
err := RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
}.ValidateInstall()
|
||||
if err == nil || !strings.Contains(err.Error(), "cluster-authority-public-key") {
|
||||
t.Fatalf("expected authority key validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerJoinBundleRejectsUnsignedEnvelope(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"docker_install_profile": {
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority_public_key": "authority-key-b64",
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-a",
|
||||
"fabric_registry_records": [{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
_, err := LoadDockerJoinBundle(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "join bundle authority envelope is missing") {
|
||||
t.Fatalf("expected unsigned bundle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerJoinBundleVerifiesAuthoritySignature(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
signedProfile := map[string]any{
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority_public_key": base64.StdEncoding.EncodeToString(publicKey),
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-a",
|
||||
}
|
||||
authorityPayload, err := json.Marshal(map[string]any{
|
||||
"docker_install_profile": signedProfile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authority payload: %v", err)
|
||||
}
|
||||
canonical, err := clusterauth.CanonicalJSON(authorityPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalJSON: %v", err)
|
||||
}
|
||||
signed := ed25519.Sign(privateKey, canonical)
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf(`{
|
||||
"schema_version": "rap.install_join_bundle.v1",
|
||||
"bundle_kind": "docker",
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority": {
|
||||
"schema_version": "%s",
|
||||
"cluster_id": "cluster-1",
|
||||
"authority_state": "active",
|
||||
"key_algorithm": "%s",
|
||||
"public_key": "%s",
|
||||
"public_key_fingerprint": "%s",
|
||||
"created_at": "%s",
|
||||
"updated_at": "%s"
|
||||
},
|
||||
"authority_payload": %s,
|
||||
"authority_signature": {
|
||||
"schema_version": "%s",
|
||||
"algorithm": "%s",
|
||||
"key_fingerprint": "%s",
|
||||
"signature": "%s",
|
||||
"signed_at": "%s"
|
||||
},
|
||||
"docker_install_profile": %s
|
||||
}`, clusterauth.AuthoritySchemaVersion, clusterauth.AlgorithmEd25519, base64.StdEncoding.EncodeToString(publicKey), clusterauth.Fingerprint(publicKey), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), string(authorityPayload), clusterauth.SignatureSchemaVersion, clusterauth.AlgorithmEd25519, clusterauth.Fingerprint(publicKey), base64.StdEncoding.EncodeToString(signed), time.Now().UTC().Format(time.RFC3339), mustBundleJSON(t, signedProfile))), 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
loaded, err := LoadDockerJoinBundle(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDockerJoinBundle: %v", err)
|
||||
}
|
||||
if loaded.NodeName != "node-a" {
|
||||
t.Fatalf("unexpected loaded profile: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerJoinBundleRejectsTamperedSignedProfile(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
signedProfile := map[string]any{
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority_public_key": base64.StdEncoding.EncodeToString(publicKey),
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-a",
|
||||
}
|
||||
tamperedProfile := map[string]any{
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority_public_key": base64.StdEncoding.EncodeToString(publicKey),
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-b",
|
||||
}
|
||||
authorityPayload, err := json.Marshal(map[string]any{
|
||||
"docker_install_profile": signedProfile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authority payload: %v", err)
|
||||
}
|
||||
canonical, err := clusterauth.CanonicalJSON(authorityPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalJSON: %v", err)
|
||||
}
|
||||
signed := ed25519.Sign(privateKey, canonical)
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf(`{
|
||||
"schema_version": "rap.install_join_bundle.v1",
|
||||
"bundle_kind": "docker",
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority": {
|
||||
"schema_version": "%s",
|
||||
"cluster_id": "cluster-1",
|
||||
"authority_state": "active",
|
||||
"key_algorithm": "%s",
|
||||
"public_key": "%s",
|
||||
"public_key_fingerprint": "%s",
|
||||
"created_at": "%s",
|
||||
"updated_at": "%s"
|
||||
},
|
||||
"authority_payload": %s,
|
||||
"authority_signature": {
|
||||
"schema_version": "%s",
|
||||
"algorithm": "%s",
|
||||
"key_fingerprint": "%s",
|
||||
"signature": "%s",
|
||||
"signed_at": "%s"
|
||||
},
|
||||
"docker_install_profile": %s
|
||||
}`, clusterauth.AuthoritySchemaVersion, clusterauth.AlgorithmEd25519, base64.StdEncoding.EncodeToString(publicKey), clusterauth.Fingerprint(publicKey), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), string(authorityPayload), clusterauth.SignatureSchemaVersion, clusterauth.AlgorithmEd25519, clusterauth.Fingerprint(publicKey), base64.StdEncoding.EncodeToString(signed), time.Now().UTC().Format(time.RFC3339), mustBundleJSON(t, tamperedProfile))), 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
_, err = LoadDockerJoinBundle(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match signed authority payload") {
|
||||
t.Fatalf("expected signed bundle mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerJoinBundleRejectsSignedProfileAuthorityKeyMismatch(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
otherPublicKey, _, err := ed25519.GenerateKey(cryptorand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey(other): %v", err)
|
||||
}
|
||||
signedProfile := map[string]any{
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority_public_key": base64.StdEncoding.EncodeToString(otherPublicKey),
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-a",
|
||||
}
|
||||
authorityPayload, err := json.Marshal(map[string]any{
|
||||
"docker_install_profile": signedProfile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authority payload: %v", err)
|
||||
}
|
||||
canonical, err := clusterauth.CanonicalJSON(authorityPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalJSON: %v", err)
|
||||
}
|
||||
signed := ed25519.Sign(privateKey, canonical)
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf(`{
|
||||
"schema_version": "rap.install_join_bundle.v1",
|
||||
"bundle_kind": "docker",
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority": {
|
||||
"schema_version": "%s",
|
||||
"cluster_id": "cluster-1",
|
||||
"authority_state": "active",
|
||||
"key_algorithm": "%s",
|
||||
"public_key": "%s",
|
||||
"public_key_fingerprint": "%s",
|
||||
"created_at": "%s",
|
||||
"updated_at": "%s"
|
||||
},
|
||||
"authority_payload": %s,
|
||||
"authority_signature": {
|
||||
"schema_version": "%s",
|
||||
"algorithm": "%s",
|
||||
"key_fingerprint": "%s",
|
||||
"signature": "%s",
|
||||
"signed_at": "%s"
|
||||
},
|
||||
"docker_install_profile": %s
|
||||
}`, clusterauth.AuthoritySchemaVersion, clusterauth.AlgorithmEd25519, base64.StdEncoding.EncodeToString(publicKey), clusterauth.Fingerprint(publicKey), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), string(authorityPayload), clusterauth.SignatureSchemaVersion, clusterauth.AlgorithmEd25519, clusterauth.Fingerprint(publicKey), base64.StdEncoding.EncodeToString(signed), time.Now().UTC().Format(time.RFC3339), mustBundleJSON(t, signedProfile))), 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
_, err = LoadDockerJoinBundle(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile authority key does not match signed bundle authority key") {
|
||||
t.Fatalf("expected authority key mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDockerJoinBundleRejectsSignedProfileClusterIDMismatch(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
signedProfile := map[string]any{
|
||||
"cluster_id": "cluster-2",
|
||||
"cluster_authority_public_key": base64.StdEncoding.EncodeToString(publicKey),
|
||||
"join_token": "join-secret",
|
||||
"node_name": "node-a",
|
||||
}
|
||||
authorityPayload, err := json.Marshal(map[string]any{
|
||||
"docker_install_profile": signedProfile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal authority payload: %v", err)
|
||||
}
|
||||
canonical, err := clusterauth.CanonicalJSON(authorityPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("CanonicalJSON: %v", err)
|
||||
}
|
||||
signed := ed25519.Sign(privateKey, canonical)
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf(`{
|
||||
"schema_version": "rap.install_join_bundle.v1",
|
||||
"bundle_kind": "docker",
|
||||
"cluster_id": "cluster-1",
|
||||
"cluster_authority": {
|
||||
"schema_version": "%s",
|
||||
"cluster_id": "cluster-1",
|
||||
"authority_state": "active",
|
||||
"key_algorithm": "%s",
|
||||
"public_key": "%s",
|
||||
"public_key_fingerprint": "%s",
|
||||
"created_at": "%s",
|
||||
"updated_at": "%s"
|
||||
},
|
||||
"authority_payload": %s,
|
||||
"authority_signature": {
|
||||
"schema_version": "%s",
|
||||
"algorithm": "%s",
|
||||
"key_fingerprint": "%s",
|
||||
"signature": "%s",
|
||||
"signed_at": "%s"
|
||||
},
|
||||
"docker_install_profile": %s
|
||||
}`, clusterauth.AuthoritySchemaVersion, clusterauth.AlgorithmEd25519, base64.StdEncoding.EncodeToString(publicKey), clusterauth.Fingerprint(publicKey), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), string(authorityPayload), clusterauth.SignatureSchemaVersion, clusterauth.AlgorithmEd25519, clusterauth.Fingerprint(publicKey), base64.StdEncoding.EncodeToString(signed), time.Now().UTC().Format(time.RFC3339), mustBundleJSON(t, signedProfile))), 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
_, err = LoadDockerJoinBundle(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile cluster_id does not match signed bundle cluster_id") {
|
||||
t.Fatalf("expected cluster mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustBundleJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal bundle json: %v", err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestValidateRejectsDisallowedMeshAdvertiseTransport(t *testing.T) {
|
||||
err := RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
MeshAdvertiseEndpoint: "quic://10.0.0.11:19443",
|
||||
MeshAdvertiseTransport: "wss",
|
||||
MeshQUICFabricEnabled: true,
|
||||
MeshQUICFabricListenAddr: ":19443",
|
||||
}.ValidateInstall()
|
||||
if err == nil || !strings.Contains(err.Error(), "QUIC transport") {
|
||||
t.Fatalf("expected QUIC transport validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsLegacyMeshAdvertiseEndpointScheme(t *testing.T) {
|
||||
func TestValidateRejectsDisallowedMeshAdvertiseEndpointScheme(t *testing.T) {
|
||||
err := RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
MeshAdvertiseEndpoint: "http://10.0.0.11:19131",
|
||||
MeshAdvertiseTransport: "direct_quic",
|
||||
MeshQUICFabricEnabled: true,
|
||||
MeshQUICFabricListenAddr: ":19443",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1"}]`,
|
||||
JoinToken: "join-secret",
|
||||
NodeName: "node-a",
|
||||
MeshAdvertiseEndpoint: "http://10.0.0.11:19131",
|
||||
MeshAdvertiseTransport: "direct_quic",
|
||||
MeshQUICFabricEnabled: true,
|
||||
MeshQUICFabricListenAddr: ":19443",
|
||||
}.ValidateInstall()
|
||||
if err == nil || !strings.Contains(err.Error(), "QUIC endpoint") {
|
||||
t.Fatalf("expected QUIC endpoint validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredUpdateServiceEndpointsPrioritizesHintOrder(t *testing.T) {
|
||||
input := []mesh.FabricRegistryEndpoint{
|
||||
{EndpointID: "ep-1", Address: "quic://10.0.0.5:19443"},
|
||||
{EndpointID: "ep-2", Address: "quic://10.0.0.6:19443"},
|
||||
{EndpointID: "ep-3", Address: "quic://10.0.0.7:19443"},
|
||||
}
|
||||
ordered := preferredUpdateServiceEndpoints(input, []string{
|
||||
"quic://10.0.0.7:19443",
|
||||
"quic://10.0.0.5:19443",
|
||||
})
|
||||
if len(ordered) != 3 {
|
||||
t.Fatalf("ordered len = %d", len(ordered))
|
||||
}
|
||||
if ordered[0].Address != "quic://10.0.0.7:19443" || ordered[1].Address != "quic://10.0.0.5:19443" {
|
||||
t.Fatalf("unexpected preferred ordering: %+v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -47,6 +48,7 @@ type LinuxInstallResult struct {
|
||||
NodeAgentPath string
|
||||
HostAgentPath string
|
||||
EnvPath string
|
||||
UpdaterEnvPath string
|
||||
UnitName string
|
||||
UnitPath string
|
||||
UpdaterUnitName string
|
||||
@@ -64,13 +66,14 @@ func LinuxInstallConfigFromProfile(profile LinuxInstallProfile) LinuxInstallConf
|
||||
installDir := firstNonEmpty(profile.InstallDir, filepath.Join(DefaultLinuxInstallRoot, safeUnitSlug(profile.NodeName)))
|
||||
return LinuxInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: profile.BackendURL,
|
||||
ClusterAuthorityPublicKey: strings.TrimSpace(profile.ClusterAuthorityPublicKey),
|
||||
FabricRegistryRecordsJSON: strings.TrimSpace(string(profile.FabricRegistryRecords)),
|
||||
ClusterID: profile.ClusterID,
|
||||
JoinToken: profile.JoinToken,
|
||||
NodeName: profile.NodeName,
|
||||
StateDir: stateDir,
|
||||
WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled,
|
||||
MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled,
|
||||
FabricRuntimeEnabled: profile.FabricRuntimeEnabled,
|
||||
MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled,
|
||||
VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled,
|
||||
MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled,
|
||||
@@ -78,15 +81,18 @@ func LinuxInstallConfigFromProfile(profile LinuxInstallProfile) LinuxInstallConf
|
||||
VPNFabricSessionStreamShards: profile.VPNFabricSessionStreamShards,
|
||||
VPNFabricQUICMaxStreamsPerConn: profile.VPNFabricQUICMaxStreamsPerConn,
|
||||
VPNFabricQUICIdleTTLSeconds: profile.VPNFabricQUICIdleTTLSeconds,
|
||||
MeshListenAddr: profile.MeshListenAddr,
|
||||
MeshListenPortMode: profile.MeshListenPortMode,
|
||||
MeshListenAutoPortStart: profile.MeshListenAutoPortStart,
|
||||
MeshListenAutoPortEnd: profile.MeshListenAutoPortEnd,
|
||||
FabricListenAddr: profile.FabricListenAddr,
|
||||
FabricListenPortMode: profile.FabricListenPortMode,
|
||||
FabricListenAutoPortStart: profile.FabricListenAutoPortStart,
|
||||
FabricListenAutoPortEnd: profile.FabricListenAutoPortEnd,
|
||||
MeshAdvertiseEndpoint: profile.MeshAdvertiseEndpoint,
|
||||
MeshAdvertiseEndpointsJSON: string(profile.MeshAdvertiseEndpointsJSON),
|
||||
MeshAdvertiseTransport: profile.MeshAdvertiseTransport,
|
||||
MeshConnectivityMode: profile.MeshConnectivityMode,
|
||||
MeshNATType: profile.MeshNATType,
|
||||
MeshSiteID: profile.MeshSiteID,
|
||||
MeshLocalityGroupID: firstNonEmpty(profile.MeshLocalityGroupID, profile.MeshSiteID),
|
||||
MeshNATGroupID: profile.MeshNATGroupID,
|
||||
MeshRegion: profile.MeshRegion,
|
||||
HeartbeatIntervalSeconds: profile.HeartbeatIntervalSeconds,
|
||||
EnrollmentPollIntervalSeconds: profile.EnrollmentPollIntervalSeconds,
|
||||
@@ -152,15 +158,16 @@ func (m LinuxManager) Install(ctx context.Context, cfg LinuxInstallConfig) (Linu
|
||||
cfg.StartupMode = strings.ToLower(firstNonEmpty(cfg.StartupMode, "systemd"))
|
||||
unitName := "rap-node-agent-" + slug + ".service"
|
||||
result := LinuxInstallResult{
|
||||
NodeName: cfg.RuntimeConfig.NodeName,
|
||||
InstallDir: cfg.InstallDir,
|
||||
StateDir: cfg.StateDir,
|
||||
ConfigDir: cfg.ConfigDir,
|
||||
NodeAgentPath: filepath.Join(cfg.InstallDir, "rap-node-agent"),
|
||||
HostAgentPath: filepath.Join(cfg.InstallDir, "rap-host-agent"),
|
||||
EnvPath: filepath.Join(cfg.ConfigDir, "rap-node-agent.env"),
|
||||
UnitName: unitName,
|
||||
UnitPath: filepath.Join(cfg.UnitDir, unitName),
|
||||
NodeName: cfg.RuntimeConfig.NodeName,
|
||||
InstallDir: cfg.InstallDir,
|
||||
StateDir: cfg.StateDir,
|
||||
ConfigDir: cfg.ConfigDir,
|
||||
NodeAgentPath: filepath.Join(cfg.InstallDir, "rap-node-agent"),
|
||||
HostAgentPath: filepath.Join(cfg.InstallDir, "rap-host-agent"),
|
||||
EnvPath: filepath.Join(cfg.ConfigDir, "rap-node-agent.env"),
|
||||
UpdaterEnvPath: filepath.Join(cfg.ConfigDir, "rap-host-agent-updater.env"),
|
||||
UnitName: unitName,
|
||||
UnitPath: filepath.Join(cfg.UnitDir, unitName),
|
||||
}
|
||||
if cfg.DryRun {
|
||||
return result, nil
|
||||
@@ -273,7 +280,7 @@ func installLinuxHostAgentUpdater(ctx context.Context, m LinuxManager, result Li
|
||||
}
|
||||
interval := cfg.AutoUpdateIntervalSeconds
|
||||
if interval == 0 {
|
||||
interval = 21600
|
||||
interval = DefaultUpdateIntervalSec
|
||||
}
|
||||
initialDelay := cfg.AutoUpdateInitialDelaySeconds
|
||||
if initialDelay == 0 {
|
||||
@@ -301,16 +308,16 @@ func installLinuxHostAgentUpdater(ctx context.Context, m LinuxManager, result Li
|
||||
"--host-agent-current-version", firstNonEmpty(cfg.AutoUpdateCurrentVersion, "0.0.0"),
|
||||
"--host-agent-binary-path", result.HostAgentPath,
|
||||
}
|
||||
if strings.TrimSpace(cfg.RuntimeConfig.BackendURL) != "" {
|
||||
args = append(args, "--backend-url", strings.TrimSpace(cfg.RuntimeConfig.BackendURL))
|
||||
}
|
||||
args = appendFabricUpdateArgs(args, cfg.RuntimeConfig)
|
||||
args = appendFabricUpdateArgs(args, cfg.RuntimeConfig, false)
|
||||
if strings.TrimSpace(cfg.NodeID) != "" {
|
||||
args = append(args, "--node-id", strings.TrimSpace(cfg.NodeID))
|
||||
}
|
||||
if strings.TrimSpace(cfg.AutoUpdateChannel) != "" {
|
||||
args = append(args, "--channel", strings.TrimSpace(cfg.AutoUpdateChannel))
|
||||
}
|
||||
if err := os.WriteFile(result.UpdaterEnvPath, []byte(linuxHostAgentUpdaterEnvFile(cfg.RuntimeConfig)), 0o600); err != nil {
|
||||
return result, err
|
||||
}
|
||||
unitName := "rap-host-agent-updater-" + safeUnitSlug(result.NodeName) + ".service"
|
||||
unitPath := filepath.Join(firstNonEmpty(cfg.UnitDir, DefaultSystemdUnitDir), unitName)
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
@@ -320,13 +327,14 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=%s
|
||||
ExecStart=%s
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, result.NodeName, result.UnitName, systemdJoin(args))
|
||||
`, result.NodeName, result.UnitName, systemdQuote(result.UpdaterEnvPath), systemdJoin(args))
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -359,12 +367,22 @@ func (m LinuxManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Updat
|
||||
result := UpdateResult{Action: plan.Action, Reason: plan.Reason, TargetVersion: plan.TargetVersion, ContainerName: req.SystemdUnitName, NewImage: req.BinaryPath}
|
||||
if plan.Action != "update" {
|
||||
if !req.DryRun {
|
||||
restarted, err := rewriteLinuxControlPlaneRuntime(ctx, m.runner(), req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "rewrite_runtime", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
result.RestartNeeded = restarted
|
||||
}
|
||||
if !req.DryRun {
|
||||
_ = saveUpdatePlanState(req, plan, req.CurrentVersion, req.SystemdUnitName, req.BinaryPath)
|
||||
status := statusFromNoopPlan(req, plan)
|
||||
if status.Payload == nil {
|
||||
status.Payload = map[string]any{}
|
||||
}
|
||||
status.Payload["systemd_unit"] = req.SystemdUnitName
|
||||
status.Payload["binary_path"] = req.BinaryPath
|
||||
status.Payload["restart_needed"] = result.RestartNeeded
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, status)
|
||||
}
|
||||
return result, nil
|
||||
@@ -387,14 +405,14 @@ func (m LinuxManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Updat
|
||||
if req.DryRun {
|
||||
return result, nil
|
||||
}
|
||||
urls := artifactURLsForBackend(*plan.Artifact, req.BackendURL)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "download", Status: "started", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"artifact_url": plan.Artifact.URL, "artifact_urls": urls, "binary_path": req.BinaryPath}})
|
||||
path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "download", Status: "started", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": req.BinaryPath, "transport": updateArtifactTransport(req, plan)}})
|
||||
path, distributors, err := downloadUpdateArtifact(ctx, req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "download", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
defer os.Remove(path)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "download", Status: "succeeded", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": req.BinaryPath, "fabric_distributors": distributors, "transport": updateArtifactTransport(req, plan)}})
|
||||
runner := m.runner()
|
||||
_, _ = runner.Run(ctx, "systemctl", "stop", req.SystemdUnitName)
|
||||
if err := copyFile(path, req.BinaryPath, 0o755); err != nil {
|
||||
@@ -402,15 +420,183 @@ func (m LinuxManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Updat
|
||||
return result, err
|
||||
}
|
||||
result.Replaced = true
|
||||
if _, err := runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName); err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err))
|
||||
restartedByRewrite, err := rewriteLinuxControlPlaneRuntime(ctx, runner, req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "rewrite_runtime", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
result.RestartNeeded = restartedByRewrite
|
||||
if !restartedByRewrite {
|
||||
if _, err := runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName); err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := ensureLinuxUnitActive(ctx, runner, req.SystemdUnitName); err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "health_check", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "health_check", Status: "succeeded", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"systemd_unit": req.SystemdUnitName, "binary_path": req.BinaryPath}})
|
||||
_ = saveUpdateState(req.StateDir, UpdateState{Product: req.Product, CurrentVersion: plan.TargetVersion, TargetVersion: plan.TargetVersion, Image: req.BinaryPath, UpdatedAt: time.Now().UTC()})
|
||||
_ = saveUpdatePlanState(req, plan, plan.TargetVersion, req.SystemdUnitName, req.BinaryPath)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func linuxHostAgentUpdaterEnvFile(cfg RuntimeConfig) string {
|
||||
lines := []string{}
|
||||
if registry := strings.TrimSpace(cfg.FabricRegistryRecordsJSON); registry != "" {
|
||||
lines = append(lines, "RAP_FABRIC_REGISTRY_RECORDS_JSON="+systemdQuote(registry))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func ensureLinuxUnitActive(ctx context.Context, runner CommandRunner, unitName string) error {
|
||||
unitName = strings.TrimSpace(unitName)
|
||||
if unitName == "" {
|
||||
return nil
|
||||
}
|
||||
out, err := runner.Run(ctx, "systemctl", "is-active", unitName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(out) != "active" {
|
||||
return fmt.Errorf("systemd unit %s is not active: %s", unitName, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteLinuxControlPlaneRuntime(ctx context.Context, runner CommandRunner, req UpdateRequest, plan NodeUpdatePlan) (bool, error) {
|
||||
_ = saveControlPlaneRuntimeState(req.StateDir, ControlPlaneRuntimeState{
|
||||
SchemaVersion: "rap.control_plane_runtime_state.v1",
|
||||
ClusterID: strings.TrimSpace(plan.ClusterID),
|
||||
NodeID: strings.TrimSpace(plan.NodeID),
|
||||
Product: strings.TrimSpace(plan.Product),
|
||||
FabricRegistryRecords: append(json.RawMessage(nil), plan.FabricRegistryRecords...),
|
||||
AuthorityPayload: append(json.RawMessage(nil), plan.AuthorityPayload...),
|
||||
AuthoritySignature: append(json.RawMessage(nil), plan.AuthoritySignature...),
|
||||
AuthorityQuorum: plan.AuthorityQuorum,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
slug := strings.TrimSuffix(strings.TrimSpace(req.SystemdUnitName), ".service")
|
||||
slug = strings.TrimPrefix(slug, "rap-node-agent-")
|
||||
if slug == "" {
|
||||
return false, nil
|
||||
}
|
||||
envChanged := false
|
||||
envPath := filepath.Join(DefaultLinuxConfigRoot, slug, "rap-node-agent.env")
|
||||
wantRegistry := strings.TrimSpace(string(plan.FabricRegistryRecords))
|
||||
if wantRegistry != "" && fileExists(envPath) {
|
||||
current, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
updatedEnv := string(current)
|
||||
updatedEnv = upsertEnvFileValue(updatedEnv, "RAP_FABRIC_REGISTRY_RECORDS_JSON", wantRegistry)
|
||||
if updatedEnv != string(current) {
|
||||
if err := os.WriteFile(envPath, []byte(updatedEnv), 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
envChanged = true
|
||||
}
|
||||
}
|
||||
updaterUnitName := "rap-host-agent-updater-" + safeUnitSlug(slug) + ".service"
|
||||
updaterUnitPath := filepath.Join(DefaultSystemdUnitDir, updaterUnitName)
|
||||
updaterEnvPath := filepath.Join(DefaultLinuxConfigRoot, slug, "rap-host-agent-updater.env")
|
||||
if wantRegistry != "" {
|
||||
current := ""
|
||||
if fileExists(updaterEnvPath) {
|
||||
payload, err := os.ReadFile(updaterEnvPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
current = string(payload)
|
||||
}
|
||||
updatedEnv := upsertEnvFileValue(current, "RAP_FABRIC_REGISTRY_RECORDS_JSON", wantRegistry)
|
||||
if updatedEnv != current {
|
||||
if err := os.MkdirAll(filepath.Dir(updaterEnvPath), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(updaterEnvPath, []byte(updatedEnv), 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
envChanged = true
|
||||
}
|
||||
}
|
||||
if wantRegistry == "" {
|
||||
if envChanged && strings.TrimSpace(req.SystemdUnitName) != "" {
|
||||
_, _ = runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName)
|
||||
}
|
||||
return envChanged, nil
|
||||
}
|
||||
if !fileExists(updaterUnitPath) {
|
||||
if envChanged && strings.TrimSpace(req.SystemdUnitName) != "" {
|
||||
_, _ = runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName)
|
||||
}
|
||||
return envChanged, nil
|
||||
}
|
||||
current, err := os.ReadFile(updaterUnitPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
updated := ensureSystemdEnvironmentFile(replaceCLIArg(string(current), "--fabric-registry-records-json", "", false), updaterEnvPath)
|
||||
if updated == string(current) {
|
||||
if envChanged && strings.TrimSpace(req.SystemdUnitName) != "" {
|
||||
_, _ = runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName)
|
||||
}
|
||||
return envChanged, nil
|
||||
}
|
||||
if err := os.WriteFile(updaterUnitPath, []byte(updated), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := runner.Run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, _ = runner.Run(ctx, "systemctl", "restart", updaterUnitName)
|
||||
if envChanged && strings.TrimSpace(req.SystemdUnitName) != "" {
|
||||
_, _ = runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureSystemdEnvironmentFile(unit string, envPath string) string {
|
||||
envPath = strings.TrimSpace(envPath)
|
||||
if envPath == "" || strings.Contains(unit, "EnvironmentFile=") {
|
||||
return unit
|
||||
}
|
||||
line := "EnvironmentFile=" + systemdQuote(envPath)
|
||||
if strings.Contains(unit, "Type=simple\n") {
|
||||
return strings.Replace(unit, "Type=simple\n", "Type=simple\n"+line+"\n", 1)
|
||||
}
|
||||
if strings.Contains(unit, "[Service]\n") {
|
||||
return strings.Replace(unit, "[Service]\n", "[Service]\n"+line+"\n", 1)
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
func upsertEnvFileValue(payload string, key string, value string) string {
|
||||
prefix := key + "="
|
||||
lines := strings.Split(payload, "\n")
|
||||
for i, line := range lines {
|
||||
rawLine := strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(rawLine)
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
if value == "" {
|
||||
lines = append(lines[:i], lines[i+1:]...)
|
||||
} else {
|
||||
lines[i] = prefix + systemdQuote(value)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
return payload
|
||||
}
|
||||
lines = append(lines, prefix+systemdQuote(value))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m LinuxManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig) error {
|
||||
req := cfg.Request
|
||||
req.InstallType = firstNonEmpty(req.InstallType, BinaryUpdateInstallType)
|
||||
@@ -421,6 +607,9 @@ func (m LinuxManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig) e
|
||||
}
|
||||
|
||||
func runLinuxUpdateLoop(ctx context.Context, m LinuxManager, cfg UpdateLoopConfig) error {
|
||||
if err := ReconcileSignedUpdateState(cfg.Request.StateDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Interval == 0 {
|
||||
cfg.Interval = time.Hour
|
||||
}
|
||||
@@ -450,6 +639,7 @@ func runLinuxUpdateLoop(ctx context.Context, m LinuxManager, cfg UpdateLoopConfi
|
||||
continue
|
||||
} else {
|
||||
logf("linux_update_loop run=%d status=failed error=%v", runs, err)
|
||||
saveUpdateLoopRescueState(cfg.Request, "linux_node_agent_update_failed", err)
|
||||
if cfg.StopOnError {
|
||||
return err
|
||||
}
|
||||
@@ -462,10 +652,12 @@ func runLinuxUpdateLoop(ctx context.Context, m LinuxManager, cfg UpdateLoopConfi
|
||||
}
|
||||
if cfg.HostAgentUpdateEnabled {
|
||||
hostReq := cfg.HostAgentUpdateRequest
|
||||
hostReq.BackendURL = firstNonEmpty(hostReq.BackendURL, cfg.Request.BackendURL)
|
||||
hostReq.ClusterID = firstNonEmpty(hostReq.ClusterID, cfg.Request.ClusterID)
|
||||
hostReq.NodeID = firstNonEmpty(hostReq.NodeID, cfg.Request.NodeID)
|
||||
hostReq.StateDir = firstNonEmpty(hostReq.StateDir, cfg.Request.StateDir)
|
||||
hostReq.ClusterAuthorityPublicKey = firstNonEmpty(hostReq.ClusterAuthorityPublicKey, cfg.Request.ClusterAuthorityPublicKey)
|
||||
hostReq.FabricRegistryRecordsJSON = firstNonEmpty(hostReq.FabricRegistryRecordsJSON, cfg.Request.FabricRegistryRecordsJSON)
|
||||
hostReq.MeshRegion = firstNonEmpty(hostReq.MeshRegion, cfg.Request.MeshRegion)
|
||||
hostReq.Channel = firstNonEmpty(hostReq.Channel, cfg.Request.Channel)
|
||||
hostReq.OS = firstNonEmpty(hostReq.OS, "linux")
|
||||
hostReq.Arch = firstNonEmpty(hostReq.Arch, runtime.GOARCH)
|
||||
@@ -473,6 +665,7 @@ func runLinuxUpdateLoop(ctx context.Context, m LinuxManager, cfg UpdateLoopConfi
|
||||
hostResult, hostErr := (DockerManager{}).ApplyHostAgentUpdate(ctx, hostReq)
|
||||
if hostErr != nil {
|
||||
logf("linux_host_agent_update_loop run=%d status=failed error=%v", runs, hostErr)
|
||||
saveUpdateLoopRescueState(cfg.Request, "linux_host_agent_update_failed", hostErr)
|
||||
} else {
|
||||
logf("linux_host_agent_update_loop run=%d action=%s reason=%s target=%s binary=%s replaced=%t restart_needed=%t", runs, hostResult.Action, hostResult.Reason, hostResult.TargetVersion, hostResult.NewImage, hostResult.Replaced, hostResult.RestartNeeded)
|
||||
if hostResult.Action == "update" && hostResult.TargetVersion != "" && !hostResult.RolledBack {
|
||||
|
||||
@@ -31,7 +31,6 @@ const (
|
||||
)
|
||||
|
||||
type MonitorConfig struct {
|
||||
BackendURL string
|
||||
ClusterID string
|
||||
NodeID string
|
||||
StateDir string
|
||||
@@ -198,7 +197,6 @@ func RunMonitorOnce(ctx context.Context, cfg MonitorConfig) MonitorResult {
|
||||
}
|
||||
|
||||
func normalizeMonitorConfig(cfg MonitorConfig) MonitorConfig {
|
||||
cfg.BackendURL = strings.TrimRight(strings.TrimSpace(cfg.BackendURL), "/")
|
||||
cfg.ClusterID = strings.TrimSpace(cfg.ClusterID)
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
cfg.StateDir = strings.TrimSpace(cfg.StateDir)
|
||||
@@ -398,7 +396,7 @@ func reportMonitorStatus(ctx context.Context, cfg MonitorConfig, result MonitorR
|
||||
}
|
||||
return err
|
||||
}
|
||||
if cfg.BackendURL == "" || clusterID == "" || nodeID == "" {
|
||||
if strings.TrimSpace(cfg.FabricRegistryRecordsJSON) == "" || strings.TrimSpace(cfg.ClusterAuthorityPublicKey) == "" || clusterID == "" || nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
@@ -425,7 +423,6 @@ func reportMonitorStatus(ctx context.Context, cfg MonitorConfig, result MonitorR
|
||||
req.ErrorMessage = &errText
|
||||
}
|
||||
return ReportNodeUpdateStatusForRequest(ctx, UpdateRequest{
|
||||
BackendURL: cfg.BackendURL,
|
||||
ClusterID: clusterID,
|
||||
NodeID: nodeID,
|
||||
StateDir: cfg.StateDir,
|
||||
|
||||
@@ -2,19 +2,39 @@ package hostagent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
clusterauth "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority"
|
||||
)
|
||||
|
||||
func trimProfileEndpointSlice(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range items {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(item), "/")
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type DockerInstallProfile struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
BackendURL string `json:"backend_url"`
|
||||
ControlPlaneEndpoints []string `json:"control_plane_endpoints"`
|
||||
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key"`
|
||||
ArtifactEndpoints []string `json:"artifact_endpoints"`
|
||||
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"`
|
||||
DockerImageArtifact *DockerArtifact `json:"docker_image_artifact"`
|
||||
@@ -29,7 +49,7 @@ type DockerInstallProfile struct {
|
||||
Replace bool `json:"replace"`
|
||||
DockerVPNGatewayEnabled bool `json:"docker_vpn_gateway_enabled"`
|
||||
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
|
||||
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
|
||||
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
|
||||
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
|
||||
VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"`
|
||||
MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"`
|
||||
@@ -37,15 +57,18 @@ type DockerInstallProfile struct {
|
||||
VPNFabricSessionStreamShards int `json:"vpn_fabric_session_stream_shards"`
|
||||
VPNFabricQUICMaxStreamsPerConn int `json:"vpn_fabric_quic_max_streams_per_conn"`
|
||||
VPNFabricQUICIdleTTLSeconds int `json:"vpn_fabric_quic_idle_ttl_seconds"`
|
||||
MeshListenAddr string `json:"mesh_listen_addr"`
|
||||
MeshListenPortMode string `json:"mesh_listen_port_mode"`
|
||||
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start"`
|
||||
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end"`
|
||||
FabricListenAddr string `json:"fabric_listen_addr"`
|
||||
FabricListenPortMode string `json:"fabric_listen_port_mode"`
|
||||
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start"`
|
||||
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end"`
|
||||
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint"`
|
||||
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json"`
|
||||
MeshAdvertiseTransport string `json:"mesh_advertise_transport"`
|
||||
MeshConnectivityMode string `json:"mesh_connectivity_mode"`
|
||||
MeshNATType string `json:"mesh_nat_type"`
|
||||
MeshSiteID string `json:"mesh_site_id"`
|
||||
MeshLocalityGroupID string `json:"mesh_locality_group_id"`
|
||||
MeshNATGroupID string `json:"mesh_nat_group_id"`
|
||||
MeshRegion string `json:"mesh_region"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"`
|
||||
EnrollmentPollIntervalSeconds int `json:"enrollment_poll_interval_seconds"`
|
||||
@@ -67,8 +90,7 @@ type DockerArtifact struct {
|
||||
type WindowsInstallProfile struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
BackendURL string `json:"backend_url"`
|
||||
ControlPlaneEndpoints []string `json:"control_plane_endpoints"`
|
||||
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key"`
|
||||
ArtifactEndpoints []string `json:"artifact_endpoints"`
|
||||
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"`
|
||||
NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact"`
|
||||
@@ -78,7 +100,7 @@ type WindowsInstallProfile struct {
|
||||
InstallDir string `json:"install_dir"`
|
||||
StartupMode string `json:"startup_mode"`
|
||||
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
|
||||
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
|
||||
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
|
||||
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
|
||||
VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"`
|
||||
MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"`
|
||||
@@ -86,15 +108,18 @@ type WindowsInstallProfile struct {
|
||||
VPNFabricSessionStreamShards int `json:"vpn_fabric_session_stream_shards"`
|
||||
VPNFabricQUICMaxStreamsPerConn int `json:"vpn_fabric_quic_max_streams_per_conn"`
|
||||
VPNFabricQUICIdleTTLSeconds int `json:"vpn_fabric_quic_idle_ttl_seconds"`
|
||||
MeshListenAddr string `json:"mesh_listen_addr"`
|
||||
MeshListenPortMode string `json:"mesh_listen_port_mode"`
|
||||
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start"`
|
||||
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end"`
|
||||
FabricListenAddr string `json:"fabric_listen_addr"`
|
||||
FabricListenPortMode string `json:"fabric_listen_port_mode"`
|
||||
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start"`
|
||||
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end"`
|
||||
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint"`
|
||||
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json"`
|
||||
MeshAdvertiseTransport string `json:"mesh_advertise_transport"`
|
||||
MeshConnectivityMode string `json:"mesh_connectivity_mode"`
|
||||
MeshNATType string `json:"mesh_nat_type"`
|
||||
MeshSiteID string `json:"mesh_site_id"`
|
||||
MeshLocalityGroupID string `json:"mesh_locality_group_id"`
|
||||
MeshNATGroupID string `json:"mesh_nat_group_id"`
|
||||
MeshRegion string `json:"mesh_region"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"`
|
||||
EnrollmentPollIntervalSeconds int `json:"enrollment_poll_interval_seconds"`
|
||||
@@ -106,8 +131,7 @@ type WindowsInstallProfile struct {
|
||||
type LinuxInstallProfile struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
BackendURL string `json:"backend_url"`
|
||||
ControlPlaneEndpoints []string `json:"control_plane_endpoints"`
|
||||
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key"`
|
||||
ArtifactEndpoints []string `json:"artifact_endpoints"`
|
||||
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"`
|
||||
NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact"`
|
||||
@@ -117,7 +141,7 @@ type LinuxInstallProfile struct {
|
||||
InstallDir string `json:"install_dir"`
|
||||
StartupMode string `json:"startup_mode"`
|
||||
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
|
||||
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
|
||||
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
|
||||
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
|
||||
VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"`
|
||||
MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"`
|
||||
@@ -125,15 +149,18 @@ type LinuxInstallProfile struct {
|
||||
VPNFabricSessionStreamShards int `json:"vpn_fabric_session_stream_shards"`
|
||||
VPNFabricQUICMaxStreamsPerConn int `json:"vpn_fabric_quic_max_streams_per_conn"`
|
||||
VPNFabricQUICIdleTTLSeconds int `json:"vpn_fabric_quic_idle_ttl_seconds"`
|
||||
MeshListenAddr string `json:"mesh_listen_addr"`
|
||||
MeshListenPortMode string `json:"mesh_listen_port_mode"`
|
||||
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start"`
|
||||
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end"`
|
||||
FabricListenAddr string `json:"fabric_listen_addr"`
|
||||
FabricListenPortMode string `json:"fabric_listen_port_mode"`
|
||||
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start"`
|
||||
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end"`
|
||||
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint"`
|
||||
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json"`
|
||||
MeshAdvertiseTransport string `json:"mesh_advertise_transport"`
|
||||
MeshConnectivityMode string `json:"mesh_connectivity_mode"`
|
||||
MeshNATType string `json:"mesh_nat_type"`
|
||||
MeshSiteID string `json:"mesh_site_id"`
|
||||
MeshLocalityGroupID string `json:"mesh_locality_group_id"`
|
||||
MeshNATGroupID string `json:"mesh_nat_group_id"`
|
||||
MeshRegion string `json:"mesh_region"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"`
|
||||
EnrollmentPollIntervalSeconds int `json:"enrollment_poll_interval_seconds"`
|
||||
@@ -143,152 +170,188 @@ type LinuxInstallProfile struct {
|
||||
}
|
||||
|
||||
type ProfileRequest struct {
|
||||
URL string
|
||||
ClusterID string
|
||||
InstallToken string
|
||||
NodeName string
|
||||
HTTPClient *http.Client
|
||||
ClusterID string
|
||||
NodeName string
|
||||
}
|
||||
|
||||
func FetchDockerInstallProfile(ctx context.Context, req ProfileRequest) (DockerInstallProfile, error) {
|
||||
url := strings.TrimRight(strings.TrimSpace(req.URL), "/")
|
||||
if url == "" || strings.TrimSpace(req.InstallToken) == "" {
|
||||
return DockerInstallProfile{}, fmt.Errorf("profile-url and install-token are required")
|
||||
}
|
||||
if !strings.HasSuffix(url, "/node-agents/docker-install-profile") {
|
||||
url += "/node-agents/docker-install-profile"
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"cluster_id": strings.TrimSpace(req.ClusterID),
|
||||
"install_token": strings.TrimSpace(req.InstallToken),
|
||||
"node_name": strings.TrimSpace(req.NodeName),
|
||||
})
|
||||
if err != nil {
|
||||
return DockerInstallProfile{}, err
|
||||
}
|
||||
httpClient := req.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return DockerInstallProfile{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return DockerInstallProfile{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return DockerInstallProfile{}, fmt.Errorf("fetch docker install profile: %s", resp.Status)
|
||||
}
|
||||
var envelope struct {
|
||||
Profile DockerInstallProfile `json:"docker_install_profile"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
return DockerInstallProfile{}, err
|
||||
}
|
||||
if strings.TrimSpace(envelope.Profile.BackendURL) == "" && len(envelope.Profile.ControlPlaneEndpoints) > 0 {
|
||||
envelope.Profile.BackendURL = envelope.Profile.ControlPlaneEndpoints[0]
|
||||
}
|
||||
return envelope.Profile, nil
|
||||
type JoinBundle struct {
|
||||
DockerInstallProfile *DockerInstallProfile `json:"docker_install_profile,omitempty"`
|
||||
WindowsInstallProfile *WindowsInstallProfile `json:"windows_install_profile,omitempty"`
|
||||
LinuxInstallProfile *LinuxInstallProfile `json:"linux_install_profile,omitempty"`
|
||||
}
|
||||
|
||||
func FetchWindowsInstallProfile(ctx context.Context, req ProfileRequest) (WindowsInstallProfile, error) {
|
||||
url := strings.TrimRight(strings.TrimSpace(req.URL), "/")
|
||||
if url == "" || strings.TrimSpace(req.InstallToken) == "" {
|
||||
return WindowsInstallProfile{}, fmt.Errorf("profile-url and install-token are required")
|
||||
}
|
||||
if !strings.HasSuffix(url, "/node-agents/windows-install-profile") {
|
||||
url += "/node-agents/windows-install-profile"
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"cluster_id": strings.TrimSpace(req.ClusterID),
|
||||
"install_token": strings.TrimSpace(req.InstallToken),
|
||||
"node_name": strings.TrimSpace(req.NodeName),
|
||||
})
|
||||
if err != nil {
|
||||
return WindowsInstallProfile{}, err
|
||||
}
|
||||
httpClient := req.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return WindowsInstallProfile{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return WindowsInstallProfile{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return WindowsInstallProfile{}, fmt.Errorf("fetch windows install profile: %s", resp.Status)
|
||||
}
|
||||
var envelope struct {
|
||||
Profile WindowsInstallProfile `json:"windows_install_profile"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
return WindowsInstallProfile{}, err
|
||||
}
|
||||
if strings.TrimSpace(envelope.Profile.BackendURL) == "" && len(envelope.Profile.ControlPlaneEndpoints) > 0 {
|
||||
envelope.Profile.BackendURL = envelope.Profile.ControlPlaneEndpoints[0]
|
||||
}
|
||||
return envelope.Profile, nil
|
||||
type ClusterAuthorityDescriptor struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
AuthorityState string `json:"authority_state"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func FetchLinuxInstallProfile(ctx context.Context, req ProfileRequest) (LinuxInstallProfile, error) {
|
||||
url := strings.TrimRight(strings.TrimSpace(req.URL), "/")
|
||||
if url == "" || strings.TrimSpace(req.InstallToken) == "" {
|
||||
return LinuxInstallProfile{}, fmt.Errorf("profile-url and install-token are required")
|
||||
type ClusterSignature struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
Signature string `json:"signature"`
|
||||
SignedAt time.Time `json:"signed_at"`
|
||||
}
|
||||
|
||||
type joinBundleEnvelope struct {
|
||||
SchemaVersion string `json:"schema_version,omitempty"`
|
||||
BundleKind string `json:"bundle_kind,omitempty"`
|
||||
ClusterID string `json:"cluster_id,omitempty"`
|
||||
ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"`
|
||||
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
|
||||
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
|
||||
}
|
||||
|
||||
type joinBundleProfileIdentity struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key"`
|
||||
}
|
||||
|
||||
func LoadDockerJoinBundle(path string) (DockerInstallProfile, error) {
|
||||
var profile DockerInstallProfile
|
||||
if err := loadJoinBundleProfile(path, "docker_install_profile", &profile); err != nil {
|
||||
return DockerInstallProfile{}, err
|
||||
}
|
||||
if !strings.HasSuffix(url, "/node-agents/linux-install-profile") {
|
||||
url += "/node-agents/linux-install-profile"
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func LoadWindowsJoinBundle(path string) (WindowsInstallProfile, error) {
|
||||
var profile WindowsInstallProfile
|
||||
if err := loadJoinBundleProfile(path, "windows_install_profile", &profile); err != nil {
|
||||
return WindowsInstallProfile{}, err
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"cluster_id": strings.TrimSpace(req.ClusterID),
|
||||
"install_token": strings.TrimSpace(req.InstallToken),
|
||||
"node_name": strings.TrimSpace(req.NodeName),
|
||||
})
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func LoadLinuxJoinBundle(path string) (LinuxInstallProfile, error) {
|
||||
var profile LinuxInstallProfile
|
||||
if err := loadJoinBundleProfile(path, "linux_install_profile", &profile); err != nil {
|
||||
return LinuxInstallProfile{}, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func SaveJoinBundle(path string, raw []byte) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("join-bundle path is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
func loadJoinBundleProfile(path, key string, target any) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("join-bundle is required")
|
||||
}
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LinuxInstallProfile{}, err
|
||||
return err
|
||||
}
|
||||
httpClient := req.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
_, err = parseJoinBundleProfileBytes(payload, key, target)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseJoinBundleProfileBytes(payload []byte, key string, target any) ([]byte, error) {
|
||||
var envelopeMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &envelopeMap); err == nil {
|
||||
profileRaw := envelopeMap[key]
|
||||
if len(bytes.TrimSpace(profileRaw)) > 0 {
|
||||
if err := verifyJoinBundleEnvelope(payload, key, profileRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(profileRaw, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profileRaw, nil
|
||||
}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
return nil, fmt.Errorf("join bundle envelope is missing signed install profile payload")
|
||||
}
|
||||
|
||||
func verifyJoinBundleEnvelope(payload []byte, profileKey string, profileRaw json.RawMessage) error {
|
||||
var envelope joinBundleEnvelope
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
return fmt.Errorf("decode join bundle envelope: %w", err)
|
||||
}
|
||||
if envelope.ClusterAuthority == nil && len(bytes.TrimSpace(envelope.AuthorityPayload)) == 0 && envelope.AuthoritySignature == nil {
|
||||
return fmt.Errorf("join bundle authority envelope is missing")
|
||||
}
|
||||
if envelope.ClusterAuthority == nil || len(bytes.TrimSpace(envelope.AuthorityPayload)) == 0 || envelope.AuthoritySignature == nil {
|
||||
return fmt.Errorf("join bundle authority envelope is incomplete")
|
||||
}
|
||||
envelopeClusterID := strings.TrimSpace(envelope.ClusterID)
|
||||
authorityClusterID := strings.TrimSpace(envelope.ClusterAuthority.ClusterID)
|
||||
if envelopeClusterID == "" || authorityClusterID == "" || envelopeClusterID != authorityClusterID {
|
||||
return fmt.Errorf("join bundle cluster identity is inconsistent")
|
||||
}
|
||||
signature := clusterauth.Signature{
|
||||
SchemaVersion: envelope.AuthoritySignature.SchemaVersion,
|
||||
Algorithm: envelope.AuthoritySignature.Algorithm,
|
||||
KeyFingerprint: envelope.AuthoritySignature.KeyFingerprint,
|
||||
Signature: envelope.AuthoritySignature.Signature,
|
||||
}
|
||||
if err := clusterauth.VerifyRaw(envelope.ClusterAuthority.PublicKey, envelope.AuthorityPayload, signature); err != nil {
|
||||
return fmt.Errorf("verify join bundle authority signature: %w", err)
|
||||
}
|
||||
var signedProfiles map[string]json.RawMessage
|
||||
if err := json.Unmarshal(envelope.AuthorityPayload, &signedProfiles); err != nil {
|
||||
return fmt.Errorf("decode join bundle authority payload: %w", err)
|
||||
}
|
||||
signedProfileRaw := signedProfiles[profileKey]
|
||||
if len(bytes.TrimSpace(signedProfileRaw)) == 0 {
|
||||
return fmt.Errorf("join bundle authority payload missing %s", profileKey)
|
||||
}
|
||||
want, err := clusterauth.CanonicalJSON(signedProfileRaw)
|
||||
if err != nil {
|
||||
return LinuxInstallProfile{}, err
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
got, err := clusterauth.CanonicalJSON(profileRaw)
|
||||
if err != nil {
|
||||
return LinuxInstallProfile{}, err
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return LinuxInstallProfile{}, fmt.Errorf("fetch linux install profile: %s", resp.Status)
|
||||
if !bytes.Equal(want, got) {
|
||||
return fmt.Errorf("join bundle profile does not match signed authority payload")
|
||||
}
|
||||
var envelope struct {
|
||||
Profile LinuxInstallProfile `json:"linux_install_profile"`
|
||||
authorityPublicKey := strings.TrimSpace(envelope.ClusterAuthority.PublicKey)
|
||||
if authorityPublicKey == "" {
|
||||
return fmt.Errorf("join bundle authority public key is empty")
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
return LinuxInstallProfile{}, err
|
||||
if fingerprint := strings.TrimSpace(envelope.ClusterAuthority.PublicKeyFingerprint); fingerprint != "" {
|
||||
publicKey, err := base64.StdEncoding.DecodeString(authorityPublicKey)
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("join bundle authority public key is invalid")
|
||||
}
|
||||
if fingerprint != clusterauth.Fingerprint(ed25519.PublicKey(publicKey)) {
|
||||
return fmt.Errorf("join bundle authority fingerprint does not match authority public key")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(envelope.Profile.BackendURL) == "" && len(envelope.Profile.ControlPlaneEndpoints) > 0 {
|
||||
envelope.Profile.BackendURL = envelope.Profile.ControlPlaneEndpoints[0]
|
||||
var identity joinBundleProfileIdentity
|
||||
if err := json.Unmarshal(profileRaw, &identity); err != nil {
|
||||
return fmt.Errorf("decode join bundle profile identity: %w", err)
|
||||
}
|
||||
return envelope.Profile, nil
|
||||
if strings.TrimSpace(identity.ClusterID) == "" || strings.TrimSpace(identity.ClusterID) != envelopeClusterID {
|
||||
return fmt.Errorf("join bundle profile cluster_id does not match signed bundle cluster_id")
|
||||
}
|
||||
if strings.TrimSpace(identity.ClusterAuthorityPublicKey) == "" || strings.TrimSpace(identity.ClusterAuthorityPublicKey) != authorityPublicKey {
|
||||
return fmt.Errorf("join bundle profile authority key does not match signed bundle authority key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RuntimeConfigFromProfile(profile DockerInstallProfile) RuntimeConfig {
|
||||
return RuntimeConfig{
|
||||
BackendURL: profile.BackendURL,
|
||||
ClusterID: profile.ClusterID,
|
||||
ClusterAuthorityPublicKey: strings.TrimSpace(profile.ClusterAuthorityPublicKey),
|
||||
JoinToken: profile.JoinToken,
|
||||
NodeName: profile.NodeName,
|
||||
Image: profile.Image,
|
||||
@@ -300,7 +363,7 @@ func RuntimeConfigFromProfile(profile DockerInstallProfile) RuntimeConfig {
|
||||
Replace: profile.Replace,
|
||||
DockerVPNGatewayEnabled: profile.DockerVPNGatewayEnabled,
|
||||
WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled,
|
||||
MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled,
|
||||
FabricRuntimeEnabled: profile.FabricRuntimeEnabled,
|
||||
MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled,
|
||||
VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled,
|
||||
MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled,
|
||||
@@ -308,16 +371,19 @@ func RuntimeConfigFromProfile(profile DockerInstallProfile) RuntimeConfig {
|
||||
VPNFabricSessionStreamShards: profile.VPNFabricSessionStreamShards,
|
||||
VPNFabricQUICMaxStreamsPerConn: profile.VPNFabricQUICMaxStreamsPerConn,
|
||||
VPNFabricQUICIdleTTLSeconds: profile.VPNFabricQUICIdleTTLSeconds,
|
||||
MeshListenAddr: profile.MeshListenAddr,
|
||||
MeshListenPortMode: profile.MeshListenPortMode,
|
||||
MeshListenAutoPortStart: profile.MeshListenAutoPortStart,
|
||||
MeshListenAutoPortEnd: profile.MeshListenAutoPortEnd,
|
||||
FabricListenAddr: profile.FabricListenAddr,
|
||||
FabricListenPortMode: profile.FabricListenPortMode,
|
||||
FabricListenAutoPortStart: profile.FabricListenAutoPortStart,
|
||||
FabricListenAutoPortEnd: profile.FabricListenAutoPortEnd,
|
||||
MeshAdvertiseEndpoint: profile.MeshAdvertiseEndpoint,
|
||||
MeshAdvertiseEndpointsJSON: string(profile.MeshAdvertiseEndpointsJSON),
|
||||
FabricRegistryRecordsJSON: string(profile.FabricRegistryRecords),
|
||||
MeshAdvertiseTransport: profile.MeshAdvertiseTransport,
|
||||
MeshConnectivityMode: profile.MeshConnectivityMode,
|
||||
MeshNATType: profile.MeshNATType,
|
||||
MeshSiteID: profile.MeshSiteID,
|
||||
MeshLocalityGroupID: firstNonEmpty(profile.MeshLocalityGroupID, profile.MeshSiteID),
|
||||
MeshNATGroupID: profile.MeshNATGroupID,
|
||||
MeshRegion: profile.MeshRegion,
|
||||
HeartbeatIntervalSeconds: profile.HeartbeatIntervalSeconds,
|
||||
EnrollmentPollIntervalSeconds: profile.EnrollmentPollIntervalSeconds,
|
||||
|
||||
@@ -2,6 +2,7 @@ package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
)
|
||||
|
||||
type HostAgentUpdateRequest struct {
|
||||
BackendURL string
|
||||
ClusterID string
|
||||
NodeID string
|
||||
StateDir string
|
||||
@@ -40,7 +40,6 @@ type HostAgentUpdateLoopConfig struct {
|
||||
|
||||
func (req HostAgentUpdateRequest) updateRequest() UpdateRequest {
|
||||
return UpdateRequest{
|
||||
BackendURL: req.BackendURL,
|
||||
ClusterID: req.ClusterID,
|
||||
NodeID: req.NodeID,
|
||||
StateDir: req.StateDir,
|
||||
@@ -79,6 +78,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
}
|
||||
if plan.Action != "update" {
|
||||
if !req.DryRun {
|
||||
_ = saveUpdatePlanState(resolved, plan, resolved.CurrentVersion, "host-agent-service", binaryPath)
|
||||
status := statusFromNoopPlan(resolved, plan)
|
||||
status.Product = HostAgentUpdateProduct
|
||||
if status.Payload == nil {
|
||||
@@ -102,7 +102,6 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
if req.DryRun {
|
||||
return result, nil
|
||||
}
|
||||
urls := artifactURLsForBackend(*plan.Artifact, resolved.BackendURL)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{
|
||||
Product: HostAgentUpdateProduct,
|
||||
CurrentVersion: resolved.CurrentVersion,
|
||||
@@ -111,14 +110,24 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
Status: "started",
|
||||
AttemptID: updateAttemptID(plan),
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"artifact_url": plan.Artifact.URL, "artifact_urls": urls, "binary_path": binaryPath},
|
||||
Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": binaryPath, "transport": updateArtifactTransport(resolved, plan)},
|
||||
})
|
||||
path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes)
|
||||
path, distributors, err := downloadUpdateArtifact(ctx, resolved, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, resolved, statusFromError(resolved, plan, "download", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
defer os.Remove(path)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{
|
||||
Product: HostAgentUpdateProduct,
|
||||
CurrentVersion: resolved.CurrentVersion,
|
||||
TargetVersion: plan.TargetVersion,
|
||||
Phase: "download",
|
||||
Status: "succeeded",
|
||||
AttemptID: updateAttemptID(plan),
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": binaryPath, "fabric_distributors": distributors, "transport": updateArtifactTransport(resolved, plan)},
|
||||
})
|
||||
if err := installHostAgentBinary(path, binaryPath); err != nil {
|
||||
stageErr := stageHostAgentBinary(path, binaryPath)
|
||||
if stageErr == nil {
|
||||
@@ -129,7 +138,24 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
TargetVersion: plan.TargetVersion,
|
||||
ContainerName: "host-agent-service",
|
||||
Image: binaryPath,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
PlanAction: plan.Action,
|
||||
PlanReason: plan.Reason,
|
||||
UpdateIntent: plan.UpdateIntent,
|
||||
RolloutLease: plan.RolloutLease,
|
||||
AuthorityPayload: func() json.RawMessage {
|
||||
if len(plan.AuthorityPayload) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append(json.RawMessage(nil), plan.AuthorityPayload...)
|
||||
}(),
|
||||
AuthoritySignature: func() json.RawMessage {
|
||||
if len(plan.AuthoritySignature) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append(json.RawMessage(nil), plan.AuthoritySignature...)
|
||||
}(),
|
||||
AuthorityQuorum: plan.AuthorityQuorum,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{
|
||||
Product: HostAgentUpdateProduct,
|
||||
@@ -149,14 +175,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
result.Loaded = true
|
||||
result.Replaced = true
|
||||
result.RestartNeeded = true
|
||||
_ = saveUpdateState(resolved.StateDir, UpdateState{
|
||||
Product: HostAgentUpdateProduct,
|
||||
CurrentVersion: plan.TargetVersion,
|
||||
TargetVersion: plan.TargetVersion,
|
||||
ContainerName: "host-agent-service",
|
||||
Image: binaryPath,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
_ = saveUpdatePlanState(resolved, plan, plan.TargetVersion, "host-agent-service", binaryPath)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{
|
||||
Product: HostAgentUpdateProduct,
|
||||
CurrentVersion: resolved.CurrentVersion,
|
||||
@@ -183,7 +202,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp
|
||||
|
||||
func (m DockerManager) RunHostAgentUpdateLoop(ctx context.Context, cfg HostAgentUpdateLoopConfig) error {
|
||||
if cfg.Interval == 0 {
|
||||
cfg.Interval = time.Hour
|
||||
cfg.Interval = time.Duration(DefaultUpdateIntervalSec) * time.Second
|
||||
}
|
||||
if cfg.InitialDelay < 0 || cfg.Interval < 0 {
|
||||
return errors.New("host-agent update loop durations must not be negative")
|
||||
@@ -191,6 +210,9 @@ func (m DockerManager) RunHostAgentUpdateLoop(ctx context.Context, cfg HostAgent
|
||||
if cfg.Jitter < 0 || cfg.Jitter > 1 {
|
||||
return errors.New("host-agent update loop jitter must be between 0 and 1")
|
||||
}
|
||||
if err := ReconcileSignedUpdateState(cfg.Request.StateDir); err != nil {
|
||||
return err
|
||||
}
|
||||
logf := cfg.Logf
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
@@ -202,6 +224,7 @@ func (m DockerManager) RunHostAgentUpdateLoop(ctx context.Context, cfg HostAgent
|
||||
}
|
||||
runs := 0
|
||||
req := cfg.Request
|
||||
lastTriggerGeneration := currentUpdateTriggerGeneration(req.StateDir)
|
||||
for {
|
||||
runs++
|
||||
result, err := m.ApplyHostAgentUpdate(ctx, req)
|
||||
@@ -210,6 +233,7 @@ func (m DockerManager) RunHostAgentUpdateLoop(ctx context.Context, cfg HostAgent
|
||||
logf("host_agent_update_loop run=%d status=waiting_for_node_identity state_dir=%s", runs, req.StateDir)
|
||||
} else {
|
||||
logf("host_agent_update_loop run=%d status=failed error=%v", runs, err)
|
||||
saveUpdateLoopRescueState(req.updateRequest(), "host_agent_self_update_failed", err)
|
||||
if cfg.StopOnError {
|
||||
return err
|
||||
}
|
||||
@@ -231,7 +255,7 @@ func (m DockerManager) RunHostAgentUpdateLoop(ctx context.Context, cfg HostAgent
|
||||
if cfg.MaxRuns > 0 && runs >= cfg.MaxRuns {
|
||||
return nil
|
||||
}
|
||||
if err := sleepContext(ctx, jitteredDuration(cfg.Interval, cfg.Jitter)); err != nil {
|
||||
if err := sleepUntilUpdateIntervalOrTrigger(ctx, req.StateDir, jitteredDuration(cfg.Interval, cfg.Jitter), &lastTriggerGeneration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
const (
|
||||
DefaultHostAgentInstallPath = "/usr/local/bin/rap-host-agent"
|
||||
DefaultSystemdUnitDir = "/etc/systemd/system"
|
||||
DefaultUpdateIntervalSec = 120
|
||||
)
|
||||
|
||||
type UpdateServiceConfig struct {
|
||||
@@ -62,7 +63,7 @@ func (m DockerManager) InstallUpdateService(ctx context.Context, cfg UpdateServi
|
||||
cfg.Product = DefaultUpdateProduct
|
||||
}
|
||||
if cfg.IntervalSeconds == 0 {
|
||||
cfg.IntervalSeconds = 21600
|
||||
cfg.IntervalSeconds = DefaultUpdateIntervalSec
|
||||
}
|
||||
if cfg.Jitter == 0 {
|
||||
cfg.Jitter = 0.15
|
||||
@@ -173,8 +174,11 @@ func (m DockerManager) InstallUpdateService(ctx context.Context, cfg UpdateServi
|
||||
func buildUpdateServiceUnit(cfg UpdateServiceConfig) (string, error) {
|
||||
runtimeCfg := cfg.RuntimeConfig.Normalize()
|
||||
var missing []string
|
||||
if runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "" {
|
||||
missing = append(missing, "backend-url-or-fabric-registry-records-json")
|
||||
if runtimeCfg.FabricRegistryRecordsJSON == "" {
|
||||
missing = append(missing, "fabric-registry-records-json")
|
||||
}
|
||||
if runtimeCfg.ClusterAuthorityPublicKey == "" {
|
||||
missing = append(missing, "cluster-authority-public-key")
|
||||
}
|
||||
if runtimeCfg.ClusterID == "" {
|
||||
missing = append(missing, "cluster-id")
|
||||
@@ -201,13 +205,10 @@ func buildUpdateServiceUnit(cfg UpdateServiceConfig) (string, error) {
|
||||
"--jitter", fmt.Sprintf("%.3f", cfg.Jitter),
|
||||
"--health-timeout-seconds", fmt.Sprintf("%d", cfg.HealthTimeoutSec),
|
||||
}
|
||||
if runtimeCfg.BackendURL != "" {
|
||||
args = append(args, "--backend-url", runtimeCfg.BackendURL)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Channel) != "" {
|
||||
args = append(args, "--channel", strings.TrimSpace(cfg.Channel))
|
||||
}
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg)
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg, true)
|
||||
execStart := systemdJoin(args)
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=RAP host-agent updater for %s
|
||||
@@ -228,8 +229,8 @@ WantedBy=multi-user.target
|
||||
|
||||
func buildHostAgentSelfUpdateUnit(cfg UpdateServiceConfig) (string, string, string, error) {
|
||||
runtimeCfg := cfg.RuntimeConfig.Normalize()
|
||||
if (runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "") || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" {
|
||||
return "", "", "", fmt.Errorf("backend-url-or-fabric-registry-records-json, cluster-id, and state-dir are required for host-agent self updater")
|
||||
if runtimeCfg.FabricRegistryRecordsJSON == "" || runtimeCfg.ClusterAuthorityPublicKey == "" || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" {
|
||||
return "", "", "", fmt.Errorf("fabric-registry-records-json, cluster-authority-public-key, cluster-id, and state-dir are required for host-agent self updater")
|
||||
}
|
||||
unitName := "rap-host-agent-self-updater.service"
|
||||
unitPath := filepath.Join(firstNonEmpty(cfg.UnitDir, DefaultSystemdUnitDir), unitName)
|
||||
@@ -245,13 +246,10 @@ func buildHostAgentSelfUpdateUnit(cfg UpdateServiceConfig) (string, string, stri
|
||||
"--initial-delay-seconds", fmt.Sprintf("%d", cfg.InitialDelaySeconds+30),
|
||||
"--jitter", fmt.Sprintf("%.3f", cfg.Jitter),
|
||||
}
|
||||
if runtimeCfg.BackendURL != "" {
|
||||
args = append(args, "--backend-url", runtimeCfg.BackendURL)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Channel) != "" {
|
||||
args = append(args, "--channel", strings.TrimSpace(cfg.Channel))
|
||||
}
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg)
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg, true)
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=RAP host-agent self updater
|
||||
After=network-online.target docker.service
|
||||
@@ -271,8 +269,8 @@ WantedBy=multi-user.target
|
||||
|
||||
func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string, error) {
|
||||
runtimeCfg := cfg.RuntimeConfig.Normalize()
|
||||
if (runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "") || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" {
|
||||
return "", "", "", fmt.Errorf("backend-url-or-fabric-registry-records-json, cluster-id, and state-dir are required for host monitor")
|
||||
if runtimeCfg.FabricRegistryRecordsJSON == "" || runtimeCfg.ClusterAuthorityPublicKey == "" || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" {
|
||||
return "", "", "", fmt.Errorf("fabric-registry-records-json, cluster-authority-public-key, cluster-id, and state-dir are required for host monitor")
|
||||
}
|
||||
containers := uniqueTrimmed(append([]string{runtimeCfg.ContainerName}, cfg.MonitorContainers...))
|
||||
if len(containers) == 0 {
|
||||
@@ -291,9 +289,6 @@ func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string,
|
||||
"--disk-cleanup-percent", fmt.Sprintf("%d", firstNonZero(cfg.MonitorDiskCleanup, DefaultMonitorDiskCleanupPercent)),
|
||||
"--disk-critical-percent", fmt.Sprintf("%d", firstNonZero(cfg.MonitorDiskCritical, DefaultMonitorDiskCriticalPercent)),
|
||||
}
|
||||
if runtimeCfg.BackendURL != "" {
|
||||
args = append(args, "--backend-url", runtimeCfg.BackendURL)
|
||||
}
|
||||
if cfg.MonitorCleanupDocker {
|
||||
args = append(args, "--cleanup-docker")
|
||||
}
|
||||
@@ -303,7 +298,7 @@ func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string,
|
||||
for _, container := range containers {
|
||||
args = append(args, "--watch-container", container)
|
||||
}
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg)
|
||||
args = appendFabricUpdateArgs(args, runtimeCfg, true)
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=RAP host-agent monitor for %s
|
||||
After=network-online.target docker.service
|
||||
@@ -321,13 +316,25 @@ WantedBy=multi-user.target
|
||||
`, runtimeCfg.ContainerName, systemdJoin(args)), unitName, unitPath, nil
|
||||
}
|
||||
|
||||
func appendFabricUpdateArgs(args []string, runtimeCfg RuntimeConfig) []string {
|
||||
if strings.TrimSpace(runtimeCfg.FabricRegistryRecordsJSON) != "" {
|
||||
func appendFabricUpdateArgs(args []string, runtimeCfg RuntimeConfig, includeStructured bool) []string {
|
||||
if includeStructured && strings.TrimSpace(runtimeCfg.FabricRegistryRecordsJSON) != "" {
|
||||
args = append(args, "--fabric-registry-records-json", strings.TrimSpace(runtimeCfg.FabricRegistryRecordsJSON))
|
||||
}
|
||||
if strings.TrimSpace(runtimeCfg.ClusterAuthorityPublicKey) != "" {
|
||||
args = append(args, "--cluster-authority-public-key", strings.TrimSpace(runtimeCfg.ClusterAuthorityPublicKey))
|
||||
}
|
||||
if strings.TrimSpace(runtimeCfg.MeshRegion) != "" {
|
||||
args = append(args, "--mesh-region", strings.TrimSpace(runtimeCfg.MeshRegion))
|
||||
}
|
||||
if strings.TrimSpace(runtimeCfg.MeshSiteID) != "" {
|
||||
args = append(args, "--mesh-site-id", strings.TrimSpace(runtimeCfg.MeshSiteID))
|
||||
}
|
||||
if strings.TrimSpace(runtimeCfg.MeshLocalityGroupID) != "" {
|
||||
args = append(args, "--mesh-locality-group-id", strings.TrimSpace(runtimeCfg.MeshLocalityGroupID))
|
||||
}
|
||||
if strings.TrimSpace(runtimeCfg.MeshNATGroupID) != "" {
|
||||
args = append(args, "--mesh-nat-group-id", strings.TrimSpace(runtimeCfg.MeshNATGroupID))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,14 @@ func TestInstallUpdateServiceWritesSystemdUnit(t *testing.T) {
|
||||
binaryPath := filepath.Join(dir, "bin", "rap-host-agent")
|
||||
result, err := (DockerManager{}).InstallUpdateService(context.Background(), UpdateServiceConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
NodeName: "node-a",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "/var/lib/rap/nodes/node-a",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
NodeName: "node-a",
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "/var/lib/rap/nodes/node-a",
|
||||
MeshSiteID: "home",
|
||||
MeshLocalityGroupID: "home-lan",
|
||||
},
|
||||
CurrentVersion: "0.1.0-current",
|
||||
IntervalSeconds: 60,
|
||||
@@ -51,8 +54,11 @@ func TestInstallUpdateServiceWritesSystemdUnit(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"ExecStart=",
|
||||
" update-loop",
|
||||
"--backend-url http://control/api/v1",
|
||||
`--fabric-registry-records-json "[{\"schema\":\"rap.fabric.registry.gossip_record.v1\",\"service_class\":\"control-api\"}]"`,
|
||||
`--cluster-authority-public-key authority-key-b64`,
|
||||
"--cluster-id cluster-1",
|
||||
"--mesh-site-id home",
|
||||
"--mesh-locality-group-id home-lan",
|
||||
"--state-dir /var/lib/rap/nodes/node-a",
|
||||
"--container-name rap-node-agent-node-a",
|
||||
"--current-version 0.1.0-current",
|
||||
@@ -76,6 +82,9 @@ func TestInstallUpdateServiceWritesSystemdUnit(t *testing.T) {
|
||||
if text := string(selfUnit); !strings.Contains(text, "update-host-agent-loop") || !strings.Contains(text, "--current-version 0.1.0-host") {
|
||||
t.Fatalf("unexpected self unit:\n%s", text)
|
||||
}
|
||||
if text := string(selfUnit); !strings.Contains(text, "--fabric-registry-records-json") {
|
||||
t.Fatalf("unexpected self updater unit structured args:\n%s", text)
|
||||
}
|
||||
if result.MonitorUnitName == "" || result.MonitorUnitPath == "" {
|
||||
t.Fatalf("monitor result = %+v", result)
|
||||
}
|
||||
@@ -95,13 +104,57 @@ func TestInstallUpdateServiceWritesSystemdUnit(t *testing.T) {
|
||||
t.Fatalf("monitor unit missing %q:\n%s", want, monitorText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(monitorText, "--fabric-registry-records-json") {
|
||||
t.Fatalf("unexpected monitor unit structured args:\n%s", monitorText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUpdateServiceDefaultsToRescuePollInterval(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "rap-host-agent-src")
|
||||
if err := os.WriteFile(source, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
result, err := (DockerManager{}).InstallUpdateService(context.Background(), UpdateServiceConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
ContainerName: "rap-node-agent-node-a",
|
||||
StateDir: "/var/lib/rap/nodes/node-a",
|
||||
},
|
||||
CurrentVersion: "0.1.0-current",
|
||||
SourceBinaryPath: source,
|
||||
BinaryInstallPath: filepath.Join(dir, "bin", "rap-host-agent"),
|
||||
UnitDir: filepath.Join(dir, "systemd"),
|
||||
ManageSystemd: false,
|
||||
InstallSelfUpdater: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install update service: %v", err)
|
||||
}
|
||||
unit, err := os.ReadFile(result.UnitPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read update unit: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(unit), "--interval-seconds 120") {
|
||||
t.Fatalf("update unit should default to rescue poll interval:\n%s", unit)
|
||||
}
|
||||
selfUnit, err := os.ReadFile(result.SelfUnitPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read self update unit: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(selfUnit), "--interval-seconds 120") {
|
||||
t.Fatalf("self update unit should default to rescue poll interval:\n%s", selfUnit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) {
|
||||
cfg := WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
},
|
||||
NodeID: "node-1",
|
||||
AutoUpdateCurrentVersion: "0.1.2",
|
||||
@@ -117,10 +170,11 @@ func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) {
|
||||
}
|
||||
script := windowsHostAgentUpdateScript(`C:\Program Files\RAP\win-a\rap-host-agent.exe`, cfg, result)
|
||||
for _, want := range []string{
|
||||
":loop",
|
||||
"RAP_HOST_AGENT_UPDATE_LOCK_DIR",
|
||||
"rap-host-agent.exe.next",
|
||||
"update-loop --cluster-id",
|
||||
"--backend-url \"http://control/api/v1\"",
|
||||
"update-loop --max-runs 1 --cluster-id",
|
||||
`--fabric-registry-records-json [{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
"--cluster-authority-public-key authority-key-b64",
|
||||
"--cluster-id \"cluster-1\"",
|
||||
"--node-id \"node-1\"",
|
||||
"--state-dir \"C:\\ProgramData\\RAP\\nodes\\win-a\"",
|
||||
@@ -131,7 +185,7 @@ func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) {
|
||||
"--current-version 0.1.2",
|
||||
"--host-agent-current-version 0.1.2",
|
||||
"--interval-seconds 120",
|
||||
"timeout /t 120",
|
||||
"wake-interval-seconds 120",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("script missing %q:\n%s", want, script)
|
||||
@@ -139,12 +193,12 @@ func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsHostAgentUpdateScriptOmitsEmptyBackendURL(t *testing.T) {
|
||||
func TestWindowsHostAgentUpdateScriptIncludesFabricRegistry(t *testing.T) {
|
||||
cfg := WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
FabricRegistryRecordsJSON: `[{"record_id":"r1"}]`,
|
||||
MeshRegion: "ru-msk",
|
||||
ClusterID: "cluster-1",
|
||||
FabricRegistryRecordsJSON: `[{"record_id":"r1"}]`,
|
||||
MeshRegion: "ru-msk",
|
||||
},
|
||||
AutoUpdateCurrentVersion: "0.1.2",
|
||||
}
|
||||
@@ -155,9 +209,6 @@ func TestWindowsHostAgentUpdateScriptOmitsEmptyBackendURL(t *testing.T) {
|
||||
TaskName: "RAP Node Agent win-a",
|
||||
}
|
||||
script := windowsHostAgentUpdateScript(`C:\Program Files\RAP\win-a\rap-host-agent.exe`, cfg, result)
|
||||
if strings.Contains(script, "--backend-url") {
|
||||
t.Fatalf("script must not include backend-url when it is empty:\n%s", script)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`--fabric-registry-records-json [{"record_id":"r1"}]`,
|
||||
"--mesh-region ru-msk",
|
||||
@@ -171,9 +222,10 @@ func TestWindowsHostAgentUpdateScriptOmitsEmptyBackendURL(t *testing.T) {
|
||||
func TestWindowsInstallReplaceAllowsExistingNodeWithoutJoinToken(t *testing.T) {
|
||||
result, err := (WindowsManager{}).Install(context.Background(), WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
NodeName: "win-a",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
NodeName: "win-a",
|
||||
},
|
||||
InstallDir: `C:\Program Files\RAP\win-a`,
|
||||
Replace: true,
|
||||
@@ -202,8 +254,9 @@ func TestWindowsRepairUpdaterStartsFromUnknownVersion(t *testing.T) {
|
||||
StartupMode: "user-task",
|
||||
}, WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: "http://control/api/v1",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterID: "cluster-1",
|
||||
ClusterAuthorityPublicKey: "authority-key-b64",
|
||||
FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`,
|
||||
},
|
||||
Replace: true,
|
||||
AutoUpdateEnabled: true,
|
||||
@@ -219,4 +272,57 @@ func TestWindowsRepairUpdaterStartsFromUnknownVersion(t *testing.T) {
|
||||
if !strings.Contains(string(script), "--current-version 0.0.0") {
|
||||
t.Fatalf("repair updater should force unknown current version:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(string(script), "--max-runs 1") {
|
||||
t.Fatalf("repair updater should run one-shot update-loop:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(string(script), "RAP_HOST_AGENT_UPDATE_LOCK_DIR") {
|
||||
t.Fatalf("repair updater should guard against overlapping runs:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(string(script), "--interval-seconds 120") {
|
||||
t.Fatalf("repair updater should use rescue poll interval:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(string(script), "wake-interval-seconds 120") {
|
||||
t.Fatalf("repair updater should document wake interval:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsRepairUpdaterUsesRecurringScheduledTask(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "rap-host-agent.exe")
|
||||
if err := os.WriteFile(source, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
runner := &recordingRunner{}
|
||||
_, err := installWindowsHostAgentUpdater(context.Background(), WindowsManager{Runner: runner}, WindowsInstallResult{
|
||||
NodeName: "win-a",
|
||||
InstallDir: dir,
|
||||
StateDir: dir,
|
||||
NodeAgentPath: filepath.Join(dir, "rap-node-agent.exe"),
|
||||
TaskName: "RAP Node Agent win-a",
|
||||
StartupMode: "user-task",
|
||||
}, WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
ClusterID: "cluster-1",
|
||||
},
|
||||
Replace: true,
|
||||
AutoUpdateEnabled: true,
|
||||
AutoUpdateIntervalSeconds: 21600,
|
||||
HostAgentSourcePath: source,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("install updater: %v", err)
|
||||
}
|
||||
foundMinuteTask := false
|
||||
for _, call := range runner.calls {
|
||||
if len(call) >= 8 && call[0] == "schtasks" && call[1] == "/Create" {
|
||||
joined := strings.Join(call, " ")
|
||||
if strings.Contains(joined, "/SC MINUTE") && strings.Contains(joined, "/MO 5") {
|
||||
foundMinuteTask = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundMinuteTask {
|
||||
t.Fatalf("expected recurring minute task, got %#v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -58,13 +58,14 @@ func WindowsInstallConfigFromProfile(profile WindowsInstallProfile) WindowsInsta
|
||||
stateDir := firstNonEmpty(profile.StateDir, filepath.Join(DefaultWindowsStateRoot, safeUnitSlug(profile.NodeName)))
|
||||
return WindowsInstallConfig{
|
||||
RuntimeConfig: RuntimeConfig{
|
||||
BackendURL: profile.BackendURL,
|
||||
ClusterAuthorityPublicKey: strings.TrimSpace(profile.ClusterAuthorityPublicKey),
|
||||
FabricRegistryRecordsJSON: strings.TrimSpace(string(profile.FabricRegistryRecords)),
|
||||
ClusterID: profile.ClusterID,
|
||||
JoinToken: profile.JoinToken,
|
||||
NodeName: profile.NodeName,
|
||||
StateDir: stateDir,
|
||||
WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled,
|
||||
MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled,
|
||||
FabricRuntimeEnabled: profile.FabricRuntimeEnabled,
|
||||
MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled,
|
||||
VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled,
|
||||
MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled,
|
||||
@@ -72,15 +73,18 @@ func WindowsInstallConfigFromProfile(profile WindowsInstallProfile) WindowsInsta
|
||||
VPNFabricSessionStreamShards: profile.VPNFabricSessionStreamShards,
|
||||
VPNFabricQUICMaxStreamsPerConn: profile.VPNFabricQUICMaxStreamsPerConn,
|
||||
VPNFabricQUICIdleTTLSeconds: profile.VPNFabricQUICIdleTTLSeconds,
|
||||
MeshListenAddr: profile.MeshListenAddr,
|
||||
MeshListenPortMode: profile.MeshListenPortMode,
|
||||
MeshListenAutoPortStart: profile.MeshListenAutoPortStart,
|
||||
MeshListenAutoPortEnd: profile.MeshListenAutoPortEnd,
|
||||
FabricListenAddr: profile.FabricListenAddr,
|
||||
FabricListenPortMode: profile.FabricListenPortMode,
|
||||
FabricListenAutoPortStart: profile.FabricListenAutoPortStart,
|
||||
FabricListenAutoPortEnd: profile.FabricListenAutoPortEnd,
|
||||
MeshAdvertiseEndpoint: profile.MeshAdvertiseEndpoint,
|
||||
MeshAdvertiseEndpointsJSON: string(profile.MeshAdvertiseEndpointsJSON),
|
||||
MeshAdvertiseTransport: profile.MeshAdvertiseTransport,
|
||||
MeshConnectivityMode: profile.MeshConnectivityMode,
|
||||
MeshNATType: profile.MeshNATType,
|
||||
MeshSiteID: profile.MeshSiteID,
|
||||
MeshLocalityGroupID: firstNonEmpty(profile.MeshLocalityGroupID, profile.MeshSiteID),
|
||||
MeshNATGroupID: profile.MeshNATGroupID,
|
||||
MeshRegion: profile.MeshRegion,
|
||||
HeartbeatIntervalSeconds: profile.HeartbeatIntervalSeconds,
|
||||
EnrollmentPollIntervalSeconds: profile.EnrollmentPollIntervalSeconds,
|
||||
|
||||
@@ -2,10 +2,12 @@ package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -42,12 +44,22 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd
|
||||
}
|
||||
if plan.Action != "update" {
|
||||
if !req.DryRun {
|
||||
restarted, err := rewriteWindowsControlPlaneRuntime(ctx, runner, m, req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "rewrite_runtime", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
result.RestartNeeded = restarted
|
||||
}
|
||||
if !req.DryRun {
|
||||
_ = saveUpdatePlanState(req, plan, req.CurrentVersion, req.WindowsTaskName, req.BinaryPath)
|
||||
status := statusFromNoopPlan(req, plan)
|
||||
if status.Payload == nil {
|
||||
status.Payload = map[string]any{}
|
||||
}
|
||||
status.Payload["task"] = req.WindowsTaskName
|
||||
status.Payload["binary_path"] = req.BinaryPath
|
||||
status.Payload["restart_needed"] = result.RestartNeeded
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, status)
|
||||
}
|
||||
return result, nil
|
||||
@@ -78,9 +90,8 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd
|
||||
Status: "accepted",
|
||||
AttemptID: updateAttemptID(plan),
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"strategy": plan.Strategy, "reason": plan.Reason, "task": req.WindowsTaskName},
|
||||
Payload: updatePlanStatusPayload(plan),
|
||||
})
|
||||
urls := artifactURLsForBackend(*plan.Artifact, req.BackendURL)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{
|
||||
Product: req.Product,
|
||||
CurrentVersion: req.CurrentVersion,
|
||||
@@ -89,14 +100,24 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd
|
||||
Status: "started",
|
||||
AttemptID: updateAttemptID(plan),
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"artifact_url": plan.Artifact.URL, "artifact_urls": urls, "binary_path": req.BinaryPath},
|
||||
Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": req.BinaryPath, "transport": updateArtifactTransport(req, plan)},
|
||||
})
|
||||
path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes)
|
||||
path, distributors, err := downloadUpdateArtifact(ctx, req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "download", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
defer os.Remove(path)
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{
|
||||
Product: req.Product,
|
||||
CurrentVersion: req.CurrentVersion,
|
||||
TargetVersion: plan.TargetVersion,
|
||||
Phase: "download",
|
||||
Status: "succeeded",
|
||||
AttemptID: updateAttemptID(plan),
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"artifact_id": plan.Artifact.ID, "binary_path": req.BinaryPath, "fabric_distributors": distributors, "transport": updateArtifactTransport(req, plan)},
|
||||
})
|
||||
m.stopExistingNodeAgent(ctx, req.WindowsTaskName, req.BinaryPath)
|
||||
if err := copyFile(path, req.BinaryPath, 0o755); err != nil {
|
||||
m.stopExistingNodeAgent(ctx, req.WindowsTaskName, req.BinaryPath)
|
||||
@@ -106,10 +127,18 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd
|
||||
}
|
||||
}
|
||||
result.Replaced = true
|
||||
if _, err := runner.Run(ctx, "schtasks", "/Run", "/TN", req.WindowsTaskName); err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err))
|
||||
restartedByRewrite, err := rewriteWindowsControlPlaneRuntime(ctx, runner, m, req, plan)
|
||||
if err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "rewrite_runtime", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
result.RestartNeeded = restartedByRewrite
|
||||
if !restartedByRewrite {
|
||||
if _, err := runner.Run(ctx, "schtasks", "/Run", "/TN", req.WindowsTaskName); err != nil {
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err))
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
_ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{
|
||||
Product: req.Product,
|
||||
CurrentVersion: req.CurrentVersion,
|
||||
@@ -120,16 +149,105 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd
|
||||
ObservedAt: time.Now().UTC(),
|
||||
Payload: map[string]any{"task": req.WindowsTaskName, "binary_path": req.BinaryPath},
|
||||
})
|
||||
_ = saveUpdateState(req.StateDir, UpdateState{
|
||||
Product: req.Product,
|
||||
CurrentVersion: plan.TargetVersion,
|
||||
TargetVersion: plan.TargetVersion,
|
||||
Image: req.BinaryPath,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
_ = saveUpdatePlanState(req, plan, plan.TargetVersion, req.WindowsTaskName, req.BinaryPath)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func rewriteWindowsControlPlaneRuntime(ctx context.Context, runner CommandRunner, manager WindowsManager, req UpdateRequest, plan NodeUpdatePlan) (bool, error) {
|
||||
_ = saveControlPlaneRuntimeState(req.StateDir, ControlPlaneRuntimeState{
|
||||
SchemaVersion: "rap.control_plane_runtime_state.v1",
|
||||
ClusterID: strings.TrimSpace(plan.ClusterID),
|
||||
NodeID: strings.TrimSpace(plan.NodeID),
|
||||
Product: strings.TrimSpace(plan.Product),
|
||||
FabricRegistryRecords: append(json.RawMessage(nil), plan.FabricRegistryRecords...),
|
||||
AuthorityPayload: append(json.RawMessage(nil), plan.AuthorityPayload...),
|
||||
AuthoritySignature: append(json.RawMessage(nil), plan.AuthoritySignature...),
|
||||
AuthorityQuorum: plan.AuthorityQuorum,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
installDir := filepath.Dir(strings.TrimSpace(req.BinaryPath))
|
||||
if installDir == "" {
|
||||
return false, nil
|
||||
}
|
||||
envPath := filepath.Join(installDir, "rap-node-agent.env.cmd")
|
||||
envRegistry := strings.TrimSpace(string(plan.FabricRegistryRecords))
|
||||
changed := false
|
||||
if envRegistry != "" && fileExists(envPath) {
|
||||
current, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
updatedEnv := string(current)
|
||||
updatedEnv = upsertWindowsEnvValue(updatedEnv, "RAP_FABRIC_REGISTRY_RECORDS_JSON", envRegistry)
|
||||
if updatedEnv != string(current) {
|
||||
if err := os.WriteFile(envPath, []byte(updatedEnv), 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if envRegistry == "" {
|
||||
return false, nil
|
||||
}
|
||||
wrapperPath := filepath.Join(installDir, "rap-host-agent-update.cmd")
|
||||
if !fileExists(wrapperPath) {
|
||||
if changed {
|
||||
manager.stopExistingNodeAgent(ctx, req.WindowsTaskName, req.BinaryPath)
|
||||
if _, err := runner.Run(ctx, "schtasks", "/Run", "/TN", req.WindowsTaskName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
script, err := os.ReadFile(wrapperPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
updated := replaceCLIArg(string(script), "--fabric-registry-records-json", envRegistry, true)
|
||||
if updated != string(script) {
|
||||
if err := os.WriteFile(wrapperPath, []byte(updated), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
manager.stopExistingNodeAgent(ctx, req.WindowsTaskName, req.BinaryPath)
|
||||
if _, err := runner.Run(ctx, "schtasks", "/Run", "/TN", req.WindowsTaskName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func upsertWindowsEnvValue(script string, key string, value string) string {
|
||||
prefix := "set " + key + "="
|
||||
lines := strings.Split(script, "\n")
|
||||
for i, line := range lines {
|
||||
rawLine := strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimSpace(rawLine)
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)) {
|
||||
if value == "" {
|
||||
lines = append(lines[:i], lines[i+1:]...)
|
||||
} else {
|
||||
lines[i] = prefix + value
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
return script
|
||||
}
|
||||
insertAt := len(lines)
|
||||
for i, line := range lines {
|
||||
if strings.EqualFold(strings.TrimSpace(strings.TrimRight(line, "\r")), "@echo off") {
|
||||
insertAt = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
lines = append(lines[:insertAt], append([]string{prefix + value}, lines[insertAt:]...)...)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m WindowsManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig) error {
|
||||
req := cfg.Request
|
||||
if strings.TrimSpace(req.InstallType) == "" || req.InstallType == DefaultUpdateInstallType {
|
||||
@@ -141,6 +259,9 @@ func (m WindowsManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig)
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ReconcileSignedUpdateState(req.StateDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Interval == 0 {
|
||||
cfg.Interval = time.Hour
|
||||
}
|
||||
@@ -179,6 +300,7 @@ func (m WindowsManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig)
|
||||
continue
|
||||
}
|
||||
logf("windows_update_loop run=%d status=failed error=%v", runs, err)
|
||||
saveUpdateLoopRescueState(req, "windows_node_agent_update_failed", err)
|
||||
if cfg.StopOnError {
|
||||
return err
|
||||
}
|
||||
@@ -197,10 +319,12 @@ func (m WindowsManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig)
|
||||
}
|
||||
if cfg.HostAgentUpdateEnabled {
|
||||
hostReq := cfg.HostAgentUpdateRequest
|
||||
hostReq.BackendURL = firstNonEmpty(hostReq.BackendURL, req.BackendURL)
|
||||
hostReq.ClusterID = firstNonEmpty(hostReq.ClusterID, req.ClusterID)
|
||||
hostReq.NodeID = firstNonEmpty(hostReq.NodeID, req.NodeID)
|
||||
hostReq.StateDir = firstNonEmpty(hostReq.StateDir, req.StateDir)
|
||||
hostReq.ClusterAuthorityPublicKey = firstNonEmpty(hostReq.ClusterAuthorityPublicKey, req.ClusterAuthorityPublicKey)
|
||||
hostReq.FabricRegistryRecordsJSON = firstNonEmpty(hostReq.FabricRegistryRecordsJSON, req.FabricRegistryRecordsJSON)
|
||||
hostReq.MeshRegion = firstNonEmpty(hostReq.MeshRegion, req.MeshRegion)
|
||||
hostReq.Channel = firstNonEmpty(hostReq.Channel, req.Channel)
|
||||
hostReq.OS = firstNonEmpty(hostReq.OS, "windows")
|
||||
hostReq.Arch = firstNonEmpty(hostReq.Arch, "amd64")
|
||||
@@ -211,6 +335,7 @@ func (m WindowsManager) RunUpdateLoop(ctx context.Context, cfg UpdateLoopConfig)
|
||||
logf("windows_host_agent_update_loop run=%d status=waiting_for_node_identity state_dir=%s", runs, hostReq.StateDir)
|
||||
} else {
|
||||
logf("windows_host_agent_update_loop run=%d status=failed error=%v", runs, hostErr)
|
||||
saveUpdateLoopRescueState(req, "windows_host_agent_update_failed", hostErr)
|
||||
if cfg.StopOnError {
|
||||
return hostErr
|
||||
}
|
||||
@@ -257,7 +382,7 @@ func installWindowsHostAgentUpdater(ctx context.Context, m WindowsManager, resul
|
||||
if err := os.WriteFile(wrapperPath, []byte(script), 0o755); err != nil {
|
||||
return result, err
|
||||
}
|
||||
started, fallback, mode, err := m.installStartupTask(ctx, taskName, wrapperPath, logPath, cfg.StartupMode)
|
||||
started, fallback, mode, err := m.installRecurringUpdaterTask(ctx, taskName, wrapperPath, logPath, cfg.StartupMode, windowsUpdaterWakeIntervalSeconds(cfg.AutoUpdateIntervalSeconds))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -277,7 +402,7 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig
|
||||
currentVersion := firstNonEmpty(cfg.AutoUpdateCurrentVersion, "0.0.0")
|
||||
interval := cfg.AutoUpdateIntervalSeconds
|
||||
if interval == 0 {
|
||||
interval = 21600
|
||||
interval = DefaultUpdateIntervalSec
|
||||
}
|
||||
initialDelay := cfg.AutoUpdateInitialDelaySeconds
|
||||
if initialDelay == 0 {
|
||||
@@ -290,6 +415,7 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig
|
||||
updateLoopArgs := []string{
|
||||
`"` + hostAgentPath + `"`,
|
||||
"update-loop",
|
||||
"--max-runs", "1",
|
||||
"--cluster-id", `"` + cfg.RuntimeConfig.ClusterID + `"`,
|
||||
"--state-dir", `"` + result.StateDir + `"`,
|
||||
"--current-version", currentVersion,
|
||||
@@ -305,10 +431,7 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig
|
||||
"--host-agent-current-version", currentVersion,
|
||||
"--host-agent-binary-path", `"` + hostAgentPath + `"`,
|
||||
}
|
||||
if strings.TrimSpace(cfg.RuntimeConfig.BackendURL) != "" {
|
||||
updateLoopArgs = append(updateLoopArgs, "--backend-url", `"`+strings.TrimSpace(cfg.RuntimeConfig.BackendURL)+`"`)
|
||||
}
|
||||
updateLoopArgs = appendFabricUpdateArgs(updateLoopArgs, cfg.RuntimeConfig)
|
||||
updateLoopArgs = appendFabricUpdateArgs(updateLoopArgs, cfg.RuntimeConfig, true)
|
||||
if strings.TrimSpace(cfg.NodeID) != "" {
|
||||
updateLoopArgs = append(updateLoopArgs, "--node-id", `"`+strings.TrimSpace(cfg.NodeID)+`"`)
|
||||
}
|
||||
@@ -320,21 +443,70 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig
|
||||
"setlocal",
|
||||
"set RAP_HOST_AGENT=" + `"` + hostAgentPath + `"`,
|
||||
"set RAP_HOST_AGENT_NEXT=" + `"` + hostAgentPath + `.next"`,
|
||||
}
|
||||
if initialDelay > 0 {
|
||||
lines = append(lines, "timeout /t "+fmt.Sprintf("%d", initialDelay)+" /nobreak >NUL")
|
||||
"set RAP_HOST_AGENT_UPDATE_LOCK_DIR=" + `"` + filepath.Join(result.StateDir, "rap-host-agent-update.lock") + `"`,
|
||||
}
|
||||
lines = append(lines, []string{
|
||||
":loop",
|
||||
"2>nul mkdir %RAP_HOST_AGENT_UPDATE_LOCK_DIR%",
|
||||
"if errorlevel 1 goto :eof",
|
||||
"if exist %RAP_HOST_AGENT_NEXT% (",
|
||||
" copy /Y %RAP_HOST_AGENT_NEXT% %RAP_HOST_AGENT% >NUL",
|
||||
" if %ERRORLEVEL% EQU 0 del /F /Q %RAP_HOST_AGENT_NEXT%",
|
||||
")",
|
||||
}...)
|
||||
if initialDelay > 0 {
|
||||
lines = append(lines, "timeout /t "+fmt.Sprintf("%d", initialDelay)+" /nobreak >NUL")
|
||||
}
|
||||
lines = append(lines, []string{
|
||||
strings.Join(updateLoopArgs, " "),
|
||||
"timeout /t " + fmt.Sprintf("%d", interval) + " /nobreak >NUL",
|
||||
"goto loop",
|
||||
"endlocal",
|
||||
"set RAP_HOST_AGENT_UPDATE_EXIT_CODE=%ERRORLEVEL%",
|
||||
"rmdir /S /Q %RAP_HOST_AGENT_UPDATE_LOCK_DIR% >NUL 2>&1",
|
||||
"endlocal & exit /b %RAP_HOST_AGENT_UPDATE_EXIT_CODE%",
|
||||
"rem initial-delay-seconds " + fmt.Sprintf("%d", initialDelay),
|
||||
"rem wake-interval-seconds " + strconv.Itoa(windowsUpdaterWakeIntervalSeconds(interval)),
|
||||
}...)
|
||||
return strings.Join(lines, "\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
func windowsUpdaterWakeIntervalSeconds(intervalSeconds int) int {
|
||||
if intervalSeconds <= 0 {
|
||||
return 300
|
||||
}
|
||||
if intervalSeconds > 300 {
|
||||
return 300
|
||||
}
|
||||
return intervalSeconds
|
||||
}
|
||||
|
||||
func (m WindowsManager) installRecurringUpdaterTask(ctx context.Context, taskName, wrapperPath, logPath, mode string, intervalSeconds int) (bool, bool, string, error) {
|
||||
if strings.EqualFold(mode, "none") {
|
||||
return false, false, mode, nil
|
||||
}
|
||||
runner := m.Runner
|
||||
if runner == nil {
|
||||
runner = ExecRunner{}
|
||||
}
|
||||
intervalMinutes := intervalSeconds / 60
|
||||
if intervalSeconds%60 != 0 {
|
||||
intervalMinutes++
|
||||
}
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 1
|
||||
}
|
||||
action := windowsTaskAction(wrapperPath, logPath)
|
||||
if mode == "auto" || mode == "system-task" {
|
||||
_, err := runner.Run(ctx, "schtasks", "/Create", "/TN", taskName, "/SC", "MINUTE", "/MO", strconv.Itoa(intervalMinutes), "/RU", "SYSTEM", "/RL", "HIGHEST", "/TR", action, "/F")
|
||||
if err == nil {
|
||||
_, _ = runner.Run(ctx, "schtasks", "/Run", "/TN", taskName)
|
||||
return true, false, "system-task", nil
|
||||
}
|
||||
if mode == "system-task" {
|
||||
return false, false, mode, err
|
||||
}
|
||||
}
|
||||
_, err := runner.Run(ctx, "schtasks", "/Create", "/TN", taskName, "/SC", "MINUTE", "/MO", strconv.Itoa(intervalMinutes), "/TR", action, "/F")
|
||||
if err != nil {
|
||||
return false, mode == "auto", "user-task", err
|
||||
}
|
||||
_, _ = runner.Run(ctx, "schtasks", "/Run", "/TN", taskName)
|
||||
return true, mode == "auto", "user-task", nil
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) Client {
|
||||
return Client{
|
||||
BaseURL: baseURL,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) SendHealth(ctx context.Context, message HealthMessage) (HealthAck, error) {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return HealthAck{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/mesh/v1/health", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return HealthAck{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return HealthAck{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return HealthAck{}, fmt.Errorf("mesh health rejected with status %d", resp.StatusCode)
|
||||
}
|
||||
var ack HealthAck
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil {
|
||||
return HealthAck{}, err
|
||||
}
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
func (c Client) SendSynthetic(ctx context.Context, envelope SyntheticEnvelope) (SyntheticEnvelope, error) {
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return SyntheticEnvelope{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/mesh/v1/synthetic/probe", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return SyntheticEnvelope{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return SyntheticEnvelope{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return SyntheticEnvelope{}, fmt.Errorf("mesh synthetic probe rejected with status %d", resp.StatusCode)
|
||||
}
|
||||
var ack SyntheticEnvelope
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil {
|
||||
return SyntheticEnvelope{}, err
|
||||
}
|
||||
return ack, nil
|
||||
}
|
||||
|
||||
func (c Client) SendProduction(ctx context.Context, envelope ProductionEnvelope) (ProductionForwardResult, error) {
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return ProductionForwardResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/mesh/v1/forward", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return ProductionForwardResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ProductionForwardResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ProductionForwardResult{}, fmt.Errorf("mesh production forward rejected with status %d", resp.StatusCode)
|
||||
}
|
||||
var result ProductionForwardResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ProductionForwardResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -70,7 +70,7 @@ const (
|
||||
FabricServiceChannelReliable = "reliable"
|
||||
FabricServiceChannelDroppable = "droppable"
|
||||
MaxProductionEnvelopePayloadBytes = 4096
|
||||
MaxProductionVPNPacketPayloadBytes = 256 * 1024
|
||||
MaxProductionVPNPacketPayloadBytes = 8 * 1024 * 1024
|
||||
MaxProductionEnvelopeFutureSkew = time.Minute
|
||||
ProductionForwardQUICStreamID = 1
|
||||
WebIngressForwardQUICStreamID = 2
|
||||
@@ -203,22 +203,6 @@ type SyntheticRelayQueueMetrics struct {
|
||||
QueueDepths map[string]int `json:"queue_depths"`
|
||||
}
|
||||
|
||||
type HealthMessage struct {
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
From PeerIdentity `json:"from"`
|
||||
To PeerIdentity `json:"to"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
LinkStatus string `json:"link_status"`
|
||||
LatencyMs *int `json:"latency_ms,omitempty"`
|
||||
QualityScore *int `json:"quality_score,omitempty"`
|
||||
}
|
||||
|
||||
type HealthAck struct {
|
||||
ProtocolVersion string `json:"protocol_version"`
|
||||
Accepted bool `json:"accepted"`
|
||||
By PeerIdentity `json:"by"`
|
||||
}
|
||||
|
||||
type ProductionEnvelope struct {
|
||||
FabricProtocolVersion string `json:"fabric_protocol_version"`
|
||||
MessageID string `json:"message_id"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -9,6 +10,9 @@ import (
|
||||
type EndpointCandidateScoreOptions struct {
|
||||
ChannelClass string
|
||||
PreferredRegion string
|
||||
SiteID string
|
||||
LocalityGroupID string
|
||||
LocalNATGroupID string
|
||||
Now time.Time
|
||||
MaxVerificationAge time.Duration
|
||||
Observations map[string]EndpointCandidateHealthObservation
|
||||
@@ -21,6 +25,7 @@ type EndpointCandidateHealthObservation struct {
|
||||
EndpointID string `json:"endpoint_id"`
|
||||
Source string `json:"source,omitempty"`
|
||||
ReporterNodeID string `json:"reporter_node_id,omitempty"`
|
||||
ReporterRegion string `json:"reporter_region,omitempty"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms,omitempty"`
|
||||
SuccessCount uint64 `json:"success_count,omitempty"`
|
||||
FailureCount uint64 `json:"failure_count,omitempty"`
|
||||
@@ -114,6 +119,9 @@ func scorePeerEndpointCandidate(candidate PeerEndpointCandidate, opts EndpointCa
|
||||
case "direct":
|
||||
score += 30
|
||||
reasons = append(reasons, "connectivity:direct")
|
||||
case "private_lan":
|
||||
score += 36
|
||||
reasons = append(reasons, "connectivity:private_lan")
|
||||
case "outbound_only":
|
||||
score += 5
|
||||
reasons = append(reasons, "connectivity:outbound_only")
|
||||
@@ -167,6 +175,7 @@ func scorePeerEndpointCandidate(candidate PeerEndpointCandidate, opts EndpointCa
|
||||
score += 18
|
||||
reasons = append(reasons, "policy:private-lan")
|
||||
}
|
||||
score, reasons = applyLocalityPreferences(candidate, opts, score, reasons)
|
||||
if hasPolicyTag(candidate.PolicyTags, "costly") {
|
||||
score -= 10
|
||||
reasons = append(reasons, "policy:costly")
|
||||
@@ -193,7 +202,7 @@ func scorePeerEndpointCandidate(candidate PeerEndpointCandidate, opts EndpointCa
|
||||
}
|
||||
}
|
||||
if observation, ok := opts.Observations[candidate.EndpointID]; ok {
|
||||
observationScore, observationReasons := scoreEndpointCandidateObservation(observation, opts)
|
||||
observationScore, observationReasons := scoreEndpointCandidateObservation(candidate, observation, opts)
|
||||
score += observationScore
|
||||
reasons = append(reasons, observationReasons...)
|
||||
}
|
||||
@@ -225,7 +234,7 @@ func scoreEndpointCandidateCapacityPressure(pressure EndpointCandidateCapacityPr
|
||||
return -penalty, []string{"capacity:pressure"}
|
||||
}
|
||||
|
||||
func scoreEndpointCandidateObservation(observation EndpointCandidateHealthObservation, opts EndpointCandidateScoreOptions) (int, []string) {
|
||||
func scoreEndpointCandidateObservation(candidate PeerEndpointCandidate, observation EndpointCandidateHealthObservation, opts EndpointCandidateScoreOptions) (int, []string) {
|
||||
score := 0
|
||||
reasons := []string{"observation:present"}
|
||||
if !opts.Now.IsZero() && !observation.ObservedAt.IsZero() && opts.MaxObservationAge > 0 {
|
||||
@@ -236,6 +245,18 @@ func scoreEndpointCandidateObservation(observation EndpointCandidateHealthObserv
|
||||
score += 6
|
||||
reasons = append(reasons, "observation:fresh")
|
||||
}
|
||||
observationScope := endpointCandidateObservationScope(candidate, observation, opts)
|
||||
if observationScope != "" {
|
||||
reasons = append(reasons, "observation_scope:"+observationScope)
|
||||
}
|
||||
if endpointRequiresExternalNetworkVerification(candidate) && (observationScope == "self" || observationScope == "same_area") {
|
||||
reasons = append(reasons, "observation:non_authoritative_same_area_public")
|
||||
if strings.TrimSpace(observation.LastFailureReason) == "capacity_limited" {
|
||||
score -= 4
|
||||
reasons = append(reasons, "capacity:limited")
|
||||
}
|
||||
return score, reasons
|
||||
}
|
||||
switch {
|
||||
case observation.LastLatencyMs > 0 && observation.LastLatencyMs <= 50:
|
||||
score += 24
|
||||
@@ -286,6 +307,118 @@ func scoreEndpointCandidateObservation(observation EndpointCandidateHealthObserv
|
||||
return score, reasons
|
||||
}
|
||||
|
||||
func endpointCandidateObservationScope(candidate PeerEndpointCandidate, observation EndpointCandidateHealthObservation, opts EndpointCandidateScoreOptions) string {
|
||||
if strings.TrimSpace(observation.ReporterNodeID) != "" &&
|
||||
strings.TrimSpace(candidate.NodeID) != "" &&
|
||||
strings.EqualFold(strings.TrimSpace(observation.ReporterNodeID), strings.TrimSpace(candidate.NodeID)) {
|
||||
return "self"
|
||||
}
|
||||
reporterRegion := strings.TrimSpace(observation.ReporterRegion)
|
||||
if reporterRegion == "" && strings.EqualFold(strings.TrimSpace(observation.Source), "local_vpn_fabric_session") {
|
||||
reporterRegion = strings.TrimSpace(opts.PreferredRegion)
|
||||
}
|
||||
candidateRegion := strings.TrimSpace(candidate.Region)
|
||||
if reporterRegion == "" || candidateRegion == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(reporterRegion, candidateRegion) {
|
||||
return "same_area"
|
||||
}
|
||||
return "cross_area"
|
||||
}
|
||||
|
||||
func endpointRequiresExternalNetworkVerification(candidate PeerEndpointCandidate) bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Reachability), "public") {
|
||||
return false
|
||||
}
|
||||
if len(candidate.Metadata) == 0 || !json.Valid(candidate.Metadata) {
|
||||
return false
|
||||
}
|
||||
var metadata struct {
|
||||
VerificationScope string `json:"verification_scope,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(candidate.Metadata, &metadata); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(metadata.VerificationScope), "external-network-required")
|
||||
}
|
||||
|
||||
func applyLocalityPreferences(candidate PeerEndpointCandidate, opts EndpointCandidateScoreOptions, score int, reasons []string) (int, []string) {
|
||||
locality := endpointCandidateLocality(candidate, opts)
|
||||
switch locality {
|
||||
case "local_segment":
|
||||
score += 65
|
||||
reasons = append(reasons, "locality:local_segment")
|
||||
case "same_nat":
|
||||
score += 45
|
||||
reasons = append(reasons, "locality:same_nat")
|
||||
case "private_scoped":
|
||||
score += 20
|
||||
reasons = append(reasons, "locality:private_scoped")
|
||||
case "private_unscoped":
|
||||
score -= 35
|
||||
reasons = append(reasons, "locality:private_unscoped")
|
||||
case "private_foreign":
|
||||
score -= 90
|
||||
reasons = append(reasons, "locality:private_foreign")
|
||||
case "public_fallback":
|
||||
score -= 5
|
||||
reasons = append(reasons, "locality:public_fallback")
|
||||
}
|
||||
return score, reasons
|
||||
}
|
||||
|
||||
func endpointCandidateLocality(candidate PeerEndpointCandidate, opts EndpointCandidateScoreOptions) string {
|
||||
reachability := strings.ToLower(strings.TrimSpace(candidate.Reachability))
|
||||
connectivity := strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode))
|
||||
isPrivate := reachability == "private" || connectivity == "private_lan" || endpointHasPrivateHost(candidate.Address)
|
||||
if !isPrivate {
|
||||
if reachability == "public" && endpointRequiresExternalNetworkVerification(candidate) {
|
||||
return "public_fallback"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
metadata := decodeEndpointCandidateLocalityMetadata(candidate.Metadata)
|
||||
localityGroupID := strings.TrimSpace(opts.LocalityGroupID)
|
||||
if localityGroupID != "" && strings.TrimSpace(metadata.LocalityGroupID) != "" &&
|
||||
strings.EqualFold(strings.TrimSpace(metadata.LocalityGroupID), localityGroupID) {
|
||||
return "local_segment"
|
||||
}
|
||||
if opts.LocalNATGroupID != "" && metadata.NATGroupID != "" && strings.EqualFold(metadata.NATGroupID, strings.TrimSpace(opts.LocalNATGroupID)) {
|
||||
return "same_nat"
|
||||
}
|
||||
if strings.TrimSpace(opts.SiteID) != "" && metadata.SiteID != "" && strings.EqualFold(metadata.SiteID, strings.TrimSpace(opts.SiteID)) {
|
||||
return "private_scoped"
|
||||
}
|
||||
if hasPolicyTag(candidate.PolicyTags, "private-lan") || hasPolicyTag(candidate.PolicyTags, "corp-lan") || hasPolicyTag(candidate.PolicyTags, "same-site") {
|
||||
return "private_scoped"
|
||||
}
|
||||
if metadata.LocalityGroupID != "" || metadata.SiteID != "" || metadata.NATGroupID != "" {
|
||||
return "private_foreign"
|
||||
}
|
||||
return "private_unscoped"
|
||||
}
|
||||
|
||||
type endpointCandidateLocalityMetadata struct {
|
||||
SiteID string `json:"site_id,omitempty"`
|
||||
LocalityGroupID string `json:"locality_group_id,omitempty"`
|
||||
NATGroupID string `json:"nat_group_id,omitempty"`
|
||||
}
|
||||
|
||||
func decodeEndpointCandidateLocalityMetadata(raw json.RawMessage) endpointCandidateLocalityMetadata {
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return endpointCandidateLocalityMetadata{}
|
||||
}
|
||||
var metadata endpointCandidateLocalityMetadata
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return endpointCandidateLocalityMetadata{}
|
||||
}
|
||||
metadata.SiteID = strings.TrimSpace(metadata.SiteID)
|
||||
metadata.LocalityGroupID = strings.TrimSpace(metadata.LocalityGroupID)
|
||||
metadata.NATGroupID = strings.TrimSpace(metadata.NATGroupID)
|
||||
return metadata
|
||||
}
|
||||
|
||||
func hasPolicyTag(tags []string, needle string) bool {
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(strings.TrimSpace(tag), needle) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -526,6 +527,161 @@ func TestRankPeerEndpointCandidatesSpreadsFreshCapacityPressure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankPeerEndpointCandidatesIgnoresSameAreaPublicVerificationFailures(t *testing.T) {
|
||||
now := time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC)
|
||||
candidate := PeerEndpointCandidate{
|
||||
EndpointID: "test-1-public",
|
||||
NodeID: "test-1",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://94.141.118.222:19191",
|
||||
Reachability: "public",
|
||||
NATType: "port_restricted",
|
||||
ConnectivityMode: "direct",
|
||||
Region: "home-test",
|
||||
Priority: 2,
|
||||
Metadata: json.RawMessage(`{"verification_scope":"external-network-required"}`),
|
||||
}
|
||||
ranked := RankPeerEndpointCandidates([]PeerEndpointCandidate{candidate}, EndpointCandidateScoreOptions{
|
||||
PreferredRegion: "home-test",
|
||||
Now: now,
|
||||
MaxObservationAge: time.Minute,
|
||||
Observations: map[string]EndpointCandidateHealthObservation{
|
||||
"test-1-public": {
|
||||
EndpointID: "test-1-public",
|
||||
ReporterNodeID: "home-1",
|
||||
ReporterRegion: "home-test",
|
||||
FailureCount: 4,
|
||||
LastFailureReason: "context_deadline_exceeded",
|
||||
ReliabilityScore: 20,
|
||||
ObservedAt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(ranked) != 1 {
|
||||
t.Fatalf("ranked length = %d, want 1", len(ranked))
|
||||
}
|
||||
if !containsReason(ranked[0].Reasons, "observation:non_authoritative_same_area_public") {
|
||||
t.Fatalf("same-area public observation should be non-authoritative: %+v", ranked[0].Reasons)
|
||||
}
|
||||
if containsReason(ranked[0].Reasons, "history:failure") || containsReason(ranked[0].Reasons, "failure:recent") {
|
||||
t.Fatalf("same-area public failures should not demote candidate: %+v", ranked[0].Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankPeerEndpointCandidatesUsesCrossAreaPublicVerificationFailures(t *testing.T) {
|
||||
now := time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC)
|
||||
candidate := PeerEndpointCandidate{
|
||||
EndpointID: "test-1-public",
|
||||
NodeID: "test-1",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://94.141.118.222:19191",
|
||||
Reachability: "public",
|
||||
NATType: "port_restricted",
|
||||
ConnectivityMode: "direct",
|
||||
Region: "home-test",
|
||||
Priority: 2,
|
||||
Metadata: json.RawMessage(`{"verification_scope":"external-network-required"}`),
|
||||
}
|
||||
ranked := RankPeerEndpointCandidates([]PeerEndpointCandidate{candidate}, EndpointCandidateScoreOptions{
|
||||
PreferredRegion: "usa",
|
||||
Now: now,
|
||||
MaxObservationAge: time.Minute,
|
||||
Observations: map[string]EndpointCandidateHealthObservation{
|
||||
"test-1-public": {
|
||||
EndpointID: "test-1-public",
|
||||
ReporterNodeID: "usa-los-1",
|
||||
ReporterRegion: "usa",
|
||||
FailureCount: 4,
|
||||
LastFailureReason: "context_deadline_exceeded",
|
||||
ReliabilityScore: 20,
|
||||
ObservedAt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(ranked) != 1 {
|
||||
t.Fatalf("ranked length = %d, want 1", len(ranked))
|
||||
}
|
||||
if !containsReason(ranked[0].Reasons, "observation_scope:cross_area") {
|
||||
t.Fatalf("cross-area scope missing: %+v", ranked[0].Reasons)
|
||||
}
|
||||
if !containsReason(ranked[0].Reasons, "history:failure") || !containsReason(ranked[0].Reasons, "failure:recent") {
|
||||
t.Fatalf("cross-area public failures should demote candidate: %+v", ranked[0].Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankPeerEndpointCandidatesPrefersScopedPrivateLANOverPublic(t *testing.T) {
|
||||
now := time.Date(2026, 5, 19, 13, 0, 0, 0, time.UTC)
|
||||
ranked := RankPeerEndpointCandidates([]PeerEndpointCandidate{
|
||||
{
|
||||
EndpointID: "node-b-public",
|
||||
NodeID: "node-b",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://94.141.118.222:19191",
|
||||
Reachability: "public",
|
||||
ConnectivityMode: "direct",
|
||||
NATType: "port_restricted",
|
||||
Priority: 2,
|
||||
},
|
||||
{
|
||||
EndpointID: "node-b-private",
|
||||
NodeID: "node-b",
|
||||
Transport: "lan_quic",
|
||||
Address: "quic://192.168.200.61:19134",
|
||||
Reachability: "private",
|
||||
ConnectivityMode: "private_lan",
|
||||
Priority: 1,
|
||||
Metadata: json.RawMessage(`{"locality_group_id":"home-test","nat_group_id":"home-router"}`),
|
||||
},
|
||||
}, EndpointCandidateScoreOptions{
|
||||
PreferredRegion: "home-test",
|
||||
LocalityGroupID: "home-test",
|
||||
LocalNATGroupID: "home-router",
|
||||
Now: now,
|
||||
})
|
||||
if ranked[0].Candidate.EndpointID != "node-b-private" {
|
||||
t.Fatalf("top endpoint = %q, want node-b-private: %+v", ranked[0].Candidate.EndpointID, ranked)
|
||||
}
|
||||
if !containsReason(ranked[0].Reasons, "locality:local_segment") {
|
||||
t.Fatalf("missing locality group reason: %+v", ranked[0].Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankPeerEndpointCandidatesPenalizesForeignPrivateEndpoint(t *testing.T) {
|
||||
now := time.Date(2026, 5, 19, 13, 0, 0, 0, time.UTC)
|
||||
ranked := RankPeerEndpointCandidates([]PeerEndpointCandidate{
|
||||
{
|
||||
EndpointID: "node-b-public",
|
||||
NodeID: "node-b",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://94.141.118.222:19191",
|
||||
Reachability: "public",
|
||||
ConnectivityMode: "direct",
|
||||
Priority: 2,
|
||||
},
|
||||
{
|
||||
EndpointID: "node-b-private-foreign",
|
||||
NodeID: "node-b",
|
||||
Transport: "lan_quic",
|
||||
Address: "quic://10.24.10.20:19443",
|
||||
Reachability: "private",
|
||||
ConnectivityMode: "private_lan",
|
||||
Priority: 1,
|
||||
Metadata: json.RawMessage(`{"locality_group_id":"other-site","nat_group_id":"other-nat"}`),
|
||||
},
|
||||
}, EndpointCandidateScoreOptions{
|
||||
PreferredRegion: "home-test",
|
||||
LocalityGroupID: "home-test",
|
||||
LocalNATGroupID: "home-router",
|
||||
Now: now,
|
||||
})
|
||||
if ranked[0].Candidate.EndpointID != "node-b-public" {
|
||||
t.Fatalf("top endpoint = %q, want node-b-public: %+v", ranked[0].Candidate.EndpointID, ranked)
|
||||
}
|
||||
if !containsReason(ranked[1].Reasons, "locality:private_foreign") {
|
||||
t.Fatalf("missing foreign private reason: %+v", ranked[1].Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func containsReason(reasons []string, reason string) bool {
|
||||
for _, item := range reasons {
|
||||
if item == reason {
|
||||
|
||||
@@ -23,7 +23,7 @@ func FabricTransportTargetFromRegistryEndpoint(endpoint FabricRegistryEndpoint)
|
||||
return FabricTransportTarget{
|
||||
EndpointID: strings.TrimSpace(endpoint.EndpointID),
|
||||
PeerID: strings.TrimSpace(endpoint.EndpointID),
|
||||
Endpoint: strings.TrimSpace(endpoint.Address),
|
||||
Endpoint: fabricControlEndpointAddress(endpoint),
|
||||
Transport: strings.TrimSpace(endpoint.Transport),
|
||||
PeerCertSHA256: strings.TrimSpace(endpoint.PeerCertSHA256),
|
||||
Timeout: 5 * time.Second,
|
||||
@@ -32,6 +32,28 @@ func FabricTransportTargetFromRegistryEndpoint(endpoint FabricRegistryEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func fabricControlEndpointAddress(endpoint FabricRegistryEndpoint) string {
|
||||
if mapped := fabricControlMetadataString(endpoint.Metadata, "maps_to"); mapped != "" {
|
||||
if strings.Contains(mapped, "://") {
|
||||
return mapped
|
||||
}
|
||||
return "quic://" + mapped
|
||||
}
|
||||
return strings.TrimSpace(endpoint.Address)
|
||||
}
|
||||
|
||||
func fabricControlMetadataString(raw json.RawMessage, key string) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := metadata[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func SendFabricControlForward(ctx context.Context, transport FabricTransport, endpoint FabricRegistryEndpoint, payload []byte, timeout time.Duration) (FabricControlForwardResult, error) {
|
||||
if transport == nil {
|
||||
return FabricControlForwardResult{}, fmt.Errorf("fabric control transport is unavailable")
|
||||
|
||||
@@ -137,7 +137,7 @@ type FabricAdjacency struct {
|
||||
PressurePercent int
|
||||
Healthy bool
|
||||
PassiveOutbound bool
|
||||
LocalSegmentID string
|
||||
LocalityGroupID string
|
||||
NATGroupID string
|
||||
LastObservedAt time.Time
|
||||
LastFailureReason string
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto"
|
||||
)
|
||||
|
||||
func ProbeFabricTarget(ctx context.Context, target FabricTransportTarget) (time.Duration, error) {
|
||||
target.Timeout = positiveDurationOr(target.Timeout, 2*time.Second)
|
||||
target.InboundBuffer = positiveIntOr(target.InboundBuffer, 2)
|
||||
target.ErrorBuffer = positiveIntOr(target.ErrorBuffer, 2)
|
||||
|
||||
transport, normalizedTarget, err := FabricTransportForTarget(target, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
session, err := transport.Connect(ctx, normalizedTarget)
|
||||
if err != nil {
|
||||
_ = transport.Close()
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
_ = session.Close()
|
||||
_ = transport.Close()
|
||||
}()
|
||||
|
||||
startedAt := time.Now()
|
||||
sequence := uint64(startedAt.UnixNano())
|
||||
if err := session.Send(ctx, fabricproto.Frame{
|
||||
Type: fabricproto.FramePing,
|
||||
TrafficClass: fabricproto.TrafficClassReliable,
|
||||
Sequence: sequence,
|
||||
Payload: []byte("fabric-live-probe"),
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case frame, ok := <-session.Frames():
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("fabric live probe session closed")
|
||||
}
|
||||
if frame.Type == fabricproto.FramePong && frame.Sequence == sequence {
|
||||
return time.Since(startedAt), nil
|
||||
}
|
||||
case err, ok := <-session.Errors():
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("fabric live probe error channel closed")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func positiveDurationOr(value time.Duration, fallback time.Duration) time.Duration {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func positiveIntOr(value int, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func StartQUICFabricServer(ctx context.Context, cfg QUICFabricServerConfig) (*QU
|
||||
if len(tlsConfig.NextProtos) == 0 {
|
||||
tlsConfig.NextProtos = []string{fabricQUICNextProto}
|
||||
}
|
||||
listener, err := quic.ListenAddr(cfg.ListenAddr, tlsConfig, cfg.QUICConfig)
|
||||
listener, err := quic.ListenAddr(cfg.ListenAddr, tlsConfig, defaultQUICFabricConfig(cfg.QUICConfig))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (s *QUICFabricServer) handleConn(ctx context.Context, conn *quic.Conn) {
|
||||
|
||||
func (s *QUICFabricServer) handleStream(ctx context.Context, conn *quic.Conn, stream *quic.Stream) {
|
||||
session := fabricproto.NewSession(fabricproto.SessionConfig{})
|
||||
sender := quicStreamFrameSender{stream: stream}
|
||||
sender := &quicStreamFrameSender{stream: stream}
|
||||
defer func() { _ = stream.Close() }()
|
||||
s.logFabricSession(FabricSessionEventLogEntry{
|
||||
Event: "fabric_session_quic_stream_opened",
|
||||
@@ -207,7 +207,7 @@ type quicStreamFrameSender struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s quicStreamFrameSender) SendFrame(ctx context.Context, frame fabricproto.Frame) error {
|
||||
func (s *quicStreamFrameSender) SendFrame(ctx context.Context, frame fabricproto.Frame) error {
|
||||
if s.stream == nil {
|
||||
return fmt.Errorf("quic fabric stream is closed")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const fabricQUICNextProto = "rap-fabric-data-session-v1"
|
||||
const fabricQUICReverseHelloPrefix = "rap-fabric-reverse-hello-v1:"
|
||||
const defaultQUICFabricConnIdleTTL = 5 * time.Minute
|
||||
const defaultQUICFabricMaxStreamsPerConn = 64
|
||||
const defaultQUICFabricHandshakeIdleTimeout = 8 * time.Second
|
||||
const defaultQUICFabricMaxIdleTimeout = 90 * time.Second
|
||||
const defaultQUICFabricKeepAlivePeriod = 15 * time.Second
|
||||
const ErrQUICFabricStreamLimitReached = quicFabricError("quic fabric stream limit reached")
|
||||
|
||||
type quicFabricError string
|
||||
@@ -31,20 +34,20 @@ func (e quicFabricError) Error() string {
|
||||
}
|
||||
|
||||
type QUICFabricTransport struct {
|
||||
Config *quic.Config
|
||||
LocalPeerID string
|
||||
IdleTTL time.Duration
|
||||
MaxStreamsPerConn int
|
||||
DialAddr func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error)
|
||||
mu sync.Mutex
|
||||
conns map[string]*quicFabricConnEntry
|
||||
reverseConns map[string]*quicFabricConnEntry
|
||||
inboundProductionHandler func(context.Context, ProductionEnvelope) (ProductionForwardResult, error)
|
||||
inboundWebIngressHandler func(context.Context, []byte) ([]byte, error)
|
||||
Config *quic.Config
|
||||
LocalPeerID string
|
||||
IdleTTL time.Duration
|
||||
MaxStreamsPerConn int
|
||||
DialAddr func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error)
|
||||
mu sync.Mutex
|
||||
conns map[string]*quicFabricConnEntry
|
||||
reverseConns map[string]*quicFabricConnEntry
|
||||
inboundProductionHandler func(context.Context, ProductionEnvelope) (ProductionForwardResult, error)
|
||||
inboundWebIngressHandler func(context.Context, []byte) ([]byte, error)
|
||||
inboundFabricControlHandler func(context.Context, []byte) ([]byte, error)
|
||||
inboundSyntheticHandler func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error)
|
||||
logger FabricSessionEventLogger
|
||||
stats QUICFabricTransportStats
|
||||
inboundSyntheticHandler func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error)
|
||||
logger FabricSessionEventLogger
|
||||
stats QUICFabricTransportStats
|
||||
}
|
||||
|
||||
type QUICFabricTransportStats struct {
|
||||
@@ -109,7 +112,25 @@ type quicFabricConnEntry struct {
|
||||
}
|
||||
|
||||
func NewQUICFabricTransport(config *quic.Config) *QUICFabricTransport {
|
||||
return &QUICFabricTransport{Config: config, IdleTTL: defaultQUICFabricConnIdleTTL, MaxStreamsPerConn: defaultQUICFabricMaxStreamsPerConn, conns: map[string]*quicFabricConnEntry{}, reverseConns: map[string]*quicFabricConnEntry{}}
|
||||
return &QUICFabricTransport{Config: defaultQUICFabricConfig(config), IdleTTL: defaultQUICFabricConnIdleTTL, MaxStreamsPerConn: defaultQUICFabricMaxStreamsPerConn, conns: map[string]*quicFabricConnEntry{}, reverseConns: map[string]*quicFabricConnEntry{}}
|
||||
}
|
||||
|
||||
func defaultQUICFabricConfig(config *quic.Config) *quic.Config {
|
||||
out := &quic.Config{}
|
||||
if config != nil {
|
||||
clone := *config
|
||||
out = &clone
|
||||
}
|
||||
if out.HandshakeIdleTimeout <= 0 {
|
||||
out.HandshakeIdleTimeout = defaultQUICFabricHandshakeIdleTimeout
|
||||
}
|
||||
if out.MaxIdleTimeout <= 0 {
|
||||
out.MaxIdleTimeout = defaultQUICFabricMaxIdleTimeout
|
||||
}
|
||||
if out.KeepAlivePeriod <= 0 {
|
||||
out.KeepAlivePeriod = defaultQUICFabricKeepAlivePeriod
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *QUICFabricTransport) SetInboundHandlers(production func(context.Context, ProductionEnvelope) (ProductionForwardResult, error), synthetic func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error), logger FabricSessionEventLogger) {
|
||||
@@ -150,6 +171,7 @@ func quicTLSConfigForTarget(target FabricTransportTarget) *tls.Config {
|
||||
expectedFingerprint := normalizeCertSHA256(target.PeerCertSHA256)
|
||||
config := &tls.Config{NextProtos: []string{fabricQUICNextProto}}
|
||||
if expectedFingerprint == "" {
|
||||
config.InsecureSkipVerify = true
|
||||
return config
|
||||
}
|
||||
config.InsecureSkipVerify = true
|
||||
@@ -198,9 +220,12 @@ func (t *QUICFabricTransport) Connect(ctx context.Context, target FabricTranspor
|
||||
stream, err := conn.OpenStreamSync(ctx)
|
||||
if err != nil {
|
||||
t.releaseStream(connKey)
|
||||
t.evictConnByKey(connKey, conn)
|
||||
t.evictConn(target, conn)
|
||||
if closeConn {
|
||||
_ = conn.CloseWithError(1, "open stream failed")
|
||||
} else {
|
||||
_ = conn.CloseWithError(1, "cached stream open failed")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -680,8 +705,28 @@ func (t *QUICFabricTransport) evictConn(target FabricTransportTarget, conn *quic
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *QUICFabricTransport) evictConnByKey(key string, conn *quic.Conn) {
|
||||
if t == nil || key == "" || conn == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if strings.HasPrefix(key, "reverse\x00") {
|
||||
peerID := strings.TrimPrefix(key, "reverse\x00")
|
||||
if entry := t.reverseConns[peerID]; entry != nil && entry.conn == conn {
|
||||
delete(t.reverseConns, peerID)
|
||||
t.stats.ClosedEvicted++
|
||||
}
|
||||
return
|
||||
}
|
||||
if entry := t.conns[key]; entry != nil && entry.conn == conn {
|
||||
delete(t.conns, key)
|
||||
t.stats.ClosedEvicted++
|
||||
}
|
||||
}
|
||||
|
||||
func (t *QUICFabricTransport) pruneIdleLocked(now time.Time) {
|
||||
if t == nil || len(t.conns) == 0 {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
ttl := t.IdleTTL
|
||||
@@ -897,7 +942,13 @@ func (s *quicFabricSession) Send(ctx context.Context, frame fabricproto.Frame) e
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
s.applyWriteDeadline(ctx)
|
||||
return fabricproto.WriteFrame(s.stream, frame)
|
||||
if err := fabricproto.WriteFrame(s.stream, frame); err != nil {
|
||||
if s.transport != nil && s.conn != nil {
|
||||
s.transport.evictConnByKey(s.connKey, s.conn)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *quicFabricSession) Frames() <-chan fabricproto.Frame {
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
type FabricRoutePlannerConfig struct {
|
||||
ClusterID string
|
||||
LocalNodeID string
|
||||
LocalSegmentID string
|
||||
LocalityGroupID string
|
||||
LocalNATGroupID string
|
||||
DefaultCapacity int
|
||||
RelayCapacity int
|
||||
@@ -34,13 +34,13 @@ type FabricRoutePlannerConfig struct {
|
||||
}
|
||||
|
||||
type FabricCandidateMetadata struct {
|
||||
LocalSegmentID string `json:"local_segment_id,omitempty"`
|
||||
NATGroupID string `json:"nat_group_id,omitempty"`
|
||||
RelayNodeID string `json:"relay_node_id,omitempty"`
|
||||
RelayEndpoint string `json:"relay_endpoint,omitempty"`
|
||||
ViaNodeID string `json:"via_node_id,omitempty"`
|
||||
STUNServer string `json:"stun_server,omitempty"`
|
||||
ICEFoundation string `json:"ice_foundation,omitempty"`
|
||||
LocalityGroupID string `json:"locality_group_id,omitempty"`
|
||||
NATGroupID string `json:"nat_group_id,omitempty"`
|
||||
RelayNodeID string `json:"relay_node_id,omitempty"`
|
||||
RelayEndpoint string `json:"relay_endpoint,omitempty"`
|
||||
ViaNodeID string `json:"via_node_id,omitempty"`
|
||||
STUNServer string `json:"stun_server,omitempty"`
|
||||
ICEFoundation string `json:"ice_foundation,omitempty"`
|
||||
}
|
||||
|
||||
func FabricRouteSetForPeerEndpointCandidates(targetNodeID string, candidates []PeerEndpointCandidate, cfg FabricRoutePlannerConfig) FabricRouteSet {
|
||||
@@ -141,7 +141,7 @@ func fabricRouteModeForPeerEndpointCandidate(candidate PeerEndpointCandidate, me
|
||||
}
|
||||
reachability := strings.ToLower(strings.TrimSpace(candidate.Reachability))
|
||||
connectivity := strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode))
|
||||
if sameLocalSegment(metadata, cfg) || sameNATGroup(metadata, cfg) {
|
||||
if sameLocalityGroup(metadata, cfg) || sameNATGroup(metadata, cfg) {
|
||||
return FabricRouteLAN
|
||||
}
|
||||
if reachability == FabricCandidateReachabilityRelay || connectivity == FabricConnectivityRelayRequired || strings.TrimSpace(metadata.RelayEndpoint) != "" {
|
||||
@@ -240,12 +240,12 @@ func candidatePressureCount(endpointID string, cfg FabricRoutePlannerConfig) int
|
||||
return 0
|
||||
}
|
||||
|
||||
func sameLocalSegment(metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) bool {
|
||||
localSegment := strings.TrimSpace(cfg.LocalSegmentID)
|
||||
if localSegment == "" {
|
||||
func sameLocalityGroup(metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) bool {
|
||||
localityGroup := strings.TrimSpace(cfg.LocalityGroupID)
|
||||
if localityGroup == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(metadata.LocalSegmentID), localSegment)
|
||||
return strings.EqualFold(strings.TrimSpace(metadata.LocalityGroupID), localityGroup)
|
||||
}
|
||||
|
||||
func sameNATGroup(metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) bool {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFabricRouteSetForPeerEndpointCandidatesPrefersLocalLAN(t *testing.T) {
|
||||
metadata, _ := json.Marshal(FabricCandidateMetadata{LocalSegmentID: "site-a", NATGroupID: "nat-a"})
|
||||
metadata, _ := json.Marshal(FabricCandidateMetadata{LocalityGroupID: "home-lan", NATGroupID: "nat-a"})
|
||||
routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{
|
||||
{
|
||||
EndpointID: "node-b-public",
|
||||
@@ -31,7 +31,7 @@ func TestFabricRouteSetForPeerEndpointCandidatesPrefersLocalLAN(t *testing.T) {
|
||||
}, FabricRoutePlannerConfig{
|
||||
ClusterID: "cluster-1",
|
||||
LocalNodeID: "node-a",
|
||||
LocalSegmentID: "site-a",
|
||||
LocalityGroupID: "home-lan",
|
||||
DefaultCapacity: 200,
|
||||
Now: time.Unix(100, 0).UTC(),
|
||||
})
|
||||
@@ -172,7 +172,7 @@ func TestFabricRouteSetForPeerEndpointCandidatesRejectsNonQUIC(t *testing.T) {
|
||||
ConnectivityMode: "direct",
|
||||
},
|
||||
{
|
||||
EndpointID: "node-b-legacy-relay",
|
||||
EndpointID: "node-b-compat-relay",
|
||||
NodeID: "node-b",
|
||||
Transport: "relay",
|
||||
Address: "quic://node-r:19443",
|
||||
@@ -180,7 +180,7 @@ func TestFabricRouteSetForPeerEndpointCandidatesRejectsNonQUIC(t *testing.T) {
|
||||
ConnectivityMode: "relay_required",
|
||||
},
|
||||
{
|
||||
EndpointID: "node-b-legacy-reverse",
|
||||
EndpointID: "node-b-compat-reverse",
|
||||
NodeID: "node-b",
|
||||
Transport: "outbound_reverse",
|
||||
Address: "quic://node-b:19443",
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,7 +29,6 @@ type FabricTransportTarget struct {
|
||||
Endpoint string
|
||||
Transport string
|
||||
Token string
|
||||
Header http.Header
|
||||
TLSConfig *tls.Config
|
||||
PeerCertSHA256 string
|
||||
Timeout time.Duration
|
||||
|
||||
@@ -11,6 +11,8 @@ const DefaultWarmPeerLimit = 8
|
||||
|
||||
type PeerCacheConfig struct {
|
||||
Local PeerIdentity
|
||||
LocalityGroupID string
|
||||
LocalNATGroupID string
|
||||
PeerEndpoints map[string]string
|
||||
PeerEndpointCandidates map[string][]PeerEndpointCandidate
|
||||
PeerEndpointObservations map[string]EndpointCandidateHealthObservation
|
||||
@@ -59,11 +61,12 @@ type PeerCacheEntry struct {
|
||||
BestCandidateScore int `json:"best_candidate_score,omitempty"`
|
||||
BestScoreReasons []string `json:"best_score_reasons,omitempty"`
|
||||
BestPeerCertSHA256 string `json:"best_peer_cert_sha256,omitempty"`
|
||||
PublicIngressCount int `json:"public_ingress_count,omitempty"`
|
||||
EndpointCandidates []PeerEndpointCandidate `json:"endpoint_candidates,omitempty"`
|
||||
RendezvousLeaseID string `json:"rendezvous_lease_id,omitempty"`
|
||||
RelayNodeID string `json:"relay_node_id,omitempty"`
|
||||
RelayEndpoint string `json:"relay_endpoint,omitempty"`
|
||||
RelayControl bool `json:"relay_control"`
|
||||
RelayQUIC bool `json:"relay_quic"`
|
||||
}
|
||||
|
||||
type peerCacheBuildEntry struct {
|
||||
@@ -119,6 +122,8 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache {
|
||||
scored := RankPeerEndpointCandidates(candidates, EndpointCandidateScoreOptions{
|
||||
ChannelClass: SyntheticChannelFabricControl,
|
||||
PreferredRegion: cfg.PreferredRegion,
|
||||
LocalityGroupID: cfg.LocalityGroupID,
|
||||
LocalNATGroupID: cfg.LocalNATGroupID,
|
||||
Now: now,
|
||||
MaxVerificationAge: time.Hour,
|
||||
Observations: cfg.PeerEndpointObservations,
|
||||
@@ -129,6 +134,7 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache {
|
||||
for _, scoredCandidate := range scored {
|
||||
entry.EndpointCandidates = append(entry.EndpointCandidates, scoredCandidate.Candidate)
|
||||
}
|
||||
entry.PublicIngressCount = publicIngressCountFromCandidates(entry.EndpointCandidates)
|
||||
entry.BestCandidateID = scored[0].Candidate.EndpointID
|
||||
entry.BestCandidateAddr = scored[0].Candidate.Address
|
||||
entry.BestTransport = scored[0].Candidate.Transport
|
||||
@@ -197,9 +203,9 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache {
|
||||
entry.RendezvousLeaseID = lease.LeaseID
|
||||
entry.RelayNodeID = lease.RelayNodeID
|
||||
entry.RelayEndpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/")
|
||||
entry.RelayControl = true
|
||||
entry.RelayQUIC = true
|
||||
entry.CandidateCount = maxInt(entry.CandidateCount, 1)
|
||||
entry.ConnectivityModes = mergeStrings(entry.ConnectivityModes, []string{firstNonEmpty(lease.ConnectivityMode, "relay_required"), "relay_control"})
|
||||
entry.ConnectivityModes = mergeStrings(entry.ConnectivityModes, []string{firstNonEmpty(lease.ConnectivityMode, "relay_required"), "relay_quic"})
|
||||
if useLeaseEndpoint {
|
||||
if localRelay {
|
||||
entry.BestTransport = "reverse_quic"
|
||||
@@ -225,7 +231,7 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache {
|
||||
entry.Endpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/")
|
||||
}
|
||||
entry.EndpointCount = maxInt(entry.EndpointCount, 1)
|
||||
entry.ConnectivityModes = mergeStrings(entry.ConnectivityModes, []string{"relay_control"})
|
||||
entry.ConnectivityModes = mergeStrings(entry.ConnectivityModes, []string{"relay_quic"})
|
||||
}
|
||||
}
|
||||
out := make([]peerCacheBuildEntry, 0, len(entries))
|
||||
@@ -334,13 +340,37 @@ func warmPeerPriority(entry peerCacheBuildEntry) int {
|
||||
if entry.bestScore > 0 {
|
||||
score += entry.bestScore
|
||||
}
|
||||
if entry.RelayControl {
|
||||
if entry.RelayQUIC {
|
||||
score += 300
|
||||
}
|
||||
if entry.PublicIngressCount > 0 {
|
||||
score += entry.PublicIngressCount * 75
|
||||
}
|
||||
score += entry.CandidateCount
|
||||
return score
|
||||
}
|
||||
|
||||
func publicIngressCountFromCandidates(candidates []PeerEndpointCandidate) int {
|
||||
if len(candidates) == 0 {
|
||||
return 0
|
||||
}
|
||||
distinct := map[string]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if strings.ToLower(strings.TrimSpace(candidate.Reachability)) != "public" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(strings.TrimSpace(candidate.Transport)), "quic") {
|
||||
continue
|
||||
}
|
||||
address := strings.TrimSpace(candidate.Address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
distinct[address] = struct{}{}
|
||||
}
|
||||
return len(distinct)
|
||||
}
|
||||
|
||||
func warmPeerReason(entry peerCacheBuildEntry) string {
|
||||
if entry.adjacentRoutePeer {
|
||||
return "route_adjacent"
|
||||
@@ -348,7 +378,7 @@ func warmPeerReason(entry peerCacheBuildEntry) string {
|
||||
if entry.RecoverySeed {
|
||||
return "recovery_seed"
|
||||
}
|
||||
if entry.RelayControl {
|
||||
if entry.RelayQUIC {
|
||||
return "rendezvous_lease"
|
||||
}
|
||||
if entry.BestCandidateID != "" {
|
||||
|
||||
@@ -98,6 +98,9 @@ func TestPeerCacheUsesBestEndpointCandidate(t *testing.T) {
|
||||
if entry.BestCandidateID != "node-b-public" || !entry.Warm {
|
||||
t.Fatalf("unexpected candidate selection: %+v", entry)
|
||||
}
|
||||
if entry.PublicIngressCount != 1 {
|
||||
t.Fatalf("public ingress count = %d, want 1", entry.PublicIngressCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCacheAppliesEndpointHealthObservations(t *testing.T) {
|
||||
@@ -224,3 +227,12 @@ func peerCacheEntryByID(snapshot PeerCacheSnapshot, nodeID string) (PeerCacheEnt
|
||||
}
|
||||
return PeerCacheEntry{}, false
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
PeerTransportModeCorporateLAN = "corporate_lan"
|
||||
PeerTransportModeOutboundOnly = "outbound_only"
|
||||
PeerTransportModeRelayRequired = "relay_required"
|
||||
PeerTransportModeRelayControl = "relay_control"
|
||||
PeerTransportModeRelayQUIC = "relay_quic"
|
||||
PeerTransportModeUnknown = "unknown"
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ type PeerConnectionIntentPlan struct {
|
||||
CorporateLANCount int `json:"corporate_lan_count"`
|
||||
OutboundOnlyCount int `json:"outbound_only_count"`
|
||||
RelayRequiredCount int `json:"relay_required_count"`
|
||||
RelayControlCount int `json:"relay_control_count"`
|
||||
RelayQUICCount int `json:"relay_quic_count"`
|
||||
RendezvousRequiredCount int `json:"rendezvous_required_count"`
|
||||
RendezvousResolvedCount int `json:"rendezvous_resolved_count"`
|
||||
RendezvousLeaseCount int `json:"rendezvous_lease_count"`
|
||||
@@ -113,8 +113,8 @@ func PlanPeerConnectionIntents(cfg PeerConnectionIntentPlanConfig) PeerConnectio
|
||||
RendezvousLeaseID: entry.RendezvousLeaseID,
|
||||
RelayNodeID: entry.RelayNodeID,
|
||||
RelayEndpoint: entry.RelayEndpoint,
|
||||
RelayCandidate: entry.RelayControl,
|
||||
ControlPlaneOnly: entry.RelayControl,
|
||||
RelayCandidate: entry.RelayQUIC,
|
||||
ControlPlaneOnly: entry.RelayQUIC,
|
||||
RecoverySeed: candidate.RecoverySeed || entry.RecoverySeed,
|
||||
Priority: candidate.Priority,
|
||||
GeneratedAt: now,
|
||||
@@ -163,8 +163,8 @@ func PlanPeerConnectionIntents(cfg PeerConnectionIntentPlanConfig) PeerConnectio
|
||||
plan.OutboundOnlyCount++
|
||||
case PeerTransportModeRelayRequired:
|
||||
plan.RelayRequiredCount++
|
||||
case PeerTransportModeRelayControl:
|
||||
plan.RelayControlCount++
|
||||
case PeerTransportModeRelayQUIC:
|
||||
plan.RelayQUICCount++
|
||||
}
|
||||
if intent.RequiresRendezvous {
|
||||
plan.RendezvousRequiredCount++
|
||||
@@ -266,7 +266,7 @@ func applyRendezvousLease(intent *PeerConnectionIntent, lease PeerRendezvousLeas
|
||||
} else {
|
||||
intent.Transport = firstNonEmpty(lease.Transport, "relay_quic")
|
||||
}
|
||||
intent.TransportMode = PeerTransportModeRelayControl
|
||||
intent.TransportMode = PeerTransportModeRelayQUIC
|
||||
intent.RequiresRendezvous = false
|
||||
intent.RendezvousResolved = true
|
||||
intent.DirectCandidate = false
|
||||
|
||||
@@ -170,11 +170,11 @@ func TestPeerConnectionIntentsResolveRendezvousWithRelayLease(t *testing.T) {
|
||||
Now: now,
|
||||
})
|
||||
|
||||
if plan.IntentCount != 1 || plan.RelayControlCount != 1 || plan.RendezvousResolvedCount != 1 || plan.RendezvousRequiredCount != 0 {
|
||||
if plan.IntentCount != 1 || plan.RelayQUICCount != 1 || plan.RendezvousResolvedCount != 1 || plan.RendezvousRequiredCount != 0 {
|
||||
t.Fatalf("unexpected relay-control plan counts: %+v", plan)
|
||||
}
|
||||
intent := plan.Intents[0]
|
||||
if intent.TransportMode != PeerTransportModeRelayControl ||
|
||||
if intent.TransportMode != PeerTransportModeRelayQUIC ||
|
||||
intent.Endpoint != "quic://node-r:19443" ||
|
||||
intent.RelayNodeID != "node-r" ||
|
||||
intent.RendezvousLeaseID != "lease-node-b-via-node-r" ||
|
||||
@@ -239,7 +239,7 @@ func TestPeerConnectionIntentsSkipExpiredRendezvousLeaseAndReselect(t *testing.T
|
||||
Now: now,
|
||||
})
|
||||
|
||||
if plan.RendezvousResolvedCount != 1 || plan.RelayControlCount != 1 || plan.RendezvousRequiredCount != 0 {
|
||||
if plan.RendezvousResolvedCount != 1 || plan.RelayQUICCount != 1 || plan.RendezvousRequiredCount != 0 {
|
||||
t.Fatalf("unexpected reselected plan counts: %+v", plan)
|
||||
}
|
||||
intent := plan.Intents[0]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user