From 66dd7da6e69b3bcad51115d1c7d5e017b7ef0dd0 Mon Sep 17 00:00:00 2001 From: Nozilla-X <332770878+Nozilla-X@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:52:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B7=BB=E5=8A=A0=E6=97=A0?= =?UTF-8?q?=E5=A4=B4=20WebAPI=20=E6=94=AF=E6=8C=81=20(#10300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add headless ServiceLib API and replaceable UI host * build(web): package API without bundled WebUI * ci(web): build and test headless API packages * docs(web): document the headless API and optional WebUI * test(web): verify restore and build before API tests * docs(web): align API release examples with current upstream * test(web): cover creating and editing an empty-URL subscription group Verify the API accepts and persists a subscription whose Url and MoreUrl stay empty, that an edit keeps the empty URL and zero interval, and that ServiceLib is untouched. * fix(web): support per-target prerelease update preferences * fix(web): require management key for non-loopback listeners * ci: reduce development Web API build matrix * fix(web): make native self-update rollback atomic and preserve progress * docs(web): remove self-update E2E report * ci(web): decouple Web releases from Linux Desktop * feat(web): support standalone WebUI clients and rename the API to v2rayN.WebAPI - Resolve every REST, login, setup, download, SSE-ticket and EventSource request through one configurable Backend base per WebUI origin, with a strict exact-match origin allowlist and no wildcard or credential CORS. - Run the origin guard before authentication, and keep first-run setup, instance diagnostics and the hosted-UI CSP same-origin as before. - Rename the project directory, csproj, namespace, executable, lock, build identity and self-update target to v2rayN.WebAPI while retaining V2RAYN_WEB_* settings, WebVersion metadata, webVersion fields and the existing release asset contracts. - Migrate API-owned update preference identifiers and reject legacy v2rayN.Web update packages with a manual reinstall instruction. - Require staged native executables to be Linux ELF images for their RID. - Add CORS, update-identity and setup-security tests, and record the standalone-WebUI audit under v2rayN.WebAPI/STANDALONE-WEBUI-AUDIT.md. * Remove WebUI hosting and configuration coupling from WebAPI * Finish WebAPI cleanup and isolate native self-update worker * Keep WebAPI client documentation implementation-neutral * ci(webapi): verify Windows win-x64 build and startup * fix(webapi): validate keys and unify loopback limits * test(webapi): isolate update smoke from systemd runner * feat(webapi): add Windows x64 release and self-update * fix(webapi): verify release manifest version * ci(webapi): publish web packages through the official release flow * ci(webapi): run the web release inside the release-all workflow * webapi: finish the rename and fix shutdown, terminal launch and help - finish the Web -> WebAPI product rename: workflow files build-WebAPI.yml / release-WebAPI.yml (path filters, reusable call sites, container tag), solution entry, health headers X-v2rayn-WebAPI-*, WebAPI.self-update capability, WebAPI-update-progress.json / WebAPI-update-runtime-state.json, WebAPIData/WebAPI-auth.json with a migration from the legacy locations, and the README compatibility notes - end /api/events when the host starts stopping so Kestrel's graceful stop cannot pin the whole shutdown budget with a client attached; a runtime cleanup that starts after the host budget was exhausted now runs a reduced fresh pass instead of skipping Core stop and shared-state saves entirely - record the interactive launch context for detached children and helpers so systemd markers leaked into a terminal (INVOCATION_ID / JOURNAL_STREAM) no longer force foreground mode or misfire the supervised-deployment key check, self-update gating and lifecycle decisions - add --help / -h with the options and common environment variables in all three launcher locales, and keep help flags out of the host arguments * fix(webapi): retry transient file replacements during native updates Windows antivirus and the search indexer can hold a freshly written executable open for a moment, so replacing the installed executable or its build identity could fail with a bare access-denied error and abort the update or its rollback. Retry these replacements for a bounded window and report the operation, target path and cause when they still fail. * ci(webapi): display the release-all Web job as release-WebAPI The reusable-workflow job key still used the pre-rename name, which is the label the Actions UI shows for the nested Web release jobs. * feat(webapi): add win-arm64, linux-riscv64 and linux-loongarch64 coverage - build, package, sign and self-update support for the three architectures: win-arm64 (windows-11-arm native startup smoke), linux-riscv64 (RISE RISC-V runners with the community xujiegb/dotnet-riscv SDK; single-file publishing verified on real hardware) and linux-loongarch64 (the upstream QEMU loong64 VM with the official Loongson SDK, smoke-tested inside the VM) - per-RID native executable validation: ELF machines x86-64/AArch64/RISC-V (0xF3)/LoongArch (0x102) and Windows PE AMD64/ARM64 (0xAA64), behind a single IsSupportedRid gate - release asset map and packager/manifest tables extended; the riscv64 and loongarch64 Core bundles ship without the cronet payload - release manifests now ignore unknown RID entries so a newer manifest cannot be rejected wholesale by an older build * fix(webapi): keep the loongarch64 QEMU smoke out of the packaged publish tree The in-VM smoke ran the app inside the virtiofs-shared publish directory and left runtime files owned by the VM root (v2rayN.WebAPI.instance.lock) that the host runner could not read, so packaging failed with EACCES. The smoke now runs in a VM-local copy, and a host-side guard verifies the shared tree is complete and readable before packaging. * docs(webapi): document the six-platform release matrix Releases now cover linux-x64/arm64/riscv64/loongarch64 and win-x64/arm64; note the per-RID machine validation and the forward-compatible manifest behavior in the API release documentation. * ci(webapi): keep the release-all desktop dispatch exactly upstream build-all.yml dispatches the desktop workflows with "ref": "master" exactly as upstream does. The fork-only github.ref_name change is reverted so the PR leaves the existing release behavior untouched; the WebAPI release stays an additive reusable job in the same workflow. * fix(webapi): probe the effective listener and refuse unverifiable updates GetCurrentHealthUri and GetLauncherUris forced every probe to 127.0.0.1 and fell back to port 5080, so IPv6-only, wildcard and port-only listeners could be checked at the wrong endpoint. The detached update helper then either rolled back a healthy replacement or restarted without being able to verify the old instance, and --stop could probe the wrong process. Both callers now resolve the probe through one WebProbeUriResolver that uses the same effective Kestrel configuration as the host (command line, urls, http/https ports, Kestrel endpoints, preferHostingUrls) and maps each listener to its matching loopback address: "*", "+" and host names probe localhost, 0.0.0.0 probes 127.0.0.1, [::] probes [::1], and loopback literals keep their family. When no loopback HTTP probe exists (HTTPS-only or an explicit non-loopback address), GetWebUpdateTarget reports canInstall=false with maintenance.webUpdateProbeUnavailable and the update is refused before anything is stopped; the helper still accepts only loopback HTTP plan URIs, now including [::1]. --stop reports the new stopUnprobeable result instead of signalling an unverified owner, and the background launcher fails closed with startUnprobeable. Regression: new resolver/listener tests (default, custom port, IPv6-only, HTTP_PORTS, Kestrel override, HTTPS-only) and the real update fixture now covers an IPv6-only replacement, asserts that a failed update leaves the old build running (replacement PID), and proves that an unprobeable instance refuses both the API and --stop while staying up. * ci(webapi): capture the real smoke PID and bound the SIGTERM waits The riscv64 smoke backgrounded "cd && app" and recorded the subshell PID, so the later kill could target the wrapper while the app kept running. Both the riscv64 and loong64 smokes now start the app directly, record the application PID, fail fast when the process exits before health, wait a bounded time for SIGTERM before printing the log and force-killing, and assert a zero exit status. The loong64 VM smoke replaces its fixed two-second sleep with the same exit assertion. * chore(webapi): drop unreferenced legacy scripts bootstrap-xray.sh installed the removed bundled core-bin layout, and test-backup-fixtures.py compared desktop/Web backup ZIPs from before the WebUI decoupling; nothing references either script. * fix(webapi): unify the loopback probe, diagnostics and helper rules The resolver rewrote a configured localhost to 127.0.0.1 while the health diagnostics and the update helper each carried their own copy of the accepted-host rule. Keep localhost as localhost (resolving it covers both families instead of forcing one IPv4 literal) and make WebProbeUriResolver.IsProbeableLoopbackUri the single rule: plain HTTP to a host the diagnostics accept as local (localhost, 127.0.0.1 or ::1). The helper validates plans with that rule, the health endpoint uses the shared WebSetupAccessPolicy.IsLocalDiagnosticSource predicate, and every probe URI the resolver produces satisfies the rule. Unprobeable listeners (HTTPS-only or an explicit non-loopback address) still refuse the self-update up front: canInstall=false with maintenance.webUpdateProbeUnavailable while the current instance keeps running. Regression: localhost stays localhost across the resolver and launcher tests, the probe-rule invariant, the shared diagnostics/probe address rule, a direct unit test of the refusal reason, and a real localhost-listener replacement scenario in the update fixture. * fix(webapi): gate generated probes through the shared loopback rule MapToLoopbackHost accepted every address the OS reports as loopback, which in .NET is the whole 127.0.0.0/8 range, and TryCreateHealthUri returned true without checking the generated URI against the same rule the diagnostics and the update helper use. A listener on 127.0.0.2 therefore passed the up-front canInstall check, but the helper rejected its plan (and refused to restart the previous build for the same reason), so an update could stop the service without bringing anything back. TryCreateHealthUri now gates the constructed probe through IsProbeableLoopbackUri, so loopback aliases are refused before the instance is stopped and a mixed listener list falls through to the next verifiable endpoint. Regression: 127.0.0.2/127.255.255.254 mapping rows, a dedicated loopback-alias rejection test, the alias canInstall=false reason case, the mixed-list fall-through, and a real 127.0.0.2-listener fixture scenario that proves the update is refused while the current instance keeps running. * refactor(webapi): centralize integration tests and simplify release scripts Move the Linux integration tests (startup security, release packaging dry-run, full-ZIP runtime, native update regression and its fixture) from Scripts/ into Tests/Integration/; Scripts/verify.sh stays the unified entry and all path resolution, workflow calls, README text and cross-script invocations were updated. No test coverage changes. Merge Test-Windows.ps1 and Test-WindowsUpdate.ps1 into one Tests/Integration/Test-Windows.ps1 with -Mode startup|update: both modes share the native process control (CREATE_NEW_PROCESS_GROUP + CTRL_BREAK), environment isolation, free-port selection, no-proxy health waiting, JSON fixtures and retained-on-failure cleanup. CI keeps the Windows x64/ARM64 startup smokes and the x64 replacement/rollback smoke. Delete the package-native.sh thin wrapper; the workflow and the packaging dry-run now call web-release-assets.py package directly with the same --rid/--publish/--dist arguments, so asset names and ZIP boundaries stay unchanged. * test(webapi): keep the Windows health-body assertion in the merged smoke The merged Wait-TestHealth only accepted HTTP 200 in startup mode, while the original startup smoke parsed the health JSON and required status == "ok". Restore the body assertion in the shared helper so both modes verify the health payload instead of trusting the status code alone. * fix(webapi): preflight and verify native backup restores * test(webapi): isolate restore preflight smoke from systemd * test(webapi): set Windows smoke autostart default * test(webapi): retry transient Windows smoke cleanup * test(webapi): retry Windows smoke temp cleanup longer * ci(webapi): check whitespace on workflow dispatch --- .github/workflows/build-WebAPI.yml | 923 +++++++++ .github/workflows/build-all.yml | 7 + .github/workflows/release-WebAPI.yml | 53 + v2rayN/v2rayN.WebAPI/.NET/.gitignore | 2 + v2rayN/v2rayN.WebAPI/.env.example | 33 + v2rayN/v2rayN.WebAPI/.gitignore | 12 + .../Adapters/HeadlessWindowDialog.cs | 9 + v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs | 21 + .../Api/DeleteAfterFileResult.cs | 21 + v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs | 405 ++++ v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs | 53 + v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs | 35 + v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs | 45 + v2rayN/v2rayN.WebAPI/Assets/web-assets.json | 40 + .../Configuration/NativeEnvironmentFile.cs | 178 ++ v2rayN/v2rayN.WebAPI/Containerfile | 49 + .../v2rayN.WebAPI/Containerfile.dockerignore | 19 + v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs | 572 ++++++ .../Contracts/WebApiCapabilities.cs | 20 + .../Deploy/Systemd/v2rayn-web.env.example | 1 + .../Deploy/Systemd/v2rayn-web.service | 32 + v2rayN/v2rayN.WebAPI/FEATURE-MAP.md | 56 + .../v2rayN.WebAPI/Launcher/Locales/en-US.json | 1 + .../v2rayN.WebAPI/Launcher/Locales/zh-CN.json | 1 + .../v2rayN.WebAPI/Launcher/Locales/zh-TW.json | 1 + .../Launcher/NativeWebRestorePreflight.cs | 155 ++ .../Launcher/NativeWebUpdateHelper.cs | 824 ++++++++ .../Launcher/NativeWebUpdateWorkflow.cs | 90 + .../v2rayN.WebAPI/Launcher/WebInstanceLock.cs | 206 ++ .../Launcher/WebLaunchOptions.cs | 223 +++ v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs | 381 ++++ .../Launcher/WebProbeUriResolver.cs | 121 ++ .../Launcher/WebRestartCoordinator.cs | 332 +++ v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs | 193 ++ v2rayN/v2rayN.WebAPI/Program.cs | 414 ++++ .../v2rayN.WebAPI/Properties/AssemblyInfo.cs | 3 + v2rayN/v2rayN.WebAPI/README.md | 238 +++ .../Scripts/fetch-core-bundle.sh | 69 + .../v2rayN.WebAPI/Scripts/publish-native.sh | 73 + v2rayN/v2rayN.WebAPI/Scripts/verify.sh | 55 + .../Scripts/web-release-assets.py | 269 +++ .../Scripts/web-update-manifest.py | 336 ++++ .../Security/WebAuthRateLimiting.cs | 53 + .../WebAuthRequestBodyLimitMiddleware.cs | 39 + .../v2rayN.WebAPI/Security/WebAuthService.cs | 241 +++ .../v2rayN.WebAPI/Security/WebAuthStorage.cs | 47 + .../v2rayN.WebAPI/Security/WebCorsPolicy.cs | 94 + .../Security/WebDeploymentSecurityPolicy.cs | 12 + .../Security/WebListenerSecurityPolicy.cs | 67 + .../Security/WebSecurityHeadersMiddleware.cs | 17 + .../WebSessionAuthenticationMiddleware.cs | 49 + .../Security/WebSessionService.cs | 387 ++++ .../Security/WebSetupAccessPolicy.cs | 70 + v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep | 2 + .../Services/BackupDatabaseCompatibility.cs | 81 + .../Services/BackupRestorePreflightGate.cs | 24 + .../Services/BuiltinDnsProfileBootstrap.cs | 43 + .../Services/CoreExecutableVersionCheck.cs | 15 + .../Services/CoreLaunchPreflight.cs | 93 + .../Services/CoreProcessTracker.cs | 315 +++ .../Services/CoreRuntimeSnapshot.cs | 46 + .../Services/CoreSettingsApplyPolicy.cs | 43 + .../Services/CoreUpdatePackageStager.cs | 221 ++ v2rayN/v2rayN.WebAPI/Services/EventHub.cs | 53 + .../Services/GeoFilesUpdateCompletion.cs | 33 + .../Services/GeoFilesUpdateGate.cs | 24 + .../Services/GeoFilesUpdateTransaction.cs | 151 ++ v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs | 84 + .../Services/RegionalPresetStager.cs | 433 ++++ .../Services/RuntimeMutationGate.cs | 39 + .../Services/RuntimeOperationCoordinator.cs | 226 +++ .../Services/RuntimeRequestOperationPolicy.cs | 51 + .../Services/RuntimeRestartRecovery.cs | 105 + .../Services/ShutdownCleanupSequence.cs | 133 ++ .../Services/V2rayHostedService.cs | 11 + .../Services/V2rayRuntime.Backup.cs | 848 ++++++++ .../Services/V2rayRuntime.CoreUpdate.cs | 1473 ++++++++++++++ .../Services/V2rayRuntime.Profiles.cs | 572 ++++++ .../Services/V2rayRuntime.Scheduling.cs | 174 ++ .../Services/V2rayRuntime.Settings.cs | 1784 +++++++++++++++++ .../Services/V2rayRuntime.WebUpdate.cs | 656 ++++++ v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs | 1535 ++++++++++++++ .../Services/WebBuildIdentity.cs | 26 + .../Services/WebReleaseAssets.cs | 49 + .../Services/WebReleaseChannel.cs | 54 + .../Services/WebUpdateDeploymentPolicy.cs | 33 + .../Services/WebUpdatePackageStager.cs | 329 +++ v2rayN/v2rayN.WebAPI/Tests/.gitignore | 3 + .../Tests/BackupArchiveValidationTests.cs | 209 ++ .../Tests/BackupRestorePreflightTests.cs | 148 ++ .../Tests/BuiltinDnsProfileBootstrapTests.cs | 89 + .../Tests/CoreProcessTrackerTests.cs | 198 ++ .../Tests/CoreRestartPreflightTests.cs | 168 ++ .../Tests/CoreSettingsApplyPolicyTests.cs | 42 + .../Tests/CoreUpdateWorkflowTests.cs | 350 ++++ .../Tests/DnsEditorParityTests.cs | 252 +++ v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs | 52 + .../Tests/GeoFilesUpdateTransactionTests.cs | 322 +++ .../Tests/Integration/Test-Windows.ps1 | 731 +++++++ .../test-backup-restore-preflight.sh | 257 +++ .../Integration/test-full-zip-runtime.sh | 208 ++ .../test-native-update-identity.py | 307 +++ .../test-native-update-regression.sh | 93 + .../Integration/test-release-packaging.sh | 346 ++++ .../Integration/test-startup-security.sh | 472 +++++ .../Tests/IpInfoColumnParityTests.cs | 36 + v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs | 59 + .../Tests/NativeEnvironmentFileTests.cs | 159 ++ .../Tests/NativeWebUpdateFileSwapTests.cs | 140 ++ .../Tests/NativeWebUpdateHelperTests.cs | 124 ++ .../Tests/NativeWebUpdateWorkflowTests.cs | 140 ++ .../Tests/ParityValidationTests.cs | 239 +++ .../Tests/RegionalPresetStagingTests.cs | 678 +++++++ .../Tests/RestoreRuntimeRecoveryTests.cs | 449 +++++ .../v2rayN.WebAPI/Tests/RoutingParityTests.cs | 82 + .../Tests/RuntimeMutationGateTests.cs | 80 + .../Tests/RuntimeOperationCoordinatorTests.cs | 129 ++ .../RuntimeRequestOperationPolicyTests.cs | 62 + .../Tests/ShutdownCleanupSequenceTests.cs | 219 ++ .../Tests/SniffingSettingsIntegrationTests.cs | 237 +++ .../Tests/SseSerializationTests.cs | 12 + .../v2rayN.WebAPI/Tests/SseShutdownTests.cs | 158 ++ .../Tests/SubscriptionCreationTests.cs | 83 + .../Tests/SubscriptionParityTests.cs | 218 ++ .../Tests/SubscriptionValidationTests.cs | 85 + .../Tests/UpdatePreReleaseSettingsTests.cs | 212 ++ .../Tests/WebApiCapabilitiesTests.cs | 16 + .../Tests/WebAuthEndpointTests.cs | 532 +++++ .../Tests/WebAuthRateLimitTests.cs | 178 ++ .../Tests/WebAuthServiceTests.cs | 175 ++ v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs | 261 +++ .../Tests/WebDeploymentSecurityPolicyTests.cs | 33 + .../Tests/WebHealthEndpointTests.cs | 62 + .../v2rayN.WebAPI/Tests/WebLauncherTests.cs | 545 +++++ .../Tests/WebListenerSecurityPolicyTests.cs | 95 + .../Tests/WebProbeUriResolverTests.cs | 198 ++ .../Tests/WebReleaseChannelTests.cs | 98 + .../Tests/WebRestartCoordinatorTests.cs | 286 +++ .../Tests/WebSecurityHeadersTests.cs | 35 + .../Tests/WebSessionServiceTests.cs | 167 ++ .../Tests/WebSetupAccessPolicyTests.cs | 72 + .../v2rayN.WebAPI/Tests/WebTunScopeTests.cs | 107 + .../Tests/WebUpdateDeploymentPolicyTests.cs | 39 + .../Tests/WebUpdatePackageStagerTests.cs | 459 +++++ .../Tests/WebUpdateReleaseSelectionTests.cs | 102 + .../Tests/XrayGeoStagingTests.cs | 36 + .../Tests/v2rayN.WebAPI.Tests.csproj | 27 + v2rayN/v2rayN.WebAPI/compose.yaml | 26 + v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj | 46 + v2rayN/v2rayN.slnx | 3 + 150 files changed, 28852 insertions(+) create mode 100644 .github/workflows/build-WebAPI.yml create mode 100644 .github/workflows/release-WebAPI.yml create mode 100644 v2rayN/v2rayN.WebAPI/.NET/.gitignore create mode 100644 v2rayN/v2rayN.WebAPI/.env.example create mode 100644 v2rayN/v2rayN.WebAPI/.gitignore create mode 100644 v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs create mode 100644 v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs create mode 100644 v2rayN/v2rayN.WebAPI/Assets/web-assets.json create mode 100644 v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs create mode 100644 v2rayN/v2rayN.WebAPI/Containerfile create mode 100644 v2rayN/v2rayN.WebAPI/Containerfile.dockerignore create mode 100644 v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs create mode 100644 v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs create mode 100644 v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example create mode 100644 v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service create mode 100644 v2rayN/v2rayN.WebAPI/FEATURE-MAP.md create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs create mode 100644 v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs create mode 100644 v2rayN/v2rayN.WebAPI/Program.cs create mode 100644 v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs create mode 100644 v2rayN/v2rayN.WebAPI/README.md create mode 100644 v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh create mode 100644 v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh create mode 100755 v2rayN/v2rayN.WebAPI/Scripts/verify.sh create mode 100755 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py create mode 100755 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs create mode 100644 v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep create mode 100644 v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/EventHub.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs create mode 100644 v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/.gitignore create mode 100644 v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 create mode 100644 v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh create mode 100644 v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh create mode 100755 v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py create mode 100755 v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh create mode 100755 v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh create mode 100644 v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh create mode 100644 v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs create mode 100644 v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj create mode 100644 v2rayN/v2rayN.WebAPI/compose.yaml create mode 100644 v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj diff --git a/.github/workflows/build-WebAPI.yml b/.github/workflows/build-WebAPI.yml new file mode 100644 index 00000000..580c087a --- /dev/null +++ b/.github/workflows/build-WebAPI.yml @@ -0,0 +1,923 @@ +name: build v2rayN.WebAPI + +on: + workflow_call: + inputs: + release_tag: + required: false + type: string + default: '' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (for example 7.x.y). Empty builds a development API package.' + required: false + default: '' + type: string + pull_request: + branches: [master] + paths: + - 'v2rayN/v2rayN.WebAPI/**' + - 'v2rayN/ServiceLib/**' + - 'v2rayN/ServiceLib.Tests/**' + - 'v2rayN/Directory.Build.*' + - 'v2rayN/Directory.Packages.props' + - 'v2rayN/v2rayN.slnx' + - 'global.json' + - '.github/workflows/build-WebAPI.yml' + - '.github/workflows/release-WebAPI.yml' + - '.github/workflows/build-all.yml' + - '.github/workflows/upload-sign.yml' + push: + branches: [master] + paths: + - 'v2rayN/v2rayN.WebAPI/**' + - 'v2rayN/ServiceLib/**' + - 'v2rayN/ServiceLib.Tests/**' + - 'v2rayN/Directory.Build.*' + - 'v2rayN/Directory.Packages.props' + - 'v2rayN/v2rayN.slnx' + - 'global.json' + - '.github/workflows/build-WebAPI.yml' + - '.github/workflows/release-WebAPI.yml' + - '.github/workflows/build-all.yml' + - '.github/workflows/upload-sign.yml' +permissions: + contents: read + +jobs: + identity: + name: Resolve API build identity + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + release: ${{ steps.resolve.outputs.release }} + build_date: ${{ steps.resolve.outputs.build_date }} + steps: + - name: Resolve release identity + id: resolve + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ -z "$RELEASE_TAG" ]]; then + { + echo "version=0.0.0-dev" + echo "tag=" + echo "release=false" + echo "build_date=$build_date" + } >> "$GITHUB_OUTPUT" + else + [[ "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid release tag: $RELEASE_TAG" >&2 + exit 1 + } + { + echo "version=$RELEASE_TAG" + echo "tag=$RELEASE_TAG" + echo "release=true" + echo "build_date=$build_date" + } >> "$GITHUB_OUTPUT" + fi + + verify: + name: Backend, ServiceLib, package smoke + runs-on: ubuntu-24.04 + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - name: Verify API build, backend and ServiceLib tests, native startup and package boundaries + run: bash v2rayN/v2rayN.WebAPI/Scripts/verify.sh "$RUNNER_TEMP/v2rayN.WebAPI-verify" + - name: Check patch whitespace + if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + run: | + set -euo pipefail + base_sha="$BASE_SHA" + if [[ -z "$base_sha" ]]; then + base_sha="$(git rev-parse HEAD^)" + fi + if [[ "$base_sha" =~ ^0+$ ]]; then + empty_tree="$(git hash-object -t tree -w /dev/null)" + git diff --check "$empty_tree" HEAD + else + git diff --check "$base_sha...HEAD" + fi + + windows: + name: Windows WebAPI build and runtime smoke + needs: identity + runs-on: windows-latest + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + WEB_VERSION: ${{ needs.identity.outputs.version }} + WEB_COMMIT: ${{ github.sha }} + WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + WEB_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Restore WebAPI + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj + - name: Restore WebAPI.Tests + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj + - name: Restore ServiceLib.Tests + shell: pwsh + run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj + - name: Build WebAPI + shell: pwsh + run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore + - name: Run WebAPI.Tests + shell: pwsh + run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore + - name: Run ServiceLib.Tests + shell: pwsh + run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore + - name: Publish win-x64 self-contained single-file WebAPI + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=$env:WEB_VERSION ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = $env:WEB_VERSION + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-x64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Verify single-file publish output + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $executable = Join-Path $publishDirectory 'v2rayN.WebAPI.exe' + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "dotnet publish did not produce $executable" + } + if ((Get-Item -LiteralPath $executable).Length -eq 0) { + throw "Published executable is empty: $executable" + } + $looseDlls = @(Get-ChildItem -LiteralPath $publishDirectory -Filter '*.dll' -File -Recurse) + if ($looseDlls.Count -gt 0) { + throw "Single-file publish left loose DLLs: $($looseDlls.FullName -join ', ')" + } + - name: Smoke-test first startup, health endpoint and graceful shutdown + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory + - name: Publish previous Windows fixture for update regression + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=0.0.1-regression ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = '0.0.1-regression' + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-x64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Add the matching Windows Core bundle + if: inputs.release_tag != '' + shell: pwsh + run: | + $bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64.zip' + $expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-core' + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $previousDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous' + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-64.zip' -OutFile $bundle + Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force + $sourceBin = Join-Path $expanded 'v2rayN-windows-64\bin' + if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) { + throw "Windows Core bundle is missing $sourceBin" + } + foreach ($directory in @($publishDirectory, $previousDirectory)) { + $destinationBin = Join-Path $directory 'bin' + New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null + Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force + } + foreach ($directory in @($publishDirectory, $previousDirectory)) { + $checkBin = Join-Path $directory 'bin' + foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) { + $required = Join-Path $checkBin $relative + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows Core asset: $required" } + } + } + $candidateBin = Join-Path $publishDirectory 'bin' + & (Join-Path $candidateBin 'xray\xray.exe') -version + if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $candidateBin 'sing_box\sing-box.exe') version + if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $candidateBin 'mihomo\mihomo.exe') -v + if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" } + - name: Smoke-test Windows backup restore handoff and Core intent recovery + if: inputs.release_tag != '' + shell: pwsh + run: | + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 ` + -Mode restore ` + -PublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64') + - name: Smoke-test Windows self-update replacement and rollback + if: inputs.release_tag != '' + shell: pwsh + run: | + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 ` + -Mode update ` + -PreviousPublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous') ` + -CandidatePublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64') + - name: Package and smoke-test Windows full-install and app-only ZIPs + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64' + $dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-dist' + python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-x64 --publish $publishDirectory --dist $dist + $fullZip = Join-Path $dist 'v2rayN-windows-64-WebAPI.zip' + $appOnlyZip = Join-Path $dist 'v2rayN-windows-64-WebAPI-update.zip' + if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) { + throw 'Windows release ZIPs were not created.' + } + $extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-WebAPI-smoke' + Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted + "Windows full-install ZIP: $fullZip" + "Windows app-only update ZIP: $appOnlyZip" + - uses: actions/upload-artifact@v7 + if: inputs.release_tag != '' + with: + name: v2rayN.WebAPI-win-x64 + path: ${{ runner.temp }}/v2rayN.WebAPI-win-x64-dist/*.zip + if-no-files-found: error + + # The self-update replacement smoke stays on win-x64: the updater logic is RID-generic + # and the per-RID PE/ELF checks are covered by unit tests. This job proves the win-arm64 + # build, first startup, health endpoint and graceful shutdown on native ARM64 Windows. + windows-arm64: + name: Windows ARM64 WebAPI build and runtime smoke + needs: identity + runs-on: windows-11-arm + env: + TUNIT_MAX_PARALLEL_TESTS: 1 + WEB_VERSION: ${{ needs.identity.outputs.version }} + WEB_COMMIT: ${{ github.sha }} + WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + WEB_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Restore WebAPI + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj + - name: Restore WebAPI.Tests + shell: pwsh + run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj + - name: Restore ServiceLib.Tests + shell: pwsh + run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj + - name: Build WebAPI + shell: pwsh + run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore + - name: Run WebAPI.Tests + shell: pwsh + run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore + - name: Run ServiceLib.Tests + shell: pwsh + run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore + - name: Publish win-arm64 self-contained single-file WebAPI + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj ` + --configuration Release ` + --runtime win-arm64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:WebVersion=$env:WEB_VERSION ` + -p:WebCommit=$env:WEB_COMMIT ` + -p:WebBuildDate=$env:WEB_BUILD_DATE ` + -p:WebRepository=$env:WEB_REPOSITORY ` + --output $publishDirectory + $identity = [ordered]@{ + product = 'v2rayN.WebAPI' + version = $env:WEB_VERSION + commit = $env:WEB_COMMIT + buildDate = $env:WEB_BUILD_DATE + rid = 'win-arm64' + } + $json = $identity | ConvertTo-Json -Compress + [IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false)) + - name: Smoke-test first startup, health endpoint and graceful shutdown + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory + - name: Add the matching Windows ARM64 Core bundle + if: inputs.release_tag != '' + shell: pwsh + run: | + $bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64.zip' + $expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-core' + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-arm64.zip' -OutFile $bundle + Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force + $sourceBin = Join-Path $expanded 'v2rayN-windows-arm64\bin' + if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) { + throw "Windows ARM64 Core bundle is missing $sourceBin" + } + $destinationBin = Join-Path $publishDirectory 'bin' + New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null + Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force + foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) { + $required = Join-Path $destinationBin $relative + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows ARM64 Core asset: $required" } + } + & (Join-Path $destinationBin 'xray\xray.exe') -version + if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $destinationBin 'sing_box\sing-box.exe') version + if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" } + & (Join-Path $destinationBin 'mihomo\mihomo.exe') -v + if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" } + - name: Package Windows ARM64 full-install and app-only ZIPs + if: inputs.release_tag != '' + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64' + $dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64-dist' + python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-arm64 --publish $publishDirectory --dist $dist + $fullZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI.zip' + $appOnlyZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI-update.zip' + if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) { + throw 'Windows ARM64 release ZIPs were not created.' + } + $extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-WebAPI-smoke' + Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force + & ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted + "Windows ARM64 full-install ZIP: $fullZip" + "Windows ARM64 app-only update ZIP: $appOnlyZip" + - uses: actions/upload-artifact@v7 + if: inputs.release_tag != '' + with: + name: v2rayN.WebAPI-win-arm64 + path: ${{ runner.temp }}/v2rayN.WebAPI-win-arm64-dist/*.zip + if-no-files-found: error + + native: + name: Native API build (${{ matrix.rid }}) + needs: identity + strategy: + fail-fast: false + matrix: + # Development builds keep x64 coverage; releases also validate ARM64. + # Only tested Linux RIDs are enabled; native RID/runner pairs are independent + # of Desktop workflows and the container platform matrix. + include: >- + ${{ fromJSON(needs.identity.outputs.release == 'true' + && '[{"rid":"linux-x64","runner":"ubuntu-24.04"},{"rid":"linux-arm64","runner":"ubuntu-24.04-arm"}]' + || '[{"rid":"linux-x64","runner":"ubuntu-24.04"}]') }} + runs-on: ${{ matrix.runner }} + env: + RID: ${{ matrix.rid }} + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.x' + - name: Publish native API + run: bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID" + - name: Verify native executable + run: test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI" + - name: Add the matching Linux Core bundle + if: needs.identity.outputs.release == 'true' + run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin" + - name: Package API install and self-update ZIPs + if: needs.identity.outputs.release == 'true' + run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist + - name: Smoke-test bundled Cores + if: needs.identity.outputs.release == 'true' + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/bin/xray/xray" -version + "$publish/bin/sing_box/sing-box" version + "$publish/bin/mihomo/mihomo" -v + - uses: actions/upload-artifact@v7 + if: needs.identity.outputs.release == 'true' + with: + name: v2rayN.WebAPI-${{ matrix.rid }} + path: dist/*.zip + if-no-files-found: error + + # linux-riscv64 runs natively on the RISE RISC-V runners with the community + # xujiegb/dotnet-riscv SDK; single-file publish is verified to work there. + native-riscv64: + name: Native API build (linux-riscv64) + needs: identity + if: needs.identity.outputs.release == 'true' + runs-on: ubuntu-24.04-riscv + container: debian:13 + env: + RID: linux-riscv64 + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + steps: + - name: Prepare tools (Debian) + shell: bash + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || \ + apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu72 + - name: Checkout repo + shell: bash + env: { GITHUB_TOKEN: "${{ github.token }}" } + run: | + set -euo pipefail + rm -rf ./* + git init . + git config --global --add safe.directory "$PWD" + git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git fetch --depth=1 origin "${GITHUB_SHA}" + git checkout FETCH_HEAD + - name: Install community .NET SDK (linux-riscv64) + shell: bash + run: | + set -euo pipefail + SDK=dotnet-sdk-10.0.111-linux-riscv64.tar.gz + curl -fL "https://github.com/xujiegb/dotnet-riscv/releases/download/10.0.111/$SDK" -o /tmp/$SDK + mkdir -p "$HOME/.dotnet" + tar -C "$HOME/.dotnet" -xzf /tmp/$SDK + echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV" + echo "$HOME/.dotnet" >> "$GITHUB_PATH" + export DOTNET_ROOT="$HOME/.dotnet" + export PATH="$DOTNET_ROOT:$PATH" + dotnet --info + - name: Publish native API + shell: bash + run: | + set -euo pipefail + export DOTNET_ROOT="$HOME/.dotnet" + export PATH="$DOTNET_ROOT:$PATH" + bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID" + test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI" + - name: Add the matching Linux Core bundle + shell: bash + run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin" + - name: Package API install and self-update ZIPs + shell: bash + run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist + - name: Smoke-test bundled Cores + shell: bash + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/bin/xray/xray" -version + "$publish/bin/sing_box/sing-box" version + "$publish/bin/mihomo/mihomo" -v + - name: Smoke-test the published API (native riscv64) + shell: bash + run: | + set -euo pipefail + publish="v2rayN/v2rayN.WebAPI/publish/$RID" + "$publish/v2rayN.WebAPI" --help | head -n 5 + export V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef + export ASPNETCORE_URLS=http://127.0.0.1:5080 + cd "$publish" + ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 & + app_pid=$! + echo "$app_pid" > /tmp/smoke.pid + echo "riscv64 API smoke application PID: $app_pid" + ok=0 + for i in $(seq 1 90); do + if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$app_pid" 2>/dev/null; then break; fi + sleep 1 + done + if [ "$ok" != 1 ]; then + echo 'riscv64 API smoke did not become healthy' + tail -n 60 /tmp/smoke.log || true + kill -TERM "$app_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$app_pid" 2>/dev/null || true + exit 1 + fi + kill -TERM "$app_pid" + stopped=0 + for i in $(seq 1 30); do + if ! kill -0 "$app_pid" 2>/dev/null; then stopped=1; break; fi + sleep 1 + done + if [ "$stopped" != 1 ]; then + echo 'riscv64 API smoke did not exit within 30s after SIGTERM' + tail -n 60 /tmp/smoke.log || true + kill -KILL "$app_pid" 2>/dev/null || true + exit 1 + fi + set +e + wait "$app_pid" + exit_code=$? + set -e + if [ "$exit_code" -ne 0 ]; then + echo "riscv64 API smoke exited with status $exit_code after SIGTERM" + tail -n 60 /tmp/smoke.log || true + exit 1 + fi + echo 'linux-riscv64 native API smoke OK' + - uses: actions/upload-artifact@v7 + with: + name: v2rayN.WebAPI-linux-riscv64 + path: dist/*.zip + if-no-files-found: error + + # linux-loongarch64 publishes inside the upstream QEMU loong64 VM with the official + # Loongson .NET SDK; the VM runs the native smoke before the host packages the ZIPs. + native-loong64: + name: Native API build (linux-loongarch64) + needs: identity + if: needs.identity.outputs.release == 'true' + runs-on: ubuntu-26.04-arm + env: + RID: linux-loongarch64 + V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }} + V2RAYN_WEB_REPOSITORY: ${{ github.repository }} + V2RAYN_WEB_COMMIT: ${{ github.sha }} + V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }} + QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2 + EFI_CODE_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-code.fd + EFI_VARS_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-vars.fd + QCOW2_IMAGE: debian13-loong64.qcow2 + EFI_CODE: edk2-loongarch64-code.fd + EFI_VARS: edk2-loongarch64-vars.fd + QEMU_VERSION: 10.2.4 + steps: + - name: Prepare host tools + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd + sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd + virtiofsd --version || virtiofsd --help | head + - name: Host clone repo + shell: bash + env: { GITHUB_TOKEN: "${{ github.token }}" } + run: | + set -euo pipefail + rm -rf repo + git init repo + git -C repo config --global --add safe.directory "$PWD/repo" || true + git -C repo remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git -C repo fetch --depth=1 origin "${GITHUB_SHA}" + git -C repo checkout FETCH_HEAD + - name: Download QEMU prebuild + run: | + set -euo pipefail + wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz" + tar -xzf qemu-linux-arm64-v8.2.tar.gz + mkdir -p "$HOME/qemu-install" + rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/" + "$HOME/qemu-install/bin/qemu-system-loongarch64" --version + - name: Download loong64 qcow2 and EFI firmware + shell: bash + run: | + set -euo pipefail + wget -O "$QCOW2_IMAGE" "$QCOW2_URL" + wget -O "$EFI_CODE" "$EFI_CODE_URL" + wget -O "$EFI_VARS" "$EFI_VARS_URL" + qemu-img info "$QCOW2_IMAGE" + - name: Stage the VM build script and the matching Core bundle + shell: bash + run: | + set -euo pipefail + cat > webapi-loong.env < webapi-loong-run.sh <<'SCRIPT' + #!/usr/bin/env bash + set +e + set -o pipefail + source /workspace/webapi-loong.env + export DOTNET_ROOT=/root/.dotnet + export PATH="$DOTNET_ROOT:$PATH" + mkdir -p /root/.dotnet + SDK=dotnet-sdk-10.0.111-linux-loongarch64.tar.gz + curl -fL "https://github.com/loongson/dotnet/releases/download/v10.0.111-loongarch64/$SDK" -o /tmp/$SDK || exit 9 + tar -C /root/.dotnet -xzf /tmp/$SDK || exit 9 + dotnet --info >/dev/null 2>&1 || exit 9 + cd /root/v2rayN-src || exit 9 + mkdir -p /workspace/publish/linux-loongarch64 + V2RAYN_WEB_VERSION="$WEB_VERSION" V2RAYN_WEB_REPOSITORY="$WEB_REPOSITORY" V2RAYN_WEB_COMMIT="$WEB_COMMIT" V2RAYN_WEB_BUILD_DATE="$WEB_BUILD_DATE" \ + bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh linux-loongarch64 /workspace/publish/linux-loongarch64 2>&1 | tee /tmp/publish.log + rc=$? + if [ $rc -eq 0 ]; then + mkdir -p /workspace/publish/linux-loongarch64/bin + cp -a /workspace/core-bundle/. /workspace/publish/linux-loongarch64/bin/ + # Smoke a VM-local copy: the shared publish tree must stay exactly the packaging + # input, without runtime lock/data files owned by the VM user. + rm -rf /root/websmoke + cp -a /workspace/publish/linux-loongarch64 /root/websmoke + cd /root/websmoke || exit 9 + ./bin/xray/xray -version >/dev/null || rc=9 + ./bin/sing_box/sing-box version >/dev/null || rc=9 + ./bin/mihomo/mihomo -v >/dev/null || rc=9 + ./v2rayN.WebAPI --help >/dev/null || rc=9 + V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef ASPNETCORE_URLS=http://127.0.0.1:5080 ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 & + APP=$! + echo "$APP" > /tmp/smoke.pid + ok=0 + for i in $(seq 1 90); do + if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$APP" 2>/dev/null; then break; fi + sleep 1 + done + if [ "$ok" != 1 ]; then + echo 'loongarch64 API smoke did not become healthy' + tail -n 60 /tmp/smoke.log + kill -KILL "$APP" 2>/dev/null || true + rc=9 + fi + if [ "$rc" = 0 ]; then + kill -TERM "$APP" + exited=0 + for i in $(seq 1 60); do + if ! kill -0 "$APP" 2>/dev/null; then exited=1; break; fi + sleep 1 + done + if [ "$exited" != 1 ]; then + echo 'loongarch64 API smoke did not exit within 60s after SIGTERM' + tail -n 60 /tmp/smoke.log + kill -KILL "$APP" 2>/dev/null || true + rc=9 + else + wait "$APP" + app_rc=$? + if [ "$app_rc" != 0 ]; then + echo "loongarch64 API smoke exited with status $app_rc after SIGTERM" + tail -n 60 /tmp/smoke.log + rc=9 + fi + fi + fi + fi + if [ "$rc" != 0 ]; then echo '=== publish.log ==='; tail -n 120 /tmp/publish.log 2>/dev/null; fi + echo "__WEBAPI_DONE__$rc" + SCRIPT + bash repo/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "$GITHUB_WORKSPACE/core-bundle" + ls -la "$GITHUB_WORKSPACE/core-bundle" | head -n 15 + - name: Build WebAPI loongarch64 in the VM (publish + native smoke) + shell: bash + timeout-minutes: 180 + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/dist" + export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-webapi-loongarch64.sock" + rm -f "$VIRTIOFSD_SOCKET" + + sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto & + + VIRTIOFSD_PID=$! + trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT + + for i in {1..100}; do + [[ -S "$VIRTIOFSD_SOCKET" ]] && break + sleep 0.1 + done + [[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; } + sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true + sudo chmod 660 "$VIRTIOFSD_SOCKET" || true + expect <<'EOF' + log_user 1 + set timeout 10800 + set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64" + set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu" + set virtiofs_socket $env(VIRTIOFSD_SOCKET) + set qcow2 $env(QCOW2_IMAGE) + set efi_code $env(EFI_CODE) + set efi_vars $env(EFI_VARS) + proc wait_prompt {} { + expect { + -re "__CI_PROMPT__# " {} + timeout { exit 1 } + eof { exit 1 } + } + } + proc run_cmd {cmd} { + send -- "$cmd\r" + wait_prompt + } + spawn $qemu_bin \ + -L $qemu_rom_dir \ + -machine virt \ + -accel tcg,thread=multi,tb-size=2048 \ + -cpu la464 \ + -m 9216 \ + -object memory-backend-memfd,id=mem0,size=9216M,share=on \ + -numa node,memdev=mem0 \ + -smp 4 \ + -drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \ + -drive if=pflash,format=raw,unit=1,file=$efi_vars \ + -device virtio-blk-pci,drive=hd0,bootindex=1 \ + -drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \ + -netdev user,id=net0 \ + -device virtio-net-pci-non-transitional,netdev=net0 \ + -chardev socket,id=charfs0,path=$virtiofs_socket \ + -device vhost-user-fs-pci,chardev=charfs0,tag=workspace \ + -display none \ + -serial stdio \ + -monitor none + expect -re "login:|debian login:" + send -- "root\r" + expect -re "Password:|密码:|密码:" + send -- "password\r" + expect { + -re "# " {} + timeout { exit 1 } + eof { exit 1 } + } + send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r" + wait_prompt + run_cmd "mkdir -p /workspace" + run_cmd "mount -t virtiofs workspace /workspace" + run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true" + run_cmd "dhclient \$IFACE || true" + run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf" + run_cmd "apt-get update || apt-get update || apt-get update" + run_cmd "DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1" + run_cmd "rm -rf /root/v2rayN-src" + run_cmd "rsync -a /workspace/repo/ /root/v2rayN-src/" + run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src" + send -- "bash /workspace/webapi-loong-run.sh\r" + expect { + -re "__WEBAPI_DONE__0" { send -- "poweroff\r" } + -re "__WEBAPI_DONE__" { exit 1 } + timeout { exit 1 } + eof { exit 1 } + } + EOF + - name: Verify the loongarch64 publish tree + shell: bash + run: | + set -euo pipefail + publish="$GITHUB_WORKSPACE/publish/linux-loongarch64" + test -x "$publish/v2rayN.WebAPI" + test -f "$publish/v2rayN.WebAPI.build.json" + unreadable="$(find "$publish" ! -readable -print -quit)" + if [ -n "$unreadable" ]; then + echo "The loongarch64 publish tree contains unreadable entries:" + find "$publish" ! -readable -print | head -n 20 + exit 1 + fi + echo "loongarch64 publish tree is complete and readable" + - name: Package API install and self-update ZIPs + shell: bash + run: | + set -euo pipefail + python3 repo/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "$GITHUB_WORKSPACE/publish/linux-loongarch64" --dist "$GITHUB_WORKSPACE/dist" + ls -la "$GITHUB_WORKSPACE/dist" + - uses: actions/upload-artifact@v7 + with: + name: v2rayN.WebAPI-linux-loongarch64 + path: dist/*.zip + if-no-files-found: error + + container: + name: API Containerfile (${{ matrix.rid }}) + needs: identity + strategy: + fail-fast: false + matrix: + # Development builds keep amd64 coverage; releases also validate ARM64. + include: >- + ${{ fromJSON(needs.identity.outputs.release == 'true' + && '[{"rid":"linux-x64","platform":"linux/amd64"},{"rid":"linux-arm64","platform":"linux/arm64"}]' + || '[{"rid":"linux-x64","platform":"linux/amd64"}]') }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@v3 + - name: Build API container (no push) + uses: docker/build-push-action@v6 + env: + DOCKER_BUILD_RECORD_UPLOAD: false + with: + context: . + file: ./v2rayN/v2rayN.WebAPI/Containerfile + platforms: ${{ matrix.platform }} + tags: v2rayn-webapi:ci + build-args: | + RID=${{ matrix.rid }} + WEB_VERSION=${{ needs.identity.outputs.version }} + WEB_REPOSITORY=${{ github.repository }} + WEB_COMMIT=${{ github.sha }} + WEB_BUILD_DATE=${{ needs.identity.outputs.build_date }} + push: false + + release-assets: + name: Assemble API release assets + if: needs.identity.outputs.release == 'true' + needs: [identity, verify, windows, windows-arm64, native, native-riscv64, native-loong64, container] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + pattern: v2rayN.WebAPI-* + path: release-assets + merge-multiple: true + - name: Build and verify WebAPI-update.json + env: + RELEASE_TAG: ${{ needs.identity.outputs.tag }} + run: | + set -euo pipefail + python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \ + --version "$RELEASE_TAG" \ + --repository "${{ github.repository }}" \ + --commit "${{ github.sha }}" \ + --build-date "${{ needs.identity.outputs.build_date }}" \ + --dist release-assets \ + --output release-assets/WebAPI-update.json + python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \ + --manifest release-assets/WebAPI-update.json \ + --dist release-assets \ + --repository "${{ github.repository }}" + - name: Smoke-test full API ZIP + run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip + - uses: actions/upload-artifact@v7 + with: + name: WebAPI-release-packages + path: release-assets/*.zip + if-no-files-found: error + - uses: actions/upload-artifact@v7 + with: + name: WebAPI-release-manifest + path: release-assets/WebAPI-update.json + if-no-files-found: error diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml index 13fdadfc..80974a29 100644 --- a/.github/workflows/build-all.yml +++ b/.github/workflows/build-all.yml @@ -20,6 +20,13 @@ jobs: secrets: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + release-WebAPI: + if: inputs.release_tag != '' + uses: ./.github/workflows/release-WebAPI.yml + with: + release_tag: ${{ inputs.release_tag }} + secrets: inherit + update: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-WebAPI.yml b/.github/workflows/release-WebAPI.yml new file mode 100644 index 00000000..03591c50 --- /dev/null +++ b/.github/workflows/release-WebAPI.yml @@ -0,0 +1,53 @@ +name: release v2rayN.WebAPI + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + secrets: + GPG_PRIVATE_KEY: + required: false + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag (for example 7.x.y).' + required: true + type: string + +permissions: + contents: write + actions: read + +jobs: + build: + uses: ./.github/workflows/build-WebAPI.yml + permissions: + contents: read + with: + release_tag: ${{ inputs.release_tag }} + + release-web-packages: + name: sign and upload Web API packages + # Upload through the shared signing flow, exactly like the desktop packages. + if: inputs.release_tag != '' + needs: build + uses: ./.github/workflows/upload-sign.yml + with: + release_tag: ${{ inputs.release_tag }} + artifact_name: WebAPI-release-packages + file_pattern: 'v2rayN-*-WebAPI*.zip' + secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" } + + release-web-manifest: + name: sign and upload Web update manifest + if: inputs.release_tag != '' + # Advertise the update only after its packages and manifest have been uploaded. + needs: [build, release-web-packages] + uses: ./.github/workflows/upload-sign.yml + with: + release_tag: ${{ inputs.release_tag }} + artifact_name: WebAPI-release-manifest + file_pattern: 'WebAPI-update.json' + secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" } diff --git a/v2rayN/v2rayN.WebAPI/.NET/.gitignore b/v2rayN/v2rayN.WebAPI/.NET/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.NET/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/v2rayN/v2rayN.WebAPI/.env.example b/v2rayN/v2rayN.WebAPI/.env.example new file mode 100644 index 00000000..1faa948f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.env.example @@ -0,0 +1,33 @@ +# v2rayN.WebAPI environment example +# +# Copy this file to .env before starting: +# +# cp .env.example .env +# ./v2rayN.WebAPI +# +# Native local installs may leave V2RAYN_WEB_API_KEY empty ONLY with loopback +# listeners (127.0.0.1, localhost, [::1]) and complete setup in a local browser. +# LAN/NAS/headless/systemd/container deployments must preconfigure a non-empty Key. +# Any non-loopback listener requires this environment Key, even after local setup. + +V2RAYN_WEB_API_KEY= + +# Optional exact browser/client origins. Empty/unset denies cross-origin API access. +# Origin = scheme + host + port, never a path, query, fragment or wildcard. +# V2RAYN_WEB_ALLOWED_ORIGINS=https://client.example.com,http://localhost:5173 + +# Web management interface. The default listens only on loopback. +# Set V2RAYN_WEB_API_KEY before changing this to http://0.0.0.0:5080 for LAN access. +ASPNETCORE_URLS=http://127.0.0.1:5080 + +# Start the selected Core automatically after authentication setup. +V2RAYN_WEB_AUTOSTART=true + +# Optional proxy listener port. +# V2RAYN_WEB_PROXY_PORT=10808 + +# Allow proxy clients outside localhost. +# V2RAYN_WEB_PROXY_LISTEN_ALL=false + +# Optional writable data directory. +# V2RAYN_DATA_HOME=/path/to/v2rayn-data diff --git a/v2rayN/v2rayN.WebAPI/.gitignore b/v2rayN/v2rayN.WebAPI/.gitignore new file mode 100644 index 00000000..1bf59abb --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/.gitignore @@ -0,0 +1,12 @@ +/.packages/ +/.NET/* +!/.NET/.gitignore +/TestResults/ +.env +/bin/ +/obj/ +/publish/ +/core-bin/ +/Tests/bin/ +/Tests/obj/ +/Tests/TestResults/ diff --git a/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs b/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs new file mode 100644 index 00000000..a04e41bd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Adapters/HeadlessWindowDialog.cs @@ -0,0 +1,9 @@ +using ServiceLib.Base; + +namespace v2rayN.WebAPI.Adapters; + +public sealed class HeadlessWindowDialog : IWindowDialog +{ + public Task ShowDialogAsync(TViewModel vm) + where TViewModel : class => Task.FromResult(false); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs b/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs new file mode 100644 index 00000000..5525d2a5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/ApiReplies.cs @@ -0,0 +1,21 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Api; + +public static class ApiReplies +{ + public static IResult Ok(T data, string messageKey = ApiMessageKeys.CommonLoaded) => + Results.Ok(ApiEnvelope.Ok(data, messageKey)); + + public static IResult Operation( + OperationView result, + int successStatus = StatusCodes.Status200OK, + int failureStatus = StatusCodes.Status400BadRequest) => + Results.Json(result, statusCode: result.Success ? successStatus : failureStatus); + + public static IResult NotFound(string code, string messageKey) => + Results.Json(ApiEnvelope.Fail(code, messageKey), statusCode: StatusCodes.Status404NotFound); + + public static IResult Conflict(string code, string messageKey, object? data = null) => + Results.Json(ApiEnvelope.Fail(code, messageKey, data), statusCode: StatusCodes.Status409Conflict); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs b/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs new file mode 100644 index 00000000..a2e29cfa --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/DeleteAfterFileResult.cs @@ -0,0 +1,21 @@ +namespace v2rayN.WebAPI.Api; + +public sealed class DeleteAfterFileResult(string filePath) : IResult +{ + public async Task ExecuteAsync(HttpContext httpContext) + { + try + { + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await Results.File(stream, "application/zip", Path.GetFileName(filePath), enableRangeProcessing: false) + .ExecuteAsync(httpContext); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs new file mode 100644 index 00000000..7b16fa9f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebApiEndpoints.cs @@ -0,0 +1,405 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Entities; +using Microsoft.AspNetCore.Mvc; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Api; + +public static class WebApiEndpoints +{ + private static readonly System.Text.Json.JsonSerializerOptions SseJsonOptions = + new(System.Text.Json.JsonSerializerDefaults.Web); + + internal static string SerializeEventData(object? data) => data is null + ? "null" + : System.Text.Json.JsonSerializer.Serialize(data, data.GetType(), SseJsonOptions); + + public static void MapWebApi(this WebApplication app) + { + app.MapGet("/api/health", (HttpContext context, V2rayRuntime runtime) => + Results.Ok(WebHealthEndpoint.CreateResponse(context, runtime))); + app.MapGet("/api/status", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetStatusAsync(), "status.loaded")); + app.MapGet("/api/operations", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetRunningOperationsAsync(), "operations.loaded")); + app.MapGet("/api/logs", (int? limit, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetRecentLogs(limit ?? 200, filter), "logs.loaded")); + app.MapGet("/api/logs/page", (int? page, int? pageSize, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetRecentLogsPage(page ?? 1, pageSize ?? 100, filter), "logs.loaded")); + app.MapDelete("/api/logs", (V2rayRuntime runtime) => + { + var generation = runtime.ClearLogs(); + return ApiReplies.Ok(new { cleared = true, generation }, "logs.cleared"); + }); + + MapSubscriptions(app); + MapProfiles(app); + MapSettings(app); + app.MapGet("/api/editor-options", () => ApiReplies.Ok(V2rayRuntime.GetEditorOptions(), ApiMessageKeys.CommonLoaded)); + MapCore(app); + MapEvents(app); + } + + private static void MapSubscriptions(WebApplication app) + { + app.MapGet("/api/subscriptions", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetSubscriptionsAsync(), "subscriptions.loaded")); + app.MapPost("/api/subscriptions", async (SubscriptionInput input, V2rayRuntime runtime) => + { + var result = await runtime.AddSubscriptionAsync(input); + return result.Success + ? Results.Created($"/api/subscriptions/{result.Data!.Id}", result) + : Results.BadRequest(result); + }); + app.MapPut("/api/subscriptions/{id}", async (string id, SubscriptionInput input, V2rayRuntime runtime) => + { + var result = await runtime.UpdateSubscriptionAsync(id, input); + return result.Success ? Results.Ok(result) : Results.BadRequest(result); + }); + app.MapDelete("/api/subscriptions/{id}", async (string id, V2rayRuntime runtime) => + { + var result = await runtime.DeleteSubscriptionAsync(id); + return ApiReplies.Operation(result, failureStatus: StatusCodes.Status404NotFound); + }); + app.MapGet("/api/subscriptions/{id}/share", async (string id, V2rayRuntime runtime) => + { + var url = await runtime.GetSubscriptionShareAsync(id); + return url is null + ? ApiReplies.NotFound("subscription_not_found", ApiMessageKeys.SubscriptionNotFound) + : ApiReplies.Ok(new { id, url }, "subscriptions.shareLoaded"); + }); + app.MapPost("/api/subscriptions/update", (SubscriptionUpdateRequest request, V2rayRuntime runtime) => + runtime.StartSubscriptionUpdate(request.SubscriptionId ?? string.Empty, request.UseProxy) + ? Results.Accepted("/api/operations", OperationView.Ok(ApiMessageKeys.SubscriptionUpdateStarted, + new { subscriptionId = request.SubscriptionId, useProxy = request.UseProxy })) + : ApiReplies.Operation(OperationView.Fail("subscription_update_busy", ApiMessageKeys.SubscriptionUpdateBusy), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/subscriptions/{id}/update", (string id, bool? useProxy, V2rayRuntime runtime) => + runtime.StartSubscriptionUpdate(id, useProxy ?? false) + ? Results.Accepted($"/api/subscriptions/{id}", OperationView.Ok(ApiMessageKeys.SubscriptionUpdateStarted, + new { subscriptionId = id, useProxy = useProxy ?? false })) + : ApiReplies.Operation(OperationView.Fail("subscription_update_busy", ApiMessageKeys.SubscriptionUpdateBusy), failureStatus: StatusCodes.Status409Conflict)); + } + + private static void MapProfiles(WebApplication app) + { + app.MapGet("/api/profile-groups", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetProfileGroupsAsync(), "profiles.groupsLoaded")); + app.MapPut("/api/profile-groups/current", async (ProfileGroupSelectionRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SelectProfileGroupAsync(request.SubscriptionId), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/profile-groups/generate/all", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.GenerateProfileGroupsAsync(subscriptionId, false))); + app.MapPost("/api/profile-groups/generate/regions", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.GenerateProfileGroupsAsync(subscriptionId, true))); + + app.MapGet("/api/profiles", async (string? subscriptionId, string? filter, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetProfilesAsync(subscriptionId, filter), "profiles.loaded")); + app.MapGet("/api/profiles/{id}", async (string id, V2rayRuntime runtime) => + { + var profile = await runtime.GetProfileDetailsAsync(id); + return profile is null + ? ApiReplies.NotFound("profile_not_found", ApiMessageKeys.ProfileNotFound) + : ApiReplies.Ok(profile, ApiMessageKeys.ProfileDetailLoaded); + }); + app.MapPost("/api/profiles/import", async (ProfileImportRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportProfilesAsync(request))); + app.MapPost("/api/profiles", async (ProfileItem profile, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveProfileAsync(profile))); + app.MapPut("/api/profiles/{id}", async (string id, ProfileItem profile, V2rayRuntime runtime) => + { + var result = await runtime.SaveProfileAsync(profile, id); + return ApiReplies.Operation(result, + failureStatus: result.Code == "profile_not_found" ? StatusCodes.Status404NotFound : StatusCodes.Status400BadRequest); + }); + app.MapDelete("/api/profiles", async ([FromBody] ProfileIdsRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteProfilesAsync(request.ProfileIds))); + app.MapPost("/api/profiles/copy", async (ProfileIdsRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.CopyProfilesAsync(request.ProfileIds))); + app.MapPost("/api/profiles/move-to-group", async (MoveProfilesRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveProfilesToGroupAsync(request))); + app.MapPost("/api/profiles/move", async (MoveProfileRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveProfileAsync(request))); + app.MapPost("/api/profiles/sort", async (SortProfilesRequest request, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SortProfilesAsync(request))); + app.MapPost("/api/profiles/deduplicate", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.RemoveDuplicateProfilesAsync(subscriptionId))); + app.MapDelete("/api/profiles/invalid-test-results", async (string? subscriptionId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.RemoveInvalidProfilesAsync(subscriptionId))); + app.MapPost("/api/profiles/export", async (ProfileExportRequest request, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.ExportProfileDataAsync(request), "profiles.exportReady")); + app.MapPost("/api/profiles/{id}/select", async (string id, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.SelectProfileAsync(id, cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/profiles/{id}/latency", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.StartLatencyTestAsync(id), successStatus: StatusCodes.Status202Accepted)); + app.MapPost("/api/speedtests", async (SpeedTestRequest request, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StartSpeedTestAsync(request, cancellationToken), successStatus: StatusCodes.Status202Accepted)); + app.MapDelete("/api/speedtests", (V2rayRuntime runtime) => ApiReplies.Operation(runtime.StopSpeedTests())); + app.MapPost("/api/latency/stop", (V2rayRuntime runtime) => ApiReplies.Operation(runtime.StopLatencyTests())); + } + + private static void MapSettings(WebApplication app) + { + app.MapGet("/api/settings", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetSettingsAsync(), "settings.loaded")); + app.MapPut("/api/settings/apply", async (SettingsApplyInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ApplySettingsAsync(input))); + app.MapPut("/api/settings/inbound", async (InboundSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateInboundSettingsAsync(input))); + app.MapPut("/api/settings/core", async (CoreSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateCoreSettingsAsync(input))); + app.MapPut("/api/settings/application", async (AppSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateAppSettingsAsync(input))); + app.MapPut("/api/settings/speedtest", async (SpeedTestSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateSpeedTestSettingsAsync(input))); + app.MapPut("/api/settings/core-types", async (CoreTypeMappingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateCoreTypeMappingsAsync(input.Mappings))); + app.MapPut("/api/settings/routing", async (RoutingSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateRoutingStrategiesAsync(input))); + app.MapGet("/api/settings/webdav", (V2rayRuntime runtime) => ApiReplies.Ok(runtime.GetWebDavSettings(), "backup.webdavSettingsLoaded")); + app.MapPut("/api/settings/webdav", async (WebDavSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateWebDavSettingsAsync(input))); + + app.MapGet("/api/settings/dns/simple", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetSimpleDNSAsync(), "dns.simpleLoaded")); + app.MapPut("/api/settings/dns/simple", async (ServiceLib.Models.Configs.SimpleDNSItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateSimpleDNSAsync(input))); + app.MapGet("/api/settings/dns/profiles", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetDnsProfilesAsync(), "dns.profilesLoaded")); + app.MapPut("/api/settings/dns/profiles/{coreType}", async (ECoreType coreType, DnsProfileInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.UpdateDnsProfileAsync(coreType, input), failureStatus: StatusCodes.Status404NotFound)); + app.MapGet("/api/settings/dns/editor-options", () => ApiReplies.Ok(V2rayRuntime.GetDnsEditorOptions(), ApiMessageKeys.CommonLoaded)); + app.MapGet("/api/settings/dns/defaults", (V2rayRuntime runtime) => ApiReplies.Ok(runtime.GetDefaultDnsProfiles(), ApiMessageKeys.CommonLoaded)); + + app.MapGet("/api/settings/routing-profiles", async (V2rayRuntime runtime) => ApiReplies.Ok(await runtime.GetRoutingProfilesAsync(), "routing.profilesLoaded")); + app.MapPost("/api/settings/routing-profiles", async (RoutingItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingProfileAsync(input))); + app.MapPut("/api/settings/routing-profiles/{id}", async (string id, RoutingItem input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingProfileAsync(input, id), failureStatus: StatusCodes.Status404NotFound)); + app.MapDelete("/api/settings/routing-profiles/{id}", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteRoutingProfileAsync(id), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/activate", async (string id, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ActivateRoutingProfileAsync(id), failureStatus: StatusCodes.Status404NotFound)); + app.MapGet("/api/settings/routing-profiles/{id}/rules", async (string id, V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetRoutingRulesAsync(id), "routing.rulesLoaded")); + app.MapPut("/api/settings/routing-profiles/{id}/rules", async (string id, List rules, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveRoutingRulesAsync(id, rules), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/rules/import", async (string id, RouteRulesImportInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportRoutingRulesAsync(id, input))); + app.MapPost("/api/settings/routing-profiles/{id}/rules/import-url", async (string id, RouteRulesUrlImportInput input, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.ImportRoutingRulesFromUrlAsync(id, input.Append, cancellationToken))); + app.MapDelete("/api/settings/routing-profiles/{id}/rules/{ruleId}", async (string id, string ruleId, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.DeleteRoutingRuleAsync(id, ruleId), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/routing-profiles/{id}/rules/move", async (string id, RouteRulesMoveRequest input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.MoveRoutingRuleAsync(id, input.RuleId, input.Direction, input.Position))); + app.MapPost("/api/settings/routing-profiles/import", async (V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.ImportRoutingProfilesAsync())); + + app.MapGet("/api/settings/core-templates", async (V2rayRuntime runtime) => + ApiReplies.Ok(await runtime.GetFullConfigTemplatesAsync(), "coreTemplates.loaded")); + app.MapPut("/api/settings/core-templates/{coreType}", async (ECoreType coreType, CoreConfigTemplateInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveFullConfigTemplateAsync(coreType, input), failureStatus: StatusCodes.Status404NotFound)); + app.MapPost("/api/settings/regional-presets/{preset}", async (EPresetType preset, V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.ApplyRegionalPresetAsync(preset, cancellationToken))); + app.MapDelete("/api/statistics", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.ClearStatisticsAsync())); + + app.MapPost("/api/backup/webdav/check", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.CheckWebDavAsync())); + app.MapPost("/api/backup/webdav", async (V2rayRuntime runtime) => ApiReplies.Operation(await runtime.BackupToWebDavAsync())); + app.MapPost("/api/backup/webdav/restore", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.RestoreFromWebDavAsync(cancellationToken), successStatus: StatusCodes.Status202Accepted)); + app.MapGet("/api/backup/download", async (V2rayRuntime runtime) => + { + var (result, filePath) = await runtime.CreateBackupArchiveAsync(); + return result.Success && filePath is not null + ? (IResult)new DeleteAfterFileResult(filePath) + : ApiReplies.Operation(result); + }); + app.MapPost("/api/backup/restore", async (IFormFile file, V2rayRuntime runtime, CancellationToken cancellationToken) => + { + if (file.Length == 0) + { + return ApiReplies.Operation(OperationView.Fail("backup_file_empty", ApiMessageKeys.BackupArchiveInvalid)); + } + if (file.Length > 64L * 1024 * 1024) + { + return ApiReplies.Operation(OperationView.Fail("backup_archive_too_large", ApiMessageKeys.BackupArchiveInvalid)); + } + await using var input = file.OpenReadStream(); + return ApiReplies.Operation(await runtime.RestoreFromUploadAsync(input, cancellationToken), successStatus: StatusCodes.Status202Accepted); + }).DisableAntiforgery() + .WithMetadata(new RequestSizeLimitAttribute(65L * 1024 * 1024)) + .WithMetadata(new RequestFormLimitsAttribute { MultipartBodyLengthLimit = 65L * 1024 * 1024 }); + } + + private static void MapCore(WebApplication app) + { + app.MapGet("/api/core-updates", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetCoreUpdateSettings(), "maintenance.updatesLoaded")); + app.MapGet("/api/web-updates", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetWebUpdateTarget(), "maintenance.updatesLoaded")); + app.MapGet("/api/web-updates/check", async (bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckWebUpdateAsync(preRelease, useProxy, cancellationToken))); + app.MapPost("/api/web-updates/update", (bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation(runtime.StartWebUpdate(preRelease, useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core-updates/progress", (V2rayRuntime runtime) => + ApiReplies.Ok(runtime.GetCoreUpdateProgress(), "maintenance.updatesLoaded")); + app.MapPut("/api/core-updates/settings", async (CoreUpdateSettingsInput input, V2rayRuntime runtime) => + ApiReplies.Operation(await runtime.SaveCoreUpdateSettingsAsync(input))); + app.MapPost("/api/core-updates/batch", (CoreUpdateBatchInput input, V2rayRuntime runtime) => + ApiReplies.Operation(runtime.StartSelectedCoreUpdateBatch(input.Apply), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core-updates/{coreType}/check", async (ECoreType coreType, bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckCoreUpdateAsync(coreType, preRelease, useProxy, cancellationToken))); + app.MapPost("/api/core-updates/{coreType}/update", (ECoreType coreType, bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartCoreUpdate(coreType, preRelease, useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapGet("/api/core/xray/check-update", async (bool? preRelease, bool? useProxy, V2rayRuntime runtime, CancellationToken cancellationToken) => + Results.Ok(await runtime.CheckXrayUpdateAsync(preRelease ?? false, useProxy ?? true, cancellationToken))); + app.MapPost("/api/core/xray/update", (bool? preRelease, bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartCoreUpdate(ECoreType.Xray, preRelease ?? false, useProxy ?? true), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/core/geo/update", (bool? useProxy, V2rayRuntime runtime) => + ApiReplies.Operation( + runtime.StartGeoUpdate(useProxy), + successStatus: StatusCodes.Status202Accepted, + failureStatus: StatusCodes.Status409Conflict)); + + app.MapPost("/api/core/start", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StartCoreAsync(null, cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + app.MapPost("/api/core/stop", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.StopCoreAsync(cancellationToken))); + app.MapPost("/api/core/restart", async (V2rayRuntime runtime, CancellationToken cancellationToken) => + ApiReplies.Operation(await runtime.RestartCoreAsync(cancellationToken), failureStatus: StatusCodes.Status409Conflict)); + } + + private static void MapEvents(WebApplication app) + { + app.MapGet("/api/events", async (HttpContext context, EventHub events, WebSessionService sessions, IHostApplicationLifetime lifetime) => + { + var session = context.Items.TryGetValue(WebSessionService.SessionSnapshotContextKey, out var sessionValue) + ? sessionValue as WebSessionSnapshot + : null; + if (session is null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var revoked = session.RevocationToken; + var applicationStopping = lifetime.ApplicationStopping; + // The stream must end as soon as the host starts stopping: Kestrel's + // graceful stop waits for open requests, and an attached client would + // otherwise pin the whole host shutdown budget. + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + context.RequestAborted, + revoked, + applicationStopping); + var cancellationToken = linkedCancellation.Token; + + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + + using var heartbeat = new PeriodicTimer(TimeSpan.FromMinutes(1)); + var includeLogs = string.Equals(context.Request.Query["include_logs"], "true", StringComparison.OrdinalIgnoreCase); + await using var subscription = events.Subscribe(cancellationToken, includeLogs).GetAsyncEnumerator(cancellationToken); + var nextEvent = subscription.MoveNextAsync().AsTask(); + var nextHeartbeat = heartbeat.WaitForNextTickAsync(cancellationToken).AsTask(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var completed = await Task.WhenAny(nextEvent, nextHeartbeat); + if (completed == nextHeartbeat) + { + if (!await nextHeartbeat) + { + break; + } + + if (!await TryWriteSseHeartbeatAsync(context, sessions, session, cancellationToken)) + { + break; + } + nextHeartbeat = heartbeat.WaitForNextTickAsync(cancellationToken).AsTask(); + continue; + } + + if (!await nextEvent) + { + break; + } + + var message = subscription.Current; + var payload = SerializeEventData(message.Data); + await context.Response.WriteAsync($"event: {message.Type}\ndata: {payload}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + nextEvent = subscription.MoveNextAsync().AsTask(); + } + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested || revoked.IsCancellationRequested) + { + await WriteUnauthorizedSseResponseAsync(context); + } + catch (OperationCanceledException) when (applicationStopping.IsCancellationRequested) + { + // The host is shutting down; end the stream quietly so the web + // server can finish its graceful stop instead of waiting for it. + } + finally + { + // Task.WhenAny can leave either asynchronous wait pending when the stream is revoked. + // Cancel and drain both before disposing the async iterator and timer. + linkedCancellation.Cancel(); + try + { + await nextEvent; + } + catch (OperationCanceledException) + { + } + + try + { + await nextHeartbeat; + } + catch (OperationCanceledException) + { + } + } + }); + } + + internal static async Task TryWriteSseHeartbeatAsync( + HttpContext context, + WebSessionService sessions, + WebSessionSnapshot session, + CancellationToken cancellationToken) + { + if (!sessions.TryValidateSseSessionWithoutRenewal(session.SessionDigest, out _)) + { + await WriteUnauthorizedSseResponseAsync(context); + return false; + } + + await context.Response.WriteAsync(": keep-alive\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + return true; + } + + private static async Task WriteUnauthorizedSseResponseAsync(HttpContext context) + { + if (context.Response.HasStarted || context.RequestAborted.IsCancellationRequested) + { + return; + } + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized), + context.RequestAborted); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs new file mode 100644 index 00000000..a28593e0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebAuthEndpoints.cs @@ -0,0 +1,53 @@ +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Api; + +public static class WebAuthEndpoints +{ + public static void MapWebAuthEndpoints(this WebApplication app) + { + app.MapPost("/api/auth/login", (WebLoginRequest request, WebAuthService auth, WebSessionService sessions) => + { + if (string.IsNullOrEmpty(request.Key) + || request.Key.Length > WebAuthService.MaximumKeyLength + || !auth.ValidateManagementKey(request.Key)) + { + return Results.Json( + ApiEnvelope.Fail("management_key_invalid", ApiMessageKeys.CommonUnauthorized), + statusCode: StatusCodes.Status401Unauthorized); + } + + var session = sessions.CreateSession(); + return Results.Ok(new + { + success = true, + data = new { token = session.Token, expiresAt = session.ExpiresAt }, + }); + }).RequireRateLimiting(WebAuthRateLimiting.LoginPolicy); + + app.MapPost("/api/auth/logout", (HttpContext context, WebSessionService sessions) => + { + sessions.Revoke(WebSessionService.ExtractPresentedToken(context)); + return Results.Ok(new { success = true }); + }); + + app.MapPost("/api/auth/sse-ticket", (HttpContext context, WebSessionService sessions) => + { + if (!sessions.TryCreateSseTicket(WebSessionService.ExtractPresentedToken(context), out var ticket)) + { + return Results.Json( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized), + statusCode: StatusCodes.Status401Unauthorized); + } + + return Results.Ok(new + { + success = true, + data = new { ticket = ticket!.Token, expiresAt = ticket.ExpiresAt }, + }); + }); + } + + public sealed record WebLoginRequest(string? Key); +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs b/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs new file mode 100644 index 00000000..89e599cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebHealthEndpoint.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Api; + +internal sealed record WebHealthResponse(string Status); + +internal static class WebHealthEndpoint +{ + public static WebHealthResponse CreateResponse(HttpContext context, V2rayRuntime runtime) + { + // Native launcher/updater probes connect directly to a loopback address. Require + // the same peer/Host pair the probe rule accepts, and no forwarding headers, so + // a local reverse proxy cannot relay internal process diagnostics to a remote + // caller. + if (WebSetupAccessPolicy.IsLocalDiagnosticSource( + context.Connection.RemoteIpAddress, context.Request.Host.Host) + && WebCorsPolicy.IsSameOriginRequest(context.Request) + && !WebSetupAccessPolicy.HasForwardedHeaders(context.Request.Headers)) + { + context.Response.Headers["X-v2rayn-WebAPI-instance-pid"] = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + context.Response.Headers["X-v2rayn-WebAPI-version"] = WebBuildIdentity.Current.Version; + context.Response.Headers["X-v2rayn-WebAPI-core-state"] = runtime.GetCoreRuntimeState(); + context.Response.Headers["X-v2rayn-WebAPI-core-process-ids"] = string.Join(',', runtime.GetCoreProcessIds()); + context.Response.Headers["X-v2rayn-WebAPI-core-profile-id"] = runtime.GetCoreRuntimeProfileId() ?? string.Empty; + if (ShutdownDiagnostics.CurrentStage is { Length: > 0 } stage) + { + context.Response.Headers["X-v2rayn-WebAPI-shutdown-stage"] = stage; + } + } + + return new WebHealthResponse("ok"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs b/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs new file mode 100644 index 00000000..d081fbce --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Api/WebSetupEndpoints.cs @@ -0,0 +1,45 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Api; + +public static class WebSetupEndpoints +{ + public static void MapWebSetupEndpoints(this WebApplication app) + { + app.MapGet("/api/setup/status", (HttpContext context, WebAuthService auth) => Results.Ok(new + { + setupRequired = auth.SetupRequired, + environmentKeyConfigured = auth.EnvironmentKeyConfigured, + setupAllowedFromThisRequest = WebSetupAccessPolicy.IsAllowedFromRequest(context), + })); + + app.MapPost("/api/setup", async (WebSetupRequest request, HttpContext context, WebAuthService auth, WebSessionService sessions) => + { + var setupAccessAllowed = WebSetupAccessPolicy.IsAllowedFromRequest(context); + var result = await auth.SetupAsync( + request.Key, + request.ConfirmKey, + setupAccessAllowed, + context.RequestAborted); + + return result switch + { + WebSetupResult.Created => CreateSetupResponse(sessions), + WebSetupResult.Forbidden => Results.Json(new { error = "private_network_required" }, statusCode: StatusCodes.Status403Forbidden), + WebSetupResult.KeyTooShort => Results.BadRequest(new { error = "key_too_short", minimumLength = WebAuthService.MinimumKeyLength }), + WebSetupResult.KeyTooLong => Results.BadRequest(new { error = "key_too_long", maximumLength = WebAuthService.MaximumKeyLength }), + WebSetupResult.KeysDoNotMatch => Results.BadRequest(new { error = "keys_do_not_match" }), + WebSetupResult.AlreadyConfigured => Results.Conflict(new { error = "already_configured" }), + _ => Results.StatusCode(StatusCodes.Status500InternalServerError), + }; + }).RequireRateLimiting(WebAuthRateLimiting.SetupPolicy); + } + + private static IResult CreateSetupResponse(WebSessionService sessions) + { + var session = sessions.CreateSession(); + return Results.Ok(new { setupRequired = false, token = session.Token, expiresAt = session.ExpiresAt }); + } + + public sealed record WebSetupRequest(string? Key, string? ConfirmKey); +} diff --git a/v2rayN/v2rayN.WebAPI/Assets/web-assets.json b/v2rayN/v2rayN.WebAPI/Assets/web-assets.json new file mode 100644 index 00000000..9b8c526c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Assets/web-assets.json @@ -0,0 +1,40 @@ +{ + "assets": [ + { + "rid": "linux-x64", + "arch": "64", + "full": "v2rayN-linux-64-WebAPI.zip", + "update": "v2rayN-linux-64-WebAPI-update.zip" + }, + { + "rid": "linux-arm64", + "arch": "arm64", + "full": "v2rayN-linux-arm64-WebAPI.zip", + "update": "v2rayN-linux-arm64-WebAPI-update.zip" + }, + { + "rid": "win-x64", + "arch": "64", + "full": "v2rayN-windows-64-WebAPI.zip", + "update": "v2rayN-windows-64-WebAPI-update.zip" + }, + { + "rid": "win-arm64", + "arch": "arm64", + "full": "v2rayN-windows-arm64-WebAPI.zip", + "update": "v2rayN-windows-arm64-WebAPI-update.zip" + }, + { + "rid": "linux-riscv64", + "arch": "riscv64", + "full": "v2rayN-linux-riscv64-WebAPI.zip", + "update": "v2rayN-linux-riscv64-WebAPI-update.zip" + }, + { + "rid": "linux-loongarch64", + "arch": "loong64", + "full": "v2rayN-linux-loong64-WebAPI.zip", + "update": "v2rayN-linux-loong64-WebAPI-update.zip" + } + ] +} diff --git a/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs b/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs new file mode 100644 index 00000000..8dc0d485 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Configuration/NativeEnvironmentFile.cs @@ -0,0 +1,178 @@ +using System.Collections; +using System.Text; + +namespace v2rayN.WebAPI.Configuration; + +internal sealed class NativeEnvironmentConfigurationException : Exception +{ + public NativeEnvironmentConfigurationException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} + +internal static class NativeEnvironmentFile +{ + internal const int MaximumFileBytes = 64 * 1024; + internal const int MaximumValueBytes = 8 * 1024; + + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// + /// Reads a minimal, non-shell KEY=value format. Comments occupy whole lines; the first '=' + /// separates key and value. A value may be unquoted or wrapped in one matching pair of + /// single/double quotes. Quotes cannot be escaped, multiline values and shell syntax are + /// rejected, and the first occurrence of a duplicate key wins. + /// + internal static void LoadFromExecutableDirectory(string executableDirectory) + { + var path = Path.Combine(Path.GetFullPath(executableDirectory), ".env"); + if (Directory.Exists(path)) + throw new NativeEnvironmentConfigurationException($"Invalid environment configuration in '{path}': expected a regular file."); + if (!File.Exists(path)) return; + + Dictionary values; + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + + using var contents = new MemoryStream(); + var buffer = new byte[4096]; + while (true) + { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (contents.Length + read > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + contents.Write(buffer, 0, read); + } + + values = Parse(StrictUtf8.GetString(contents.GetBuffer(), 0, checked((int)contents.Length))); + } + catch (NativeEnvironmentConfigurationException exception) + { + throw new NativeEnvironmentConfigurationException($"Invalid environment configuration in '{path}': {exception.Message}", exception); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DecoderFallbackException) + { + throw new NativeEnvironmentConfigurationException($"Could not read environment configuration from '{path}'.", exception); + } + + var processEnvironment = ReadProcessEnvironment(); + var mergedValues = Merge(values, processEnvironment); + foreach (var key in values.Keys) + { + // Never replace a variable inherited from the OS, systemd, or the container runtime. + if (!processEnvironment.ContainsKey(key)) + Environment.SetEnvironmentVariable(key, mergedValues[key]); + } + } + + internal static Dictionary Parse(string contents) + { + ArgumentNullException.ThrowIfNull(contents); + if (StrictUtf8.GetByteCount(contents) > MaximumFileBytes) + throw new NativeEnvironmentConfigurationException($"The file exceeds the {MaximumFileBytes}-byte size limit."); + if (contents.Contains('\0')) + throw new NativeEnvironmentConfigurationException("NUL characters are not allowed."); + + if (contents.Length > 0 && contents[0] == '\uFEFF') + contents = contents[1..]; + + var parsed = new Dictionary(StringComparer.Ordinal); + using var reader = new StringReader(contents); + var lineNumber = 0; + while (reader.ReadLine() is { } rawLine) + { + lineNumber++; + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + + var separator = line.IndexOf('='); + if (separator <= 0) + throw InvalidLine(lineNumber, "expected KEY=value"); + + var key = line[..separator]; + if (!IsValidKey(key)) + throw InvalidLine(lineNumber, "invalid key name"); + + var value = ParseValue(line[(separator + 1)..], lineNumber); + // Duplicate keys are valid but deterministic: the first occurrence wins. + if (!parsed.ContainsKey(key)) parsed.Add(key, value); + } + + return parsed; + } + + internal static Dictionary Merge( + IReadOnlyDictionary fileValues, + IReadOnlyDictionary processEnvironment) + { + var merged = new Dictionary(EnvironmentVariableComparer()); + foreach (var (key, fileValue) in fileValues) + { + // Presence wins even when the process supplied an empty value. + merged[key] = processEnvironment.TryGetValue(key, out var processValue) + ? processValue ?? string.Empty + : fileValue; + } + return merged; + } + + private static Dictionary ReadProcessEnvironment() + { + var values = new Dictionary(EnvironmentVariableComparer()); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + values[(string)entry.Key] = entry.Value?.ToString(); + return values; + } + + private static string ParseValue(string value, int lineNumber) + { + if (value.Length > 0 && value[0] is '\'' or '"') + { + var quote = value[0]; + if (value.Length < 2 || value[^1] != quote) + throw InvalidLine(lineNumber, "malformed quoted value"); + + value = value[1..^1]; + if (value.Contains(quote)) + throw InvalidLine(lineNumber, "quoted values do not support escapes or embedded matching quotes"); + } + else if (value.Contains('\'') || value.Contains('"')) + { + throw InvalidLine(lineNumber, "quotes must wrap the entire value"); + } + + if (StrictUtf8.GetByteCount(value) > MaximumValueBytes) + throw InvalidLine(lineNumber, $"value exceeds the {MaximumValueBytes}-byte size limit"); + + // This is a plain-text format, not a shell: reject shell expansion/escape syntax rather + // than interpreting it. No command or variable expansion is ever performed. + if (value.Contains('$') || value.Contains('`') || value.Contains('\\')) + throw InvalidLine(lineNumber, "shell expansion and escape syntax are not supported"); + + return value; + } + + private static bool IsValidKey(string key) + { + if (key.Length == 0 || !IsAsciiLetter(key[0]) && key[0] != '_') return false; + for (var index = 1; index < key.Length; index++) + { + var character = key[index]; + if (!IsAsciiLetter(character) && !char.IsAsciiDigit(character) && character != '_') return false; + } + return true; + } + + private static bool IsAsciiLetter(char value) => value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static StringComparer EnvironmentVariableComparer() => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static NativeEnvironmentConfigurationException InvalidLine(int lineNumber, string reason) => + new($"Line {lineNumber}: {reason}."); +} diff --git a/v2rayN/v2rayN.WebAPI/Containerfile b/v2rayN/v2rayN.WebAPI/Containerfile new file mode 100644 index 00000000..5f26498a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Containerfile @@ -0,0 +1,49 @@ +ARG RID=linux-x64 +ARG WEB_VERSION=0.0.0-dev +ARG WEB_COMMIT=unknown +ARG WEB_BUILD_DATE=unknown +ARG WEB_REPOSITORY=2dust/v2rayN + +FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.22 AS core-assets +ARG RID +RUN apk add --no-cache ca-certificates curl unzip +COPY v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh /usr/local/bin/fetch-core-bundle +RUN sh /usr/local/bin/fetch-core-bundle "$RID" /out/bin + +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS publish +ARG RID +ARG WEB_VERSION +ARG WEB_COMMIT +ARG WEB_BUILD_DATE +ARG WEB_REPOSITORY +WORKDIR /src +COPY v2rayN/ ./v2rayN/ +RUN NUGET_PACKAGES=/src/.packages/nuget dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj \ + --configuration Release \ + --runtime "$RID" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="$WEB_VERSION" \ + -p:WebCommit="$WEB_COMMIT" \ + -p:WebBuildDate="$WEB_BUILD_DATE" \ + -p:WebRepository="$WEB_REPOSITORY" \ + --output /out \ + && mkdir -p /runtime-data +# Copy the complete upstream bin/ payload; do not curate or enumerate Core files here. +COPY --from=core-assets /out/bin/ /out/bin/ + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime +WORKDIR /app +COPY --from=publish --chown=10001:10001 /out/ ./ +COPY --from=publish --chown=10001:10001 /runtime-data/ /data/ +ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ + V2RAYN_DATA_HOME=/data \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR=/data/.net-bundle \ + V2RAYN_WEB_PROXY_PORT=10808 \ + V2RAYN_WEB_PROXY_LISTEN_ALL=true \ + V2RAYN_WEB_AUTOSTART=true +VOLUME ["/data"] +EXPOSE 5080/tcp 10808/tcp 10808/udp +USER 10001:10001 +ENTRYPOINT ["/app/v2rayN.WebAPI", "--foreground"] diff --git a/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore b/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore new file mode 100644 index 00000000..73a839d6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Containerfile.dockerignore @@ -0,0 +1,19 @@ +.git +.env +**/.env +.packages +**/.packages +**/.NET +**/bin +**/obj +**/node_modules +**/dist +**/publish +**/.npm-cache +**/TestResults +**/guiConfigs +**/guiLogs +**/guiTemps +**/guiBackups +v2rayN/v2rayN.WebAPI/Tests +v2rayN/v2rayN.WebAPI/core-bin diff --git a/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs b/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs new file mode 100644 index 00000000..bfb6a402 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Contracts/ApiModels.cs @@ -0,0 +1,572 @@ +using ServiceLib.Enums; + +namespace v2rayN.WebAPI.Contracts; + +public sealed record SubscriptionInput( + string Remarks, + string Url, + string? MoreUrl = null, + bool? Enabled = null, + string? UserAgent = null, + string? RequestHeaders = null, + string? Filter = null, + int? AutoUpdateInterval = null, + string? ConvertTarget = null, + string? Memo = null, + int? Sort = null, + string? PrevProfile = null, + string? NextProfile = null, + int? PreSocksPort = null, + ECoreType? CustomCoreType = null); + +public sealed record SubscriptionView( + string Id, + string Remarks, + string Url, + bool Enabled, + string? MoreUrl, + string? Filter, + int AutoUpdateInterval, + long UpdateTime, + string UserAgent, + string? RequestHeaders, + string? ConvertTarget, + string? Memo, + int Sort, + string? PrevProfile, + string? NextProfile, + int? PreSocksPort, + string? CustomCoreType); + +public sealed record SubscriptionMutationResult(bool Success, string Code, string MessageKey, SubscriptionView? Data); + +public sealed record ProfileView( + string IndexId, + string Remarks, + string Protocol, + string Address, + int Port, + string Network, + string StreamSecurity, + string? SubscriptionId, + string? SubscriptionName, + string CoreType, + int Sort, + int Delay, + decimal Speed, + string? IpInfo, + long TodayUp, + long TodayDown, + long TotalUp, + long TotalDown, + bool IsCurrent, + bool CanTest); + +public sealed record ProfileGroupView(string Id, string Code, string? Name, string? MessageKey, int ProfileCount, bool IsCurrent); + +public sealed record ProfileImportRequest(string Content, string? SubscriptionId = null, bool IsSubscription = false); + +public sealed record ProfileIdsRequest(string[] ProfileIds); + +public sealed record ProfileExportRequest( + string[] ProfileIds, + bool IncludeShareUris = true, + bool Base64ShareUris = false, + bool IncludeInnerUri = false, + bool IncludeClientConfig = true); + +public sealed record MoveProfilesRequest(string[] ProfileIds, string SubscriptionId); + +public sealed record SortProfilesRequest(string? SubscriptionId, string Column, bool Ascending); + +public sealed record MoveProfileRequest(string ProfileId, EMove Direction, int Position = -1); + +public sealed record ProfileGroupSelectionRequest(string? SubscriptionId); + +public sealed record SpeedTestRequest(ESpeedActionType Action, string[]? ProfileIds = null); + +public sealed record SubscriptionUpdateRequest(string? SubscriptionId = null, bool UseProxy = false); + +public sealed record InboundSettingsView( + int LocalPort, + bool SecondLocalPortEnabled, + bool UdpEnabled, + bool SniffingEnabled, + string[] DestOverride, + bool RouteOnly, + bool AllowLANConn, + bool NewPort4LAN, + string? User, + string? Pass); + +public sealed record CoreSettingsView( + bool LogEnabled, + string? Loglevel, + string? DefFingerprint, + string? DefUserAgent, + string? SendThrough, + string? BindInterface, + int? Mux4RayConcurrency, + int? Mux4RayXudpConcurrency, + string? Mux4RayXudpProxyUDP443, + string? Mux4SboxProtocol, + bool? Mux4SboxPadding, + bool EnableCacheFile4Sbox, + int Hy2UpMbps, + int Hy2DownMbps, + bool EnableFragment, + bool EnableFinalFragment, + string? FragmentPackets, + string[] FragmentLengths, + string[] FragmentDelays, + string? FragmentMaxSplit); + +public sealed record AppSettingsView( + bool EnableStatistics, + bool DisplayRealTimeSpeed, + bool KeepOlderDedupl, + int GeoAutoUpdateInterval, + string? RootCertProvider, + string? GeoSourceUrl, + string? SrsSourceUrl, + string? RouteRulesTemplateSourceUrl, + string? SubConvertUrl); + +public sealed record SpeedTestSettingsView( + int SpeedTestTimeout, + string? SpeedTestUrl, + string? SpeedPingTestUrl, + int MixedConcurrencyCount, + string? IPAPIUrl, + string? UdpTestTarget); + +public sealed record WebSettingsOptionsView( + string[] LogLevels, + string[] RootCertProviders, + string[] Fingerprints, + string[] UserAgents, + string[] Mux4SboxProtocols, + string[] Mux4RayXudpProxyUDP443Options, + string[] FragmentPacketsOptions, + string[] DestOverrideProtocols, + string[] RoutingBasicDomainStrategies, + string[] RoutingBasicDomainStrategies4Singbox, + string[] RoutingProfileDomainStrategies, + string[] RoutingProfileDomainStrategies4Singbox, + int[] MixedConcurrencyCounts, + int[] SpeedTestTimeouts, + string[] SpeedTestUrls, + string[] SpeedPingTestUrls, + string[] UdpTestTargets, + string[] IPAPIUrls, + string[] SubConvertUrls, + string[] GeoFilesSources, + string[] SingboxRulesetSources, + string[] RoutingRulesSources); + +public sealed record CoreTypeMapping(EConfigType ConfigType, ECoreType CoreType); + +public sealed record WebSettingsView( + InboundSettingsView Inbound, + CoreSettingsView Core, + AppSettingsView App, + SpeedTestSettingsView SpeedTest, + string? DomainStrategy, + string? DomainStrategy4Singbox, + IReadOnlyList CoreTypes, + WebSettingsOptionsView Options, + bool ShowIpInfoColumn); + +public sealed record InboundSettingsInput( + int LocalPort, + bool SecondLocalPortEnabled, + bool UdpEnabled, + bool SniffingEnabled, + string[]? DestOverride, + bool RouteOnly, + bool AllowLANConn, + bool NewPort4LAN, + string? User, + string? Pass); + +public sealed record CoreSettingsInput( + bool LogEnabled, + string? Loglevel, + string? DefFingerprint, + string? DefUserAgent, + string? SendThrough, + string? BindInterface, + int? Mux4RayConcurrency, + int? Mux4RayXudpConcurrency, + string? Mux4RayXudpProxyUDP443, + string? Mux4SboxProtocol, + bool? Mux4SboxPadding, + bool EnableCacheFile4Sbox, + int Hy2UpMbps, + int Hy2DownMbps, + bool EnableFragment, + bool EnableFinalFragment, + string? FragmentPackets, + string[]? FragmentLengths, + string[]? FragmentDelays, + string? FragmentMaxSplit); + +public sealed record AppSettingsInput( + bool EnableStatistics, + bool DisplayRealTimeSpeed, + bool KeepOlderDedupl, + int GeoAutoUpdateInterval, + string? RootCertProvider, + string? GeoSourceUrl, + string? SrsSourceUrl, + string? RouteRulesTemplateSourceUrl, + string? SubConvertUrl); + +public sealed record SpeedTestSettingsInput( + int SpeedTestTimeout, + string SpeedTestUrl, + string SpeedPingTestUrl, + int MixedConcurrencyCount, + string IPAPIUrl, + string UdpTestTarget); + +public sealed record RoutingSettingsInput(string? DomainStrategy, string? DomainStrategy4Singbox); + +public sealed record CoreConfigTemplateView( + string Id, + string Remarks, + bool Enabled, + ECoreType CoreType, + string? Config, + string? TunConfig, + bool? AddProxyOnly, + string? ProxyDetour); + +public sealed record CoreConfigTemplateInput( + string? Remarks, + bool Enabled, + string? Config, + string? TunConfig, + bool? AddProxyOnly, + string? ProxyDetour); + +public sealed record DnsProfileView( + string Id, + string Remarks, + bool Enabled, + ECoreType CoreType, + bool UseSystemHosts, + string? NormalDNS, + string? TunDNS, + string? DomainStrategy4Freedom, + string? DomainDNSAddress); + +public sealed record DnsProfileInput( + string? Remarks, + bool Enabled, + bool UseSystemHosts, + string? NormalDNS, + string? TunDNS, + string? DomainStrategy4Freedom, + string? DomainDNSAddress); + +public sealed record DnsDefaultProfileView(ECoreType CoreType, string NormalDNS, string TunDNS); + +public sealed record DnsEditorOptionsView( + string[] CoreTypes, + string[] DomainStrategies4Freedom, + string[] DomainStrategies4Singbox, + string[] DomainDnsAddresses, + string[] DirectDnsAddresses, + string[] RemoteDnsAddresses, + string[] BootstrapDnsAddresses, + string[] FakeIpRanges, + string[] ExpectedIps); + +public sealed record SubscriptionEditorOptionsView(string[] ConvertTargets, string[] CustomCoreTypes); + +public sealed record ProfileEditorOptionsView( + string[] CoreTypes, + string[] ConfigTypes, + string[] SingboxOnlyConfigTypes, + string[] Networks, + string DefaultNetwork, + string DefaultStreamSecurity, + string DefaultSecurity, + string DefaultVlessEncryption, + string DefaultXhttpMode, + string DefaultGrpcMode, + int DefaultWireGuardMtu, + string[] RawHeaderTypes, + string[] XhttpModes, + string[] KcpHeaderTypes, + string[] GrpcModes, + string[] VmessSecurities, + string[] Flows, + string[] TuicCongestionControls, + string[] NaiveCongestionControls, + string[] Fingerprints, + string[] Alpns, + IReadOnlyDictionary ShadowsocksSecurities, + string[] StreamSecurityTypes, + string[] MultipleLoadStrategies); + +public sealed record WebEditorOptionsView( + SubscriptionEditorOptionsView Subscriptions, + ProfileEditorOptionsView Profiles, + DnsEditorOptionsView Dns); + +public sealed record RouteRulesImportInput(string Content, bool Append = false); + +public sealed record RouteRulesUrlImportInput(bool Append = false); + +public sealed record ProfileExportItem(string IndexId, string Remarks, string Format, string Content); + +public sealed record TrafficView(long ProxyUp, long ProxyDown, long DirectUp, long DirectDown); + +public sealed record CoreUpdateCheckView(bool UpdateAvailable, string? Version, bool IsUpToDate = false, string? Detail = null); + +public sealed record CoreUpdateTargetView( + string CoreType, + string NameKey, + bool IsSupported, + bool CanInstall, + bool SupportsPreRelease, + bool Selected, + string? UnsupportedReasonKey); + +public sealed record CoreUpdateSettingsView( + IReadOnlyList Targets, + bool GeoFilesSelected, + bool PreRelease, + bool UseProxy, + IReadOnlyList? CheckPreReleaseCoreTypes = null); + +public sealed record CoreUpdateSettingsInput( + string[] SelectedCoreTypes, + bool PreRelease, + bool UseProxy, + string[]? CheckPreReleaseCoreTypes = null); + +public sealed record CoreUpdateBatchInput(bool Apply); + +public sealed record CoreUpdateProgressView( + string CoreType, + string Phase, + bool IsComplete, + bool Success, + bool CoreWasRunning, + string? Version, + string? Detail, + bool Batch = false, + bool? RollbackSucceeded = null); + +public sealed record WebUpdateTargetView( + string Name, + string Version, + string Commit, + string BuildDate, + string Rid, + string Deployment, + bool IsSupported, + bool CanCheck, + bool CanInstall, + bool Selected, + string? InstallReasonKey, + string? LatestVersion = null); + +public sealed record WebUpdateCheckView( + bool UpdateAvailable, + string CurrentVersion, + string CurrentCommit, + string Rid, + string? LatestVersion, + string? LatestCommit, + bool CanInstall, + string? InstallReasonKey, + string? Detail = null); + +public sealed record WebDavSettingsView(string? Url, string? UserName, string? DirName, bool HasPassword); + +public sealed record WebDavSettingsInput(string? Url, string? UserName, string? Password, string? DirName); + +public sealed record RouteRulesMoveRequest(string RuleId, EMove Direction, int Position = -1); + +public sealed record CoreTypeMappingsInput(IReadOnlyList Mappings); + +public sealed record SettingsApplyInput( + InboundSettingsInput Inbound, + CoreSettingsInput Core, + AppSettingsInput Application, + SpeedTestSettingsInput SpeedTest, + IReadOnlyList CoreTypes, + string? DomainStrategy, + string? DomainStrategy4Singbox); + +public sealed record ApiEnvelope(bool Success, string Code, string MessageKey, T? Data) +{ + public static ApiEnvelope Ok(T? data, string messageKey = ApiMessageKeys.CommonLoaded) => new(true, "ok", messageKey, data); + + public static ApiEnvelope Fail(string code, string messageKey, T? data = default) => new(false, code, messageKey, data); +} + +public sealed record OperationView(bool Success, string Code, string MessageKey, object? Data = null) +{ + public static OperationView Ok(string messageKey, object? data = null) => new(true, "ok", messageKey, data); + + public static OperationView Fail(string code, string messageKey, object? data = null) => new(false, code, messageKey, data); +} + +public static class ApiMessageKeys +{ + public const string CommonLoaded = "common.loaded"; + public const string CommonCompleted = "common.completed"; + public const string CommonSaved = "common.saved"; + public const string CommonDeleted = "common.deleted"; + public const string CommonNotFound = "errors.notFound"; + public const string CommonInvalidInput = "errors.invalidInput"; + public const string CommonUnauthorized = "errors.unauthorized"; + public const string CommonInternal = "errors.internal"; + public const string CommonRouteNotFound = "errors.routeNotFound"; + public const string CommonCoreRestartRequired = "settings.savedRestartRequired"; + public const string CoreStarted = "core.started"; + public const string CoreStopped = "core.stopped"; + public const string CoreRestarted = "core.restarted"; + public const string CoreStartFailed = "core.startFailed"; + public const string CoreStopFailed = "core.stopFailed"; + public const string CoreNotRunning = "core.notRunning"; + public const string CoreRuntimeBusy = "core.runtimeBusy"; + public const string WebUpdateCheckFailed = "maintenance.webUpdateCheckFailed"; + public const string CoreBinaryMissing = "core.binaryMissing"; + public const string CorePortInUse = "core.portInUse"; + public const string CoreTunNotSupported = "core.tunNotSupported"; + public const string CoreUnsupported = "core.unsupported"; + public const string ProfileNotFound = "profiles.notFound"; + public const string ProfileDetailLoaded = "profiles.detailLoaded"; + public const string ProfileNoneSelected = "profiles.noneSelected"; + public const string ProfileInvalid = "profiles.invalid"; + public const string ProfileUnsupportedCore = "profiles.unsupportedCore"; + public const string ProfileSelectRequired = "profiles.selectRequired"; + public const string ProfileNotInGroup = "profiles.notInGroup"; + public const string ProfileImported = "profiles.imported"; + public const string ProfileDeleted = "profiles.deleted"; + public const string ProfileCopied = "profiles.copied"; + public const string ProfileMoved = "profiles.moved"; + public const string ProfileOrderSaved = "profiles.orderSaved"; + public const string ProfileDeduplicated = "profiles.deduplicated"; + public const string ProfileInvalidRemoved = "profiles.invalidRemoved"; + public const string ProfileGrouped = "profiles.grouped"; + public const string ProfileSaved = "profiles.saved"; + public const string ProfileSaveFailed = "profiles.saveFailed"; + public const string SubscriptionNotFound = "subscriptions.notFound"; + public const string SubscriptionAdded = "subscriptions.added"; + public const string SubscriptionSaved = "subscriptions.saved"; + public const string SubscriptionDeleted = "subscriptions.deleted"; + public const string SubscriptionUpdateStarted = "subscriptions.updateStarted"; + public const string SubscriptionUpdateBusy = "subscriptions.updateBusy"; + public const string SubscriptionInvalidUrl = "subscriptions.invalidUrl"; + public const string SubscriptionNameRequired = "subscriptions.nameRequired"; + public const string SubscriptionSaveFailed = "subscriptions.saveFailed"; + public const string SpeedTestStarted = "speedtest.started"; + public const string SpeedTestBusy = "speedtest.busy"; + public const string SpeedTestUnsupportedCore = "speedtest.unsupportedCore"; + public const string SpeedTestSelectRequired = "speedtest.selectRequired"; + public const string SpeedTestInvalidProfile = "speedtest.invalidProfile"; + public const string SpeedTestSettingsSaved = "speedtest.settingsSaved"; + public const string SpeedTestResult = "speedtest.result"; + public const string SettingsInvalidPort = "settings.invalidPort"; + public const string SettingsInvalidCoreLogLevel = "settings.invalidCoreLogLevel"; + public const string SettingsInvalidCoreValue = "settings.invalidCoreValue"; + public const string SettingsInvalidFragment = "settings.invalidFragment"; + public const string SettingsInvalidSpeedTest = "settings.invalidSpeedTest"; + public const string SettingsCoreApplyFailed = "settings.coreApplyFailed"; + public const string SettingsInvalidRoutingStrategy = "settings.invalidRoutingStrategy"; + public const string DnsProfileNotFound = "dns.profileNotFound"; + public const string DnsSaveFailed = "dns.saveFailed"; + public const string RoutingNameRequired = "routing.nameRequired"; + public const string RoutingProfileNotFound = "routing.profileNotFound"; + public const string RoutingRulesInvalid = "routing.rulesInvalid"; + public const string RoutingRuleNotFound = "routing.ruleNotFound"; + public const string RoutingSaveFailed = "routing.saveFailed"; + public const string RegionalPresetInvalid = "settings.invalidRegionalPreset"; + public const string RegionalPresetFailed = "settings.regionalPresetFailed"; + public const string XrayUpdateCheckFailed = "core.updateCheckFailed"; + public const string XrayUpdateStarted = "core.updateStarted"; + public const string XrayUpdateBusy = "core.updateBusy"; + public const string XrayUpdateFailed = "core.updateFailed"; + public const string XrayUpdateCompleted = "core.updateCompleted"; + public const string XrayUpdateAvailable = "core.updateAvailable"; + public const string XrayUpdateCurrent = "core.updateCurrent"; + public const string CoreUpdateCheckFailed = "maintenance.updateCheckFailed"; + public const string CoreUpdateStarted = "maintenance.updateStarted"; + public const string CoreUpdateBatchStarted = "maintenance.batchStarted"; + public const string CoreUpdateBusy = "maintenance.updateBusy"; + public const string CoreUpdateFailed = "maintenance.updateFailed"; + public const string CoreUpdateCompleted = "maintenance.updateCompleted"; + public const string CoreUpdateSettingsSaved = "maintenance.updateSettingsSaved"; + public const string CoreUpdateUnsupported = "maintenance.updateUnsupported"; + public const string CoreUpdateAvailable = "maintenance.updateAvailableGeneric"; + public const string CoreUpdateCurrent = "maintenance.upToDateGeneric"; + public const string GeoUpdateStarted = "updates.geoStarted"; + public const string GeoUpdateBusy = "updates.geoBusy"; + public const string GeoUpdateProgress = "updates.geoProgress"; + public const string GeoUpdateNotSelected = "maintenance.geoNotSelected"; + public const string SubscriptionUpdateProgress = "subscriptions.updateProgress"; + public const string ProfilesAllGroup = "profiles.allGroup"; + public const string BackupCreated = "backup.created"; + public const string BackupRestoreStarted = "backup.restoreStarted"; + public const string BackupRestoreFailed = "backup.restoreFailed"; + public const string BackupRestoreProbeUnavailable = "backup.restoreProbeUnavailable"; + public const string BackupRestoreSetSidUnavailable = "backup.restoreSetSidUnavailable"; + public const string BackupRestoreExecutableUnavailable = "backup.restoreExecutableUnavailable"; + public const string BackupArchiveInvalid = "backup.archiveInvalid"; + public const string BackupDatabaseIncompatible = "backup.databaseIncompatible"; + public const string WebDavSettingsSaved = "backup.webdavSettingsSaved"; + public const string WebDavCheckSucceeded = "backup.webdavCheckSucceeded"; + public const string WebDavCheckFailed = "backup.webdavCheckFailed"; + public const string WebDavBackupSucceeded = "backup.webdavBackupSucceeded"; + public const string WebDavRestoreFailed = "backup.webdavRestoreFailed"; +} + +public sealed record ListenerView( + string Name, + string[] Protocols, + string ListenAddress, + int Port, + bool Listening); + +public sealed record StatusView( + bool CoreRunning, + string? CoreType, + string? CurrentProfileId, + string? CurrentProfileName, + DateTimeOffset? CoreStartedAt, + ListenerView[] Listeners, + bool XrayAvailable, + string Runtime, + int ProfileCount, + int SubscriptionCount, + DateTimeOffset CheckedAt, + bool StatisticsEnabled, + TrafficView? Traffic, + string RuntimeState = "stopped", + int? ConfiguredProxyPort = null, + int? RunningProxyPort = null, + string? RunningProfileId = null, + string? RunningProfileName = null, + int? ApiPort = null, + int[]? CoreProcessIds = null, + string? RuntimeFailure = null, + string? WebVersion = null, + string? GitCommit = null, + string? RuntimeIdentifier = null, + string? WebBuildDate = null, + bool SniffingEnabled = false, + string[]? DestOverride = null, + string[]? Capabilities = null); + +public sealed record LogView(DateTimeOffset Timestamp, string Source, string Message, long Generation = 0); + +public sealed record LogPageView(IReadOnlyList Items, int Page, int PageSize, int Total, int TotalPages); + +public sealed record WebEvent(string Type, object? Data, DateTimeOffset Timestamp); diff --git a/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs b/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs new file mode 100644 index 00000000..8541886d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Contracts/WebApiCapabilities.cs @@ -0,0 +1,20 @@ +namespace v2rayN.WebAPI.Contracts; + +public static class WebApiCapabilities +{ + public static string[] Current => + [ + "auth.sessions", + "events.sse", + "editor.options", + "profiles", + "subscriptions", + "routing", + "dns", + "settings", + "backup.restore", + "core.runtime", + "core.updates", + "WebAPI.self-update", + ]; +} diff --git a/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example new file mode 100644 index 00000000..5d9f33cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.env.example @@ -0,0 +1 @@ +V2RAYN_WEB_API_KEY= diff --git a/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service new file mode 100644 index 00000000..cd173b44 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Deploy/Systemd/v2rayn-web.service @@ -0,0 +1,32 @@ +[Unit] +Description=v2rayN Web API +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=v2rayn +Group=v2rayn +WorkingDirectory=/opt/v2rayn-web +ExecStart=/opt/v2rayn-web/v2rayN.WebAPI --foreground +EnvironmentFile=/etc/v2rayn-web.env +Environment=ASPNETCORE_URLS=http://0.0.0.0:5080 +Environment=V2RAYN_WEB_AUTOSTART=true +Environment=V2RAYN_DATA_HOME=/var/lib/v2rayn-web +Environment=DOTNET_BUNDLE_EXTRACT_BASE_DIR=/var/lib/v2rayn-web/.net-bundle +StateDirectory=v2rayn-web +StateDirectoryMode=0750 +UMask=0077 +Restart=always +RestartSec=3 +KillSignal=SIGTERM +SendSIGKILL=no +TimeoutStopSec=30s +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/v2rayn-web + +[Install] +WantedBy=multi-user.target diff --git a/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md b/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md new file mode 100644 index 00000000..699901f0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/FEATURE-MAP.md @@ -0,0 +1,56 @@ +# v2rayN.WebAPI feature map + +The API uses existing public ServiceLib capabilities without changing desktop or Core behavior. +API scheduling and cancellation live in `V2rayRuntime.Scheduling.cs`; the desktop `TaskManager` +is not registered. `RuntimeMutationGate` serializes API-side writes to configuration and SQLite. + +## ServiceLib → WebAPI capabilities + +| Capability | Existing ServiceLib capability | WebAPI | +|---|---|---| +| Runtime status and operations | `MainWindowViewModel`; `StatusBarViewModel`; `CoreManager` | `GET /api/status`; `GET /api/operations` | +| Subscription groups and current group | `AppManager.SubItems`; `Config.SubIndexId`; `ProfilesViewModel.RefreshSubscriptions` | `GET /api/profile-groups`; `PUT /api/profile-groups/current` | +| Profiles and filtering | `AppManager.ProfileModels/ProfileItems`; `ProfileExManager`; `StatisticsManager`; `Utils.IsRegexMatch` | `GET /api/profiles?subscriptionId=&filter=`; filtering uses ServiceLib's regex guard | +| Current profile and Core lifecycle | `ConfigHandler.SetDefaultServerIndex`; `CoreConfigContextBuilder.BuildAll`; `CoreManager.LoadCore` | `POST /api/profiles/{id}/select`; `POST /api/core/start`, `/stop`, `/restart` | +| Add/edit profiles and group profiles | `ConfigHandler.AddServer/AddServerCommon`; protocol-specific `Add*Server`; `ProfileItem.IsValid`; `GroupProfileManager` | `GET /api/profiles/{id}`; `POST /api/profiles`; `PUT /api/profiles/{id}` | +| Import profiles and custom configuration | `ConfigHandler.AddBatchServers`; `FmtHandler.ResolveConfig`; custom-config parsers | `POST /api/profiles/import` | +| Remove/copy profiles | `ConfigHandler.RemoveServers/CopyServer` | `DELETE /api/profiles`; `POST /api/profiles/copy` | +| Deduplicate and remove failed test results | `ConfigHandler.DedupServerList/RemoveInvalidServerResult` | `POST /api/profiles/deduplicate`; `DELETE /api/profiles/invalid-test-results` | +| Move/reorder profiles | `ConfigHandler.MoveToGroup/MoveServer`; `ProfileExManager.SetSort` | `POST /api/profiles/move-to-group`; `POST /api/profiles/move` | +| Sort profiles | `ConfigHandler.SortServers`; `EServerColName` | `POST /api/profiles/sort` | +| Latency, UDP and speed tests | `SpeedtestService.RunLoop/ExitLoop`; `ESpeedActionType` | `POST /api/profiles/{id}/latency`; `POST/DELETE /api/speedtests`; progress/results through SSE | +| Share/export profiles and Core configuration | `FmtHandler.GetShareUri`; `InnerFmt.ToUri`; `CoreConfigContextBuilder.Build`; `CoreConfigHandler.GenerateClientConfig` | `POST /api/profiles/export` | +| Generate all-node/regional groups | `ConfigHandler.AddGroupAllServer/AddGroupRegionServer` | `POST /api/profile-groups/generate/all`; `POST /api/profile-groups/generate/regions` | +| Manage/share subscriptions | `SubItem`; `ConfigHandler.AddSubItem/DeleteSubItem`; `SubSettingViewModel` | `GET/POST/PUT/DELETE /api/subscriptions`; `GET /api/subscriptions/{id}/share` | +| Update subscriptions | `SubscriptionHandler.UpdateProcess` | `POST /api/subscriptions/update`; group/proxy options; progress through `GET /api/operations` and SSE | +| Scheduled subscription updates | `SubItem.AutoUpdateInterval/UpdateTime`; `SubscriptionHandler.UpdateProcess` | `V2rayRuntime.Scheduling.RunScheduledSubscriptionUpdatesAsync` checks intervals and starts API-owned background tasks | +| Core/GeoFiles updates | `CoreInfoManager`; `UpdateService`; `CoreManager` | `GET/POST /api/core-updates/{coreType}/check`, `/update`; `POST /api/core-updates/batch`; `POST /api/core/geo/update`; GeoFiles backup/verify/rollback | +| API self-update (separate from Desktop) | API-owned release manifest, package validation and native helper | `GET /api/web-updates`; `GET /api/web-updates/check`; `POST /api/web-updates/update`; protected systemd/container deployments are check-only | +| Inbound listeners | `Config.Inbound`; `StatusBarViewModel.InboundDisplayStatus`; `AppManager.GetLocalPort` | `GET /api/status`; `GET/PUT /api/settings/inbound` | +| Traffic/statistics | `StatisticsManager`; `ServerStatItem`; `ServerSpeedItem` | Profile byte counters; `GET /api/status`; SSE `traffic`; `DELETE /api/statistics` | +| Logs | `MsgViewModel`; `AppEvents.SendMsgViewRequested`; `CoreManager` callback | `GET/DELETE /api/logs`; `GET /api/logs/page?page=&pageSize=&filter=`; `/api/events` log/state/progress events | +| General settings | `OptionSettingViewModel`; `Config`; `ConfigHandler.SaveConfig` | `GET /api/settings`; atomic `PUT /api/settings/apply`; section settings APIs | +| Routing profiles | `AppManager.RoutingItems`; `ConfigHandler.SaveRoutingItem/RemoveRoutingItem/SetDefaultRouting/InitRouting` | `GET/POST/PUT/DELETE /api/settings/routing-profiles`; `/activate`; `/import`; `PUT /api/settings/routing` | +| Routing rules | `RoutingItem.RuleSet`; `RulesItem`; `ConfigHandler.AddBatchRoutingRules/MoveRoutingRule` | `GET/PUT /api/settings/routing-profiles/{id}/rules`; `/rules/import`; `/rules/move`; `/rules/{ruleId}` | +| Simple/per-Core DNS | `Config.SimpleDNSItem`; `DNSItem`; `ConfigHandler.SaveDNSItems/GetExternalDNSItem` | `GET/PUT /api/settings/dns/simple`; `GET/PUT /api/settings/dns/profiles` | +| Core configuration templates | `FullConfigTemplateItem`; `ConfigHandler.SaveFullConfigTemplate` | `GET /api/settings/core-templates`; `PUT /api/settings/core-templates/{coreType}`; stored TUN fields are preserved, not editable | +| Regional presets | `ConfigHandler.ApplyRegionalPreset/InitRouting` | `POST /api/settings/regional-presets/{preset}` | +| WebDAV and backup/restore | `WebDavManager`; existing `guiConfigs/` ZIP format; `FileUtils` | `GET/PUT /api/settings/webdav`; `/api/backup/webdav/check`; `/webdav`; `/webdav/restore`; `GET /api/backup/download`; multipart `POST /api/backup/restore` | + +Core selection/startup remain delegated to ServiceLib's `CoreInfoManager` and `CoreManager`, +and require the selected Core executable. Generated speed-test configurations support Xray +and sing-box; TCPing is available for any profile with a port. Release/container artifacts +copy the complete architecture-matched official `2dust/v2rayN-core-bin` payload. + +## Unsupported desktop capabilities + +TUN settings/control, desktop system proxy, Clash Proxies/Connections, tray icons, global +hotkeys, window placement, desktop startup integration and native dialogs/scanners are outside +the headless API boundary. Core start/restart/autostart rejects generated TUN configurations +without changing saved settings. These exclusions are not claims of full desktop parity. + +## Response contract + +JSON field names and enum/state codes are stable identifiers. Operation/error responses use +`{ success, code, messageKey, data }`; SSE uses stable event/type codes plus data. User-provided +names, URLs, remarks and log lines remain data. The API does not render HTML or translate responses. diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json new file mode 100644 index 00000000..0eb42913 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/en-US.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI is running in the background.\nAPI health endpoint: {url}\nThe service is detached from this terminal; Ctrl+C will not stop it.\nStop it with: {command} --stop\nRun in the foreground with: {command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI is already running.\nOpening the API health endpoint: {url}\nStop it with: {command} --stop","alreadyRunning":"v2rayN.WebAPI is already running.\nAPI health endpoint: {url}\nStop it with: {command} --stop","foregroundStarted":"v2rayN.WebAPI is running in the foreground.\nAPI health endpoint: {url}\nPress Ctrl+C to stop the service gracefully.","stopNotRunning":"v2rayN.WebAPI is not running for this data directory.","stopSucceeded":"v2rayN.WebAPI stopped.","stopIdentityUnverified":"Cannot safely stop v2rayN.WebAPI: instance identity could not be verified; no signal was sent.","stopUnprobeable":"Cannot safely stop v2rayN.WebAPI: the configured listeners have no loopback HTTP health endpoint to verify the instance (for example HTTPS-only or an explicit non-loopback address); no signal was sent.","stopSupervisorManaged":"This instance is managed by systemd. Use systemctl stop for its unit; --stop will not compete with the supervisor.","stopCoreStillRunning":"The Web host stopped, but a tracked Core child process is still running. Check the service log before force-stopping it.","stopSignalFailed":"Could not send SIGTERM to v2rayN.WebAPI.","stopTimedOut":"v2rayN.WebAPI received SIGTERM but did not stop within the expected time.","stopTimedOutStage":"Last observed cleanup stage: {stage}.","stopUnsupported":"--stop is supported only on Linux.","startFailed":"v2rayN.WebAPI failed to start; run with --foreground to see details.","startUnprobeable":"v2rayN.WebAPI cannot start in the background: the configured listeners have no loopback HTTP health endpoint to verify the instance (for example HTTPS-only or an explicit non-loopback address). Run with --foreground instead.","existingUnhealthy":"An instance for this data directory is starting but is not ready yet; try again shortly.","instanceInUse":"A v2rayN.WebAPI instance is already using this data directory.","help":"v2rayN.WebAPI - the headless Web API service of v2rayN\n\nUsage: v2rayN.WebAPI [options]\n\nOptions:\n --foreground Run in the foreground; Ctrl+C stops it safely and logs stay on the terminal\n --background Run in the background, detached from the terminal (Linux default in a terminal)\n --no-open Do not open the browser on startup\n --stop Stop the instance running for the same data directory (Linux only)\n --help, -h Show this help and exit\n\nOther arguments (for example --urls http://0.0.0.0:5080) are passed to the web host.\n\nCommon configuration (environment variables; can live in an .env file next to the executable):\n V2RAYN_WEB_API_KEY Management Key; required when listening beyond 127.0.0.1\n ASPNETCORE_URLS Listen address, default http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS Allowed cross-origin sources\n V2RAYN_WEB_AUTOSTART=true Start the Core together with the web service\n V2RAYN_WEB_PROXY_PORT Local proxy port (used by update checks)\n\nDocs: https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json new file mode 100644 index 00000000..e71a7954 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-CN.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI 已在后台启动。\nAPI 健康端点:{url}\n服务已脱离当前终端,Ctrl+C 不会停止它。\n停止服务:{command} --stop\n前台运行:{command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI 已在运行。\n正在打开 API 健康端点:{url}\n停止服务:{command} --stop","alreadyRunning":"v2rayN.WebAPI 已在运行。\nAPI 健康端点:{url}\n停止服务:{command} --stop","foregroundStarted":"v2rayN.WebAPI 正在前台运行。\nAPI 健康端点:{url}\n按 Ctrl+C 可安全停止服务。","stopNotRunning":"v2rayN.WebAPI 未在此数据目录运行。","stopSucceeded":"v2rayN.WebAPI 已停止。","stopIdentityUnverified":"无法安全停止 v2rayN.WebAPI:无法确认实例身份,未发送信号。","stopUnprobeable":"无法安全停止 v2rayN.WebAPI:当前监听配置没有可供验证实例身份的回环 HTTP 健康端点(例如仅 HTTPS 或仅绑定非回环地址),未发送信号。","stopSupervisorManaged":"此实例由 systemd 管理,请使用 systemctl stop 停止对应服务;--stop 不会与服务管理器争抢生命周期。","stopCoreStillRunning":"Web 主进程已停止,但检测到 Core 子进程仍在运行。请先检查服务日志,再决定是否强制结束。","stopSignalFailed":"无法向 v2rayN.WebAPI 发送 SIGTERM。","stopTimedOut":"v2rayN.WebAPI 已收到 SIGTERM,但未能在预期时间内停止。","stopTimedOutStage":"最后观察到的清理阶段:{stage}。","stopUnsupported":"--stop 仅支持 Linux。","startFailed":"v2rayN.WebAPI 启动失败;请使用 --foreground 查看详细日志。","startUnprobeable":"v2rayN.WebAPI 无法在后台启动:当前监听配置没有可供验证实例身份的回环 HTTP 健康端点(例如仅 HTTPS 或仅绑定非回环地址)。请改用 --foreground 运行。","existingUnhealthy":"检测到同一数据目录的实例正在启动,但尚未就绪;请稍后重试。","instanceInUse":"同一数据目录下已有 v2rayN.WebAPI 实例正在运行。","help":"v2rayN.WebAPI - v2rayN 的 Web API 服务(无界面)\n\n用法:v2rayN.WebAPI [选项]\n\n选项:\n --foreground 前台运行;Ctrl+C 可安全停止,日志直接输出到终端\n --background 后台运行并脱离终端(Linux 终端下的默认行为)\n --no-open 启动时不自动打开浏览器\n --stop 停止同一数据目录下正在运行的实例(仅 Linux)\n --help, -h 显示本帮助并退出\n\n其余参数(例如 --urls http://0.0.0.0:5080)会原样传递给 Web 主机。\n\n常用配置(环境变量,可写入可执行文件同目录的 .env 文件):\n V2RAYN_WEB_API_KEY 管理密钥;监听非 127.0.0.1 地址时必填\n ASPNETCORE_URLS 监听地址,默认 http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS 允许跨域的来源列表\n V2RAYN_WEB_AUTOSTART=true 启动 Web 服务时自动启动 Core\n V2RAYN_WEB_PROXY_PORT 本地代理端口(供更新检查等使用)\n\n文档:https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json new file mode 100644 index 00000000..806ebe2d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/Locales/zh-TW.json @@ -0,0 +1 @@ +{"launcher":{"started":"v2rayN.WebAPI 已在背景啟動。\nAPI 健康端點:{url}\n服務已脫離目前終端,Ctrl+C 不會停止它。\n停止服務:{command} --stop\n前台執行:{command} --foreground","alreadyRunningOpening":"v2rayN.WebAPI 已在執行。\n正在開啟 API 健康端點:{url}\n停止服務:{command} --stop","alreadyRunning":"v2rayN.WebAPI 已在執行。\nAPI 健康端點:{url}\n停止服務:{command} --stop","foregroundStarted":"v2rayN.WebAPI 正在前台執行。\nAPI 健康端點:{url}\n按 Ctrl+C 可安全停止服務。","stopNotRunning":"v2rayN.WebAPI 未在此資料目錄執行。","stopSucceeded":"v2rayN.WebAPI 已停止。","stopIdentityUnverified":"無法安全停止 v2rayN.WebAPI:無法確認執行個體身分,未傳送訊號。","stopUnprobeable":"無法安全停止 v2rayN.WebAPI:目前監聽設定沒有可供驗證執行個體身分的回送 HTTP 健康端點(例如僅 HTTPS 或僅繫結非回送位址),未傳送訊號。","stopSupervisorManaged":"此執行個體由 systemd 管理,請使用 systemctl stop 停止對應服務;--stop 不會與服務管理器爭搶生命週期。","stopCoreStillRunning":"Web 主程序已停止,但偵測到 Core 子程序仍在執行。請先檢查服務記錄,再決定是否強制結束。","stopSignalFailed":"無法向 v2rayN.WebAPI 傳送 SIGTERM。","stopTimedOut":"v2rayN.WebAPI 已收到 SIGTERM,但未能在預期時間內停止。","stopTimedOutStage":"最後觀察到的清理階段:{stage}。","stopUnsupported":"--stop 僅支援 Linux。","startFailed":"v2rayN.WebAPI 啟動失敗;請使用 --foreground 查看詳細記錄。","startUnprobeable":"v2rayN.WebAPI 無法在背景啟動:目前監聽設定沒有可供驗證執行個體身分的回送 HTTP 健康端點(例如僅 HTTPS 或僅繫結非回送位址)。請改用 --foreground 執行。","existingUnhealthy":"偵測到相同資料目錄的執行個體正在啟動,但尚未就緒;請稍後重試。","instanceInUse":"相同資料目錄下已有 v2rayN.WebAPI 執行個體正在執行。","help":"v2rayN.WebAPI - v2rayN 的 Web API 服務(無介面)\n\n用法:v2rayN.WebAPI [選項]\n\n選項:\n --foreground 前台執行;Ctrl+C 可安全停止,記錄直接輸出到終端\n --background 背景執行並脫離終端(Linux 終端下的預設行為)\n --no-open 啟動時不自動開啟瀏覽器\n --stop 停止同一資料目錄下正在執行的執行個體(僅 Linux)\n --help, -h 顯示本說明並結束\n\n其餘參數(例如 --urls http://0.0.0.0:5080)會原樣傳遞給 Web 主機。\n\n常用設定(環境變數,可寫入執行檔同目錄的 .env 檔案):\n V2RAYN_WEB_API_KEY 管理金鑰;監聽非 127.0.0.1 位址時必填\n ASPNETCORE_URLS 監聽位址,預設 http://127.0.0.1:5080\n V2RAYN_WEB_ALLOWED_ORIGINS 允許跨來源的來源清單\n V2RAYN_WEB_AUTOSTART=true 啟動 Web 服務時自動啟動 Core\n V2RAYN_WEB_PROXY_PORT 本機代理連接埠(供更新檢查等使用)\n\n文件:https://github.com/2dust/v2rayN/tree/master/v2rayN/v2rayN.WebAPI"}} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs new file mode 100644 index 00000000..10faf126 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebRestorePreflight.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Launcher; + +public sealed record NativeWebRestoreReplacement( + Uri HealthUri, + WebReplacementCommand Command, + bool CoreWasRunning); + +public sealed record NativeWebRestorePreflightResult( + bool Allowed, + WebLifecycleOwner Owner, + NativeWebRestoreReplacement? Replacement, + string? FailureCode = null, + string? MessageKey = null) +{ + public OperationView ToFailureOperation() + { + if (Allowed || string.IsNullOrWhiteSpace(FailureCode) || string.IsNullOrWhiteSpace(MessageKey)) + { + throw new InvalidOperationException("A successful native restore preflight has no failure response."); + } + + return OperationView.Fail(FailureCode, MessageKey); + } +} + +/// +/// Resolves everything needed to safely restart a native restore before Core or config state +/// is touched. Supervised deployments deliberately leave restart ownership to their supervisor. +/// +public static class NativeWebRestorePreflight +{ + public static NativeWebRestorePreflightResult Evaluate( + IConfiguration configuration, + bool isLinux, + bool isWindows, + bool externalSupervisor, + bool containerEnvironment, + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments, + Func? fileExists = null, + Func? isUnixExecutable = null, + bool coreWasRunning = false) + { + var lifecycle = WebLifecyclePlanner.Create( + WebLifecycleAction.RestoreAndRestart, + supportsNativeRestart: isLinux || isWindows, + daemonEnvironment: externalSupervisor, + containerEnvironment); + if (lifecycle.Owner != WebLifecycleOwner.NativeProcess) + { + return new(true, lifecycle.Owner, null); + } + + if (!WebProbeUriResolver.TryResolve(configuration, out var endpoints)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_health_probe_unavailable", + ApiMessageKeys.BackupRestoreProbeUnavailable); + } + + var exists = fileExists ?? File.Exists; + if (string.IsNullOrWhiteSpace(processPath) || !exists(processPath)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + var isDotnet = Path.GetFileNameWithoutExtension(processPath) + .Equals("dotnet", StringComparison.OrdinalIgnoreCase); + if (isDotnet + && (string.IsNullOrWhiteSpace(managedEntryPoint) + || !managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + || !exists(managedEntryPoint))) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + if (isLinux) + { + var executable = isUnixExecutable ?? IsUnixExecutable; + if (string.IsNullOrWhiteSpace(setsidPath) + || !exists(setsidPath) + || !executable(setsidPath)) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_setsid_unavailable", + ApiMessageKeys.BackupRestoreSetSidUnavailable); + } + } + + WebReplacementCommand? command; + try + { + command = WebReplacementCommand.CreateNative( + isLinux, + isWindows, + setsidPath, + processPath, + managedEntryPoint, + hostArguments); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + command = null; + } + + if (command is null) + { + return Refused( + WebLifecycleOwner.NativeProcess, + "backup_restore_executable_unavailable", + ApiMessageKeys.BackupRestoreExecutableUnavailable); + } + + return new(true, WebLifecycleOwner.NativeProcess, + new NativeWebRestoreReplacement(endpoints.HealthUri, command, coreWasRunning)); + } + + private static NativeWebRestorePreflightResult Refused( + WebLifecycleOwner owner, + string code, + string messageKey) => new(false, owner, null, code, messageKey); + + private static bool IsUnixExecutable(string path) + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + try + { + const UnixFileMode executeBits = UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & executeBits) != 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs new file mode 100644 index 00000000..ddca95b4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateHelper.cs @@ -0,0 +1,824 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ServiceLib.Common; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Launcher; + +internal sealed record NativeWebUpdatePlan( + string InstallDirectory, + string CandidateDirectory, + string BackupDirectory, + string InstanceLockPath, + string HealthUri, + string[] HostArguments, + string ExpectedVersion, + string ExpectedCommit, + string Rid, + string PreviousVersion, + string RuntimeIntentPath, + string ProgressPath, + bool CoreWasRunning = false); + +internal sealed record WebUpdateProgressState( + string Phase, + bool IsComplete, + bool Success, + string? Version, + string? Detail, + bool? RollbackSucceeded = null, + bool CoreWasRunning = false); + +internal static class NativeWebUpdateHelper +{ + private static readonly TimeSpan OwnerReleaseTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan HealthTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(200); + private static readonly TimeSpan FileReplaceRetryWindow = TimeSpan.FromSeconds(15); + private static readonly TimeSpan FileReplaceRetryDelay = TimeSpan.FromMilliseconds(250); + private const string IsolatedHelperPrefix = ".v2rayn-WebAPI-update-helper-"; + + public static async Task RunAsync(string planPath) + { + var executable = Environment.ProcessPath; + try + { + return await RunCoreAsync(planPath); + } + finally + { + ScheduleWindowsHelperCleanup(executable); + } + } + + private static async Task RunCoreAsync(string planPath) + { + NativeWebUpdatePlan? plan = null; + try + { + plan = JsonSerializer.Deserialize(await File.ReadAllTextAsync(planPath), JsonOptions); + ValidatePlan(plan); + if (OperatingSystem.IsLinux() && Path.GetFileName(Environment.ProcessPath) == "v2rayN.WebAPI") + { + return RunFromIsolatedBundle(planPath, Environment.ProcessPath!); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"Could not prepare the native Web update: {exception.Message}"); + var previousRestarted = false; + if (plan is not null && CanRestartPreviousWithoutApplying(plan)) + { + try + { + WriteProgress(plan, new WebUpdateProgressState("rolling-back", false, false, plan.ExpectedVersion, + "The update could not be prepared; restarting the unchanged application.")); + using var oldProcess = StartWebInstance(plan); + previousRestarted = await WaitForExpectedHealthAsync(plan, oldProcess, plan.PreviousVersion, HealthTimeout); + if (!previousRestarted) StopOwnedProcess(oldProcess, plan.InstanceLockPath); + if (previousRestarted) DeleteRuntimeIntent(plan.RuntimeIntentPath); + } + catch (Exception restartException) + { + Console.Error.WriteLine($"Could not restart the unchanged Web application: {restartException.Message}"); + } + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + previousRestarted + ? $"The update could not be prepared ({exception.Message}); the unchanged Web build is healthy." + : $"The update could not be prepared ({exception.Message}); the unchanged Web build could not be verified.", + previousRestarted)); + } + TryDeleteFile(planPath); + return previousRestarted ? 1 : 2; + } + + Process? windowsOwnerProcess; + try + { + windowsOwnerProcess = CaptureWindowsOwnerProcess(plan!); + } + catch (Exception exception) when (exception is InvalidDataException or IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not identify the previous Windows Web process: {exception.Message}"); + DeleteRuntimeIntent(plan!.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Windows Web process could not be identified; the installed application was not modified.")); + TryDeleteFile(planPath); + return 1; + } + using var ownedWindowsProcess = windowsOwnerProcess; + WriteProgress(plan!, new WebUpdateProgressState("waiting", false, false, plan!.ExpectedVersion, + "Waiting for the previous Web instance to stop gracefully.")); + if (!await WaitForLockReleaseAsync(plan.InstanceLockPath, OwnerReleaseTimeout)) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Web instance did not release its instance lock; the old application was not modified.", false)); + TryDeleteFile(planPath); + return 1; + } + if (windowsOwnerProcess is not null + && !await WaitForProcessExitAsync(windowsOwnerProcess, OwnerReleaseTimeout)) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.CandidateDirectory); + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + "The previous Windows Web process did not exit after releasing its instance lock; the old application was not modified.", false)); + TryDeleteFile(planPath); + return 1; + } + + var backupCreated = false; + Process? replacementProcess = null; + try + { + WriteProgress(plan, new WebUpdateProgressState("installing", false, false, plan.ExpectedVersion, null)); + CopyCurrentAppToBackup(plan); + backupCreated = true; + var transaction = await NativeWebUpdateWorkflow.ApplyAsync( + () => + { + SwapCandidateAppIntoPlace(plan); + return Task.CompletedTask; + }, + async () => + { + replacementProcess = StartWebInstance(plan); + var healthy = await WaitForExpectedHealthAsync(plan, replacementProcess, plan.ExpectedVersion, HealthTimeout); + if (!healthy) StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + return healthy; + }, + () => + { + StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + RestorePreviousApp(plan); + return Task.CompletedTask; + }, + async () => + { + replacementProcess = StartWebInstance(plan); + var healthy = await WaitForExpectedHealthAsync(plan, replacementProcess, plan.PreviousVersion, HealthTimeout); + if (!healthy) StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + return healthy; + }, + phase => WriteProgress(plan, new WebUpdateProgressState(phase, false, false, plan.ExpectedVersion, null)), + () => + { + TryDeleteDirectory(plan.BackupDirectory); + TryDeleteDirectory(plan.CandidateDirectory); + return Task.CompletedTask; + }); + + if (transaction.Success || transaction.RollbackSucceeded) + DeleteRuntimeIntent(plan.RuntimeIntentPath); + WriteProgress(plan, new WebUpdateProgressState( + transaction.Success ? "completed" : "failed", + true, + transaction.Success, + plan.ExpectedVersion, + transaction.Success + ? "The new v2rayN.WebAPI build is healthy." + : transaction.RollbackSucceeded + ? transaction.Detail + : $"{transaction.Detail} Previous application backup retained at {plan.BackupDirectory}.", + transaction.RollbackSucceeded)); + return transaction.Success ? 0 : transaction.RollbackSucceeded ? 1 : 2; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Native Web update failed: {exception}"); + StopOwnedProcess(replacementProcess, plan.InstanceLockPath); + var rollbackSucceeded = false; + if (backupCreated) + { + try + { + RestorePreviousApp(plan); + var restoredProcess = StartWebInstance(plan); + rollbackSucceeded = await WaitForExpectedHealthAsync(plan, restoredProcess, plan.PreviousVersion, HealthTimeout); + if (rollbackSucceeded) + { + DeleteRuntimeIntent(plan.RuntimeIntentPath); + TryDeleteDirectory(plan.BackupDirectory); + } + } + catch (Exception rollbackException) + { + Console.Error.WriteLine($"Native Web update rollback failed: {rollbackException}"); + } + } + else + { + try + { + var restoredProcess = StartWebInstance(plan); + rollbackSucceeded = await WaitForExpectedHealthAsync(plan, restoredProcess, plan.PreviousVersion, HealthTimeout); + if (rollbackSucceeded) DeleteRuntimeIntent(plan.RuntimeIntentPath); + } + catch (Exception restartException) + { + Console.Error.WriteLine($"Could not restart the unchanged Web application: {restartException}"); + } + TryDeleteDirectory(plan.BackupDirectory); + TryDeleteDirectory(plan.CandidateDirectory); + } + WriteProgress(plan, new WebUpdateProgressState("failed", true, false, plan.ExpectedVersion, + rollbackSucceeded + ? $"{exception.Message} The previous Web build was restored and is healthy." + : $"{exception.Message} The previous Web build could not be confirmed healthy; backup: {plan.BackupDirectory}.", + rollbackSucceeded)); + return rollbackSucceeded ? 1 : 2; + } + finally + { + replacementProcess?.Dispose(); + TryDeleteFile(planPath); + } + } + + private static int RunFromIsolatedBundle(string planPath, string executable) + { + var helperPath = CreateIsolatedHelperExecutable(executable); + try + { + // The worker must keep an unchanged executable path for the entire + // transaction: the single-file loader resolves assemblies lazily + // from that path, even after the application executable is swapped. + var startInfo = new ProcessStartInfo + { + FileName = helperPath, + WorkingDirectory = Path.GetDirectoryName(helperPath)!, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("--apply-WebAPI-update"); + startInfo.ArgumentList.Add(planPath); + using var worker = Process.Start(startInfo) + ?? throw new InvalidOperationException("The isolated native Web update helper could not be started."); + worker.WaitForExit(); + return worker.ExitCode; + } + finally + { + // The outer helper waits for exit before deleting the bundle. The + // worker never deletes its own executable while it can still load it. + TryDeleteFile(helperPath); + } + } + + internal static string CreateIsolatedHelperExecutable(string executable) + { + if (new FileInfo(executable).LinkTarget is not null) + throw new InvalidDataException("The native update helper cannot be copied from a symlink."); + var extension = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + var helperPath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(executable))!, + $"{IsolatedHelperPrefix}{Guid.NewGuid():N}{extension}"); + try + { + File.Copy(executable, helperPath, overwrite: false); + if (OperatingSystem.IsLinux()) + File.SetUnixFileMode(helperPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return helperPath; + } + catch + { + TryDeleteFile(helperPath); + throw; + } + } + + internal static bool IsUpdateHelperExecutableName(string name) + { + if (name is "v2rayN.WebAPI" or "v2rayN.WebAPI.exe") return true; + return IsIsolatedHelperExecutableName(name); + } + + private static bool IsIsolatedHelperExecutableName(string name) + { + var helperName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; + return helperName.StartsWith(IsolatedHelperPrefix, StringComparison.Ordinal) + && Guid.TryParseExact(helperName[IsolatedHelperPrefix.Length..], "N", out _); + } + + internal static void ScheduleWindowsHelperCleanup(string? executable) + { + if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(executable) + || !Path.GetFileName(executable).StartsWith(IsolatedHelperPrefix, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + var powershell = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var escapedPath = Path.GetFullPath(executable).Replace("'", "''", StringComparison.Ordinal); + var script = $"$targetPid={Environment.ProcessId};$targetPath='{escapedPath}';" + + "for($i=0;$i -lt 600;$i++){if(-not(Get-Process -Id $targetPid -ErrorAction SilentlyContinue)){break};Start-Sleep -Milliseconds 100};" + + "Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + var startInfo = new ProcessStartInfo + { + FileName = powershell, + WorkingDirectory = Path.GetTempPath(), + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-EncodedCommand"); + startInfo.ArgumentList.Add(encoded); + Process.Start(startInfo)?.Dispose(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or InvalidOperationException or System.ComponentModel.Win32Exception) + { + Console.Error.WriteLine($"Could not schedule cleanup of the Windows update helper: {exception.Message}"); + _ = MoveFileEx(Path.GetFullPath(executable), null, MoveFileDelayUntilReboot); + } + } + + private const int MoveFileDelayUntilReboot = 0x00000004; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool MoveFileEx(string existingFileName, string? newFileName, int flags); + + private static void ValidatePlan(NativeWebUpdatePlan? plan) + { + var isLinux = OperatingSystem.IsLinux(); + var isWindows = OperatingSystem.IsWindows(); + var isManagedLinuxService = isLinux && LauncherEnvironment.IsSystemdManagedDeployment(); + if ((!isLinux && !isWindows) || plan is null + || File.Exists("/.dockerenv") || File.Exists("/run/.containerenv") + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) + || isManagedLinuxService + || !WebUpdatePackageStager.IsValidVersion(plan.ExpectedVersion) + || !WebUpdatePackageStager.IsValidVersion(plan.PreviousVersion) + || !WebUpdatePackageStager.IsSupportedRid(plan.Rid) + || plan.Rid != System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier + || string.IsNullOrWhiteSpace(plan.ExpectedCommit) + || !WebProbeUriResolver.IsProbeableLoopbackUri(plan.HealthUri)) + { + throw new InvalidDataException("The update plan has an unsupported target or identity."); + } + + var executable = Environment.ProcessPath + ?? throw new InvalidDataException("The update helper process path is unavailable."); + var install = Path.GetFullPath(plan.InstallDirectory); + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + var processFileName = Path.GetFileName(executable); + var pathComparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!string.Equals(Path.GetFullPath(Path.GetDirectoryName(executable) ?? string.Empty), install, pathComparison) + || !IsUpdateHelperExecutableName(processFileName) + || (isWindows && !IsIsolatedHelperExecutableName(processFileName)) + || new FileInfo(executable).LinkTarget is not null) + { + throw new InvalidDataException("The update plan does not target this native v2rayN.WebAPI installation."); + } + var parent = Path.GetDirectoryName(install) + ?? throw new InvalidDataException("The installation directory has no parent."); + EnsureSiblingDirectory(plan.CandidateDirectory, parent, ".v2rayn-WebAPI-candidate-"); + EnsureSiblingDirectory(plan.BackupDirectory, parent, ".v2rayn-WebAPI-backup-"); + if (!Path.IsPathFullyQualified(plan.InstanceLockPath) + || !Path.IsPathFullyQualified(plan.RuntimeIntentPath) + || !Path.IsPathFullyQualified(plan.ProgressPath) + || !string.Equals(Path.GetFullPath(plan.InstanceLockPath), + Path.GetFullPath(Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock")), pathComparison) + || !string.Equals(Path.GetFullPath(plan.RuntimeIntentPath), + Path.GetFullPath(V2rayRuntime.WebUpdateRuntimeStatePath), pathComparison) + || !string.Equals(Path.GetFullPath(plan.ProgressPath), + Path.GetFullPath(Utils.GetTempPath("WebAPI-update-progress.json")), pathComparison) + || !Directory.Exists(plan.CandidateDirectory) + || !File.Exists(Path.Combine(plan.CandidateDirectory, executableName)) + || !File.Exists(Path.Combine(plan.CandidateDirectory, "v2rayN.WebAPI.build.json"))) + { + throw new InvalidDataException("The update plan paths or staged application files are invalid."); + } + EnsureNoLinks(plan.CandidateDirectory); + WebUpdatePackageStager.RequireNativeExecutable(Path.Combine(plan.CandidateDirectory, executableName), plan.Rid); + var identity = JsonSerializer.Deserialize( + File.ReadAllText(Path.Combine(plan.CandidateDirectory, "v2rayN.WebAPI.build.json")), JsonOptions); + if (identity is null || identity.Product != "v2rayN.WebAPI" + || identity.Version != plan.ExpectedVersion || identity.Commit != plan.ExpectedCommit + || identity.Rid != plan.Rid) + { + throw new InvalidDataException("The staged executable identity does not match the verified update plan."); + } + var writableProbe = Path.Combine(install, $".v2rayn-WebAPI-helper-write-probe-{Guid.NewGuid():N}"); + using (new FileStream(writableProbe, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { } + File.Delete(writableProbe); + if (new DirectoryInfo(install).LinkTarget is not null + || new FileInfo(Path.Combine(install, executableName)).LinkTarget is not null) + { + throw new InvalidDataException("Self-update is disabled for symlink-managed installations."); + } + } + + private static bool CanRestartPreviousWithoutApplying(NativeWebUpdatePlan plan) + { + try + { + var executable = Environment.ProcessPath; + var isLinux = OperatingSystem.IsLinux(); + var isWindows = OperatingSystem.IsWindows(); + var pathComparison = isWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return (isLinux || isWindows) + && (!isLinux || !LauncherEnvironment.IsSystemdManagedDeployment()) + && !File.Exists("/.dockerenv") + && !File.Exists("/run/.containerenv") + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) + && !string.IsNullOrWhiteSpace(executable) + && IsUpdateHelperExecutableName(Path.GetFileName(executable)) + && new FileInfo(executable).LinkTarget is null + && string.Equals(Path.GetFullPath(Path.GetDirectoryName(executable) ?? string.Empty), + Path.GetFullPath(plan.InstallDirectory), pathComparison) + && Path.IsPathFullyQualified(plan.InstanceLockPath) + && Path.IsPathFullyQualified(plan.ProgressPath) + && Path.IsPathFullyQualified(plan.RuntimeIntentPath) + && string.Equals(Path.GetFullPath(plan.InstanceLockPath), + Path.GetFullPath(Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock")), pathComparison) + && string.Equals(Path.GetFullPath(plan.RuntimeIntentPath), + Path.GetFullPath(V2rayRuntime.WebUpdateRuntimeStatePath), pathComparison) + && string.Equals(Path.GetFullPath(plan.ProgressPath), + Path.GetFullPath(Utils.GetTempPath("WebAPI-update-progress.json")), pathComparison) + && WebProbeUriResolver.IsProbeableLoopbackUri(plan.HealthUri) + && WebUpdatePackageStager.IsValidVersion(plan.PreviousVersion) + && plan.HostArguments is not null + && !plan.HostArguments.Any(argument => argument is "--stop" or "--apply-WebAPI-update" or "--background" or "--background-child"); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static void EnsureSiblingDirectory(string value, string parent, string prefix) + { + var full = Path.GetFullPath(value); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!string.Equals(Path.GetDirectoryName(full), parent, comparison) + || !Path.GetFileName(full).StartsWith(prefix, StringComparison.Ordinal)) + { + throw new InvalidDataException("A staged update directory is outside the installation parent."); + } + } + + // Windows can transiently deny replacing a freshly written executable while + // antivirus or the search indexer holds it open. Retry for a bounded window so + // a transient lock cannot fail the update or its rollback; permanent failures + // surface with the operation and cause instead of a bare access-denied code. + internal static void WithTransientFileRetry( + Action operation, + string description, + TimeSpan? retryWindow = null, + TimeSpan? retryDelay = null) + { + var window = retryWindow ?? FileReplaceRetryWindow; + var delay = retryDelay ?? FileReplaceRetryDelay; + var deadline = DateTime.UtcNow + window; + while (true) + { + try + { + operation(); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (DateTime.UtcNow >= deadline) + { + throw new IOException( + $"{description} failed after retrying for {window.TotalSeconds:0.#}s: {exception.Message}", + exception); + } + + Thread.Sleep(delay); + } + } + } + + internal static void CopyCurrentAppToBackup(NativeWebUpdatePlan plan) + { + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + Directory.CreateDirectory(plan.BackupDirectory); + if (new FileInfo(Path.Combine(plan.InstallDirectory, executableName)).LinkTarget is not null) + throw new InvalidDataException("The installed Web executable is a symlink and cannot be transactionally replaced."); + File.Copy(Path.Combine(plan.InstallDirectory, executableName), Path.Combine(plan.BackupDirectory, executableName)); + var oldIdentity = Path.Combine(plan.InstallDirectory, "v2rayN.WebAPI.build.json"); + if (File.Exists(oldIdentity)) + { + if (new FileInfo(oldIdentity).LinkTarget is not null) + throw new InvalidDataException("The installed Web build identity is a symlink."); + File.Copy(oldIdentity, Path.Combine(plan.BackupDirectory, "v2rayN.WebAPI.build.json")); + } + } + + internal static void SwapCandidateAppIntoPlace(NativeWebUpdatePlan plan) + { + var install = plan.InstallDirectory; + var candidate = plan.CandidateDirectory; + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + var installedExecutable = Path.Combine(install, executableName); + var installedIdentity = Path.Combine(install, "v2rayN.WebAPI.build.json"); + + WithTransientFileRetry( + () => File.Move(Path.Combine(candidate, executableName), installedExecutable, overwrite: true), + $"Replacing the installed Web executable '{installedExecutable}'"); + WithTransientFileRetry( + () => File.Move(Path.Combine(candidate, "v2rayN.WebAPI.build.json"), installedIdentity, overwrite: true), + $"Replacing the installed Web build identity '{installedIdentity}'"); + SetExecutableMode(installedExecutable); + } + + internal static void RestorePreviousApp(NativeWebUpdatePlan plan) + { + var install = plan.InstallDirectory; + var backup = plan.BackupDirectory; + var executableName = WebUpdatePackageStager.ExecutableNameForRid(plan.Rid); + if (!Directory.Exists(backup)) throw new DirectoryNotFoundException("The previous Web application backup is missing."); + + var previousIdentity = Path.Combine(backup, "v2rayN.WebAPI.build.json"); + var restoredExecutable = Path.Combine(install, $".v2rayn-WebAPI-restore-{Guid.NewGuid():N}{Path.GetExtension(executableName)}"); + var restoredIdentity = restoredExecutable + ".build.json"; + var installedExecutable = Path.Combine(install, executableName); + var installedIdentity = Path.Combine(install, "v2rayN.WebAPI.build.json"); + try + { + // Never truncate an executable inode: the single-file helper may still + // have bundled assemblies mapped from it. Restore by atomic rename, + // just like the forward swap, so existing mappings remain unchanged. + WithTransientFileRetry( + () => File.Copy(Path.Combine(backup, executableName), restoredExecutable), + $"Staging the previous Web executable '{restoredExecutable}'"); + if (File.Exists(previousIdentity)) + { + WithTransientFileRetry( + () => File.Copy(previousIdentity, restoredIdentity), + $"Staging the previous Web build identity '{restoredIdentity}'"); + } + + SetExecutableMode(restoredExecutable); + WithTransientFileRetry( + () => File.Move(restoredExecutable, installedExecutable, overwrite: true), + $"Restoring the previous Web executable '{installedExecutable}'"); + if (File.Exists(restoredIdentity)) + { + WithTransientFileRetry( + () => File.Move(restoredIdentity, installedIdentity, overwrite: true), + $"Restoring the previous Web build identity '{installedIdentity}'"); + } + else + { + TryDeleteFile(installedIdentity); + } + } + finally + { + TryDeleteFile(restoredExecutable); + TryDeleteFile(restoredIdentity); + } + } + + private static Process StartWebInstance(NativeWebUpdatePlan plan) + { + var executable = Path.Combine(plan.InstallDirectory, WebUpdatePackageStager.ExecutableNameForRid(plan.Rid)); + ProcessStartInfo startInfo; + if (OperatingSystem.IsLinux()) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid") + ?? throw new InvalidOperationException("The setsid executable is required for a detached native Web restart."); + startInfo = new ProcessStartInfo + { + FileName = setsid, + WorkingDirectory = plan.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(executable); + startInfo.ArgumentList.Add(WebLaunchOptions.BackgroundChildFlag); + } + else if (OperatingSystem.IsWindows()) + { + startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = plan.InstallDirectory, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + } + else + { + throw new PlatformNotSupportedException("Native Web self-update is supported on Linux and Windows x64."); + } + foreach (var argument in plan.HostArguments) startInfo.ArgumentList.Add(argument); + return Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the updated Web application."); + } + + private static async Task WaitForExpectedHealthAsync( + NativeWebUpdatePlan plan, + Process process, + string expectedVersion, + TimeSpan timeout) + { + var healthProbe = new HttpWebHealthProbe(); + var healthUri = new Uri(plan.HealthUri); + var deadline = DateTimeOffset.UtcNow + timeout; + int? lastOwnerProcessId = null; + var lastHealth = new WebHealthProbeResult(false, null); + var lastRuntimeIntentSatisfied = false; + while (DateTimeOffset.UtcNow < deadline) + { + if (WebInstanceLock.IsHeld(plan.InstanceLockPath)) + { + lastOwnerProcessId = WebInstanceLock.ReadOwnerProcessId(plan.InstanceLockPath); + lastHealth = await healthProbe.ProbeAsync(healthUri, CancellationToken.None); + lastRuntimeIntentSatisfied = RuntimeIntentIsSatisfied(plan.RuntimeIntentPath, lastHealth); + if (lastOwnerProcessId.HasValue && lastHealth.IsHealthy && lastHealth.InstanceProcessId == lastOwnerProcessId + && string.Equals(lastHealth.WebVersion, expectedVersion, StringComparison.Ordinal) + && lastRuntimeIntentSatisfied) + { + return true; + } + } + if (process.HasExited && !WebInstanceLock.IsHeld(plan.InstanceLockPath)) + { + WriteHealthMismatchDiagnostic(plan, expectedVersion, lastOwnerProcessId, lastHealth, lastRuntimeIntentSatisfied); + return false; + } + await Task.Delay(PollInterval); + } + WriteHealthMismatchDiagnostic(plan, expectedVersion, lastOwnerProcessId, lastHealth, lastRuntimeIntentSatisfied); + return false; + } + + private static void WriteHealthMismatchDiagnostic( + NativeWebUpdatePlan plan, + string expectedVersion, + int? ownerProcessId, + WebHealthProbeResult health, + bool runtimeIntentSatisfied) => + Console.Error.WriteLine( + $"Native Web update health mismatch: expectedVersion={expectedVersion}; lockOwner={ownerProcessId}; " + + $"healthy={health.IsHealthy}; healthPid={health.InstanceProcessId}; observedVersion={health.WebVersion}; " + + $"coreState={health.CoreState}; corePids={string.Join(',', health.CoreProcessIds ?? [])}; " + + $"runtimeIntentSatisfied={runtimeIntentSatisfied}; progress={plan.ProgressPath}."); + + private static bool RuntimeIntentIsSatisfied(string intentPath, WebHealthProbeResult health) + { + try + { + if (!File.Exists(intentPath)) return false; + var intent = JsonSerializer.Deserialize(File.ReadAllText(intentPath), JsonOptions); + return intent is not null && NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(intent, health); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return false; + } + } + + private static async Task WaitForLockReleaseAsync(string lockPath, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + if (!WebInstanceLock.IsHeld(lockPath)) return true; + await Task.Delay(PollInterval); + } + return !WebInstanceLock.IsHeld(lockPath); + } + + private static Process? CaptureWindowsOwnerProcess(NativeWebUpdatePlan plan) + { + if (!OperatingSystem.IsWindows() || !WebInstanceLock.IsHeld(plan.InstanceLockPath)) return null; + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(plan.InstanceLockPath) + ?? throw new InvalidDataException("The running Windows Web process identity could not be read from its instance lock."); + try + { + return Process.GetProcessById(ownerProcessId); + } + catch (ArgumentException exception) + { + throw new InvalidDataException("The running Windows Web process no longer matches its instance lock.", exception); + } + } + + private static async Task WaitForProcessExitAsync(Process process, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + try + { + if (process.HasExited) return true; + } + catch (InvalidOperationException) + { + return true; + } + await Task.Delay(PollInterval); + } + try { return process.HasExited; } + catch (InvalidOperationException) { return true; } + } + + private static void StopOwnedProcess(Process? process, string lockPath) + { + if (process is null) return; + try + { + if (process.HasExited) return; + if (WebInstanceLock.ReadOwnerProcessId(lockPath) == process.Id) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(5000); + } + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // The managed update child may exit during health verification. + } + } + + private static void SetExecutableMode(string path) + { + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + private static void EnsureNoLinks(string directory) + { + if (new DirectoryInfo(directory).LinkTarget is not null) + throw new InvalidDataException("The staged update directory is a symlink."); + foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly)) + { + if (new FileInfo(file).LinkTarget is not null) + throw new InvalidDataException("The staged Web update contains a symbolic link."); + } + foreach (var childDirectory in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly)) + { + if (new DirectoryInfo(childDirectory).LinkTarget is not null) + throw new InvalidDataException("The staged Web update contains a symbolic link."); + EnsureNoLinks(childDirectory); + } + } + + private static void WriteProgress(NativeWebUpdatePlan plan, WebUpdateProgressState state) + { + try + { + var temporary = plan.ProgressPath + $".{Environment.ProcessId}.tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(state with { CoreWasRunning = plan.CoreWasRunning }, JsonOptions)); + File.Move(temporary, plan.ProgressPath, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not persist native Web update progress: {exception.Message}"); + } + } + + private static void DeleteRuntimeIntent(string path) => TryDeleteFile(path); + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not clean a Web update directory {path}: {exception.Message}"); + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not clean a Web update file {path}: {exception.Message}"); + } + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs new file mode 100644 index 00000000..17583ead --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/NativeWebUpdateWorkflow.cs @@ -0,0 +1,90 @@ +namespace v2rayN.WebAPI.Launcher; + +internal sealed record NativeWebUpdateTransactionResult(bool Success, bool RollbackSucceeded, string? Detail); + +internal static class NativeWebUpdateWorkflow +{ + public static bool RuntimeIntentIsSatisfied(v2rayN.WebAPI.Services.RuntimeRestartIntent intent, WebHealthProbeResult health) + { + var hasCore = (health.CoreProcessIds?.Count ?? 0) > 0; + if (!intent.WasRunning) + return string.Equals(health.CoreState, "stopped", StringComparison.OrdinalIgnoreCase) && !hasCore; + return string.Equals(health.CoreState, "running", StringComparison.OrdinalIgnoreCase) + && hasCore + && !string.IsNullOrWhiteSpace(health.CoreProfileId); + } + + public static async Task ApplyAsync( + Func installCandidateAsync, + Func> startCandidateAndVerifyHealthAsync, + Func restorePreviousFilesAsync, + Func> startPreviousAndVerifyHealthAsync, + Action setPhase, + Func cleanBackupsAsync) + { + try + { + setPhase("installing"); + await installCandidateAsync(); + setPhase("restarting"); + setPhase("verifying-health"); + if (await startCandidateAndVerifyHealthAsync()) + { + await cleanBackupsAsync(); + return new(true, false, null); + } + } + catch (Exception installException) + { + setPhase("rolling-back"); + return await TryRestoreAndVerifyAsync( + restorePreviousFilesAsync, + startPreviousAndVerifyHealthAsync, + cleanBackupsAsync, + setPhase, + installException.Message); + } + + setPhase("rolling-back"); + return await TryRestoreAndVerifyAsync( + restorePreviousFilesAsync, + startPreviousAndVerifyHealthAsync, + cleanBackupsAsync, + setPhase, + "The new Web application did not become healthy."); + } + + private static async Task TryRestoreAndVerifyAsync( + Func restorePreviousFilesAsync, + Func> startPreviousAndVerifyHealthAsync, + Func cleanBackupsAsync, + Action setPhase, + string failure) + { + string? restoreFailure = null; + try + { + await restorePreviousFilesAsync(); + } + catch (Exception rollbackException) + { + restoreFailure = rollbackException.Message; + } + + try + { + setPhase("restarting"); + setPhase("verifying-health"); + if (!await startPreviousAndVerifyHealthAsync()) + return new(false, false, $"{failure} The previous Web build did not become healthy. {restoreFailure}"); + if (restoreFailure is not null) + return new(false, false, $"{failure} The previous Web process is responding, but restoring all previous app files failed: {restoreFailure}"); + await cleanBackupsAsync(); + return new(false, true, $"{failure} The previous Web build was restored and is healthy."); + } + catch (Exception restartException) + { + return new(false, false, $"{failure} Rollback/restart failed: {restoreFailure} {restartException.Message}"); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs new file mode 100644 index 00000000..cbf058e4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebInstanceLock.cs @@ -0,0 +1,206 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace v2rayN.WebAPI.Launcher; + +public sealed class WebInstanceLock : IDisposable +{ + private readonly FileStream _stream; + private bool _disposed; + + private WebInstanceLock(FileStream stream) + { + _stream = stream; + } + + public static bool TryAcquire(string path, bool writeOwner, out WebInstanceLock? instanceLock) + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("Instance lock path has no parent directory."); + Directory.CreateDirectory(directory); + + FileStream stream; + if (OperatingSystem.IsLinux()) + { + var descriptor = Open(fullPath, OpenReadWrite | OpenCreate | OpenCloseOnExec, UserReadWriteMode); + if (descriptor < 0) + { + throw new IOException("Unable to open the v2rayN.WebAPI instance lock.", new Win32Exception(Marshal.GetLastPInvokeError())); + } + stream = new FileStream(new SafeFileHandle((IntPtr)descriptor, ownsHandle: true), FileAccess.ReadWrite); + } + else + { + var options = new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.ReadWrite, + // Windows uses a byte-range lock below, leaving the owner PID at the start + // of the file readable by the detached update helper while still excluding + // every competing lock acquisition. + Share = OperatingSystem.IsWindows() ? FileShare.ReadWrite : FileShare.None, + }; + try + { + stream = new FileStream(fullPath, options); + } + catch (IOException) + { + instanceLock = null; + return false; + } + } + + var acquired = OperatingSystem.IsLinux() + ? TryFlock(stream, LockExclusive | LockNonBlocking) + : OperatingSystem.IsWindows() ? TryWindowsLock(stream) : true; + if (!acquired) + { + stream.Dispose(); + instanceLock = null; + return false; + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + if (writeOwner) + { + var owner = Encoding.ASCII.GetBytes($"{Environment.ProcessId}\n"); + stream.SetLength(0); + stream.Position = 0; + stream.Write(owner); + stream.Flush(flushToDisk: true); + } + + instanceLock = new WebInstanceLock(stream); + return true; + } + + public static int? ReadOwnerProcessId(string path) + { + try + { + string ownerText; + if (OperatingSystem.IsLinux()) + { + var descriptor = Open(Path.GetFullPath(path), OpenReadOnly | OpenCloseOnExec, 0); + if (descriptor < 0) + { + return null; + } + using var stream = new FileStream(new SafeFileHandle((IntPtr)descriptor, ownsHandle: true), FileAccess.Read); + using var reader = new StreamReader(stream, Encoding.ASCII); + ownerText = reader.ReadToEnd().Trim(); + } + else + { + if (OperatingSystem.IsWindows()) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, Encoding.ASCII); + ownerText = reader.ReadToEnd().Trim(); + } + else + { + ownerText = File.ReadAllText(path, Encoding.ASCII).Trim(); + } + } + return int.TryParse(ownerText, out var processId) && processId > 0 ? processId : null; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + public static bool IsHeld(string path) + { + if (!TryAcquire(path, writeOwner: false, out var probe)) + { + return true; + } + + probe!.Dispose(); + return false; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (OperatingSystem.IsLinux()) + { + var descriptor = _stream.SafeFileHandle.DangerousGetHandle().ToInt32(); + _ = Flock(descriptor, LockUnlock); + } + else if (OperatingSystem.IsWindows()) + { + try { _stream.Unlock(WindowsLockOffset, WindowsLockLength); } + catch (IOException) { } + } + _stream.Dispose(); + } + + private const int LockExclusive = 2; + private const int LockNonBlocking = 4; + private const int LockUnlock = 8; + private const int OpenReadOnly = 0; + private const int OpenReadWrite = 2; + private const int OpenCreate = 64; + private const int OpenCloseOnExec = 524288; + private const uint UserReadWriteMode = 0x180; + private const long WindowsLockOffset = 1024 * 1024; + private const long WindowsLockLength = 1; + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open(string path, int flags, uint mode); + + private static bool TryFlock(FileStream stream, int operation) + { + var descriptor = stream.SafeFileHandle.DangerousGetHandle().ToInt32(); + if (Flock(descriptor, operation) == 0) + { + return true; + } + + var error = Marshal.GetLastPInvokeError(); + if (error is 11 or 35) + { + return false; + } + + throw new IOException("Unable to acquire the v2rayN.WebAPI instance lock.", new Win32Exception(error)); + } + + [SupportedOSPlatform("windows")] + private static bool TryWindowsLock(FileStream stream) + { + try + { + stream.Lock(WindowsLockOffset, WindowsLockLength); + return true; + } + catch (IOException) + { + return false; + } + } + + [DllImport("libc", EntryPoint = "flock", SetLastError = true)] + private static extern int Flock(int fileDescriptor, int operation); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs new file mode 100644 index 00000000..60a02cc5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebLaunchOptions.cs @@ -0,0 +1,223 @@ +namespace v2rayN.WebAPI.Launcher; + +public enum WebLaunchMode +{ + Stop, + Foreground, + BackgroundLauncher, + BackgroundChild, +} + +public sealed record WebLaunchOptions(WebLaunchMode Mode, bool NoOpen, string[] HostArguments) +{ + public const string StopFlag = "--stop"; + public const string ForegroundFlag = "--foreground"; + public const string BackgroundFlag = "--background"; + public const string NoOpenFlag = "--no-open"; + public const string BackgroundChildFlag = "--background-child"; + public const string HelpFlag = "--help"; + public const string HelpShortFlag = "-h"; + + public static WebLaunchOptions Parse( + string[] arguments, + bool isLinux, + bool daemonEnvironment, + bool containerEnvironment, + bool interactiveLaunch) + { + var mode = arguments.Contains(StopFlag, StringComparer.Ordinal) + ? WebLaunchMode.Stop + : arguments.Contains(BackgroundChildFlag, StringComparer.Ordinal) + ? WebLaunchMode.BackgroundChild + : arguments.Contains(ForegroundFlag, StringComparer.Ordinal) + ? WebLaunchMode.Foreground + : arguments.Contains(BackgroundFlag, StringComparer.Ordinal) + ? WebLaunchMode.BackgroundLauncher + : isLinux && !containerEnvironment && (!daemonEnvironment || interactiveLaunch) + ? WebLaunchMode.BackgroundLauncher + : WebLaunchMode.Foreground; + + var hostArguments = arguments + .Where(argument => argument is not StopFlag + and not ForegroundFlag + and not BackgroundFlag + and not NoOpenFlag + and not BackgroundChildFlag + and not HelpFlag + and not HelpShortFlag) + .ToArray(); + return new WebLaunchOptions( + mode, + arguments.Contains(NoOpenFlag, StringComparer.Ordinal), + hostArguments); + } +} + +public static class LauncherEnvironment +{ + // Set when the launch chain started in an interactive terminal and inherited + // by every later child, helper, and replacement process. Terminals that were + // started by systemd export INVOCATION_ID/JOURNAL_STREAM to everything they + // run, so the launch context is recorded once instead of being probed again. + public const string InteractiveLaunchEnvironmentVariable = "V2RAYN_WEB_INTERACTIVE_LAUNCH"; + + public static bool IsDaemonEnvironment(IReadOnlyDictionary environment) + { + return HasValue(environment, "INVOCATION_ID") + || HasValue(environment, "JOURNAL_STREAM") + || HasValue(environment, "NOTIFY_SOCKET"); + } + + public static bool IsContainerEnvironment(IReadOnlyDictionary environment) => + HasValue(environment, "DOTNET_RUNNING_IN_CONTAINER") + || HasValue(environment, "container"); + + public static bool IsCurrentContainerEnvironment() + { + var environment = ReadCurrentEnvironment(); + return IsContainerEnvironment(environment) + || File.Exists("/.dockerenv") + || File.Exists("/run/.containerenv"); + } + + public static bool IsInteractiveLaunchMarker(IReadOnlyDictionary environment) => + HasValue(environment, InteractiveLaunchEnvironmentVariable); + + /// + /// True when this process runs with an interactive terminal attached or the + /// launch chain was marked as interactive by the process that had one. + /// + public static bool IsInteractiveLaunch(IReadOnlyDictionary environment) => + !Console.IsInputRedirected || IsInteractiveLaunchMarker(environment); + + /// + /// True when systemd markers or the service cgroup claim this process, unless + /// the markers merely leaked into a terminal (or its detached children), + /// where no external supervisor exists. + /// + public static bool IsSystemdManagedDeployment() + { + if (WebStopper.IsManagedBySystemd(Environment.ProcessId)) + { + return true; + } + + var environment = ReadCurrentEnvironment(); + return IsDaemonEnvironment(environment) && !IsInteractiveLaunch(environment); + } + + public static Dictionary ReadCurrentEnvironment() => + Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase); + + private static bool HasValue(IReadOnlyDictionary environment, string key) => + environment.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value); +} + +public enum LauncherLocale +{ + SimplifiedChinese, + TraditionalChinese, + English, +} + +public static class LauncherMessages +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary MessageCache = new(StringComparer.Ordinal); + + public static LauncherLocale ResolveLocale(params string?[] localeValues) + { + var locale = localeValues.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + var normalized = locale.Replace('_', '-'); + if (normalized.StartsWith("zh-TW", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-HK", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-MO", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("zh-Hant", StringComparison.OrdinalIgnoreCase)) + { + return LauncherLocale.TraditionalChinese; + } + + return normalized.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + ? LauncherLocale.SimplifiedChinese + : LauncherLocale.English; + } + + public static string Started(string url, string command, LauncherLocale locale) => + Format("started", locale, url, command); + + public static string AlreadyRunning(string url, string command, bool browserOpened, LauncherLocale locale) => + Format(browserOpened ? "alreadyRunningOpening" : "alreadyRunning", locale, url, command); + + public static string ForegroundStarted(string url, LauncherLocale locale) => Format("foregroundStarted", locale, url); + + public static string StopMessage(WebStopResult result, LauncherLocale locale, string? lastStage = null) + { + var message = Get(result switch + { + WebStopResult.NotRunning => "stopNotRunning", + WebStopResult.Stopped => "stopSucceeded", + WebStopResult.ProbeUnavailable => "stopUnprobeable", + WebStopResult.IdentityUnverified => "stopIdentityUnverified", + WebStopResult.SupervisorManaged => "stopSupervisorManaged", + WebStopResult.CoreProcessStillRunning => "stopCoreStillRunning", + WebStopResult.SignalFailed => "stopSignalFailed", + WebStopResult.TimedOut => "stopTimedOut", + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }, locale); + + return result == WebStopResult.TimedOut && !string.IsNullOrWhiteSpace(lastStage) + ? $"{message} {Get("stopTimedOutStage", locale).Replace("{stage}", lastStage, StringComparison.Ordinal)}" + : message; + } + + public static string StopUnsupported(LauncherLocale locale) => Get("stopUnsupported", locale); + + public static string Help(LauncherLocale locale) => Get("help", locale); + + public static string StartFailed(LauncherLocale locale) => Get("startFailed", locale); + + public static string StartUnprobeable(LauncherLocale locale) => Get("startUnprobeable", locale); + + public static string ExistingUnhealthy(LauncherLocale locale) => Get("existingUnhealthy", locale); + + public static string InstanceInUse(LauncherLocale locale) => Get("instanceInUse", locale); + + public static string ExecutableCommand(string executablePath, string? managedEntryPoint = null) + { + if (Path.GetFileNameWithoutExtension(executablePath).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(managedEntryPoint) + && managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return $"\"{executablePath}\" \"{Path.GetFullPath(managedEntryPoint)}\""; + } + + return $"./{Path.GetFileName(executablePath)}"; + } + + private static string Format(string key, LauncherLocale locale, string url, string? command = null) => + Get(key, locale) + .Replace("{url}", url, StringComparison.Ordinal) + .Replace("{command}", command ?? string.Empty, StringComparison.Ordinal); + + private static string Get(string key, LauncherLocale locale) + { + var localeName = locale switch + { + LauncherLocale.SimplifiedChinese => "zh-CN", + LauncherLocale.TraditionalChinese => "zh-TW", + _ => "en-US", + }; + return MessageCache.GetOrAdd($"{localeName}:{key}", _ => ReadMessage(localeName, key)); + } + + private static string ReadMessage(string localeName, string key) + { + var resourceName = $"v2rayN.WebAPI.Launcher.Locales.{localeName}.json"; + using var stream = typeof(LauncherMessages).Assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded locale resource: {resourceName}"); + using var document = System.Text.Json.JsonDocument.Parse(stream); + return document.RootElement.GetProperty("launcher").GetProperty(key).GetString() + ?? throw new InvalidOperationException($"Missing launcher locale key: {localeName}.{key}"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs new file mode 100644 index 00000000..1bf65067 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebLauncher.cs @@ -0,0 +1,381 @@ +using System.Diagnostics; +using System.Net.Http; +using System.Net.Sockets; +using System.Runtime.InteropServices; +namespace v2rayN.WebAPI.Launcher; + +public interface IWebHealthProbe +{ + Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken); +} + +public sealed record WebHealthProbeResult( + bool IsHealthy, + int? InstanceProcessId, + string? ShutdownStage = null, + IReadOnlyList? CoreProcessIds = null, + string? CoreState = null, + string? WebVersion = null, + string? CoreProfileId = null); + +public interface IBrowserOpener +{ + bool TryOpen(Uri apiUri); +} + +public sealed class HttpWebHealthProbe : IWebHealthProbe +{ + private readonly HttpClient _client = new(new SocketsHttpHandler + { + ConnectTimeout = TimeSpan.FromMilliseconds(500), + UseProxy = false, + }) + { + Timeout = TimeSpan.FromSeconds(1), + }; + + public async Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) + { + try + { + using var response = await _client.GetAsync(healthUri, cancellationToken); + var instanceProcessId = response.Headers.TryGetValues("X-v2rayn-WebAPI-instance-pid", out var values) + && int.TryParse(values.FirstOrDefault(), out var parsedProcessId) + ? parsedProcessId + : (int?)null; + var shutdownStage = response.Headers.TryGetValues("X-v2rayn-WebAPI-shutdown-stage", out var stageValues) + ? stageValues.FirstOrDefault() + : null; + var coreProcessIds = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-process-ids", out var processValues) + ? processValues.FirstOrDefault()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => int.TryParse(value, out var processId) ? processId : 0) + .Where(processId => processId > 0) + .Distinct() + .ToArray() + : []; + var coreState = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-state", out var stateValues) + ? stateValues.FirstOrDefault() + : null; + var webVersion = response.Headers.TryGetValues("X-v2rayn-WebAPI-version", out var versionValues) + ? versionValues.FirstOrDefault() + : null; + var coreProfileId = response.Headers.TryGetValues("X-v2rayn-WebAPI-core-profile-id", out var profileValues) + ? profileValues.FirstOrDefault() + : null; + return new WebHealthProbeResult(response.IsSuccessStatusCode, instanceProcessId, shutdownStage, + coreProcessIds, coreState, webVersion, coreProfileId); + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + return new WebHealthProbeResult(false, null); + } + } +} + +public sealed class LinuxXdgBrowserOpener : IBrowserOpener +{ + public bool TryOpen(Uri apiUri) + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DISPLAY")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + { + return false; + } + + var xdgOpen = FindExecutable("xdg-open"); + if (xdgOpen is null) + { + return false; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = xdgOpen, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(apiUri.ToString()); + return Process.Start(startInfo) is not null; + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return false; + } + } + + internal static string? FindExecutable(string name) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } +} + +public static class ExistingInstanceHandler +{ + public static async Task TryReuseAsync( + string lockPath, + Uri healthUri, + Uri apiUri, + IWebHealthProbe healthProbe, + IBrowserOpener browserOpener, + bool noOpen, + TimeSpan waitTimeout, + CancellationToken cancellationToken = default) + { + if (WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var probe)) + { + probe!.Dispose(); + return new ExistingInstanceResult(false, false); + } + + var timeout = Stopwatch.StartNew(); + do + { + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(lockPath); + var health = await healthProbe.ProbeAsync(healthUri, cancellationToken); + if (health.IsHealthy && ownerProcessId.HasValue && health.InstanceProcessId == ownerProcessId) + { + if (!noOpen) + { + var browserOpened = browserOpener.TryOpen(apiUri); + return new ExistingInstanceResult(true, browserOpened); + } + return new ExistingInstanceResult(true, false); + } + + if (WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out probe)) + { + probe!.Dispose(); + return new ExistingInstanceResult(false, false); + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + while (timeout.Elapsed < waitTimeout); + + throw new TimeoutException("The existing v2rayN.WebAPI instance holds its lock but did not become healthy."); + } +} + +public sealed record ExistingInstanceResult(bool Existing, bool BrowserOpened); + +public sealed class WebLauncher +{ + private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan HealthPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IBrowserOpener _browserOpener; + private readonly LauncherLocale _locale; + + public WebLauncher(IWebHealthProbe healthProbe, IBrowserOpener browserOpener, LauncherLocale locale) + { + _healthProbe = healthProbe; + _browserOpener = browserOpener; + _locale = locale; + } + + public async Task RunAsync( + string executablePath, + string[] hostArguments, + string instanceLockPath, + Uri healthUri, + Uri apiUri, + bool noOpen, + CancellationToken cancellationToken = default) + { + var commandLine = Environment.GetCommandLineArgs(); + var launcherCommand = LauncherMessages.ExecutableCommand(executablePath, commandLine.FirstOrDefault()); + try + { + var existing = await ExistingInstanceHandler.TryReuseAsync( + instanceLockPath, + healthUri, + apiUri, + _healthProbe, + _browserOpener, + noOpen, + StartupTimeout, + cancellationToken); + if (existing.Existing) + { + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + existing.BrowserOpened, + _locale)); + return 0; + } + } + catch (TimeoutException) + { + Print(LauncherMessages.ExistingUnhealthy(_locale)); + return 1; + } + + using var child = StartDetachedChild(executablePath, hostArguments); + if (child is null) + { + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < StartupTimeout && !cancellationToken.IsCancellationRequested) + { + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(instanceLockPath); + var lockHeld = WebInstanceLock.IsHeld(instanceLockPath); + var health = lockHeld + ? await _healthProbe.ProbeAsync(healthUri, cancellationToken) + : new WebHealthProbeResult(false, null); + if (lockHeld && ownerProcessId.HasValue + && health.IsHealthy && health.InstanceProcessId == ownerProcessId) + { + var opened = !noOpen && _browserOpener.TryOpen(apiUri); + if (ownerProcessId.Value == child.Id) + { + Print(LauncherMessages.Started( + apiUri.ToString(), + launcherCommand, + _locale)); + return 0; + } + + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + opened, + _locale)); + return 0; + } + + if (child.HasExited) + { + // A competing launcher for the same data scope may have won the startup race. + if (lockHeld && ownerProcessId.HasValue && ownerProcessId != child.Id + && health.IsHealthy && health.InstanceProcessId == ownerProcessId) + { + var opened = !noOpen && _browserOpener.TryOpen(apiUri); + Print(LauncherMessages.AlreadyRunning( + apiUri.ToString(), + launcherCommand, + opened, + _locale)); + return 0; + } + + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + await Task.Delay(HealthPollInterval, cancellationToken); + } + + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + // The child can exit between the check and Kill. + } + + Print(LauncherMessages.StartFailed(_locale)); + return 1; + } + + private static Process? StartDetachedChild(string executablePath, string[] hostArguments) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid"); + var processPath = Environment.ProcessPath ?? executablePath; + var commandLine = Environment.GetCommandLineArgs(); + var command = WebReplacementCommand.Create( + setsid, + processPath, + WebReplacementCommand.GetManagedEntryPoint(processPath, commandLine), + hostArguments); + if (command is null) return null; + + try + { + return Process.Start(command.ToProcessStartInfo()); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + private static void Print(string message) => Console.Out.WriteLine(message); +} + +public static class BackgroundChildProcess +{ + public static void DetachStandardHandles() + { + if (OperatingSystem.IsWindows()) + { + _ = FreeConsole(); + Console.SetIn(TextReader.Null); + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + return; + } + + if (!OperatingSystem.IsLinux()) + { + return; + } + + var nullDescriptor = Open("/dev/null", OpenReadWrite); + if (nullDescriptor < 0) + { + return; + } + + _ = Dup2(nullDescriptor, 0); + _ = Dup2(nullDescriptor, 1); + _ = Dup2(nullDescriptor, 2); + if (nullDescriptor > 2) + { + _ = Close(nullDescriptor); + } + + Console.SetIn(TextReader.Null); + Console.SetOut(TextWriter.Null); + Console.SetError(TextWriter.Null); + } + + private const int OpenReadWrite = 2; + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open(string path, int flags); + + [DllImport("libc", EntryPoint = "dup2", SetLastError = true)] + private static extern int Dup2(int oldDescriptor, int newDescriptor); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int Close(int descriptor); + + [DllImport("kernel32.dll", EntryPoint = "FreeConsole", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs new file mode 100644 index 00000000..87161017 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebProbeUriResolver.cs @@ -0,0 +1,121 @@ +using System.Net; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Launcher; + +/// +/// Resolves the loopback HTTP health-probe endpoint that matches an instance's effective +/// Kestrel listeners. The diagnostic endpoint is loopback-only, so a probe is produced +/// only for listeners that can be reached over a loopback address; listeners that cannot +/// (HTTPS-only, an explicit non-loopback address, or malformed configuration) are +/// rejected instead of being rewritten to a guessed address or port. The rule set is +/// shared with the health diagnostics and the update helper: every produced URI passes +/// . +/// +public static class WebProbeUriResolver +{ + public const string HealthPath = "/api/health"; + + public sealed record WebProbeEndpoints(Uri HealthUri, Uri ApiUri); + + /// + /// True when a URI can carry a verifiable health probe: plain HTTP to a host the + /// health diagnostics accept as local (localhost, 127.0.0.1 or ::1). The update + /// helper and the produced probe URIs both use this single rule. + /// + public static bool IsProbeableLoopbackUri(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttp + && WebSetupAccessPolicy.IsLocalHost(uri.Host); + + /// + /// Resolves the health and API URLs for the first effective listener that can be probed + /// over loopback HTTP. Returns false when no listener can be probed reliably. + /// + public static bool TryResolve(IConfiguration configuration, out WebProbeEndpoints endpoints) + { + foreach (var listenUrl in WebListenerSecurityPolicy.GetEffectiveUrls(configuration)) + { + if (!TryCreateHealthUri(listenUrl, out var healthUri)) + { + continue; + } + + endpoints = new WebProbeEndpoints(healthUri, new UriBuilder(healthUri) { Path = "/" }.Uri); + return true; + } + + endpoints = null!; + return false; + } + + /// + /// Maps one effective listen URL to its loopback health-probe URL. Kestrel treats + /// non-loopback host names as wildcard binds, so those probe the local host name to + /// cover both address families; explicit wildcards keep their address family. + /// + public static bool TryCreateHealthUri(string listenUrl, out Uri healthUri) + { + healthUri = null!; + var trimmed = listenUrl?.Trim() ?? string.Empty; + var schemeSeparator = trimmed.IndexOf("://", StringComparison.Ordinal); + if (schemeSeparator <= 0) + { + return false; + } + + // Kestrel's "*" and "+" wildcard hosts are not valid System.Uri hosts; replace the + // host token so the URL can be parsed, and remember to probe the local host name. + var authorityStart = schemeSeparator + 3; + var wildcard = authorityStart < trimmed.Length && trimmed[authorityStart] is '*' or '+'; + var normalized = wildcard + ? string.Concat(trimmed.AsSpan(0, authorityStart), "localhost", trimmed.AsSpan(authorityStart + 1)) + : trimmed; + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttp) + { + return false; + } + + var probeHost = wildcard ? "localhost" : MapToLoopbackHost(uri.Host); + if (probeHost is null) + { + return false; + } + + healthUri = new UriBuilder(Uri.UriSchemeHttp, probeHost, uri.Port, HealthPath).Uri; + // Gate the generated probe through the same rule the health diagnostics and the + // update helper use; loopback aliases such as 127.0.0.2 are not verifiable there. + return IsProbeableLoopbackUri(healthUri.ToString()); + } + + private static string? MapToLoopbackHost(string host) + { + if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + // Keep the configured name: resolving "localhost" covers both families and + // avoids forcing a configured name onto one IPv4 literal. + return "localhost"; + } + + // System.Uri keeps the brackets around IPv6 literals. + var literal = host.Trim('[', ']'); + if (!IPAddress.TryParse(literal, out var address)) + { + // Kestrel binds a host name that is not "localhost" as a wildcard, which + // includes both loopback addresses where the platform provides them. + return "localhost"; + } + + if (address.Equals(IPAddress.Any)) + { + return IPAddress.Loopback.ToString(); + } + + if (address.Equals(IPAddress.IPv6Any)) + { + return IPAddress.IPv6Loopback.ToString(); + } + + return IPAddress.IsLoopback(address) ? literal : null; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs new file mode 100644 index 00000000..219ae16d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebRestartCoordinator.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; +using System.ComponentModel; +using System.IO; + +namespace v2rayN.WebAPI.Launcher; + +public enum WebLifecycleAction +{ + Stop, + Restart, + RestoreAndRestart, + Shutdown, +} + +public enum WebLifecycleOwner +{ + None, + ExternalSupervisor, + NativeProcess, +} + +public sealed record WebLifecyclePlan(WebLifecycleAction Action, WebLifecycleOwner Owner) +{ + public bool ShouldStartReplacement => Owner == WebLifecycleOwner.NativeProcess + && Action is WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart; + + public bool ExitsForSupervisor => Owner == WebLifecycleOwner.ExternalSupervisor + && Action is WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart; +} + +public static class WebLifecyclePlanner +{ + public static WebLifecyclePlan Create( + WebLifecycleAction action, + bool supportsNativeRestart, + bool daemonEnvironment, + bool containerEnvironment) + { + var owner = action is not (WebLifecycleAction.Restart or WebLifecycleAction.RestoreAndRestart) + ? WebLifecycleOwner.None + : daemonEnvironment || containerEnvironment + ? WebLifecycleOwner.ExternalSupervisor + : supportsNativeRestart + ? WebLifecycleOwner.NativeProcess + : WebLifecycleOwner.None; + return new WebLifecyclePlan(action, owner); + } +} + +public sealed record WebReplacementCommand( + string LauncherPath, + string[] Arguments, + string WorkingDirectory, + bool CreateNoWindow = false) +{ + public ProcessStartInfo ToProcessStartInfo() + { + var startInfo = new ProcessStartInfo + { + FileName = LauncherPath, + WorkingDirectory = WorkingDirectory, + UseShellExecute = false, + CreateNoWindow = CreateNoWindow, + }; + foreach (var argument in Arguments) + { + startInfo.ArgumentList.Add(argument); + } + return startInfo; + } + + public static string? GetManagedEntryPoint(string? processPath, IReadOnlyList commandLine) => + !string.IsNullOrWhiteSpace(processPath) + && Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + && commandLine.Count > 0 + && commandLine[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + ? commandLine[0] + : null; + + public static WebReplacementCommand? Create( + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments) => + CreateNative( + isLinux: true, + isWindows: false, + setsidPath, + processPath, + managedEntryPoint, + hostArguments); + + public static WebReplacementCommand? CreateNative( + bool isLinux, + bool isWindows, + string? setsidPath, + string? processPath, + string? managedEntryPoint, + IEnumerable hostArguments) + { + if ((!isLinux && !isWindows) + || string.IsNullOrWhiteSpace(processPath) + || (isLinux && string.IsNullOrWhiteSpace(setsidPath))) + { + return null; + } + + var arguments = new List(); + var launcherPath = isLinux ? setsidPath! : processPath; + if (Path.GetFileNameWithoutExtension(processPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(managedEntryPoint) + || !managedEntryPoint.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (isLinux) + { + arguments.Add(Path.GetFullPath(processPath)); + } + arguments.Add(Path.GetFullPath(managedEntryPoint)); + } + else if (isLinux) + { + arguments.Add(Path.GetFullPath(processPath)); + } + + arguments.Add(WebLaunchOptions.BackgroundChildFlag); + arguments.AddRange(hostArguments); + return new WebReplacementCommand( + Path.GetFullPath(launcherPath), + arguments.ToArray(), + Environment.CurrentDirectory, + CreateNoWindow: isWindows); + } +} + +public interface IWebReplacementProcess : IDisposable +{ + int Id { get; } + bool HasExited { get; } + void KillTree(); +} + +public interface IWebReplacementProcessStarter +{ + IWebReplacementProcess? Start(WebReplacementCommand command); +} + +public interface IWebInstanceOwnershipProbe +{ + bool IsLockHeld(string lockPath); + int? ReadOwnerProcessId(string lockPath); +} + +public sealed class FileWebInstanceOwnershipProbe : IWebInstanceOwnershipProbe +{ + public bool IsLockHeld(string lockPath) => File.Exists(lockPath) && WebInstanceLock.IsHeld(lockPath); + + public int? ReadOwnerProcessId(string lockPath) => WebInstanceLock.ReadOwnerProcessId(lockPath); +} + +public sealed class NativeReplacementProcessStarter : IWebReplacementProcessStarter +{ + public IWebReplacementProcess? Start(WebReplacementCommand command) + { + try + { + var process = Process.Start(command.ToProcessStartInfo()); + return process is null ? null : new ReplacementProcess(process); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or Win32Exception) + { + throw new InvalidOperationException( + $"Could not start '{command.LauncherPath}' for the native Web replacement: {exception.Message}", + exception); + } + } + + private sealed class ReplacementProcess(Process process) : IWebReplacementProcess + { + public int Id => process.Id; + + public bool HasExited + { + get + { + try { return process.HasExited; } + catch (InvalidOperationException) { return true; } + } + } + + public void KillTree() + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception) + { + // A process may exit between the check and the kill request. + } + } + + public void Dispose() => process.Dispose(); + } +} + +public sealed record WebRestartHandoffResult(bool Success, string Message, int? ReplacementProcessId = null); + +public sealed class NativeWebRestartCoordinator +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(35); + private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IWebReplacementProcessStarter _processStarter; + private readonly IWebInstanceOwnershipProbe _ownershipProbe; + private readonly TimeSpan _timeout; + private readonly TimeSpan _pollInterval; + private readonly Func _delay; + + public NativeWebRestartCoordinator( + IWebHealthProbe healthProbe, + IWebReplacementProcessStarter processStarter, + IWebInstanceOwnershipProbe ownershipProbe, + TimeSpan? timeout = null, + TimeSpan? pollInterval = null, + Func? delay = null) + { + _healthProbe = healthProbe; + _processStarter = processStarter; + _ownershipProbe = ownershipProbe; + _timeout = timeout ?? DefaultTimeout; + _pollInterval = pollInterval ?? DefaultPollInterval; + _delay = delay ?? Task.Delay; + if (_timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout)); + if (_pollInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(pollInterval)); + } + + public async Task StartReplacementAfterOwnerReleaseAsync( + string lockPath, + Uri healthUri, + WebReplacementCommand command, + CancellationToken cancellationToken = default, + bool? expectedCoreRunning = null) + { + var timeout = Stopwatch.StartNew(); + while (_ownershipProbe.IsLockHeld(lockPath)) + { + if (timeout.Elapsed >= _timeout) + { + return new(false, "The previous Web owner did not release the current data-scope instance lock."); + } + await _delay(Min(_pollInterval, _timeout - timeout.Elapsed), cancellationToken); + } + + IWebReplacementProcess? replacement; + try + { + replacement = _processStarter.Start(command); + } + catch (Exception exception) when (exception is InvalidOperationException or IOException or Win32Exception) + { + return new(false, $"The native replacement process could not be started: {exception.Message}"); + } + if (replacement is null) + { + return new(false, "The native replacement process could not be started."); + } + + using (replacement) + { + while (timeout.Elapsed < _timeout) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_ownershipProbe.IsLockHeld(lockPath)) + { + var ownerProcessId = _ownershipProbe.ReadOwnerProcessId(lockPath); + if (ownerProcessId == replacement.Id) + { + var health = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + if (health.IsHealthy && health.InstanceProcessId == replacement.Id) + { + if (expectedCoreRunning is bool wasRunning && !CoreIntentMatches(health, wasRunning)) + { + return new(false, + $"The native replacement Web instance is healthy, but the previous Core {(wasRunning ? "running" : "stopped")} intent was not restored.", + replacement.Id); + } + return new(true, "The native replacement Web instance is healthy.", replacement.Id); + } + } + } + + if (replacement.HasExited) + { + return new(false, "The native replacement process exited before its Web health endpoint became ready.", replacement.Id); + } + + await _delay(Min(_pollInterval, _timeout - timeout.Elapsed), cancellationToken); + } + + var currentOwner = _ownershipProbe.IsLockHeld(lockPath) + ? _ownershipProbe.ReadOwnerProcessId(lockPath) + : null; + if (!replacement.HasExited && (!currentOwner.HasValue || currentOwner == replacement.Id)) + { + replacement.KillTree(); + } + return new(false, "The native replacement Web instance did not become healthy before the startup timeout.", replacement.Id); + } + } + + private static bool CoreIntentMatches(WebHealthProbeResult health, bool expectedRunning) + { + if (!string.Equals(health.CoreState, expectedRunning ? "running" : "stopped", StringComparison.Ordinal)) + { + return false; + } + + return expectedRunning + ? health.CoreProcessIds is { Count: > 0 } + : health.CoreProcessIds is { Count: 0 }; + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) => left < right ? left : right; +} diff --git a/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs b/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs new file mode 100644 index 00000000..90a2999f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Launcher/WebStopper.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.IO; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Launcher; + +public enum WebStopResult +{ + NotRunning, + Stopped, + ProbeUnavailable, + IdentityUnverified, + SupervisorManaged, + CoreProcessStillRunning, + SignalFailed, + TimedOut, +} + +public interface IProcessSignalSender +{ + bool SendSigTerm(int processId); +} + +public sealed class LinuxProcessSignalSender : IProcessSignalSender +{ + private const int SigTerm = 15; + + public bool SendSigTerm(int processId) => OperatingSystem.IsLinux() && Kill(processId, SigTerm) == 0; + + [DllImport("libc", EntryPoint = "kill", SetLastError = true)] + private static extern int Kill(int processId, int signal); +} + +public sealed class WebStopper +{ + private static readonly TimeSpan DefaultStopTimeout = RuntimeShutdownBudgets.LauncherWait; + private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(200); + + private readonly IWebHealthProbe _healthProbe; + private readonly IProcessSignalSender _signalSender; + private readonly TimeSpan _stopTimeout; + private readonly TimeSpan _pollInterval; + + public string? LastObservedShutdownStage { get; private set; } + + public WebStopper( + IWebHealthProbe healthProbe, + IProcessSignalSender signalSender, + TimeSpan? stopTimeout = null, + TimeSpan? pollInterval = null) + { + _healthProbe = healthProbe; + _signalSender = signalSender; + _stopTimeout = stopTimeout ?? DefaultStopTimeout; + _pollInterval = pollInterval ?? DefaultPollInterval; + if (_stopTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(stopTimeout)); + } + if (_pollInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(pollInterval)); + } + } + + public async Task StopAsync( + string lockPath, + Uri? healthUri, + CancellationToken cancellationToken = default) + { + if (!IsLockHeld(lockPath)) + { + return WebStopResult.NotRunning; + } + + var ownerProcessId = WebInstanceLock.ReadOwnerProcessId(lockPath); + if (!ownerProcessId.HasValue) + { + return WebStopResult.IdentityUnverified; + } + + if (IsManagedBySystemd(ownerProcessId.Value)) + { + return WebStopResult.SupervisorManaged; + } + + // A null probe means the configured listeners have no loopback HTTP health + // endpoint; without it the owner identity cannot be confirmed, so no signal + // may be sent. + if (healthUri is null) + { + return WebStopResult.ProbeUnavailable; + } + + var health = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + if (!health.IsHealthy || health.InstanceProcessId != ownerProcessId) + { + return WebStopResult.IdentityUnverified; + } + + // Recheck the lock and owner immediately before signaling, so a released or + // replaced instance cannot turn a stale PID into an unrelated process signal. + if (!IsLockHeld(lockPath)) + { + return WebStopResult.NotRunning; + } + if (WebInstanceLock.ReadOwnerProcessId(lockPath) != ownerProcessId) + { + return WebStopResult.IdentityUnverified; + } + + var coreProcessIds = new HashSet(health.CoreProcessIds ?? []); + + if (!_signalSender.SendSigTerm(ownerProcessId.Value)) + { + return WebStopResult.SignalFailed; + } + + var timeout = Stopwatch.StartNew(); + while (timeout.Elapsed < _stopTimeout) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Keep probing health while waiting, but only report completion once the + // instance has released its lock (after host cleanup and service shutdown). + var currentHealth = await _healthProbe.ProbeAsync(healthUri, cancellationToken); + LastObservedShutdownStage = currentHealth.ShutdownStage ?? LastObservedShutdownStage; + coreProcessIds.UnionWith(currentHealth.CoreProcessIds ?? []); + var lockHeld = IsLockHeld(lockPath); + var currentOwnerProcessId = lockHeld ? WebInstanceLock.ReadOwnerProcessId(lockPath) : null; + var endpointStillIdentifiesOwner = currentHealth.IsHealthy + && currentHealth.InstanceProcessId == ownerProcessId; + if (!endpointStillIdentifiesOwner && (!lockHeld || currentOwnerProcessId != ownerProcessId)) + { + var remainingCoreProcesses = coreProcessIds.Where(IsProcessAlive).ToArray(); + return remainingCoreProcesses.Length == 0 + ? WebStopResult.Stopped + : WebStopResult.CoreProcessStillRunning; + } + + var remaining = _stopTimeout - timeout.Elapsed; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining < _pollInterval ? remaining : _pollInterval, cancellationToken); + } + } + + return WebStopResult.TimedOut; + } + + private static bool IsLockHeld(string lockPath) => + File.Exists(lockPath) && WebInstanceLock.IsHeld(lockPath); + + internal static bool IsManagedBySystemd(int processId) + { + if (!OperatingSystem.IsLinux()) return false; + try + { + return IsSystemdServiceCgroup(File.ReadAllText($"/proc/{processId}/cgroup")); + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + internal static bool IsSystemdServiceCgroup(string? cgroup) => + !string.IsNullOrWhiteSpace(cgroup) + && cgroup.Contains("system.slice/", StringComparison.Ordinal) + && cgroup.Contains(".service", StringComparison.Ordinal); + + private static bool IsProcessAlive(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Program.cs b/v2rayN/v2rayN.WebAPI/Program.cs new file mode 100644 index 00000000..acbcc02c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Program.cs @@ -0,0 +1,414 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using ServiceLib; +using ServiceLib.Common; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Configuration; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +internal static class Program +{ + private const string ManagementKeyEnvironmentVariable = "V2RAYN_WEB_API_KEY"; + + public static async Task Main(string[] args) + { + if (args.Contains(WebLaunchOptions.HelpFlag, StringComparer.Ordinal) + || args.Contains(WebLaunchOptions.HelpShortFlag, StringComparer.Ordinal)) + { + Console.Out.WriteLine(LauncherMessages.Help(GetLauncherLocale())); + return 0; + } + + try + { + NativeEnvironmentFile.LoadFromExecutableDirectory(AppContext.BaseDirectory); + } + catch (NativeEnvironmentConfigurationException exception) + { + Console.Error.WriteLine($"Invalid v2rayN.WebAPI environment configuration: {exception.Message}"); + return 1; + } + + if (args.Length == 2 && args[0] == "--apply-WebAPI-update") + { + return await NativeWebUpdateHelper.RunAsync(args[1]); + } + + var environment = ReadEnvironment(); + var interactiveTerminal = !Console.IsInputRedirected; + if (interactiveTerminal) + { + // Record the launch context for detached children and later helper + // processes, which can no longer probe the terminal themselves. + Environment.SetEnvironmentVariable(LauncherEnvironment.InteractiveLaunchEnvironmentVariable, "1"); + } + + var daemonEnvironment = LauncherEnvironment.IsDaemonEnvironment(environment); + var interactiveLaunch = interactiveTerminal || LauncherEnvironment.IsInteractiveLaunchMarker(environment); + var supervisedEnvironment = daemonEnvironment && !interactiveLaunch; + var containerEnvironment = LauncherEnvironment.IsCurrentContainerEnvironment(); + var managementKey = environment.GetValueOrDefault(ManagementKeyEnvironmentVariable); + if (!string.IsNullOrEmpty(managementKey) + && (managementKey.Length < WebAuthService.MinimumKeyLength + || managementKey.Length > WebAuthService.MaximumKeyLength)) + { + Console.Error.WriteLine( + $"{ManagementKeyEnvironmentVariable} length must be between {WebAuthService.MinimumKeyLength} and {WebAuthService.MaximumKeyLength} characters."); + return 1; + } + + if (WebDeploymentSecurityPolicy.GetStartupError(supervisedEnvironment, containerEnvironment, managementKey) is { } startupError) + { + Console.Error.WriteLine(startupError); + return 1; + } + + PrepareDataScope(); + var launchOptions = WebLaunchOptions.Parse( + args, + OperatingSystem.IsLinux(), + daemonEnvironment, + containerEnvironment, + interactiveLaunch); + + if (launchOptions.Mode == WebLaunchMode.Stop) + { + if (!OperatingSystem.IsLinux()) + { + Console.Error.WriteLine(LauncherMessages.StopUnsupported(GetLauncherLocale())); + return 1; + } + + // The stop probe must follow the same configuration sources as the running + // instance (process/.env environment and command line), so build the same + // configuration the host would have used. + var stopConfiguration = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = launchOptions.HostArguments, + ContentRootPath = AppContext.BaseDirectory, + }).Configuration; + var (stopHealthUri, _) = GetLauncherUris(stopConfiguration); + var stopper = new WebStopper(new HttpWebHealthProbe(), new LinuxProcessSignalSender()); + var result = await stopper.StopAsync(GetInstanceLockPath(), stopHealthUri); + Console.Out.WriteLine(LauncherMessages.StopMessage(result, GetLauncherLocale(), stopper.LastObservedShutdownStage)); + return result is WebStopResult.NotRunning or WebStopResult.Stopped ? 0 : 1; + } + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = launchOptions.HostArguments, + ContentRootPath = AppContext.BaseDirectory, + }); + WebListenerSecurityPolicy.ApplyDefault(builder); + if (WebListenerSecurityPolicy.GetStartupError(builder.Configuration, managementKey) is { } listenerError) + { + Console.Error.WriteLine(listenerError); + return 1; + } + + WebCorsPolicy cors; + try + { + cors = new WebCorsPolicy(builder.Configuration[WebCorsPolicy.EnvironmentVariable]); + } + catch (ArgumentException exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + builder.Services.AddSingleton(cors); + builder.Services.AddCors(options => options.AddPolicy(WebCorsPolicy.PolicyName, cors.Configure)); + + if (launchOptions.Mode == WebLaunchMode.BackgroundLauncher) + { + if (!OperatingSystem.IsLinux()) + { + Console.Error.WriteLine(LauncherMessages.StartFailed(GetLauncherLocale())); + return 1; + } + + var (healthUri, apiUri) = GetLauncherUris(builder.Configuration); + if (healthUri is null || apiUri is null) + { + Console.Error.WriteLine(LauncherMessages.StartUnprobeable(GetLauncherLocale())); + return 1; + } + + var launcher = new WebLauncher(new HttpWebHealthProbe(), new LinuxXdgBrowserOpener(), GetLauncherLocale()); + var processPath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(processPath)) + { + Console.Error.WriteLine(LauncherMessages.StartFailed(GetLauncherLocale())); + return 1; + } + + var lockPath = GetInstanceLockPath(); + return await launcher.RunAsync( + processPath, + launchOptions.HostArguments, + lockPath, + healthUri, + apiUri, + launchOptions.NoOpen); + } + + if (launchOptions.Mode == WebLaunchMode.BackgroundChild) + { + BackgroundChildProcess.DetachStandardHandles(); + } + + var instanceLockPath = GetInstanceLockPath(); + if (!WebInstanceLock.TryAcquire(instanceLockPath, writeOwner: true, out var instanceLock)) + { + if (launchOptions.Mode == WebLaunchMode.BackgroundChild) + { + return 73; + } + + Console.Error.WriteLine(LauncherMessages.InstanceInUse(GetLauncherLocale())); + return 73; + } + + WebHostRunResult hostResult; + using (instanceLock) + { + var showForegroundPrompt = launchOptions.Mode == WebLaunchMode.Foreground + && args.Contains(WebLaunchOptions.ForegroundFlag, StringComparer.Ordinal) + && !supervisedEnvironment + && !containerEnvironment; + hostResult = await RunWebHostAsync(builder, showForegroundPrompt); + } + + // The host cleanup is complete and the old owner has disposed this lock. The + // handoff still verifies the lock state before allowing a replacement to start. + var exitAction = hostResult.RestoreAndRestartRequested + ? WebLifecycleAction.RestoreAndRestart + : WebLifecycleAction.Shutdown; + var lifecyclePlan = WebLifecyclePlanner.Create( + exitAction, + supportsNativeRestart: OperatingSystem.IsLinux() || OperatingSystem.IsWindows(), + LauncherEnvironment.IsSystemdManagedDeployment(), + containerEnvironment); + if (lifecyclePlan.ShouldStartReplacement) + { + var replacement = hostResult.NativeRestoreReplacement; + if (replacement is null) + { + WriteNativeRestartDiagnostic("Restore requested a native Web restart without a validated replacement plan."); + return 1; + } + + var coordinator = new NativeWebRestartCoordinator( + new HttpWebHealthProbe(), + new NativeReplacementProcessStarter(), + new FileWebInstanceOwnershipProbe()); + var handoff = await coordinator.StartReplacementAfterOwnerReleaseAsync( + GetInstanceLockPath(), + replacement.HealthUri, + replacement.Command, + expectedCoreRunning: replacement.CoreWasRunning); + WriteNativeRestartDiagnostic(handoff.Message, handoff.Success, handoff.ReplacementProcessId); + return handoff.Success ? hostResult.ExitCode : 1; + } + return hostResult.ExitCode; + } + + private static async Task RunWebHostAsync(WebApplicationBuilder builder, bool showForegroundPrompt) + { + // "guiConfigs/web-auth.json" is the pre-rename legacy location; WebAuthStorage + // migrates it (and the previous private location) to WebAPIData/WebAPI-auth.json. + var configPath = WebAuthStorage.MigrateAndGetPath( + Utils.StartupPath(), + Utils.GetConfigPath("web-auth.json")); + var webAuth = new WebAuthService(configPath, Environment.GetEnvironmentVariable(ManagementKeyEnvironmentVariable)); + // EventSource carries only a one-time, short-lived SSE ticket in its URL; keep + // request lifecycle logs from recording that ticket as well. + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.Configure(options => options.ShutdownTimeout = RuntimeShutdownBudgets.HostShutdown); + builder.Services.AddHostedService(services => + new V2rayHostedService( + services.GetRequiredService(), + services.GetRequiredService())); + builder.Services.AddSingleton(webAuth); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase))); + + var app = builder.Build(); + var runtime = app.Services.GetRequiredService(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseRouting(); + app.UseMiddleware(); + app.UseCors(WebCorsPolicy.PolicyName); // OPTIONS must complete before session auth. + app.UseRateLimiter(); + + app.UseMiddleware(); + + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (BadHttpRequestException exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogInformation(exception, "Invalid API request."); + var isPayloadTooLarge = exception.StatusCode == StatusCodes.Status413PayloadTooLarge; + context.Response.StatusCode = isPayloadTooLarge ? StatusCodes.Status413PayloadTooLarge : StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail( + isPayloadTooLarge ? "payload_too_large" : "request_invalid", + ApiMessageKeys.CommonInvalidInput)); + } + catch (JsonException exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogInformation(exception, "Invalid API JSON payload."); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("json_invalid", ApiMessageKeys.CommonInvalidInput)); + } + catch (OperationCanceledException) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + } + catch (Exception exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + app.Logger.LogError(exception, "Unhandled API request failure."); + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("internal_error", ApiMessageKeys.CommonInternal)); + } + }); + + app.Use(async (context, next) => + { + if (!context.Request.Path.StartsWithSegments("/api") + || context.Request.Path == "/api/health" + || context.Request.Path == "/api/events" + || context.Request.Path.StartsWithSegments("/api/setup") + || context.Request.Path.StartsWithSegments("/api/auth")) + { + await next(); + return; + } + + var path = context.Request.Path.Value?.TrimEnd('/') ?? string.Empty; + var leaseKind = RuntimeRequestOperationPolicy.Classify(context.Request.Method, path); + if (leaseKind == RuntimeRequestOperationKind.Background) + { + await next(); + return; + } + + var operations = context.RequestServices.GetRequiredService(); + await using var operation = leaseKind switch + { + RuntimeRequestOperationKind.Exclusive => await operations.EnterExclusiveAsync(context.RequestAborted), + RuntimeRequestOperationKind.ExclusiveReadOnly => await operations.EnterExclusiveAsync( + context.RequestAborted, + allowReadOnlyObservations: true), + RuntimeRequestOperationKind.Observation => await operations.EnterObservationAsync(context.RequestAborted), + _ => await operations.EnterOperationAsync(context.RequestAborted), + }; + context.RequestAborted = operation.Token; + await next(); + }); + + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapFallback("/api/{**path}", () => + Results.NotFound(ApiEnvelope.Fail("route_not_found", ApiMessageKeys.CommonRouteNotFound))); + + if (showForegroundPrompt) + { + var (_, apiUri) = GetLauncherUris(builder.Configuration); + if (apiUri is not null) + { + var displayUri = apiUri.ToString(); + app.Lifetime.ApplicationStarted.Register(() => + Console.Out.WriteLine(LauncherMessages.ForegroundStarted(displayUri, GetLauncherLocale()))); + } + } + + await app.RunAsync(); + return new WebHostRunResult(0, runtime.RestoreAndRestartRequested, runtime.NativeRestoreReplacement); + } + + private static void WriteNativeRestartDiagnostic(string message, bool success = false, int? processId = null) + { + var line = $"{DateTimeOffset.UtcNow:O} {(success ? "INFO" : "ERROR")} {message}" + + (processId.HasValue ? $" ProcessId={processId.Value}." : string.Empty) + + Environment.NewLine; + if (!success) Console.Error.WriteLine(message); + try + { + var path = Utils.GetLogPath("native-restore-restart.log"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.AppendAllText(path, line); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (!success) Console.Error.WriteLine($"Could not persist the native restart diagnostic: {exception.Message}"); + } + } + + private sealed record WebHostRunResult( + int ExitCode, + bool RestoreAndRestartRequested, + NativeWebRestoreReplacement? NativeRestoreReplacement); + + private static void PrepareDataScope() + { + var dataHome = Environment.GetEnvironmentVariable("V2RAYN_DATA_HOME"); + if (!string.IsNullOrWhiteSpace(dataHome)) + { + var fullDataHome = Path.GetFullPath(dataHome); + Directory.CreateDirectory(fullDataHome); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", fullDataHome); + Environment.SetEnvironmentVariable(Global.LocalAppData, "1"); + return; + } + + if (!Utils.HasWritePermission()) + { + Environment.SetEnvironmentVariable(Global.LocalAppData, "1"); + } + } + + private static string GetInstanceLockPath() => + Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock"); + + internal static (Uri? HealthUri, Uri? ApiUri) GetLauncherUris(IConfiguration configuration) + { + if (WebProbeUriResolver.TryResolve(configuration, out var endpoints)) + { + return (endpoints.HealthUri, endpoints.ApiUri); + } + + // Never guess a loopback address or port: a probe that does not match the + // configured listeners would verify (or stop) the wrong process. + return (null, null); + } + + private static Dictionary ReadEnvironment() => + LauncherEnvironment.ReadCurrentEnvironment(); + + private static LauncherLocale GetLauncherLocale() + { + var environment = ReadEnvironment(); + return LauncherMessages.ResolveLocale( + environment.GetValueOrDefault("LC_ALL"), + environment.GetValueOrDefault("LC_MESSAGES"), + environment.GetValueOrDefault("LANG"), + CultureInfo.CurrentUICulture.Name); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs b/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..c4a254de --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("v2rayN.WebAPI.Tests")] diff --git a/v2rayN/v2rayN.WebAPI/README.md b/v2rayN/v2rayN.WebAPI/README.md new file mode 100644 index 00000000..cd363e5a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/README.md @@ -0,0 +1,238 @@ +# v2rayN.WebAPI + +`v2rayN.WebAPI` is a headless ASP.NET Core host for v2rayN's existing `ServiceLib`. It exposes +REST APIs, authenticated SSE/runtime events, setup and sessions, settings and options, +profiles/subscriptions, routing and DNS, backups, updates, and the existing Core runtime bridge. +It runs without a browser or desktop session. The API has no dependency on +Vue, React, Vite, Node.js, npm, or any frontend manifest. + +The API only serves API endpoints; it does not host, inspect, or configure a WebUI. +An independent WebUI or other HTTP client manages its own hosting and configuration. + +## Start the API + +For a native install, unpack the API release ZIP and start the executable from its install +directory: + +```sh +cp .env.example .env +chmod 600 .env +# Set V2RAYN_WEB_API_KEY in .env for supervised, container, or remote deployments. +./v2rayN.WebAPI --foreground --no-open +``` + +On Linux, a terminal launch without flags starts the API in the background, detached from the +terminal, and prints its health endpoint. `--foreground` keeps it attached and stops it safely with +Ctrl+C, `--stop` stops the instance running for the same data directory, and `--help` prints every +option together with the common environment variables; `--no-open` suppresses the browser opener. +Detached children and later helper processes keep the launch context, so an instance started from a +terminal is never mistaken for a systemd-managed service (systemd exports `INVOCATION_ID` / +`JOURNAL_STREAM` to everything launched from such a terminal). + +The default listener is `http://127.0.0.1:5080`. A local native install may temporarily leave +`V2RAYN_WEB_API_KEY` empty when **all** listeners are loopback (`127.0.0.1`, `localhost`, or +`[::1]`), then complete first-run setup through a direct loopback API client. + +For LAN / NAS / headless / container / systemd deployments, preconfigure +`V2RAYN_WEB_API_KEY`. Systemd and containers require it even on loopback. Any non-loopback +listener (including `0.0.0.0`, `[::]`, wildcard/hostname bindings, and LAN IPs) fails startup +without this environment Key, even if a Key was previously saved through local setup. +The check uses the effective ASP.NET Core configuration, including `ASPNETCORE_URLS`, +command-line `--urls`, `ASPNETCORE_HTTP_PORTS` / `ASPNETCORE_HTTPS_PORTS` (wildcard bindings), +and `Kestrel:Endpoints`, respecting their precedence. With multiple URLs, one non-loopback +listener is enough to require a Key; request Host headers never determine exposure. +For remote access, set a Key before selecting e.g. `ASPNETCORE_URLS=http://0.0.0.0:5080`. +Protect it with a firewall/VPN or an HTTPS reverse proxy; do not expose it directly to the Internet. + +`GET /api/health` is a non-sensitive health probe. After authentication, `GET /api/status` +reports the running Backend version, commit, runtime identifier, and runtime capability fields. +The API option endpoints (including `/api/editor-options` and DNS editor options) are authoritative +for values derived from ServiceLib; clients should consume these instead of copying enums or +option lists. + +## Authentication + +- `V2RAYN_WEB_API_KEY` is the Management Key. Exchange it only at `POST /api/auth/login`. +- Use the returned session token as `Authorization: Bearer ` for protected REST + endpoints. The Management Key is not a REST or SSE bearer token. +- `POST /api/auth/sse-ticket` returns a short-lived, one-time ticket for `/api/events`. +- Setup, login rate limits, session expiry/revocation, LAN/setup policy, DNS rebinding checks, + and API authorization remain Backend-owned and apply equally to every client. +- The API never enables wildcard CORS. Client hosting and configuration are independent. + +## Browser/client origins and strict CORS + +An independently hosted browser client can connect directly to this API: + +```ini +V2RAYN_WEB_API_KEY= +V2RAYN_WEB_ALLOWED_ORIGINS=https://client.example.com,https://another.example.com +``` + +Unset/empty origins deny cross-origin API requests. Entries must be exact HTTP(S) origins +(scheme, host and port); paths, queries, fragments, credentials, empty list entries and wildcard +hosts are rejected at startup. Default ports are normalized. `https://good.example.com.evil.com` +does not match `https://good.example.com`. No forwarding headers are used to determine origin. + +The origin guard rejects disallowed `Origin` requests before rate limiting, session authorization, +SSE-ticket consumption or mutations. Origin-less native clients and same-origin clients retain +their existing behavior and still require session Bearer authentication for protected endpoints. +Allowed-origin OPTIONS preflights complete before session authorization. CORS supports GET, +HEAD, POST, PUT, DELETE and OPTIONS, `Authorization` / `Content-Type`, and exposes only +`Content-Disposition` for downloads. It does not enable credentials, cookies, wildcard origins +or blanket private-network preflight bypasses. SSE tickets and `/api/events` use the same policy. +The allowlist grants browser access, not authentication; trust every allowed browser/client origin. + +A same-origin browser behind a TLS/path-prefix reverse proxy retains REST access even when +Kestrel sees an internal HTTP URL: browser-controlled `Sec-Fetch-Site: same-origin` identifies +that case. It does not grant CORS response headers, setup access or internal health diagnostics. +Same-site/cross-site browser requests still require an exact allowed origin. Native clients +cannot gain privileges by forging this header: they already have origin-less access and still +must authenticate with the same Management Key/session model. + +First-run setup remains **direct loopback + localhost/loopback Host + no forwarding headers**, +and additionally requires same-origin (or an origin-less native request). Allowed CORS origins +cannot initialize a Management Key. `/api/setup/status` remains public and reports +`setupAllowedFromThisRequest=false` for standalone clients. LAN/remote/supervised deployment +must configure `V2RAYN_WEB_API_KEY` before startup. The historical private-network setup branch +was removed to align with the local-only initialization policy. + +HTTPS public dashboards connecting to HTTP localhost/LAN remain subject to browser Local +Network Access permissions and mixed-content restrictions. CORS cannot override them; verify +REST and native EventSource in the target browser. Some browsers block HTTP LAN even with +permission; use an HTTPS API or a same-origin reverse proxy. Never expose an unauthenticated/wildcard +listener or disable the existing listener/Management Key policies to work around browser limits. + +Non-API paths, including `GET /`, return 404. The launcher opens the API health endpoint, +not a client application. API packages contain no frontend installation directory or placeholder. + +## Prerelease update preferences + +Update checks and installs default to each target's entry in `CheckPreReleaseCoreTypes`; +the API self-update target is `v2rayN.WebAPI`, independent of Desktop's `v2rayN` and Core targets. +A missing list or entry defaults to stable releases. An explicit `preRelease=true` or `false` +overrides the preference only for that request; batches use each target's own preference. + +`GET /api/core-updates` returns `checkPreReleaseCoreTypes`. Pass this list to +`PUT /api/core-updates/settings` to save independent preferences (an empty list disables them). +Targets not exposed by this updater retain their stored preferences. For older clients that +omit the list, `preRelease` still applies to all exposed prerelease-capable targets, including +`v2rayN.WebAPI`; the response's legacy `preRelease` flag reflects the Web target's preference. + +## Build and test the Backend + +Requires the .NET 10 SDK. No Node.js or npm is used by the Backend build, tests, native publish, +Containerfile, or API release packaging. + +```sh +bash Scripts/publish-native.sh linux-x64 # also linux-arm64 / linux-riscv64 / linux-loongarch64 +bash Scripts/verify.sh +``` + +`Scripts/verify.sh` builds and tests the Backend, runs ServiceLib tests, checks startup/security, +and exercises API ZIP/self-update package boundaries and real different-layout Linux update, +rollback and invalid-executable rejection. Integration tests live under `Tests/Integration/` +(security defaults, release packaging dry-run, full-ZIP runtime, native update regression, and +the Windows smoke: `Test-Windows.ps1 -Mode startup|update`). The independent `build-WebAPI.yml` +workflow runs these checks, Windows x64/ARM64 build/startup smoke (the self-update +replacement/rollback smoke stays on win-x64), native Linux builds, and Linux container builds on +relevant PRs and pushes to `master`; it can also be dispatched manually or called by a release +workflow. Releases include Linux x64/ARM64/RISC-V 64/LoongArch 64 and Windows x64/ARM64 packages; +Linux containers are built for amd64 and arm64. + +`Tests/Integration/test-native-update-identity.py ` +additionally exercises real native helper replacement and health-mismatch rollback with two +distinct versions, isolated data/ports and preserved unrelated file/Core markers. It never uses +user configuration. + +### Native self-update and legacy helpers + +The update worker runs from a GUID-named copy of the current executable beside the installation +(owner-only permissions on Linux). This keeps its single-file bundle path unchanged during +replacement and rollback: .NET loads dependencies lazily, and replacing a running helper's own +executable with a different bundle layout can break later assembly loads. On Linux the outer +helper waits for the worker, propagates its result, and removes the copy; on Windows the detached +helper waits for the prior process and schedules its own copy for deletion after exit. Configuration +and data paths remain unchanged. + +`Tests/Integration/test-native-update-regression.sh ` deliberately +changes the candidate's bundle layout with test-only assembly metadata; version-only fixtures can +conceal this failure. It checks actual replacement, rollback, invalid-executable rejection and +worker cleanup, and is included in `Scripts/verify.sh`. + +**Linux builds predating helper isolation need a one-time manual reinstall.** Their already-running +old helper cannot gain this fix from the candidate package. Stop the instance, back up the +executable, build identity, data and `.env`, and install the corrected API package. For a failed +legacy update, retain the previous-application backup and verify the restored application's +health before discarding it. Systemd/container deployments remain check-only and use redeploy. + +## Pre-merge product rename and update migration + +The directory, project file, namespace, assembly/executable, embedded resource names, build +identity file/product and updater target are now `v2rayN.WebAPI`. Launcher/update helpers, +native/package scripts, Containerfile, solution registration, CI and tests use that identity. +The lock is `v2rayN.WebAPI.instance.lock`; the build identity is `v2rayN.WebAPI.build.json`; +health headers are `X-v2rayn-WebAPI-*`; the update capability is `WebAPI.self-update`; update +progress/runtime-state files are `WebAPI-update-progress.json` and +`WebAPI-update-runtime-state.json`; workflow files are `build-WebAPI.yml` and +`release-WebAPI.yml`. + +**Old experimental `v2rayN.Web` installs require a one-time manual reinstall.** Stop the old +instance first (including its systemd/container supervisor), back up data and `.env`, install +the new full package, update ExecStart/container entrypoint, and restore the retained data. +Do not run the two executables against the same data directory. There is no cross-name update +helper, executable alias or silent in-place migration. Old-product manifests/packages are not +accepted by the new updater; new `WebAPI → WebAPI` updates preserve the transactional helper, +health/PID/lock checks, atomic replacement/rollback and executable/Core/data boundaries. +Package staging checks Linux ELF64 headers/program tables (x86-64, AArch64, RISC-V, LoongArch) or +Windows PE32+ headers (AMD64, ARM64) against the declared RID before replacement; a script, +truncated image, or wrong-architecture candidate cannot reach shutdown/swap merely by carrying +the correct JSON identity. + +API-owned `SelectedCoreTypes` and `CheckPreReleaseCoreTypes` entries `v2rayN.Web` are normalized +to `v2rayN.WebAPI` at startup; legacy settings payloads are also normalized. Desktop/Core/hidden +preferences are preserved. This migrates preferences, not the executable or package identity. + +Intentional compatibility names remain: `V2RAYN_WEB_*` configuration (including the Management +Key and allowed-origin variables), `WebVersion`/`WebCommit`/`WebBuildDate`/`WebRepository` build metadata, +`webVersion` and related status fields, the `/api/web-updates` routes, generic Web host/security +class names, existing systemd unit / install-path examples, and the legacy `guiConfigs/web-auth.json` +location that `WebAuthStorage` migrates to `WebAPIData/WebAPI-auth.json`. These are not stale +executable assumptions. + +## Releases and containers + +`build-all.yml` ("release all platforms") dispatches the desktop platform workflows and calls +`release-WebAPI.yml`; the WebAPI packages ride the same `upload-sign.yml` path and land in the same +release as the desktop assets. `release-WebAPI.yml` can also be dispatched directly with a +`release_tag` (`x.y.z`) to call `build-WebAPI.yml`, assemble and validate the Linux and Windows +WebAPI assets, then sign and upload them through the existing `upload-sign.yml` workflow. Signing +uses the repository GPG key, so a fork that runs this flow publishes with its own +`GPG_PRIVATE_KEY` secret. `build-WebAPI.yml` can also be dispatched with a tag for package-only +verification. Web release orchestration and artifact names are RID-based, separately from +containers and Desktop workflows. riscv64 builds on RISC-V runners with the community .NET SDK; +loongarch64 builds inside the QEMU loong64 VM with the official Loongson SDK; both run a native +smoke before packaging. Windows x64/ARM64 have full-install and app-only update ZIPs; Windows +containers are not built. + +The API release provides architecture-specific full-install and app-only update ZIPs plus +`WebAPI-update.json`: + +- `v2rayN-linux-64-WebAPI.zip` / `v2rayN-linux-arm64-WebAPI.zip` / `v2rayN-linux-riscv64-WebAPI.zip` + / `v2rayN-linux-loong64-WebAPI.zip` — API, runtime files, `.env.example`, and Core bundle. +- `v2rayN-linux-64-WebAPI-update.zip` / `v2rayN-linux-arm64-WebAPI-update.zip` / + `v2rayN-linux-riscv64-WebAPI-update.zip` / `v2rayN-linux-loong64-WebAPI-update.zip` — API + executable and build identity only; user data, Core files, credentials, and unrelated files are + excluded. +- `v2rayN-windows-64-WebAPI.zip` / `v2rayN-windows-arm64-WebAPI.zip` — Windows API, runtime/Core + files, and `.env.example`. +- `v2rayN-windows-64-WebAPI-update.zip` / `v2rayN-windows-arm64-WebAPI-update.zip` — Windows API + executable and build identity only; user data, Core files, credentials, and unrelated files are + excluded. +- `WebAPI-update.json` — version, commit, per-RID asset names, sizes, and SHA-256 digests. A RID + entry a build does not recognize is ignored instead of rejecting the manifest. + +For Docker/Podman, the included `Containerfile` builds only the API and Core runtime. +The Compose example requires a non-empty Management Key. + +See [`FEATURE-MAP.md`](FEATURE-MAP.md) for the current ServiceLib/API feature boundary. diff --git a/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh b/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh new file mode 100644 index 00000000..d578f493 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +rid="$1" +destination="$2" + +case "$rid" in + linux-x64) bundle="v2rayN-linux-64" ;; + linux-arm64) bundle="v2rayN-linux-arm64" ;; + linux-riscv64) bundle="v2rayN-linux-riscv64" ;; + linux-loongarch64) bundle="v2rayN-linux-loong64" ;; + *) + echo "Unsupported runtime identifier: $rid" >&2 + exit 2 + ;; +esac + +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT HUP INT TERM + +bundle_url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/$bundle.zip" +curl --fail --location --retry 3 --retry-delay 2 --silent --show-error \ + --output "$temporary_directory/core-bundle.zip" "$bundle_url" +unzip -q "$temporary_directory/core-bundle.zip" -d "$temporary_directory/unpacked" + +source_bin="$temporary_directory/unpacked/$bundle/bin" +# These files are sanity checks for the current upstream payload, never an allowlist. +for required_file in \ + xray/xray \ + sing_box/sing-box \ + mihomo/mihomo \ + Country.mmdb \ + geoip-only-cn-private.dat \ + geoip.dat \ + geoip.metadb \ + geosite.dat \ + srss/geoip-cn.srs \ + srss/geosite-cn.srs \ + srss/geosite-category-ads-all.srs; do + if [ ! -f "$source_bin/$required_file" ]; then + echo "Core bundle is missing required asset: $required_file" >&2 + exit 1 + fi +done +# The cronet payload is only present in the x64/arm64 bundles. +if [ "$rid" = "linux-x64" ] || [ "$rid" = "linux-arm64" ]; then + if [ ! -f "$source_bin/sing_box/libcronet.so" ]; then + echo "Core bundle is missing required asset: sing_box/libcronet.so" >&2 + exit 1 + fi +fi + +mkdir -p "$destination" +# Preserve the complete official bin/ tree, including future upstream assets and Cores. +cp -a "$source_bin/." "$destination/" +chmod 755 \ + "$destination/xray/xray" \ + "$destination/sing_box/sing-box" \ + "$destination/mihomo/mihomo" +if [ -f "$destination/sing_box/libcronet.so" ]; then + chmod 644 "$destination/sing_box/libcronet.so" +fi + +printf 'Integrated complete upstream %s Core/data bin payload into %s\n' "$rid" "$destination" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh b/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh new file mode 100644 index 00000000..9a044c4e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +upstream_root="$(dirname -- "$web_root")" +repo_root="$(dirname -- "$upstream_root")" +rid="${1:-linux-x64}" +output_dir="${2:-$web_root/publish/$rid}" + +dotnet="${DOTNET:-}" +if [[ "$dotnet" != */* && -n "$dotnet" ]]; then + dotnet="$(command -v "$dotnet" || true)" +fi +if [[ -z "$dotnet" ]]; then + if [[ -x "$web_root/.NET/dotnet" ]]; then + dotnet="$web_root/.NET/dotnet" + else + dotnet="$(command -v dotnet || true)" + fi +fi +if [[ -z "$dotnet" || ! -x "$dotnet" ]]; then + printf 'Required build tool is not available: dotnet (expected .NET/dotnet or PATH)\n' >&2 + exit 1 +fi +case "$rid" in + linux-x64|linux-arm64|linux-riscv64|linux-loongarch64) ;; + *) printf 'Supported runtime identifiers: linux-x64, linux-arm64, linux-riscv64, linux-loongarch64 (received %s)\n' "$rid" >&2; exit 2 ;; +esac + +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" +# Official release builds pass the upstream release tag (for example 7.25.4) so the Web +# build identity follows the same version as the v2rayN Release. Local builds keep a dev identity. +web_version="${V2RAYN_WEB_VERSION:-0.0.0-dev}" +web_repository="${V2RAYN_WEB_REPOSITORY:-${GITHUB_REPOSITORY:-2dust/v2rayN}}" +web_commit="${V2RAYN_WEB_COMMIT:-$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf unknown)}" +web_build_date="${V2RAYN_WEB_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +if [[ ! "$web_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then + printf 'Invalid Web version identity: %s\n' "$web_version" >&2 + exit 2 +fi +if [[ ! "$web_repository" =~ ^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$ ]]; then + printf 'Invalid Web release repository: %s\n' "$web_repository" >&2 + exit 2 +fi + +"$dotnet" publish "$web_root/v2rayN.WebAPI.csproj" \ + --configuration Release \ + --runtime "$rid" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="$web_version" \ + -p:WebCommit="$web_commit" \ + -p:WebBuildDate="$web_build_date" \ + -p:WebRepository="$web_repository" \ + --output "$output_dir" + +python3 - "$output_dir/v2rayN.WebAPI.build.json" "$web_version" "$web_commit" "$web_build_date" "$rid" <<'PY' +import json +import sys +from pathlib import Path + +path, version, commit, build_date, rid = sys.argv[1:] +Path(path).write_text(json.dumps({ + "product": "v2rayN.WebAPI", + "version": version, + "commit": commit, + "buildDate": build_date, + "rid": rid, +}, separators=(",", ":")) + "\n", encoding="utf-8") +PY +test -x "$output_dir/v2rayN.WebAPI" +printf 'Native publish ready: %s (version %s, repository %s)\n' "$output_dir/v2rayN.WebAPI" "$web_version" "$web_repository" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/verify.sh b/v2rayN/v2rayN.WebAPI/Scripts/verify.sh new file mode 100755 index 00000000..434df1d7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/verify.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +upstream_root="$(dirname -- "$web_root")" +output_dir="${1:-$web_root/publish/verify-linux-x64}" + +dotnet="${DOTNET:-}" +if [[ "$dotnet" != */* && -n "$dotnet" ]]; then + dotnet="$(command -v "$dotnet" || true)" +fi +if [[ -z "$dotnet" ]]; then + if [[ -x "$web_root/.NET/dotnet" ]]; then + dotnet="$web_root/.NET/dotnet" + else + dotnet="$(command -v dotnet || true)" + fi +fi +if [[ -z "$dotnet" || ! -x "$dotnet" ]]; then + printf 'Required build tool is not available: dotnet (expected .NET/dotnet or PATH)\n' >&2 + exit 1 +fi +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" + +"$dotnet" restore "$web_root/v2rayN.WebAPI.csproj" +"$dotnet" restore "$web_root/Tests/v2rayN.WebAPI.Tests.csproj" +"$dotnet" restore "$upstream_root/ServiceLib.Tests/ServiceLib.Tests.csproj" +"$dotnet" build "$web_root/v2rayN.WebAPI.csproj" --configuration Release --no-restore +"$dotnet" test --project "$web_root/Tests/v2rayN.WebAPI.Tests.csproj" --configuration Release --no-restore +"$dotnet" test --project "$upstream_root/ServiceLib.Tests/ServiceLib.Tests.csproj" --configuration Release --no-restore +bash "$web_root/Scripts/publish-native.sh" linux-x64 "$output_dir" +test -x "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-startup-security.sh" "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-backup-restore-preflight.sh" "$output_dir/v2rayN.WebAPI" +bash "$web_root/Tests/Integration/test-release-packaging.sh" +smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/v2rayn-api-package-smoke.XXXXXX")" +trap 'rm -rf "$smoke_dir"' EXIT HUP INT TERM +mkdir -p "$smoke_dir/package/bin" +cp "$output_dir/v2rayN.WebAPI" "$output_dir/v2rayN.WebAPI.build.json" \ + "$web_root/.env.example" "$smoke_dir/package/" +touch "$smoke_dir/package/bin/.keep" +python3 - "$smoke_dir/package" "$smoke_dir/v2rayN-api-smoke.zip" <<'PY' +from pathlib import Path +import sys +import zipfile + +root, archive_path = map(Path, sys.argv[1:]) +with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(root.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(root).as_posix()) +PY +bash "$web_root/Tests/Integration/test-full-zip-runtime.sh" "$smoke_dir/v2rayN-api-smoke.zip" +bash "$web_root/Tests/Integration/test-native-update-regression.sh" "$output_dir" +printf 'WebAPI verification passed; native publish ready: %s\n' "$output_dir/v2rayN.WebAPI" diff --git a/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py b/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py new file mode 100755 index 00000000..258016d1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Package the headless v2rayN Web API release ZIPs and report their asset names. + +The RID-to-asset map lives in `../Assets/web-assets.json`. The Web assembly embeds the same +file, so the runtime updater, the release manifest, and this packager never disagree about the +release asset names: + + linux-x64 -> v2rayN-linux-64-WebAPI.zip + v2rayN-linux-64-WebAPI-update.zip + linux-arm64 -> v2rayN-linux-arm64-WebAPI.zip + v2rayN-linux-arm64-WebAPI-update.zip + linux-riscv64 -> v2rayN-linux-riscv64-WebAPI.zip + v2rayN-linux-riscv64-WebAPI-update.zip + linux-loongarch64-> v2rayN-linux-loong64-WebAPI.zip + v2rayN-linux-loong64-WebAPI-update.zip + win-x64 -> v2rayN-windows-64-WebAPI.zip + v2rayN-windows-64-WebAPI-update.zip + win-arm64 -> v2rayN-windows-arm64-WebAPI.zip + v2rayN-windows-arm64-WebAPI-update.zip + +Usage: + web-release-assets.py get --rid linux-x64 --field full + web-release-assets.py package --rid linux-x64 --publish publish/linux-x64 --dist dist +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +ASSET_MAP_PATH = SCRIPT_DIR.parent / "Assets" / "web-assets.json" +FIXED_TIME = (1980, 1, 1, 0, 0, 0) +BUILD_IDENTITY_NAME = "v2rayN.WebAPI.build.json" +ENVIRONMENT_FILE_NAMES = frozenset((".env", ".env.example")) +EXECUTABLE_NAMES = { + "linux-x64": "v2rayN.WebAPI", + "linux-arm64": "v2rayN.WebAPI", + "linux-riscv64": "v2rayN.WebAPI", + "linux-loongarch64": "v2rayN.WebAPI", + "win-x64": "v2rayN.WebAPI.exe", + "win-arm64": "v2rayN.WebAPI.exe", +} +REQUIRED_FILES = { + "linux-x64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/sing_box/libcronet.so", "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-arm64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/sing_box/libcronet.so", "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-riscv64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "linux-loongarch64": ( + "v2rayN.WebAPI", BUILD_IDENTITY_NAME, "bin/geosite.dat", + "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", "bin/srss/geosite-category-ads-all.srs", + ), + "win-x64": ( + "v2rayN.WebAPI.exe", BUILD_IDENTITY_NAME, "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", + "bin/mihomo/mihomo.exe", "bin/geosite.dat", "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", + ), + "win-arm64": ( + "v2rayN.WebAPI.exe", BUILD_IDENTITY_NAME, "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", + "bin/mihomo/mihomo.exe", "bin/geosite.dat", "bin/geoip.dat", "bin/geoip.metadb", "bin/Country.mmdb", + ), +} +LINUX_REQUIRED_EXECUTABLES = ("bin/xray/xray", "bin/sing_box/sing-box", "bin/mihomo/mihomo") +WINDOWS_REQUIRED_EXECUTABLES = ( + "v2rayN.WebAPI.exe", "bin/xray/xray.exe", "bin/sing_box/sing-box.exe", "bin/mihomo/mihomo.exe", +) +BLOCK_SIZE = 1024 * 1024 + + +def is_windows_rid(rid: str) -> bool: + return rid.startswith("win-") + + +def fail(message: str) -> None: + print(f"web release assets error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_assets() -> dict[str, dict[str, str]]: + try: + data = json.loads(ASSET_MAP_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {ASSET_MAP_PATH}: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("assets"), list): + fail(f"{ASSET_MAP_PATH} is missing its assets list") + assets: dict[str, dict[str, str]] = {} + for entry in data["assets"]: + if not isinstance(entry, dict): + fail(f"{ASSET_MAP_PATH} contains a non-object asset entry") + values = (entry.get("rid"), entry.get("arch"), entry.get("full"), entry.get("update")) + if not all(isinstance(value, str) and value for value in values) or values[0] in assets: + fail(f"{ASSET_MAP_PATH} contains an invalid or duplicate asset entry") + assets[values[0]] = {"arch": values[1], "full": values[2], "update": values[3]} + if not assets: + fail(f"{ASSET_MAP_PATH} contains no assets") + return assets + + +def normalize_entry(name: str) -> str: + """Normalize an archive entry name the same way the Web runtime package stager does.""" + if not name or name.startswith("/") or "\\" in name or "\0" in name: + fail(f"invalid archive path: {name!r}") + segments = [segment for segment in name.split("/") if segment] + if not segments or any(segment in (".", "..") or ":" in segment for segment in segments): + fail(f"invalid archive path: {name!r}") + return "/".join(segments) + + +def collect_tree(root: Path) -> list[tuple[str, Path, str]]: + entries: list[tuple[str, Path, str]] = [] + for current, dirnames, filenames in os.walk(root, followlinks=False): + dirnames.sort() + filenames.sort() + for name in list(dirnames): + path = Path(current) / name + if path.is_symlink(): + entries.append((path.relative_to(root).as_posix(), path, "symlink")) + dirnames.remove(name) + for name in filenames: + path = Path(current) / name + if path.is_symlink(): + entries.append((path.relative_to(root).as_posix(), path, "symlink")) + elif path.is_file(): + entries.append((path.relative_to(root).as_posix(), path, "file")) + else: + fail(f"unsupported file type in the Web package: {path}") + return entries + + +def write_file(handle: zipfile.ZipFile, source: Path, arcname: str, mode: int) -> None: + info = zipfile.ZipInfo(arcname, date_time=FIXED_TIME) + info.create_system = 3 + info.external_attr = ((stat.S_IFREG | mode) & 0xFFFF) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + with source.open("rb") as reader, handle.open(info, "w") as writer: + shutil.copyfileobj(reader, writer, BLOCK_SIZE) + + +def write_symlink(handle: zipfile.ZipFile, target: str, arcname: str) -> None: + info = zipfile.ZipInfo(arcname, date_time=FIXED_TIME) + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + info.compress_type = zipfile.ZIP_STORED + handle.writestr(info, target) + + +def create_full_zip(publish: Path, output: Path, rid: str) -> None: + env_example = SCRIPT_DIR.parent / ".env.example" + if env_example.is_symlink() or not env_example.is_file(): + fail(f"full package is missing the source environment example: {env_example}") + + # Only the reviewed template is shipped. A local .env (or another copied template) is + # never taken from the publish tree, even if a build tool happened to copy it there. + entries = [ + entry for entry in collect_tree(publish) + if not any(segment in ENVIRONMENT_FILE_NAMES for segment in entry[0].split("/")) + ] + names = {relative for relative, _, _ in entries} + for required in REQUIRED_FILES[rid]: + if required not in names: + fail(f"full package is missing required file: {required}") + required_executables = WINDOWS_REQUIRED_EXECUTABLES if is_windows_rid(rid) else LINUX_REQUIRED_EXECUTABLES + for required in required_executables: + if required not in names or (not is_windows_rid(rid) and not os.access(publish / required, os.X_OK)): + fail(f"full package is missing required executable: {required}") + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as handle: + for relative, path, kind in entries: + if kind == "symlink": + write_symlink(handle, os.readlink(path), relative) + else: + write_file(handle, path, relative, stat.S_IMODE(path.stat().st_mode)) + write_file(handle, env_example, ".env.example", 0o644) + + +def create_update_zip(publish: Path, output: Path, rid: str) -> None: + executable_name = EXECUTABLE_NAMES[rid] + if not (publish / executable_name).is_file() or (not is_windows_rid(rid) and not os.access(publish / executable_name, os.X_OK)): + fail(f"app-only package is missing required executable: {executable_name}") + entries: list[tuple[str, Path]] = [] + for name in (executable_name, BUILD_IDENTITY_NAME): + path = publish / name + if path.is_symlink() or not path.is_file(): + fail(f"app-only package is missing required file: {name}") + entries.append((executable_name, publish / executable_name)) + entries.append((BUILD_IDENTITY_NAME, publish / BUILD_IDENTITY_NAME)) + + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as handle: + for arcname, path in entries: + write_file(handle, path, arcname, stat.S_IMODE(path.stat().st_mode)) + verify_update_zip(output, rid) + + +def verify_update_zip(path: Path, rid: str) -> None: + executable_name = EXECUTABLE_NAMES[rid] + try: + handle = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as exc: + fail(f"app-only package {path.name} could not be read: {exc}") + with handle: + seen: set[str] = set() + for info in handle.infolist(): + if (info.external_attr >> 16) & 0xF000 not in (0, 0x4000, 0x8000): + fail(f"app-only package {path.name} contains a link or special entry: {info.filename}") + name = normalize_entry(info.filename) + if any(segment in ENVIRONMENT_FILE_NAMES for segment in name.split("/")): + fail(f"app-only package {path.name} must not contain environment files: {name}") + if name in seen: + fail(f"app-only package {path.name} contains a duplicate archive path: {name}") + seen.add(name) + if name not in (executable_name, BUILD_IDENTITY_NAME): + fail(f"app-only package {path.name} contains an unexpected entry: {name}") + for required in (executable_name, BUILD_IDENTITY_NAME): + if required not in seen: + fail(f"app-only package {path.name} is missing {required}") + + +def get_command(args: argparse.Namespace) -> None: + assets = load_assets() + entry = assets.get(args.rid) + if entry is None: + fail(f"unsupported runtime identifier: {args.rid}") + print(entry[args.field]) + + +def package_command(args: argparse.Namespace) -> None: + assets = load_assets() + entry = assets.get(args.rid) + if entry is None: + fail(f"unsupported runtime identifier: {args.rid}") + publish = Path(args.publish) + dist = Path(args.dist) + if not publish.is_dir(): + fail(f"publish directory does not exist: {publish}") + dist.mkdir(parents=True, exist_ok=True) + full_archive = dist / entry["full"] + update_archive = dist / entry["update"] + create_full_zip(publish, full_archive, args.rid) + create_update_zip(publish, update_archive, args.rid) + print(f"Packaged {full_archive} and {update_archive}") + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + subparsers = parser.add_subparsers(dest="command", required=True) + + get_parser = subparsers.add_parser("get", help="print a shared release asset name") + get_parser.add_argument("--rid", required=True) + get_parser.add_argument("--field", required=True, choices=("arch", "full", "update")) + get_parser.set_defaults(func=get_command) + + package_parser = subparsers.add_parser("package", help="build the full and app-only release ZIPs") + package_parser.add_argument("--rid", required=True) + package_parser.add_argument("--publish", required=True) + package_parser.add_argument("--dist", required=True) + package_parser.set_defaults(func=package_command) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py b/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py new file mode 100755 index 00000000..3ba8d649 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Build or verify the v2rayN WebAPI `WebAPI-update.json` release manifest. + +The manifest is published next to the official v2rayN Release assets. Every URL points at the +official release tag in the repository that produced the assets, so the Web self-update never +depends on a fork-specific release channel. + +Verification also opens each app-only archive and cross-checks its embedded +`v2rayN.WebAPI.build.json` against the manifest. App-only archives contain only the API executable +and build identity; unrelated files are neither packaged nor touched by self-update. + +Usage: + web-update-manifest.py write --version 7.25.4 --repository 2dust/v2rayN --commit \ + --build-date 2026-09-28T00:00:00Z --dist dist --output dist/WebAPI-update.json + web-update-manifest.py verify --manifest dist/WebAPI-update.json --dist dist [--repository 2dust/v2rayN] +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path + +PRODUCT = "v2rayN.WebAPI" +ASSET_MAP_PATH = Path(__file__).resolve().parent.parent / "Assets" / "web-assets.json" +BUILD_IDENTITY_NAME = "v2rayN.WebAPI.build.json" +APP_EXECUTABLE_NAMES = { + "linux-x64": "v2rayN.WebAPI", + "linux-arm64": "v2rayN.WebAPI", + "linux-riscv64": "v2rayN.WebAPI", + "linux-loongarch64": "v2rayN.WebAPI", + "win-x64": "v2rayN.WebAPI.exe", + "win-arm64": "v2rayN.WebAPI.exe", +} +WINDOWS_PE_MACHINES = { + "win-x64": 0x8664, + "win-arm64": 0xAA64, +} +MAX_ARCHIVE_ENTRIES = 10000 +MAX_EXPANDED_ARCHIVE_BYTES = 1024 * 1024 * 1024 +VERSION_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$") +OFFICIAL_URL_RE = re.compile( + r"^https://github\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/download/(?P[^/]+)/(?P[^/]+)$" +) + + +def fail(message: str) -> None: + print(f"web-update manifest error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_assets() -> dict[str, dict[str, str]]: + """Read the RID-to-asset map shared with the packager and the embedded runtime map.""" + try: + data = json.loads(ASSET_MAP_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {ASSET_MAP_PATH}: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("assets"), list): + fail(f"{ASSET_MAP_PATH} is missing its assets list") + assets: dict[str, dict[str, str]] = {} + for entry in data["assets"]: + if not isinstance(entry, dict): + fail(f"{ASSET_MAP_PATH} contains a non-object asset entry") + values = (entry.get("rid"), entry.get("arch"), entry.get("full"), entry.get("update")) + if not all(isinstance(value, str) and value for value in values) or values[0] in assets: + fail(f"{ASSET_MAP_PATH} contains an invalid or duplicate asset entry") + assets[values[0]] = {"arch": values[1], "full": values[2], "update": values[3]} + if not assets: + fail(f"{ASSET_MAP_PATH} contains no assets") + return assets + + +ASSETS = load_assets() + + +def full_asset_name(rid: str) -> str: + return ASSETS[rid]["full"] + + +def app_asset_name(rid: str) -> str: + return ASSETS[rid]["update"] + + +def asset_url(repository: str, version: str, asset: str) -> str: + return f"https://github.com/{repository}/releases/download/{version}/{asset}" + + +def require_release_files(dist: Path) -> None: + for rid in ASSETS: + for asset in (full_asset_name(rid), app_asset_name(rid)): + path = dist / asset + if not path.is_file() or path.stat().st_size == 0: + fail(f"release asset is missing or empty: {path}") + + +def normalize_member_path(archive_name: str, member_name: str) -> str: + """Normalize an archive entry exactly like the Web runtime package stager does.""" + if not member_name or member_name.startswith("/") or "\\" in member_name or "\0" in member_name: + fail(f"app-only archive {archive_name} contains an invalid archive path: {member_name!r}") + segments = [segment for segment in member_name.split("/") if segment] + if not segments or any(segment in (".", "..") or ":" in segment for segment in segments): + fail(f"app-only archive {archive_name} contains an invalid archive path: {member_name!r}") + return "/".join(segments) + + +def app_member_allowed(path: str, rid: str) -> bool: + return path in (APP_EXECUTABLE_NAMES[rid], BUILD_IDENTITY_NAME) + + +def verify_windows_pe(stream, size: int, archive_name: str, expected_machine: int) -> None: + if size < 64: + fail(f"app-only archive {archive_name} does not contain a Windows PE executable") + dos_header = stream.read(64) + if len(dos_header) != 64 or dos_header[:2] != b"MZ": + fail(f"app-only archive {archive_name} does not contain a Windows PE executable") + pe_offset = int.from_bytes(dos_header[0x3C:0x40], "little") + if pe_offset < 64 or pe_offset + 26 > size: + fail(f"app-only archive {archive_name} has a truncated Windows PE header") + stream.seek(pe_offset) + pe_header = stream.read(26) + if len(pe_header) != 26 or pe_header[:4] != b"PE\0\0": + fail(f"app-only archive {archive_name} has a truncated Windows PE header") + machine = int.from_bytes(pe_header[4:6], "little") + sections = int.from_bytes(pe_header[6:8], "little") + optional_size = int.from_bytes(pe_header[20:22], "little") + characteristics = int.from_bytes(pe_header[22:24], "little") + optional_start = pe_offset + 24 + if (machine != expected_machine or sections == 0 or characteristics & 0x0002 == 0 + or optional_size < 2 or optional_start + optional_size > size): + fail(f"app-only archive {archive_name} executable is not a Windows PE32+ image for its declared runtime identifier") + if int.from_bytes(pe_header[24:26], "little") != 0x20B: + fail(f"app-only archive {archive_name} executable is not a Windows PE32+ image for its declared runtime identifier") + + +def verify_app_archive_identity(archive: Path, rid: str, version: str, commit: str, build_date: str) -> None: + """Cross-check the app-only ZIP layout and its embedded build identity. + + The archive must only carry the executable and its `v2rayN.WebAPI.build.json` identity. The + identity must match the release manifest exactly, mirroring the checks the Web runtime + performs before installing a self-update package. + """ + archive_name = archive.name + executable_name = APP_EXECUTABLE_NAMES[rid] + seen: set[str] = set() + identity_bytes: bytes | None = None + executable_info: zipfile.ZipInfo | None = None + entry_count = 0 + expanded_bytes = 0 + try: + handle = zipfile.ZipFile(archive) + except (OSError, zipfile.BadZipFile) as exc: + fail(f"app-only archive {archive_name} could not be read: {exc}") + with handle: + for info in handle.infolist(): + entry_count += 1 + if entry_count > MAX_ARCHIVE_ENTRIES: + fail(f"app-only archive {archive_name} has more than {MAX_ARCHIVE_ENTRIES} entries") + path = normalize_member_path(archive_name, info.filename) + if path in seen: + fail(f"app-only archive {archive_name} contains a duplicate archive path: {path}") + seen.add(path) + if not app_member_allowed(path, rid): + fail(f"app-only archive {archive_name} contains an entry outside the app-only layout: {path}") + unix_type = (info.external_attr >> 16) & 0xF000 + if unix_type not in (0, 0x4000, 0x8000): + fail(f"app-only archive {archive_name} contains a link or special entry: {path}") + if unix_type == 0x4000 or info.filename.endswith("/"): + continue + expanded_bytes += info.file_size + if expanded_bytes > MAX_EXPANDED_ARCHIVE_BYTES: + fail(f"app-only archive {archive_name} expands beyond the supported size") + if path == BUILD_IDENTITY_NAME: + identity_bytes = handle.read(info) + elif path == executable_name: + executable_info = info + if rid in WINDOWS_PE_MACHINES and executable_info is not None: + with handle.open(executable_info) as executable_stream: + verify_windows_pe(executable_stream, executable_info.file_size, archive_name, WINDOWS_PE_MACHINES[rid]) + + for required in (executable_name, BUILD_IDENTITY_NAME): + if required not in seen: + fail(f"app-only archive {archive_name} is missing {required}") + if identity_bytes is None: + fail(f"app-only archive {archive_name} has an unreadable {BUILD_IDENTITY_NAME}") + if executable_info is None: + fail(f"app-only archive {archive_name} has an unreadable {executable_name}") + try: + identity = json.loads(identity_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + fail(f"app-only archive {archive_name} has an invalid {BUILD_IDENTITY_NAME}: {exc}") + if not isinstance(identity, dict): + fail(f"app-only archive {archive_name} has an invalid {BUILD_IDENTITY_NAME}: expected a JSON object") + + expected = {"product": PRODUCT, "version": version, "commit": commit, "buildDate": build_date, "rid": rid} + for field, value in expected.items(): + actual = identity.get(field) + if actual != value: + fail( + f"app-only archive {archive_name} identity mismatch for {field}: " + f"{actual!r} does not match the manifest value {value!r}" + ) + + +def package_entry(dist: Path, repository: str, version: str, rid: str) -> dict: + path = dist / app_asset_name(rid) + data = path.read_bytes() + return { + "rid": rid, + "asset": path.name, + "url": asset_url(repository, version, path.name), + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + } + + +def build_manifest(dist: Path, version: str, repository: str, commit: str, build_date: str) -> dict: + if not VERSION_RE.match(version): + fail(f"official release versions must be x.y.z, got: {version}") + if not REPOSITORY_RE.match(repository): + fail(f"invalid release repository: {repository}") + if not commit or commit == "unknown": + fail("the manifest requires a real commit identity") + if not build_date or build_date == "unknown": + fail("the manifest requires a real build date") + require_release_files(dist) + return { + "product": PRODUCT, + "version": version, + "commit": commit, + "buildDate": build_date, + "packages": [package_entry(dist, repository, version, rid) for rid in ASSETS], + } + + +def verify_manifest(manifest_path: Path, dist: Path, repository: str | None, version: str | None = None) -> None: + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"could not read {manifest_path}: {exc}") + if not isinstance(manifest, dict) or manifest.get("product") != PRODUCT: + fail(f"{manifest_path} is not a {PRODUCT} manifest") + manifest_version = manifest.get("version") + if not isinstance(manifest_version, str) or not VERSION_RE.match(manifest_version): + fail(f"{manifest_path} has an invalid version: {manifest_version!r}") + if version is not None and manifest_version != version: + fail(f"{manifest_path} version {manifest_version} does not match {version}") + if not isinstance(manifest.get("commit"), str) or not manifest["commit"]: + fail(f"{manifest_path} has no commit identity") + if not isinstance(manifest.get("buildDate"), str) or not manifest["buildDate"]: + fail(f"{manifest_path} has no build date") + + packages = manifest.get("packages") + if not isinstance(packages, list) or len(packages) != len(ASSETS): + fail(f"{manifest_path} must contain exactly {len(ASSETS)} packages") + by_rid = {} + for package in packages: + if not isinstance(package, dict): + fail(f"{manifest_path} contains a non-object package entry") + rid = package.get("rid") + if rid not in ASSETS: + fail(f"{manifest_path} contains an unsupported runtime identifier: {rid!r}") + if rid in by_rid: + fail(f"{manifest_path} contains duplicate runtime identifiers") + asset = package.get("asset") + if asset != app_asset_name(rid): + fail(f"{manifest_path} has an unexpected asset for {rid}: {asset!r}") + path = dist / asset + if not path.is_file() or path.stat().st_size == 0: + fail(f"release asset is missing or empty: {path}") + data = path.read_bytes() + if package.get("sha256") != hashlib.sha256(data).hexdigest(): + fail(f"sha256 mismatch for {asset}") + if package.get("size") != len(data): + fail(f"size mismatch for {asset}") + url = package.get("url") + if not isinstance(url, str): + fail(f"missing asset URL for {rid}") + match = OFFICIAL_URL_RE.match(url) + if not match or match.group("tag") != manifest_version or match.group("asset") != asset: + fail(f"asset URL is not an official release URL for this tag: {url}") + if repository is not None and not url.startswith(f"https://github.com/{repository}/"): + fail(f"asset URL does not use repository {repository}: {url}") + verify_app_archive_identity(path, rid, manifest_version, manifest["commit"], manifest["buildDate"]) + by_rid[rid] = package + + for rid in ASSETS: + if rid not in by_rid: + fail(f"{manifest_path} is missing runtime {rid}") + require_release_files(dist) + + +def write_manifest(args: argparse.Namespace) -> None: + dist = Path(args.dist) + manifest = build_manifest(dist, args.version, args.repository, args.commit, args.build_date) + output = Path(args.output) + output.write_text(json.dumps(manifest, separators=(",", ":")) + "\n", encoding="utf-8") + verify_manifest(output, dist, args.repository, args.version) + print(f"Wrote {output} for {PRODUCT} {args.version} ({args.repository})") + + +def verify_command(args: argparse.Namespace) -> None: + verify_manifest(Path(args.manifest), Path(args.dist), args.repository, args.version) + print(f"Verified {args.manifest}") + + +def main(argv: list[str]) -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + subparsers = parser.add_subparsers(dest="command", required=True) + + write_parser = subparsers.add_parser("write", help="write WebAPI-update.json from the packaged assets") + write_parser.add_argument("--version", required=True) + write_parser.add_argument("--repository", required=True) + write_parser.add_argument("--commit", required=True) + write_parser.add_argument("--build-date", required=True) + write_parser.add_argument("--dist", required=True) + write_parser.add_argument("--output", required=True) + write_parser.set_defaults(func=write_manifest) + + verify_parser = subparsers.add_parser("verify", help="verify WebAPI-update.json against the packaged assets") + verify_parser.add_argument("--manifest", required=True) + verify_parser.add_argument("--dist", required=True) + verify_parser.add_argument("--repository") + verify_parser.add_argument("--version", help="require the manifest to match this release version") + verify_parser.set_defaults(func=verify_command) + + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs new file mode 100644 index 00000000..82be7281 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthRateLimiting.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; + +namespace v2rayN.WebAPI.Security; + +public static class WebAuthRateLimiting +{ + public const string LoginPolicy = "WebAPI-auth-login"; + public const string SetupPolicy = "WebAPI-auth-setup"; + + public static void Configure(RateLimiterOptions options) + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = (context, _) => + { + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + var seconds = Math.Ceiling(retryAfter.TotalSeconds); + context.HttpContext.Response.Headers["Retry-After"] = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + return ValueTask.CompletedTask; + }; + options.AddPolicy(LoginPolicy, context => CreateIpPartition(context, permitLimit: 5)); + options.AddPolicy(SetupPolicy, context => CreateIpPartition(context, permitLimit: 10)); + } + + private static RateLimitPartition CreateIpPartition(HttpContext context, int permitLimit) => + RateLimitPartition.GetFixedWindowLimiter( + GetPartitionKey(context.Connection.RemoteIpAddress), + _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = permitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromMinutes(1), + }); + + private static string GetPartitionKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + return IsLoopback(address) ? "loopback" : address.ToString(); + } + + private static bool IsLoopback(IPAddress address) => + IPAddress.IsLoopback(address) + || (address.IsIPv4MappedToIPv6 && IPAddress.IsLoopback(address.MapToIPv4())); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs new file mode 100644 index 00000000..0ca835b9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthRequestBodyLimitMiddleware.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http.Features; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebAuthRequestBodyLimitMiddleware(RequestDelegate next) +{ + public const long MaximumRequestBodyBytes = 64 * 1024; + + public async Task InvokeAsync(HttpContext context) + { + if (!IsLimitedAuthRequest(context.Request)) + { + await next(context); + return; + } + + var requestBodySizeFeature = context.Features.Get(); + if (requestBodySizeFeature is { IsReadOnly: false }) + { + requestBodySizeFeature.MaxRequestBodySize = MaximumRequestBodyBytes; + } + + if (context.Request.ContentLength > MaximumRequestBodyBytes) + { + context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("payload_too_large", ApiMessageKeys.CommonInvalidInput), + context.RequestAborted); + return; + } + + await next(context); + } + + private static bool IsLimitedAuthRequest(HttpRequest request) => + HttpMethods.IsPost(request.Method) + && (request.Path == "/api/auth/login" || request.Path == "/api/setup"); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs new file mode 100644 index 00000000..e3e96ceb --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthService.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebAuthService +{ + private const int CurrentVersion = 1; + private const int Pbkdf2Iterations = 600_000; + private const int SaltLength = 16; + private const int VerifierLength = 32; + public const int MinimumKeyLength = 12; + public const int MaximumKeyLength = 4096; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly byte[] DummySalt = RandomNumberGenerator.GetBytes(SaltLength); + + private readonly string _configPath; + private readonly string? _environmentKey; + private readonly SemaphoreSlim _setupLock = new(1, 1); + private StoredWebAuth? _storedAuth; + + public WebAuthService(string configPath, string? environmentKey) + { + _configPath = Path.GetFullPath(configPath); + _environmentKey = string.IsNullOrWhiteSpace(environmentKey) ? null : environmentKey; + if (_environmentKey is null && File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + } + } + + public bool EnvironmentKeyConfigured => _environmentKey is not null; + + public bool SetupRequired => _environmentKey is null && _storedAuth is null; + + public bool ValidateManagementKey(string? suppliedKey) + { + var key = suppliedKey ?? string.Empty; + + if (_environmentKey is not null) + { + DeriveAndDiscard(key, DummySalt, Pbkdf2Iterations); + if (string.IsNullOrEmpty(suppliedKey)) + { + return false; + } + return FixedTimeEquals(_environmentKey, suppliedKey); + } + + var stored = _storedAuth; + if (stored is null) + { + DeriveAndDiscard(key, DummySalt, Pbkdf2Iterations); + return false; + } + + var salt = Convert.FromBase64String(stored.Salt); + var expected = Convert.FromBase64String(stored.KeyVerifier); + var supplied = DeriveVerifier(key, salt, stored.Iterations); + return CryptographicOperations.FixedTimeEquals(expected, supplied); + } + + public async Task SetupAsync( + string? key, + string? confirmKey, + bool setupAccessAllowed, + CancellationToken cancellationToken = default) + { + if (!setupAccessAllowed) + { + return WebSetupResult.Forbidden; + } + + if (key is null || key.Length < MinimumKeyLength) + { + return WebSetupResult.KeyTooShort; + } + if (key.Length > MaximumKeyLength) + { + return WebSetupResult.KeyTooLong; + } + + if (!string.Equals(key, confirmKey, StringComparison.Ordinal)) + { + return WebSetupResult.KeysDoNotMatch; + } + + await _setupLock.WaitAsync(cancellationToken); + try + { + if (!SetupRequired) + { + return WebSetupResult.AlreadyConfigured; + } + + // Do not replace a config created by another process between startup and setup. + if (File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + return WebSetupResult.AlreadyConfigured; + } + + var salt = RandomNumberGenerator.GetBytes(SaltLength); + var verifier = DeriveVerifier(key, salt, Pbkdf2Iterations); + var stored = new StoredWebAuth( + CurrentVersion, + "PBKDF2-HMAC-SHA256", + Pbkdf2Iterations, + Convert.ToBase64String(salt), + Convert.ToBase64String(verifier)); + await WriteAtomicallyAsync(stored, cancellationToken); + _storedAuth = stored; + return WebSetupResult.Created; + } + catch (IOException) when (File.Exists(_configPath)) + { + _storedAuth = LoadStoredAuth(_configPath); + return WebSetupResult.AlreadyConfigured; + } + finally + { + _setupLock.Release(); + } + } + + private static bool FixedTimeEquals(string expected, string actual) + { + var expectedBytes = Encoding.UTF8.GetBytes(expected); + var actualBytes = Encoding.UTF8.GetBytes(actual); + return expectedBytes.Length == actualBytes.Length + && CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); + } + + private static byte[] DeriveVerifier(string key, byte[] salt, int iterations) + { + var password = Encoding.UTF8.GetBytes(key); + try + { + return Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + iterations, + HashAlgorithmName.SHA256, + VerifierLength); + } + finally + { + CryptographicOperations.ZeroMemory(password); + } + } + + private static void DeriveAndDiscard(string key, byte[] salt, int iterations) + { + var derived = DeriveVerifier(key, salt, iterations); + CryptographicOperations.ZeroMemory(derived); + } + + private static StoredWebAuth LoadStoredAuth(string path) + { + try + { + var stored = JsonSerializer.Deserialize(File.ReadAllBytes(path), JsonOptions) + ?? throw new InvalidDataException("Web auth config is empty."); + var salt = Convert.FromBase64String(stored.Salt); + var verifier = Convert.FromBase64String(stored.KeyVerifier); + if (stored.Version != CurrentVersion + || stored.Kdf != "PBKDF2-HMAC-SHA256" + || stored.Iterations is < 100_000 or > 2_000_000 + || salt.Length != SaltLength + || verifier.Length != VerifierLength) + { + throw new InvalidDataException("Web auth config has unsupported or invalid parameters."); + } + + return stored; + } + catch (Exception exception) when (exception is JsonException or FormatException) + { + throw new InvalidDataException("Web auth config is malformed.", exception); + } + } + + private async Task WriteAtomicallyAsync(StoredWebAuth stored, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(_configPath) + ?? throw new InvalidOperationException("Web auth config path has no parent directory."); + Directory.CreateDirectory(directory); + + var temporaryPath = Path.Combine(directory, $".WebAPI-auth-{Guid.NewGuid():N}.tmp"); + try + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.WriteThrough, + }; + if (OperatingSystem.IsLinux()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + await using (var stream = new FileStream(temporaryPath, options)) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(stored, JsonOptions); + await stream.WriteAsync(bytes, cancellationToken); + await stream.FlushAsync(cancellationToken); + stream.Flush(flushToDisk: true); + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(temporaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + File.Move(temporaryPath, _configPath, overwrite: false); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private sealed record StoredWebAuth(int Version, string Kdf, int Iterations, string Salt, string KeyVerifier); +} + +public enum WebSetupResult +{ + Created, + Forbidden, + KeyTooShort, + KeyTooLong, + KeysDoNotMatch, + AlreadyConfigured, +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs b/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs new file mode 100644 index 00000000..ddbdf56d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebAuthStorage.cs @@ -0,0 +1,47 @@ +namespace v2rayN.WebAPI.Security; + +internal static class WebAuthStorage +{ + public static string MigrateAndGetPath(string startupPath, string legacyConfigPath) + { + var directory = Path.Combine(startupPath, "WebAPIData"); + Directory.CreateDirectory(directory); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var authPath = Path.Combine(directory, "WebAPI-auth.json"); + // Pre-rename locations that can still hold the verifier on existing installs: + // the previous private location and the original guiConfigs location. + var previousPrivatePath = Path.Combine(startupPath, "webData", "web-auth.json"); + if (!File.Exists(authPath)) + { + if (File.Exists(previousPrivatePath)) + { + File.Move(previousPrivatePath, authPath); + } + else if (File.Exists(legacyConfigPath)) + { + File.Move(legacyConfigPath, authPath); + } + } + + // Stale pre-rename copies must not survive next to the current verifier. + if (File.Exists(previousPrivatePath)) + { + File.Delete(previousPrivatePath); + } + if (File.Exists(legacyConfigPath)) + { + File.Delete(legacyConfigPath); + } + + if (OperatingSystem.IsLinux() && File.Exists(authPath)) + { + File.SetUnixFileMode(authPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + return authPath; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs new file mode 100644 index 00000000..b5a261ec --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebCorsPolicy.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebCorsPolicy +{ + public const string EnvironmentVariable = "V2RAYN_WEB_ALLOWED_ORIGINS"; + public const string PolicyName = "WebApiOrigins"; + private readonly HashSet _origins; + + public WebCorsPolicy(string? configuration) + { + _origins = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(configuration)) return; + foreach (var item in configuration.Split(',')) + { + _origins.Add(NormalizeOrigin(item.Trim())); + } + } + + public static string NormalizeOrigin(string value, string variable = EnvironmentVariable) + { + var authorityStart = value.IndexOf("://", StringComparison.Ordinal); + var slash = authorityStart < 0 ? -1 : value.IndexOf('/', authorityStart + 3); + if (string.IsNullOrWhiteSpace(value) || value.Any(char.IsWhiteSpace) + || authorityStart < 0 || (slash >= 0 && slash != value.Length - 1) + || value.IndexOfAny(['*', '\\', '?', '#']) >= 0 + || !Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) + || uri.UserInfo.Length != 0 || uri.AbsolutePath != "/") + { + // Do not echo configuration: an invalid value might contain credentials. + throw new ArgumentException($"{variable} must contain only comma-separated http(s) origins (scheme, host and port), without credentials, paths, queries, fragments or wildcards."); + } + return uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped); + } + + public bool Allows(string origin) => _origins.Contains(origin); + + // A same-origin browser may reach HTTP Kestrel through a TLS/path-prefix + // reverse proxy. Its public origin need not equal Kestrel's internal URL. + // Fetch Metadata is browser-controlled (JS cannot forge Sec-Fetch-Site). + // This is NOT used to grant setup access or internal health diagnostics. + public static bool IsBrowserSameOriginRequest(HttpRequest request) + { + if (request.Headers["Sec-Fetch-Site"].Count != 1 + || request.Headers["Sec-Fetch-Site"] != "same-origin" + || request.Headers.Origin.Count != 1) return false; + try + { + var origin = request.Headers.Origin.ToString(); + return NormalizeOrigin(origin) == origin; + } + catch (ArgumentException) { return false; } + } + + public static bool IsSameOriginRequest(HttpRequest request) + { + if (!request.Headers.ContainsKey("Origin")) return true; // Native clients. + if (request.Headers.Origin.Count != 1) return false; + try + { + return string.Equals(request.Headers.Origin.ToString(), + NormalizeOrigin($"{request.Scheme}://{request.Host}"), StringComparison.Ordinal); + } + catch (ArgumentException) { return false; } + } + + public void Configure(CorsPolicyBuilder builder) => builder + .SetIsOriginAllowed(Allows) + .WithMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") + .WithHeaders("Authorization", "Content-Type") + .WithExposedHeaders("Content-Disposition"); +} + +// CORS alone controls readability, not execution. Reject a disallowed Origin +// before authentication, rate limiting, SSE-ticket consumption or API mutations. +public sealed class WebOriginGuardMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, WebCorsPolicy cors) + { + if (context.Request.Path.StartsWithSegments("/api") + && !WebCorsPolicy.IsSameOriginRequest(context.Request) + && !WebCorsPolicy.IsBrowserSameOriginRequest(context.Request) + && (context.Request.Headers.Origin.Count != 1 || !cors.Allows(context.Request.Headers.Origin.ToString()))) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("origin_not_allowed", ApiMessageKeys.CommonInvalidInput)); + return; + } + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs new file mode 100644 index 00000000..c6ac830d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebDeploymentSecurityPolicy.cs @@ -0,0 +1,12 @@ +namespace v2rayN.WebAPI.Security; + +public static class WebDeploymentSecurityPolicy +{ + public const string MissingManagementKeyMessage = + "Management Key is required in supervised/container deployments. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI."; + + public static string? GetStartupError(bool supervisedEnvironment, bool containerEnvironment, string? managementKey) => + (supervisedEnvironment || containerEnvironment) && string.IsNullOrWhiteSpace(managementKey) + ? MissingManagementKeyMessage + : null; +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs new file mode 100644 index 00000000..b47d7d58 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebListenerSecurityPolicy.cs @@ -0,0 +1,67 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; + +namespace v2rayN.WebAPI.Security; + +/// Checks server binding configuration, never request Host/forwarding headers. +public static class WebListenerSecurityPolicy +{ + public const string DefaultUrl = "http://127.0.0.1:5080"; + public const string MissingManagementKeyMessage = + "Management Key is required for non-loopback Web listeners. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI, or bind only to 127.0.0.1, localhost, or [::1] for local first-run setup."; + + public static void ApplyDefault(WebApplicationBuilder builder) + { + if (string.IsNullOrWhiteSpace(builder.Configuration[WebHostDefaults.ServerUrlsKey]) + && string.IsNullOrWhiteSpace(builder.Configuration["http_ports"]) + && string.IsNullOrWhiteSpace(builder.Configuration["https_ports"])) + { + builder.WebHost.UseUrls(DefaultUrl); + } + } + + public static string? GetStartupError(IConfiguration configuration, string? managementKey) => + string.IsNullOrWhiteSpace(managementKey) && GetEffectiveUrls(configuration).Any(url => !IsLoopbackUrl(url)) + ? MissingManagementKeyMessage + : null; + + public static IReadOnlyList GetEffectiveUrls(IConfiguration configuration) + { + var urls = Split(configuration[WebHostDefaults.ServerUrlsKey]); + if (urls.Length == 0) + { + // ASP.NET Core expands port-only bindings to wildcard addresses, not loopback. + urls = Split(configuration["http_ports"]).Select(port => $"http://*:{port}") + .Concat(Split(configuration["https_ports"]).Select(port => $"https://*:{port}")) + .ToArray(); + } + + var endpoints = configuration.GetSection("Kestrel:Endpoints").GetChildren() + .Select(endpoint => endpoint["Url"] ?? string.Empty).ToArray(); + // Kestrel endpoints override hosting URLs unless PreferHostingUrls is enabled. + if (endpoints.Length > 0 + && !(bool.TryParse(configuration[WebHostDefaults.PreferHostingUrlsKey], out var preferUrls) + && preferUrls && urls.Length > 0)) + { + return endpoints; + } + + return urls.Length > 0 ? urls : [DefaultUrl]; + } + + public static bool IsLoopbackUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return false; + } + + var host = uri.Host.Trim('[', ']'); + return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); + } + + private static string[] Split(string? value) => + (value ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs new file mode 100644 index 00000000..8b8c452f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSecurityHeadersMiddleware.cs @@ -0,0 +1,17 @@ +namespace v2rayN.WebAPI.Security; + +public sealed class WebSecurityHeadersMiddleware(RequestDelegate next) +{ + public const string ContentSecurityPolicy = + "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; " + + "script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'"; + + public async Task InvokeAsync(HttpContext context) + { + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["Content-Security-Policy"] = ContentSecurityPolicy; + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs b/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs new file mode 100644 index 00000000..87d71f75 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSessionAuthenticationMiddleware.cs @@ -0,0 +1,49 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebSessionAuthenticationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, WebSessionService sessions) + { + var path = context.Request.Path; + if (path == "/api/events") + { + if (!sessions.TryConsumeSseTicket(context.Request.Query["sse_ticket"].ToString(), out var eventSession)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized)); + return; + } + + context.Items[WebSessionService.SessionSnapshotContextKey] = eventSession!; + await next(context); + return; + } + + if (!path.StartsWithSegments("/api") + || path == "/api/health" + || path == "/api/setup/status" + || path == "/api/setup" + || path == "/api/auth/login") + { + await next(context); + return; + } + + var presentedToken = WebSessionService.ExtractPresentedToken(context); + var authenticated = path == "/api/auth/sse-ticket" + ? sessions.TryValidateWithoutRenewal(presentedToken, out _) + : sessions.TryValidateAndRenew(presentedToken, out _); + if (!authenticated) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync( + ApiEnvelope.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized)); + return; + } + + await next(context); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs b/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs new file mode 100644 index 00000000..40fe2f0d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSessionService.cs @@ -0,0 +1,387 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; + +namespace v2rayN.WebAPI.Security; + +public sealed class WebSessionService : IDisposable +{ + public const string SessionSnapshotContextKey = "v2rayn.web.session.snapshot"; + public static readonly TimeSpan SlidingLifetime = TimeSpan.FromDays(7); + public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(30); + public static readonly TimeSpan SseTicketLifetime = TimeSpan.FromSeconds(45); + private static readonly TimeSpan CleanupInterval = TimeSpan.FromMinutes(15); + private static readonly TimeSpan SseTicketCleanupInterval = TimeSpan.FromMinutes(1); + private const int TokenByteLength = 32; + private const int TokenEncodedLength = 43; + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sseTickets = new(StringComparer.Ordinal); + private readonly TimeProvider _timeProvider; + private readonly Timer _cleanupTimer; + private readonly Timer _sseTicketCleanupTimer; + private bool _disposed; + + public WebSessionService(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _cleanupTimer = new Timer( + static state => ((WebSessionService)state!).CleanupExpiredSessions(), + this, + CleanupInterval, + CleanupInterval); + _sseTicketCleanupTimer = new Timer( + static state => ((WebSessionService)state!).CleanupExpiredSseTickets(), + this, + SseTicketCleanupInterval, + SseTicketCleanupInterval); + } + + public WebSessionToken CreateSession() + { + ObjectDisposedException.ThrowIf(_disposed, this); + while (true) + { + var tokenBytes = RandomNumberGenerator.GetBytes(TokenByteLength); + var token = EncodeBase64Url(tokenBytes); + var digest = GetDigest(tokenBytes); + CryptographicOperations.ZeroMemory(tokenBytes); + + var now = _timeProvider.GetUtcNow(); + var absoluteExpiresAt = now + AbsoluteLifetime; + var entry = new SessionEntry( + now, + now, + Min(now + SlidingLifetime, absoluteExpiresAt), + absoluteExpiresAt, + new CancellationTokenSource()); + if (_sessions.TryAdd(digest, entry)) + { + return new WebSessionToken(token, entry.ExpiresAt); + } + entry.Revoked.Dispose(); + } + } + + public bool TryValidateAndRenew(string? token, out WebSessionSnapshot? session) => + TryValidateSessionCore(token, renew: true, out session); + + public bool TryValidateWithoutRenewal(string? token, out WebSessionSnapshot? session) => + TryValidateSessionCore(token, renew: false, out session); + + private bool TryValidateSessionCore(string? token, bool renew, out WebSessionSnapshot? session) + { + session = null; + if (_disposed || !TryGetDigest(token, out var digest)) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var current)) + { + var now = _timeProvider.GetUtcNow(); + if (now >= current.ExpiresAt || now >= current.AbsoluteExpiresAt) + { + if (TryRemove(digest, current)) + { + return false; + } + continue; + } + + if (!renew) + { + session = new WebSessionSnapshot( + current.CreatedAt, + current.LastSeenAt, + current.ExpiresAt, + current.AbsoluteExpiresAt, + current.Revoked.Token, + digest); + return true; + } + + var renewed = current with + { + LastSeenAt = now, + ExpiresAt = Min(now + SlidingLifetime, current.AbsoluteExpiresAt), + }; + if (_sessions.TryUpdate(digest, renewed, current)) + { + session = new WebSessionSnapshot( + renewed.CreatedAt, + renewed.LastSeenAt, + renewed.ExpiresAt, + renewed.AbsoluteExpiresAt, + renewed.Revoked.Token, + digest); + return true; + } + } + + return false; + } + + private bool TryValidateSessionDigestWithoutRenewal(string digest, out WebSessionSnapshot? session) + { + session = null; + if (_disposed) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var current)) + { + var now = _timeProvider.GetUtcNow(); + if (now >= current.ExpiresAt || now >= current.AbsoluteExpiresAt) + { + if (TryRemove(digest, current)) + { + return false; + } + continue; + } + + session = new WebSessionSnapshot( + current.CreatedAt, + current.LastSeenAt, + current.ExpiresAt, + current.AbsoluteExpiresAt, + current.Revoked.Token, + digest); + return true; + } + + return false; + } + + internal bool TryValidateSseSessionWithoutRenewal(string sessionDigest, out WebSessionSnapshot? session) => + TryValidateSessionDigestWithoutRenewal(sessionDigest, out session); + + public bool Revoke(string? token) + { + if (_disposed || !TryGetDigest(token, out var digest)) + { + return false; + } + + while (_sessions.TryGetValue(digest, out var entry)) + { + if (TryRemove(digest, entry)) + { + return true; + } + } + return false; + } + + public bool TryCreateSseTicket(string? sessionToken, out WebSseTicket? ticket) + { + ticket = null; + if (_disposed || !TryGetDigest(sessionToken, out var sessionDigest) + || !TryValidateSessionDigestWithoutRenewal(sessionDigest, out _)) + { + return false; + } + + CleanupExpiredSseTickets(); + while (true) + { + var ticketBytes = RandomNumberGenerator.GetBytes(TokenByteLength); + var ticketToken = EncodeBase64Url(ticketBytes); + var ticketDigest = GetDigest(ticketBytes); + CryptographicOperations.ZeroMemory(ticketBytes); + + var expiresAt = _timeProvider.GetUtcNow() + SseTicketLifetime; + if (_sseTickets.TryAdd(ticketDigest, new SseTicketEntry(sessionDigest, expiresAt))) + { + // Close the race with logout/expiry while the ticket was being minted. + if (!TryValidateSessionDigestWithoutRenewal(sessionDigest, out _)) + { + _sseTickets.TryRemove(ticketDigest, out _); + return false; + } + + ticket = new WebSseTicket(ticketToken, expiresAt); + return true; + } + } + } + + public bool TryConsumeSseTicket(string? ticketToken, out WebSessionSnapshot? session) + { + session = null; + if (_disposed || !TryGetDigest(ticketToken, out var ticketDigest) + || !_sseTickets.TryRemove(ticketDigest, out var ticket)) + { + return false; + } + + if (_timeProvider.GetUtcNow() >= ticket.ExpiresAt) + { + return false; + } + + return TryValidateSessionDigestWithoutRenewal(ticket.SessionDigest, out session); + } + + public int CleanupExpiredSessions() + { + if (_disposed) + { + return 0; + } + + var now = _timeProvider.GetUtcNow(); + var removed = 0; + foreach (var (digest, entry) in _sessions) + { + if ((now >= entry.ExpiresAt || now >= entry.AbsoluteExpiresAt) + && TryRemove(digest, entry)) + { + removed++; + } + } + return removed; + } + + private void CleanupExpiredSseTickets() + { + if (_disposed) + { + return; + } + + var now = _timeProvider.GetUtcNow(); + foreach (var (digest, ticket) in _sseTickets) + { + if (now >= ticket.ExpiresAt) + { + _sseTickets.TryRemove(digest, out _); + } + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _cleanupTimer.Dispose(); + _sseTicketCleanupTimer.Dispose(); + _sseTickets.Clear(); + foreach (var entry in _sessions.Values) + { + Cancel(entry.Revoked); + } + _sessions.Clear(); + } + + public static string ExtractPresentedToken(HttpContext context) + { + var authorization = context.Request.Headers.Authorization.ToString(); + if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return authorization[7..].Trim(); + } + + return string.Empty; + } + + private bool TryRemove(string digest, SessionEntry entry) + { + var removed = ((ICollection>)_sessions) + .Remove(new KeyValuePair(digest, entry)); + if (removed) + { + Cancel(entry.Revoked); + RemoveTicketsForSession(digest); + } + return removed; + } + + private void RemoveTicketsForSession(string sessionDigest) + { + foreach (var (ticketDigest, ticket) in _sseTickets) + { + if (ticket.SessionDigest == sessionDigest) + { + _sseTickets.TryRemove(ticketDigest, out _); + } + } + } + + private static void Cancel(CancellationTokenSource source) + { + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + + private static bool TryGetDigest(string? token, out string digest) + { + digest = string.Empty; + if (token is null || token.Length != TokenEncodedLength + || token.Any(character => !(character is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-' or '_'))) + { + return false; + } + + try + { + var base64 = token.Replace('-', '+').Replace('_', '/') + "="; + var tokenBytes = Convert.FromBase64String(base64); + if (tokenBytes.Length != TokenByteLength || EncodeBase64Url(tokenBytes) != token) + { + CryptographicOperations.ZeroMemory(tokenBytes); + return false; + } + + digest = GetDigest(tokenBytes); + CryptographicOperations.ZeroMemory(tokenBytes); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static string EncodeBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static string GetDigest(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + + private static DateTimeOffset Min(DateTimeOffset first, DateTimeOffset second) => first <= second ? first : second; + + private sealed record SessionEntry( + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset AbsoluteExpiresAt, + CancellationTokenSource Revoked); + + private sealed record SseTicketEntry(string SessionDigest, DateTimeOffset ExpiresAt); +} + +public sealed record WebSessionToken(string Token, DateTimeOffset ExpiresAt); + +public sealed record WebSseTicket(string Token, DateTimeOffset ExpiresAt); + +public sealed record WebSessionSnapshot( + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt, + DateTimeOffset ExpiresAt, + DateTimeOffset AbsoluteExpiresAt, + CancellationToken RevocationToken, + string SessionDigest); diff --git a/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs b/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs new file mode 100644 index 00000000..c51e96b2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Security/WebSetupAccessPolicy.cs @@ -0,0 +1,70 @@ +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace v2rayN.WebAPI.Security; + +public static class WebSetupAccessPolicy +{ + public static bool IsAllowed( + IPAddress? remoteAddress, + string? host, + bool forwardedHeadersPresent) => + !forwardedHeadersPresent + && IsLoopbackAddress(remoteAddress) && IsLocalHost(host); + + public static bool IsAllowedFromRequest(HttpContext context) => + WebCorsPolicy.IsSameOriginRequest(context.Request) + && IsAllowed(context.Connection.RemoteIpAddress, context.Request.Host.Host, + HasForwardedHeaders(context.Request.Headers)); + + /// + /// The loopback peer + local Host pair required for per-instance health diagnostics. + /// The update/launcher probe rules (see WebProbeUriResolver) target exactly this pair. + /// + public static bool IsLocalDiagnosticSource(IPAddress? remoteAddress, string? host) => + IsLoopbackAddress(remoteAddress) && IsLocalHost(host); + + public static bool IsLoopbackAddress(IPAddress? remoteAddress) + { + if (remoteAddress is null) + { + return false; + } + + if (remoteAddress.Equals(IPAddress.Loopback) || remoteAddress.Equals(IPAddress.IPv6Loopback)) + { + return true; + } + + return remoteAddress.IsIPv4MappedToIPv6 + && remoteAddress.MapToIPv4().Equals(IPAddress.Loopback); + } + + public static bool IsLocalHost(string? host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + var normalized = host.Trim(); + if (normalized.Length > 1 && normalized[0] == '[' && normalized[^1] == ']') + { + normalized = normalized[1..^1]; + } + + if (string.Equals(normalized, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return IPAddress.TryParse(normalized, out var address) + && (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback)); + } + + public static bool HasForwardedHeaders(IHeaderDictionary headers) => + headers.Keys.Any(key => + key.Equals("Forwarded", StringComparison.OrdinalIgnoreCase) + || key.Equals("X-Real-IP", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("X-Forwarded-", StringComparison.OrdinalIgnoreCase)); +} diff --git a/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep b/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep new file mode 100644 index 00000000..d0b8ced7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/ServiceLibBin/.keep @@ -0,0 +1,2 @@ +This directory is copied to the Web application's output as `bin/`. +ServiceLib's CoreManager copies bundled core files from that directory into its writable data path. diff --git a/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs b/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs new file mode 100644 index 00000000..1f240942 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BackupDatabaseCompatibility.cs @@ -0,0 +1,81 @@ +using SQLite; + +namespace v2rayN.WebAPI.Services; + +internal static class BackupDatabaseCompatibility +{ + private static readonly IReadOnlyDictionary RequiredPrimaryKeys = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["DNSItem"] = "Id", + ["FullConfigTemplateItem"] = "Id", + ["ProfileExItem"] = "IndexId", + ["ProfileGroupItem"] = "IndexId", + ["ProfileItem"] = "IndexId", + ["RoutingItem"] = "Id", + ["ServerStatItem"] = "IndexId", + ["SubItem"] = "Id", + }; + + public static bool IsCompatible(string databasePath, out string error) + { + try + { + using var database = new SQLiteConnection(databasePath, SQLiteOpenFlags.ReadOnly, false); + if (!string.Equals(database.ExecuteScalar("PRAGMA quick_check;"), "ok", StringComparison.OrdinalIgnoreCase)) + { + error = "SQLite quick_check did not return ok."; + return false; + } + + var tableNames = database.Query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';") + .Select(item => item.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!tableNames.Any(name => RequiredPrimaryKeys.ContainsKey(name))) + { + error = "The database does not contain any known v2rayN data tables."; + return false; + } + + foreach (var (tableName, primaryKey) in RequiredPrimaryKeys) + { + // Older backups may not yet have every optional ServiceLib table; AppManager + // creates missing tables during initialization. Existing tables must retain + // their stable primary key so that SQLite-net can add newer columns safely. + if (!tableNames.Contains(tableName)) + { + continue; + } + + var columns = database.Query($"PRAGMA table_info(\"{tableName}\");"); + if (!columns.Any(column => string.Equals(column.Name, primaryKey, StringComparison.OrdinalIgnoreCase) + && column.PrimaryKey == 1)) + { + error = $"Table {tableName} is missing its expected primary key {primaryKey}."; + return false; + } + } + + error = string.Empty; + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + private sealed class TableName + { + public string Name { get; set; } = string.Empty; + } + + private sealed class TableColumn + { + public string Name { get; set; } = string.Empty; + + [Column("pk")] + public int PrimaryKey { get; set; } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs b/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs new file mode 100644 index 00000000..d7f8be81 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BackupRestorePreflightGate.cs @@ -0,0 +1,24 @@ +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +internal static class BackupRestorePreflightGate +{ + public static Task ExecuteAsync( + NativeWebRestorePreflightResult preflight, + Func> performRestore) + { + if (!preflight.Allowed) + { + return Task.FromResult(preflight.ToFailureOperation()); + } + + if (preflight.Owner == WebLifecycleOwner.NativeProcess && preflight.Replacement is null) + { + throw new InvalidOperationException("A native restore cannot proceed without a verified restart plan."); + } + + return performRestore(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs b/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs new file mode 100644 index 00000000..13e5e2be --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/BuiltinDnsProfileBootstrap.cs @@ -0,0 +1,43 @@ +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Services; + +internal static class BuiltinDnsProfileBootstrap +{ + private static readonly (ECoreType CoreType, string Remarks)[] BuiltinProfiles = + [ + (ECoreType.Xray, "V2ray"), + (ECoreType.sing_box, "sing-box"), + ]; + + public static async Task EnsureAsync( + Config config, + Func>> load, + Func> save) + { + var existing = await load(); + foreach (var (coreType, remarks) in BuiltinProfiles) + { + if (existing.Any(item => item.CoreType == coreType)) + { + continue; + } + + var item = new DNSItem + { + Remarks = remarks, + CoreType = coreType, + Enabled = false, + }; + if (await save(config, item) != 0) + { + throw new IOException($"The built-in {coreType} DNS profile could not be created."); + } + + existing = [.. existing, item]; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs b/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs new file mode 100644 index 00000000..7c0c73fc --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreExecutableVersionCheck.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace v2rayN.WebAPI.Services; + +internal static class CoreExecutableVersionCheck +{ + private static readonly Regex VersionPattern = new( + @"\b[vV]?\d+\.\d+\.\d+\b", + RegexOptions.CultureInvariant); + + public static bool IsValid(string? versionOutput, string expectedName) => + !string.IsNullOrWhiteSpace(versionOutput) + && versionOutput.Contains(expectedName, StringComparison.OrdinalIgnoreCase) + && VersionPattern.IsMatch(versionOutput); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs b/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs new file mode 100644 index 00000000..1c497308 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreLaunchPreflight.cs @@ -0,0 +1,93 @@ +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal sealed record CoreLaunchPreflightResult( + ProfileItem Profile, + CoreConfigContextBuilderAllResult BuiltContext, + OperationView? Failure) +{ + public bool Success => Failure is null; +} + +internal static class CoreLaunchPreflight +{ + public static async Task BuildAndValidateAsync( + Config config, + ProfileItem profile, + Func validateCoreBinary) + { + var built = await CoreConfigContextBuilder.BuildAll(config, profile); + return Validate(profile, built, validateCoreBinary); + } + + internal static CoreLaunchPreflightResult Validate( + ProfileItem profile, + CoreConfigContextBuilderAllResult built, + Func validateCoreBinary) + { + var tunRejection = GetTunLaunchRejection(built); + if (tunRejection is not null) + { + return new CoreLaunchPreflightResult(profile, built, tunRejection); + } + + if (!built.Success) + { + return new CoreLaunchPreflightResult(profile, built, + OperationView.Fail("profile_validation_failed", ApiMessageKeys.ProfileInvalid)); + } + + var requiredCoreTypes = new[] + { + (ECoreType?)built.MainResult.Context.RunCoreType, + built.PreSocksResult?.Context.RunCoreType, + } + .Where(coreType => coreType.HasValue) + .Select(coreType => coreType!.Value) + .Distinct(); + + foreach (var coreType in requiredCoreTypes) + { + var failure = validateCoreBinary(coreType); + if (failure is not null) + { + return new CoreLaunchPreflightResult(profile, built, failure); + } + } + + return new CoreLaunchPreflightResult(profile, built, null); + } + + internal static OperationView? GetTunLaunchRejection(CoreConfigContextBuilderAllResult built) => + built.MainResult.Context.IsTunEnabled || built.PreSocksResult?.Context.IsTunEnabled == true + ? OperationView.Fail("tun_not_supported", ApiMessageKeys.CoreTunNotSupported) + : null; +} + +internal static class CoreRestartFlow +{ + public static async Task ExecuteAsync( + Func> buildAndValidate, + Func stopCore, + Action markCoreStopped, + Func> launch) + { + var preflight = await buildAndValidate(); + if (preflight.Failure is { } failure) + { + return failure; + } + + await stopCore(); + markCoreStopped(); + var startResult = await launch(preflight); + return startResult.Success + ? OperationView.Ok(ApiMessageKeys.CoreRestarted, startResult.Data) + : startResult; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs b/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs new file mode 100644 index 00000000..7a0a07d4 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreProcessTracker.cs @@ -0,0 +1,315 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace v2rayN.WebAPI.Services; + +internal sealed record CoreProcessIdentity(int ProcessId, string ProcessName, long StartTimeUtcTicks, string ExecutablePath); + +/// +/// Tracks Core processes started by this Web host without depending on ServiceLib internals. +/// CoreManager remains responsible for launching/stopping; Web snapshots process identities +/// around its public LoadCore call and validates those identities during runtime transitions. +/// +internal static class CoreProcessTracker +{ + public static HashSet CaptureExistingProcessIds() + { + var ids = new HashSet(); + foreach (var process in Process.GetProcesses()) + { + using (process) + { + try { ids.Add(process.Id); } + catch (InvalidOperationException) { } + } + } + return ids; + } + + public static CoreProcessIdentity[] CaptureStartedProcesses( + IReadOnlySet existingProcessIds, + IEnumerable expectedExecutablePaths, + IEnumerable? expectedConfigPaths = null) + { + var expected = expectedExecutablePaths + .Select(NormalizeExecutablePath) + .Where(path => path is not null) + .Select(path => path!) + .ToHashSet(PathComparer); + var expectedConfigs = expectedConfigPaths? + .Select(NormalizeAbsolutePath) + .Where(path => path is not null) + .Select(path => path!) + .ToHashSet(PathComparer); + if (expected.Count == 0) return []; + + var processes = new List(); + foreach (var process in Process.GetProcesses()) + { + using (process) + { + try + { + if (existingProcessIds.Contains(process.Id) || process.HasExited) continue; + var startTimeTicks = GetStartTimeUtcTicks(process); + var executablePath = GetProcessExecutablePath(process); + if (startTimeTicks is null || executablePath is null + || !IsExpectedCoreProcess(process.Id, executablePath, expected, expectedConfigs)) + { + continue; + } + + processes.Add(new CoreProcessIdentity( + process.Id, + NormalizeProcessName(process.ProcessName), + startTimeTicks.Value, + executablePath)); + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException) + { + // A process may exit or become inaccessible while the process table is sampled. + } + } + } + + return processes + .DistinctBy(process => process.ProcessId) + .OrderBy(process => process.ProcessId) + .ToArray(); + } + + public static CoreProcessIdentity[] GetActiveProcesses(IEnumerable identities) + { + var active = new List(); + foreach (var identity in identities.DistinctBy(process => process.ProcessId)) + { + try + { + using var process = Process.GetProcessById(identity.ProcessId); + if (process.HasExited + || !string.Equals(NormalizeProcessName(process.ProcessName), identity.ProcessName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var startTimeTicks = GetStartTimeUtcTicks(process); + if (startTimeTicks is long observedStartTime && observedStartTime != identity.StartTimeUtcTicks) continue; + var executablePath = GetProcessExecutablePath(process); + if (executablePath is not null && !PathComparer.Equals(executablePath, identity.ExecutablePath)) continue; + + // If an otherwise matching live PID becomes inaccessible, keep it as an + // uncertain active identity. This may report a conservative stop failure, + // but it cannot mistake the process for stopped or authorize killing a PID. + active.Add(identity); + } + catch (ArgumentException) + { + // The PID no longer exists. + } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException or UnauthorizedAccessException) + { + // An inaccessible PID is uncertain, so report it as active rather than + // claiming a clean stop. No process is ever terminated from this tracker. + active.Add(identity); + } + } + return active.OrderBy(process => process.ProcessId).ToArray(); + } + + private static string NormalizeProcessName(string name) => + Path.GetFileNameWithoutExtension(name.Trim()); + + private static bool IsExpectedCoreProcess( + int processId, + string executablePath, + HashSet expectedPaths, + HashSet? expectedConfigPaths) + { + if (!OperatingSystem.IsLinux()) return expectedPaths.Contains(executablePath); + + try + { + var arguments = File.ReadAllText($"/proc/{processId}/cmdline") + .Split('\0', StringSplitOptions.RemoveEmptyEntries); + var executableMatches = expectedPaths.Contains(executablePath); + foreach (var expectedPath in expectedPaths) + { + if (arguments.Any(argument => PathComparer.Equals(NormalizeExecutablePath(argument), expectedPath)) + && GetShebangInterpreters(expectedPath).Contains(executablePath, PathComparer)) + { + executableMatches = true; + break; + } + } + + return executableMatches && (expectedConfigPaths is null + || expectedConfigPaths.Count == 0 + || CommandLineReferencesConfig(processId, arguments, expectedConfigPaths)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + // Process metadata can disappear between enumeration and inspection. + } + + return false; + } + + private static bool CommandLineReferencesConfig(int processId, IEnumerable arguments, HashSet expectedConfigPaths) + { + var workingDirectory = GetProcessWorkingDirectory(processId); + if (workingDirectory is null) return false; + + foreach (var argument in arguments) + { + var configArgument = argument.StartsWith("--config=", StringComparison.Ordinal) + ? argument["--config=".Length..] + : argument.StartsWith("--config-file=", StringComparison.Ordinal) + ? argument["--config-file=".Length..] + : argument; + if (configArgument.Length == 0 || configArgument.StartsWith("-", StringComparison.Ordinal)) continue; + var fullPath = NormalizeAbsolutePath(Path.IsPathRooted(configArgument) + ? configArgument + : Path.Combine(workingDirectory, configArgument)); + if (fullPath is not null && expectedConfigPaths.Contains(fullPath)) return true; + } + + return false; + } + + private static string? GetProcessWorkingDirectory(int processId) + { + try + { + return new DirectoryInfo($"/proc/{processId}/cwd").ResolveLinkTarget(returnFinalTarget: false)?.FullName; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static long? GetStartTimeUtcTicks(Process process) + { + try { return process.StartTime.ToUniversalTime().Ticks; } + catch (Exception exception) when (exception is InvalidOperationException or Win32Exception or NotSupportedException or UnauthorizedAccessException) + { + return null; + } + } + + private static string? GetProcessExecutablePath(Process process) + { + try + { + var path = OperatingSystem.IsLinux() + ? new FileInfo($"/proc/{process.Id}/exe").ResolveLinkTarget(returnFinalTarget: false)?.FullName + : process.MainModule?.FileName; + return path is null ? null : NormalizeExecutablePath(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or Win32Exception or NotSupportedException or ArgumentException) + { + return null; + } + } + + private static string? NormalizeExecutablePath(string? path) + { + var fullPath = NormalizeAbsolutePath(path); + if (fullPath is null) return null; + try + { + var executablePath = fullPath; + if (OperatingSystem.IsLinux() && executablePath.EndsWith(" (deleted)", StringComparison.Ordinal)) + { + executablePath = executablePath[..^" (deleted)".Length]; + } + + if (!OperatingSystem.IsLinux()) + { + var target = new FileInfo(executablePath).ResolveLinkTarget(returnFinalTarget: true); + return Path.GetFullPath(target?.FullName ?? executablePath); + } + + var root = Path.GetPathRoot(executablePath)!; + var current = root; + foreach (var segment in executablePath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); + var linkTarget = info.ResolveLinkTarget(returnFinalTarget: false); + if (linkTarget is not null) current = linkTarget.FullName; + } + return Path.GetFullPath(current); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static string? NormalizeAbsolutePath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path)) return null; + try { return Path.GetFullPath(path.TrimEnd('\0')); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return null; + } + } + + private static HashSet GetShebangInterpreters(string executablePath) + { + var interpreters = new HashSet(PathComparer); + try + { + using var stream = File.OpenRead(executablePath); + var prefix = new byte[256]; + var length = stream.Read(prefix); + var lineEnd = Array.IndexOf(prefix, (byte)'\n', 0, length); + if (lineEnd < 0) lineEnd = length; + var firstLine = System.Text.Encoding.UTF8.GetString(prefix, 0, lineEnd).TrimEnd('\r'); + if (!firstLine.StartsWith("#!", StringComparison.Ordinal)) return interpreters; + + var tokens = firstLine[2..].Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return interpreters; + var interpreter = tokens[0]; + if (string.Equals(Path.GetFileName(interpreter), "env", StringComparison.Ordinal)) + { + var index = 1; + while (index < tokens.Length && tokens[index].StartsWith("-", StringComparison.Ordinal)) + { + if (tokens[index] is "-u" or "--unset" or "-C" or "--chdir" or "-a" or "--argv0") index++; + index++; + } + if (index >= tokens.Length) return interpreters; + interpreter = FindOnPath(tokens[index]) ?? string.Empty; + } + + var normalized = NormalizeExecutablePath(interpreter); + if (normalized is not null) interpreters.Add(normalized); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // A script that cannot be inspected is not claimed based on its process name alone. + } + + return interpreters; + } + + private static string? FindOnPath(string executableName) + { + if (Path.IsPathRooted(executableName)) return executableName; + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) return null; + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory, executableName); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs b/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs new file mode 100644 index 00000000..259399ee --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreRuntimeSnapshot.cs @@ -0,0 +1,46 @@ +using ServiceLib.Enums; + +namespace v2rayN.WebAPI.Services; + +internal enum CoreRuntimeState +{ + Stopped, + Starting, + Running, + Stopping, + Restarting, + Faulted, +} + +internal sealed record RuntimeListenerSnapshot( + string Name, + string[] Protocols, + string ListenAddress, + int Port); + +/// +/// Immutable description of the Core process that was actually launched. Config changes +/// never mutate this snapshot; only confirmed runtime transitions replace it. +/// +internal sealed record CoreRuntimeSnapshot( + CoreRuntimeState State, + string? ProfileId, + ECoreType? CoreType, + DateTimeOffset? StartedAt, + int? ProxyPort, + int? ApiPort, + RuntimeListenerSnapshot[] Listeners, + int[] ProcessIds, + string? LastFailure) +{ + public static CoreRuntimeSnapshot Stopped { get; } = new( + CoreRuntimeState.Stopped, + null, + null, + null, + null, + null, + [], + [], + null); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs new file mode 100644 index 00000000..8c71ac39 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreSettingsApplyPolicy.cs @@ -0,0 +1,43 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal enum CoreSettingsApplyAction +{ + SaveOnly, + Restart, + Busy, + Inconsistent, +} + +internal static class CoreSettingsApplyPolicy +{ + public static CoreSettingsApplyAction Decide(CoreRuntimeState state, bool hasActiveChild, bool changed) => state switch + { + CoreRuntimeState.Stopped when !hasActiveChild => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Stopped => CoreSettingsApplyAction.Inconsistent, + CoreRuntimeState.Running when hasActiveChild && changed => CoreSettingsApplyAction.Restart, + CoreRuntimeState.Running when hasActiveChild => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Running => CoreSettingsApplyAction.Inconsistent, + CoreRuntimeState.Faulted when hasActiveChild => CoreSettingsApplyAction.Restart, + CoreRuntimeState.Faulted => CoreSettingsApplyAction.SaveOnly, + CoreRuntimeState.Starting or CoreRuntimeState.Stopping or CoreRuntimeState.Restarting => CoreSettingsApplyAction.Busy, + _ => CoreSettingsApplyAction.Inconsistent, + }; +} + +internal sealed record CoreSettingsApplyExecution(CoreSettingsApplyAction Action, OperationView? RestartResult); + +internal static class CoreSettingsApplyExecutor +{ + public static async Task ExecuteAsync( + CoreRuntimeState state, + bool hasActiveChild, + bool changed, + Func> restart) + { + var action = CoreSettingsApplyPolicy.Decide(state, hasActiveChild, changed); + var restartResult = action == CoreSettingsApplyAction.Restart ? await restart() : null; + return new(action, restartResult); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs b/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs new file mode 100644 index 00000000..eed24cc2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/CoreUpdatePackageStager.cs @@ -0,0 +1,221 @@ +using System.Formats.Tar; +using System.IO.Compression; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Manager; + +namespace v2rayN.WebAPI.Services; + +internal static class CoreUpdatePackageStager +{ + private const int MaxPackageEntries = 4096; + private const long MaxExpandedBytes = 512L * 1024 * 1024; + private const long MaxArchiveBytes = 512L * 1024 * 1024; + + public static bool SupportsCore(ECoreType coreType) => coreType is ECoreType.Xray or ECoreType.mihomo or ECoreType.sing_box; + + public static bool IsSupported(ECoreType coreType, string archivePath) + { + var name = archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + ? ".tar.gz" + : Path.GetExtension(archivePath); + return coreType switch + { + ECoreType.Xray => name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + ECoreType.mihomo => name.Equals(".gz", StringComparison.OrdinalIgnoreCase) + || name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + ECoreType.sing_box => name.Equals(".tar.gz", StringComparison.OrdinalIgnoreCase) + || name.Equals(".zip", StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + public static async Task ExtractAsync(ECoreType coreType, string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + var archiveInfo = new FileInfo(archivePath); + if (!archiveInfo.Exists || archiveInfo.Length is <= 0 or > MaxArchiveBytes) + { + throw new InvalidDataException("The core update archive is empty or exceeds the size limit."); + } + if (!IsSupported(coreType, archivePath)) + { + throw new InvalidDataException($"The downloaded archive format is not supported for {coreType}."); + } + + Directory.CreateDirectory(destinationDirectory); + if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await ExtractTarGzipAsync(archivePath, destinationDirectory, cancellationToken); + } + else if (Path.GetExtension(archivePath).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + { + await ExtractGzipBinaryAsync(coreType, archivePath, destinationDirectory, cancellationToken); + } + else + { + await ExtractZipAsync(coreType, archivePath, destinationDirectory, cancellationToken); + } + } + + private static async Task ExtractZipAsync(ECoreType coreType, string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + using var archive = ZipFile.OpenRead(archivePath); + var files = archive.Entries.Where(entry => !string.IsNullOrEmpty(entry.Name)).ToArray(); + if (files.Length is 0 or > MaxPackageEntries || files.Sum(entry => entry.Length) > MaxExpandedBytes) + { + throw new InvalidDataException("The core update ZIP has an invalid file count or expanded size."); + } + + var writtenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var fileName = GetSafeFlatFileName(entry.FullName, entry.ExternalAttributes); + // The desktop updater deliberately leaves bundled Geo data untouched; GeoFiles + // are updated independently and are not part of a Core binary replacement. + if (coreType == ECoreType.Xray && fileName.StartsWith("geo", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + EnsureUniqueName(writtenNames, fileName); + await using var source = entry.Open(); + await using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + await CopyWithLimitAsync(source, target, MaxExpandedBytes, cancellationToken); + } + + if (writtenNames.Count == 0) + { + throw new InvalidDataException("The core update ZIP contains no applicable files."); + } + } + + private static async Task ExtractGzipBinaryAsync( + ECoreType coreType, + string archivePath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var executableName = CoreInfoManager.Instance.GetCoreInfo(coreType)?.CoreExes?.FirstOrDefault() + ?? coreType.ToString(); + var destination = Path.Combine(destinationDirectory, Utils.GetExeName(executableName)); + await using var source = File.OpenRead(archivePath); + await using var gzip = new GZipStream(source, CompressionMode.Decompress); + await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await CopyWithLimitAsync(gzip, target, MaxExpandedBytes, cancellationToken); + } + + private static async Task ExtractTarGzipAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken) + { + using var source = File.OpenRead(archivePath); + using var gzip = new GZipStream(source, CompressionMode.Decompress); + using var reader = new TarReader(gzip); + var writtenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + long expandedBytes = 0; + var entryCount = 0; + + while (reader.GetNextEntry() is { } entry) + { + cancellationToken.ThrowIfCancellationRequested(); + entryCount++; + if (entryCount > MaxPackageEntries) + { + throw new InvalidDataException("The core update tarball has too many entries."); + } + + if (entry.EntryType == TarEntryType.Directory) + { + continue; + } + if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.ContiguousFile)) + { + throw new InvalidDataException("The core update tarball contains a link or unsupported entry type."); + } + if (entry.Length < 0 || entry.Length > MaxExpandedBytes) + { + throw new InvalidDataException("The core update tarball has an invalid file size."); + } + + expandedBytes = checked(expandedBytes + entry.Length); + if (expandedBytes > MaxExpandedBytes) + { + throw new InvalidDataException("The core update tarball exceeds the expanded size limit."); + } + + var fileName = GetSafeFlatFileName(entry.Name, externalAttributes: 0); + EnsureUniqueName(writtenNames, fileName); + if (entry.DataStream is null) + { + throw new InvalidDataException("The core update tarball contains a file without data."); + } + + await using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + await CopyWithLimitAsync(entry.DataStream, target, MaxExpandedBytes, cancellationToken); + } + + if (writtenNames.Count == 0) + { + throw new InvalidDataException("The core update tarball contains no regular files."); + } + } + + private static string GetSafeFlatFileName(string path, int externalAttributes) + { + var normalized = path.Replace('\\', '/'); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(normalized) + || segments.Length == 0 + || segments.Any(segment => segment is "." or "..") + || normalized.Contains(':') + || ((externalAttributes >> 16) & 0xF000) == 0xA000) + { + throw new InvalidDataException("The core update archive contains an unsafe path or symbolic link."); + } + + var fileName = Path.GetFileName(normalized); + if (string.IsNullOrWhiteSpace(fileName) || fileName is "." or "..") + { + throw new InvalidDataException("The core update archive contains an invalid file name."); + } + return fileName; + } + + private static void EnsureUniqueName(HashSet writtenNames, string fileName) + { + if (!writtenNames.Add(fileName)) + { + throw new InvalidDataException("The core update archive contains duplicate file names after flattening."); + } + } + + private static async Task CopyWithLimitAsync(Stream source, Stream target, long limit, CancellationToken cancellationToken) + { + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return; + } + total += read; + if (total > limit) + { + throw new InvalidDataException("The core update archive expands beyond the size limit."); + } + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/EventHub.cs b/v2rayN/v2rayN.WebAPI/Services/EventHub.cs new file mode 100644 index 00000000..817db241 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/EventHub.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed class EventHub +{ + private const int EventBufferCapacity = 256; + private readonly ConcurrentDictionary _subscribers = new(); + + public void Publish(string type, object? data) + { + var message = new WebEvent(type, data, DateTimeOffset.UtcNow); + foreach (var subscriber in _subscribers.Values) + { + if (message.Type != "log" || subscriber.IncludeLogs) + { + subscriber.Channel.Writer.TryWrite(message); + } + } + } + + public async IAsyncEnumerable Subscribe( + [EnumeratorCancellation] CancellationToken cancellationToken = default, + bool includeLogs = false) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateBounded(new BoundedChannelOptions(EventBufferCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + _subscribers[id] = new EventSubscriber(channel, includeLogs); + try + { + await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return message; + } + } + finally + { + _subscribers.TryRemove(id, out _); + channel.Writer.TryComplete(); + } + } + + private sealed record EventSubscriber(Channel Channel, bool IncludeLogs); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs new file mode 100644 index 00000000..6f2baaae --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateCompletion.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; + +namespace v2rayN.WebAPI.Services; + +internal sealed class GeoFilesUpdateCompletion +{ + private readonly ConcurrentQueue _failures = new(); + private int _completionReported; + + public void Report(bool success, string message, bool isProgressMessage) + { + if (success) + { + Interlocked.Exchange(ref _completionReported, 1); + } + else if (!isProgressMessage) + { + _failures.Enqueue(message); + } + } + + public void EnsureSuccessful() + { + if (!_failures.IsEmpty) + { + throw new IOException("GeoFiles update reported an error: " + string.Join("; ", _failures)); + } + if (Volatile.Read(ref _completionReported) == 0) + { + throw new IOException("GeoFiles update did not report successful completion."); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs new file mode 100644 index 00000000..fef5545c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateGate.cs @@ -0,0 +1,24 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Serializes every Web-owned GeoFiles transaction, regardless of whether it was started +/// manually, as part of a batch update, or by a regional preset. +/// +internal sealed class GeoFilesUpdateGate +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task RunAsync(Func update, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(update); + await _gate.WaitAsync(cancellationToken); + try + { + await update(); + } + finally + { + _gate.Release(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs new file mode 100644 index 00000000..c1441ff0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/GeoFilesUpdateTransaction.cs @@ -0,0 +1,151 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Adds Web-owned backup/verification/rollback around ServiceLib's public GeoFiles updater. +/// The updater remains responsible for deciding which files to download and how to download them. +/// +internal static class GeoFilesUpdateTransaction +{ + public static async Task ApplyAsync( + IEnumerable managedFiles, + IEnumerable requiredFiles, + Func update, + CancellationToken cancellationToken, + Func, CancellationToken, Task>? beforeCommit = null) + { + var required = requiredFiles + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .ToArray(); + var originalTargets = managedFiles + .Concat(required) + .Select(Path.GetFullPath) + .Distinct(PathComparer) + .ToArray(); + var backupRoot = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-geofiles-{Guid.NewGuid():N}"); + var backups = new Dictionary(PathComparer); + var transactionCompleted = false; + var rollbackCompleted = false; + var updateStarted = false; + + Task RollBackFilesAsync() + { + if (rollbackCompleted) return Task.CompletedTask; + + var originallyExisting = backups.Keys.ToHashSet(PathComparer); + foreach (var target in originalTargets) + { + if (!originallyExisting.Contains(target) && (File.Exists(target) || IsSymbolicLink(target))) + { + File.Delete(target); + } + } + + foreach (var (target, backup) in backups) + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (IsSymbolicLink(target)) File.Delete(target); + File.Copy(backup.BackupPath, target, overwrite: true); + File.SetLastWriteTimeUtc(target, backup.LastWriteTimeUtc); + } + rollbackCompleted = true; + return Task.CompletedTask; + } + + try + { + Directory.CreateDirectory(backupRoot); + for (var index = 0; index < originalTargets.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = originalTargets[index]; + if (Directory.Exists(target)) + { + throw new IOException($"GeoFiles target is a directory: {target}"); + } + if (IsSymbolicLink(target)) + { + throw new IOException($"GeoFiles target is a symbolic link and cannot be updated safely: {target}"); + } + if (!File.Exists(target)) continue; + var backup = Path.Combine(backupRoot, index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture)); + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(target); + File.Copy(target, backup); + backups.Add(target, new GeoFileBackup(backup, lastWriteTimeUtc)); + } + + try + { + updateStarted = true; + await update(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + foreach (var requiredFile in required) + { + if (!File.Exists(requiredFile) || new FileInfo(requiredFile).Length <= 0) + { + throw new IOException($"GeoFiles update did not produce a non-empty {Path.GetFileName(requiredFile)}."); + } + } + if (beforeCommit is not null) await beforeCommit(RollBackFilesAsync, cancellationToken); + transactionCompleted = true; + } + catch (Exception updateException) + { + if (!rollbackCompleted) + { + try + { + await RollBackFilesAsync(); + } + catch (Exception rollbackException) + { + throw new GeoFilesUpdateRollbackException(backupRoot, updateException, rollbackException); + } + } + + throw; + } + } + finally + { + if ((transactionCompleted || rollbackCompleted || !updateStarted) && Directory.Exists(backupRoot)) + { + try { Directory.Delete(backupRoot, recursive: true); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A cleanup failure does not invalidate a successful update or rollback. + } + } + } + } + + private static StringComparer PathComparer => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static bool IsSymbolicLink(string path) + { + try + { + if (new FileInfo(path).LinkTarget is not null) return true; + return File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + } + + private sealed record GeoFileBackup(string BackupPath, DateTime LastWriteTimeUtc); +} + +internal sealed class GeoFilesUpdateRollbackException( + string backupRoot, + Exception updateException, + Exception rollbackException) + : IOException( + $"GeoFiles update failed and rollback is incomplete; the backup set was retained at {backupRoot}.", + new AggregateException(updateException, rollbackException)) +{ + public string BackupRoot { get; } = backupRoot; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs b/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs new file mode 100644 index 00000000..f630fe54 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/LogBuffer.cs @@ -0,0 +1,84 @@ +using v2rayN.WebAPI.Contracts; +using ServiceLib.Common; + +namespace v2rayN.WebAPI.Services; + +public sealed class LogBuffer +{ + private const int Capacity = 2000; + private const int MaximumMessageCharacters = 16 * 1024; + private const int MaximumRetainedCharacters = 1_000_000; + private readonly object _gate = new(); + private readonly Queue _items = new(); + private int _retainedCharacters; + private long _generation; + + public LogView Add(string source, string message) + { + var normalized = message.TrimEnd(); + if (normalized.Length > MaximumMessageCharacters) + { + const string marker = "… [log entry truncated]"; + normalized = normalized[..(MaximumMessageCharacters - marker.Length)] + marker; + } + LogView entry; + lock (_gate) + { + entry = new LogView(DateTimeOffset.UtcNow, source, normalized, _generation); + _items.Enqueue(entry); + _retainedCharacters += entry.Source.Length + entry.Message.Length; + while (_items.Count > Capacity || _retainedCharacters > MaximumRetainedCharacters) + { + var removed = _items.Dequeue(); + _retainedCharacters -= removed.Source.Length + removed.Message.Length; + } + } + + return entry; + } + + public IReadOnlyList Recent(int limit, string? filter = null) + { + lock (_gate) + { + var query = _items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter)) + { + query = query.Where(item => Utils.IsRegexMatch($"{item.Source} {item.Message}", filter)); + } + return query.TakeLast(Math.Clamp(limit, 1, Capacity)).ToArray(); + } + } + + public LogPageView RecentPage(int page, int pageSize, string? filter = null) + { + lock (_gate) + { + var filtered = _items.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(filter)) + { + filtered = filtered.Where(item => Utils.IsRegexMatch($"{item.Source} {item.Message}", filter)); + } + + var items = filtered.ToArray(); + var total = items.Length; + var safePageSize = Math.Clamp(pageSize, 1, 200); + var totalPages = Math.Max(1, (total + safePageSize - 1) / safePageSize); + var safePage = Math.Clamp(page, 1, totalPages); + var end = total - ((safePage - 1) * safePageSize); + var start = Math.Max(0, end - safePageSize); + + return new LogPageView(items.Skip(start).Take(end - start).ToArray(), safePage, safePageSize, total, totalPages); + } + } + + public long Clear() + { + lock (_gate) + { + _items.Clear(); + _retainedCharacters = 0; + return ++_generation; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs b/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs new file mode 100644 index 00000000..73c152ca --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RegionalPresetStager.cs @@ -0,0 +1,433 @@ +using System.Net; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Services; + +internal sealed record RegionalRoutingPlan( + string? TemplateVersion, + IReadOnlyList Items, + string? DefaultRemarks = null, + IReadOnlyList? RemoveItemIds = null, + string? ExistingDefaultItemId = null, + bool ClearLegacyRoutingIndex = false, + bool IsBuiltinFallback = false); + +internal sealed record RegionalPresetStage( + EPresetType Preset, + string GeoSourceUrl, + string SrsSourceUrl, + string RouteRulesTemplateSourceUrl, + SimpleDNSItem SimpleDnsItem, + IReadOnlyList DnsItems, + RegionalRoutingPlan Routing); + +internal sealed class RegionalPresetStager( + Func> download, + TimeSpan? timeout = null) +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + private readonly TimeSpan _timeout = timeout ?? DefaultTimeout; + + public async Task StageAsync( + EPresetType preset, + IReadOnlyList existingDnsItems, + IReadOnlyList existingRoutingItems, + IWebProxy? proxy, + CancellationToken cancellationToken, + string? legacyRoutingIndexId = null) + { + if (preset == EPresetType.Default) + { + return new( + preset, + string.Empty, + string.Empty, + string.Empty, + ConfigHandler.InitBuiltinSimpleDNS(), + [CreateDefaultDns(ECoreType.Xray, "V2ray"), CreateDefaultDns(ECoreType.sing_box, "sing-box")], + new(null, [])); + } + + var sourceIndex = preset switch + { + EPresetType.Russia => 1, + EPresetType.Iran => 2, + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, "Unsupported regional preset."), + }; + var xrayItem = existingDnsItems.FirstOrDefault(item => item.CoreType == ECoreType.Xray) + ?? throw new InvalidOperationException("The Xray DNS profile is missing."); + var singboxItem = existingDnsItems.FirstOrDefault(item => item.CoreType == ECoreType.sing_box) + ?? throw new InvalidOperationException("The sing-box DNS profile is missing."); + var dnsBaseUrl = Global.DNSTemplateSources[sourceIndex]; + var routeTemplateUrl = Global.RoutingRulesSources[sourceIndex]; + + var xrayTask = StageDnsProfileAsync(ECoreType.Xray, xrayItem, dnsBaseUrl + "v2ray.json", proxy, cancellationToken); + var singboxTask = StageDnsProfileAsync(ECoreType.sing_box, singboxItem, dnsBaseUrl + "sing_box.json", proxy, cancellationToken); + var simpleDnsTask = StageSimpleDnsAsync(dnsBaseUrl + "simple_dns.json", proxy, cancellationToken); + var routingTask = StageRoutingAsync(routeTemplateUrl, existingRoutingItems, legacyRoutingIndexId, proxy, cancellationToken); + await Task.WhenAll(xrayTask, singboxTask, simpleDnsTask, routingTask); + + var xrayDns = await xrayTask; + var singboxDns = await singboxTask; + var simpleDns = await simpleDnsTask; + if (simpleDns.UsedFallback) + { + // Desktop enables both Core-specific DNS profiles when simple_dns.json is unavailable. + xrayDns.Enabled = true; + singboxDns.Enabled = true; + } + + return new( + preset, + Global.GeoFilesSources[sourceIndex], + Global.SingboxRulesetSources[sourceIndex], + routeTemplateUrl, + simpleDns.Item, + [xrayDns, singboxDns], + await routingTask); + } + + private async Task StageDnsProfileAsync( + ECoreType coreType, + DNSItem existing, + string url, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + if (template is null) + { + // ConfigHandler.GetExternalDNSItem returns the current DNS item when the + // template cannot be downloaded or parsed. + return CloneDnsItem(existing); + } + + var normalDnsTask = string.IsNullOrWhiteSpace(template.NormalDNS) + ? Task.FromResult(null) + : TryDownloadAsync(template.NormalDNS, proxy, cancellationToken); + var tunDnsTask = string.IsNullOrWhiteSpace(template.TunDNS) + ? Task.FromResult(null) + : TryDownloadAsync(template.TunDNS, proxy, cancellationToken); + await Task.WhenAll(normalDnsTask, tunDnsTask); + + // Desktop keeps a successfully downloaded template even when its referenced + // NormalDNS/TunDNS resource fails; TryDownloadString then leaves that field empty. + if (!string.IsNullOrWhiteSpace(template.NormalDNS)) + { + template.NormalDNS = await normalDnsTask ?? string.Empty; + } + if (!string.IsNullOrWhiteSpace(template.TunDNS)) + { + template.TunDNS = await tunDnsTask ?? string.Empty; + } + + template.Id = existing.Id; + template.Remarks = existing.Remarks; + template.Enabled = existing.Enabled; + template.CoreType = coreType; + return template; + } + + private async Task<(SimpleDNSItem Item, bool UsedFallback)> StageSimpleDnsAsync( + string url, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + return template is null + ? (ConfigHandler.InitBuiltinSimpleDNS(), true) + : (template, false); + } + + private async Task StageRoutingAsync( + string url, + IReadOnlyList existingItems, + string? legacyRoutingIndexId, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + var content = await TryDownloadAsync(url, proxy, cancellationToken); + var template = content is null ? null : JsonUtils.Deserialize(content); + if (template is null || string.IsNullOrWhiteSpace(template.Version) || template.RoutingItems is null) + { + return StageBuiltinRouting(existingItems, legacyRoutingIndexId); + } + + if (existingItems.Any(item => item.Remarks?.StartsWith(template.Version, StringComparison.Ordinal) == true)) + { + // Importing an already-present template version must not change the active route. + return new(template.Version, []); + } + + var stagedTasks = template.RoutingItems + .Select(sourceItem => StageRoutingItemAsync(sourceItem, template.Version, proxy, cancellationToken)) + .ToArray(); + var stagedResults = await Task.WhenAll(stagedTasks); + var stagedItems = stagedResults.Where(item => item is not null).Cast().ToArray(); + for (var index = 0; index < stagedItems.Length; index++) + { + stagedItems[index].Sort = existingItems.Count + index + 1; + } + + return new(template.Version, stagedItems, stagedItems.FirstOrDefault()?.Remarks); + } + + private async Task StageRoutingItemAsync( + RoutingItem? sourceItem, + string templateVersion, + IWebProxy? proxy, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (sourceItem is null || (string.IsNullOrWhiteSpace(sourceItem.Url) && string.IsNullOrWhiteSpace(sourceItem.RuleSet))) + { + return null; + } + + var rulesContent = !string.IsNullOrWhiteSpace(sourceItem.RuleSet) + ? sourceItem.RuleSet + : await TryDownloadAsync(sourceItem.Url, proxy, cancellationToken); + var rules = rulesContent is null ? null : JsonUtils.Deserialize>(rulesContent); + if (rules is null) + { + // Desktop skips an entry whose URL returns no content. Invalid rule JSON is + // likewise not a usable AddBatchRoutingRules payload, so skip only that entry. + return null; + } + + var item = JsonUtils.DeepCopy(sourceItem); + if (item is null) + { + return null; + } + item.Id = string.Empty; + item.Remarks = $"{templateVersion}-{item.Remarks}"; + item.Enabled = true; + item.Url = string.Empty; + item.IsActive = false; + item.RuleNum = rules.Count; + item.RuleSet = JsonUtils.Serialize(rules, false); + return item; + } + + private static RegionalRoutingPlan StageBuiltinRouting( + IReadOnlyList existingItems, + string? legacyRoutingIndexId) + { + // Match InitBuiltinRouting's one-time locked-profile removal and migration of + // RoutingIndexId, while keeping all database writes in the later apply phase. + var lockedItem = existingItems.FirstOrDefault(item => item.Locked); + var items = lockedItem is null + ? existingItems.ToList() + : existingItems.Where(item => item.Id != lockedItem.Id).ToList(); + var removeIds = lockedItem is null || string.IsNullOrEmpty(lockedItem.Id) + ? Array.Empty() + : [lockedItem.Id]; + var clearLegacyIndex = !string.IsNullOrWhiteSpace(legacyRoutingIndexId); + + if (items.Count > 0) + { + var migrationTarget = clearLegacyIndex + ? items.FirstOrDefault(item => item.Id == legacyRoutingIndexId) + : null; + return new( + null, + [], + RemoveItemIds: removeIds, + ExistingDefaultItemId: migrationTarget is { IsActive: false } ? migrationTarget.Id : null, + ClearLegacyRoutingIndex: clearLegacyIndex, + IsBuiltinFallback: true); + } + + var defaults = new[] + { + (Name: "绕过大陆(Whitelist)", Resource: "white"), + (Name: "黑名单(Blacklist)", Resource: "black"), + (Name: "全局(Global)", Resource: "global"), + }; + var builtins = defaults.Select((entry, index) => + { + var rulesContent = EmbedUtils.GetEmbedText(Global.CustomRoutingFileName + entry.Resource); + var rules = JsonUtils.Deserialize>(rulesContent) + ?? throw new InvalidDataException($"The built-in routing rules '{entry.Resource}' are invalid."); + return new RoutingItem + { + Remarks = $"V4-{entry.Name}", + Url = string.Empty, + RuleNum = rules.Count, + RuleSet = JsonUtils.Serialize(rules, false), + Enabled = true, + Sort = index + 1, + }; + }).ToArray(); + + return new( + null, + builtins, + builtins[0].Remarks, + removeIds, + ClearLegacyRoutingIndex: clearLegacyIndex, + IsBuiltinFallback: true); + } + + private async Task TryDownloadAsync(string url, IWebProxy? proxy, CancellationToken cancellationToken) + { + try + { + return await DownloadRequiredAsync(url, proxy, cancellationToken); + } + catch (Exception exception) when (IsFallbackFailure(exception, cancellationToken)) + { + return null; + } + } + + private async Task DownloadRequiredAsync(string url, IWebProxy? proxy, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_timeout); + try + { + var content = await download(url, proxy, timeoutCts.Token); + if (string.IsNullOrWhiteSpace(content)) + { + throw new InvalidDataException($"The download returned no content: {url}"); + } + return content; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"The download exceeded {_timeout.TotalSeconds:0.###} seconds: {url}"); + } + } + + private static bool IsFallbackFailure(Exception exception, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested + && exception is not OperationCanceledException + && exception is TimeoutException or HttpRequestException or WebException or IOException or InvalidDataException; + + private static DNSItem CloneDnsItem(DNSItem item) => + JsonUtils.DeepCopy(item) ?? throw new InvalidOperationException("A DNS profile could not be copied for regional preset staging."); + + private static DNSItem CreateDefaultDns(ECoreType coreType, string remarks) => new() + { + Remarks = remarks, + CoreType = coreType, + Enabled = false, + }; +} + +internal static class RegionalPresetWorkflow +{ + public static async Task RunAsync( + Func> stage, + Func> apply, + CancellationToken cancellationToken) + { + TStage staged; + try + { + staged = await stage(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new RegionalPresetStagingException(exception); + } + cancellationToken.ThrowIfCancellationRequested(); + return await apply(staged, cancellationToken); + } +} + +internal sealed class RegionalPresetStagingException(Exception innerException) + : Exception("Regional preset staging failed.", innerException) { } + +internal static class RegionalPresetApplyWorkflow +{ + public static async Task RunAsync( + Func applyConfiguration, + Func, CancellationToken, Task>, CancellationToken, Task> updateGeoFilesTransaction, + Func, CancellationToken, Task> completeCoreApply, + Func rollbackConfigurationAndRuntime, + CancellationToken cancellationToken) + { + var configurationApplied = false; + try + { + await applyConfiguration(cancellationToken); + configurationApplied = true; + cancellationToken.ThrowIfCancellationRequested(); + + await updateGeoFilesTransaction(async (rollbackGeoFiles, transactionToken) => + { + transactionToken.ThrowIfCancellationRequested(); + await completeCoreApply(rollbackGeoFiles, transactionToken); + }, cancellationToken); + } + catch (RegionalPresetCoreApplyFailedException) + { + // CoreSettingsApply already restored its snapshot/runtime. The enclosing GeoFiles + // transaction restores files before this exception is observed by its caller. + throw; + } + catch (Exception applyException) + { + if (configurationApplied) + { + try + { + await rollbackConfigurationAndRuntime(); + } + catch (Exception rollbackException) + { + throw new RegionalPresetApplyRollbackException(applyException, rollbackException); + } + } + throw; + } + } +} + +internal sealed class RegionalPresetCoreApplyFailedException : Exception { } + +internal sealed class RegionalPresetApplyRollbackException(Exception applyException, Exception rollbackException) + : Exception("The regional preset failed and its configuration/runtime rollback also failed.", + new AggregateException(applyException, rollbackException)) { } + +internal sealed record RegionalPresetApplyResult(bool Success, bool RollbackSucceeded, Exception? Error); + +internal static class RegionalPresetTransaction +{ + public static async Task RunAsync(Func apply, Func> rollback) + { + try + { + await apply(); + return new(true, true, null); + } + catch (Exception exception) + { + var rollbackSucceeded = false; + try + { + rollbackSucceeded = await rollback(); + } + catch + { + // Rollback failure is represented in the result; preserve the original apply failure. + } + return new(false, rollbackSucceeded, exception); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs new file mode 100644 index 00000000..58b09a37 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeMutationGate.cs @@ -0,0 +1,39 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Serializes Web-owned mutations of ServiceLib's shared configuration and SQLite state. +/// Subscription updates hold the gate across ServiceLib's download/import operation so their +/// profile, configuration, and timestamp changes remain ordered with other Web mutations. +/// +public sealed class RuntimeMutationGate +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + public async Task RunAsync(Func mutation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutation); + await _semaphore.WaitAsync(cancellationToken); + try + { + await mutation(); + } + finally + { + _semaphore.Release(); + } + } + + public async Task RunAsync(Func> mutation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutation); + await _semaphore.WaitAsync(cancellationToken); + try + { + return await mutation(); + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs new file mode 100644 index 00000000..0b61a93d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeOperationCoordinator.cs @@ -0,0 +1,226 @@ +namespace v2rayN.WebAPI.Services; + +/// +/// Tracks stateful runtime work. Normal operations may overlap; maintenance operations +/// (core updates and restore) take an exclusive lease. Exclusive waiters prevent new +/// normal work from starving them, and shutdown cancels all linked operation tokens. +/// +public sealed class RuntimeOperationCoordinator +{ + private static readonly TimeSpan DefaultDrainTimeout = TimeSpan.FromSeconds(10); + private readonly object _sync = new(); + private readonly CancellationTokenSource _shutdown = new(); + private TaskCompletionSource _changed = NewSignal(); + private int _activeOperations; + private int _waitingExclusive; + private bool _exclusiveActive; + private bool _exclusiveAllowsObservations; + private bool _stopping; + + public CancellationToken ShutdownToken => _shutdown.Token; + public bool IsStopping + { + get + { + lock (_sync) return _stopping; + } + } + + public void RejectNewOperations() + { + lock (_sync) + { + _stopping = true; + SignalChange(); + } + } + + public ValueTask EnterOperationAsync(CancellationToken cancellationToken = default) => + EnterAsync(exclusive: false, allowReadOnlyObservation: false, cancellationToken); + + /// + /// Acquires a shared lease for an observational request such as status, operations, or logs. + /// During selected maintenance windows these requests may also run concurrently with the + /// exclusive operation; they must remain read-only and must not access resources being swapped. + /// + public ValueTask EnterObservationAsync(CancellationToken cancellationToken = default) => + EnterAsync(exclusive: false, allowReadOnlyObservation: true, cancellationToken); + + public ValueTask EnterExclusiveAsync( + CancellationToken cancellationToken = default, + bool allowReadOnlyObservations = false) => + EnterAsync(exclusive: true, allowReadOnlyObservation: false, cancellationToken, allowReadOnlyObservations); + + private async ValueTask EnterAsync( + bool exclusive, + bool allowReadOnlyObservation, + CancellationToken cancellationToken, + bool allowReadOnlyObservations = false) + { + var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token); + var waiting = false; + try + { + while (true) + { + linked.Token.ThrowIfCancellationRequested(); + Task waitTask; + lock (_sync) + { + if (_stopping) + { + throw new OperationCanceledException(_shutdown.Token); + } + if (linked.IsCancellationRequested) + { + throw new OperationCanceledException(linked.Token); + } + + if (exclusive && !waiting) + { + _waitingExclusive++; + waiting = true; + } + + if (exclusive && !_exclusiveActive && _activeOperations == 0) + { + _waitingExclusive--; + waiting = false; + _exclusiveActive = true; + _exclusiveAllowsObservations = allowReadOnlyObservations; + return new Lease(this, exclusive: true, linked); + } + + if (!exclusive && !_exclusiveActive && _waitingExclusive == 0) + { + _activeOperations++; + return new Lease(this, exclusive: false, linked); + } + + if (allowReadOnlyObservation + && _exclusiveActive + && _exclusiveAllowsObservations + && _waitingExclusive == 0) + { + // Count observations as active so shutdown drains them and a subsequent + // exclusive operation cannot begin while one is still running. + _activeOperations++; + return new Lease(this, exclusive: false, linked); + } + + waitTask = _changed.Task; + } + + await waitTask.WaitAsync(linked.Token); + } + } + catch + { + linked.Dispose(); + if (waiting) + { + lock (_sync) + { + _waitingExclusive--; + SignalChange(); + } + } + throw; + } + } + + public async Task StopAndDrainAsync(CancellationToken cancellationToken, TimeSpan? timeout = null) + { + lock (_sync) + { + _stopping = true; + SignalChange(); + } + + using var drainCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + drainCancellation.CancelAfter(timeout ?? DefaultDrainTimeout); + + try + { + await _shutdown.CancelAsync().WaitAsync(drainCancellation.Token); + while (true) + { + Task waitTask; + lock (_sync) + { + if (_activeOperations == 0 && !_exclusiveActive && _waitingExclusive == 0) + { + return true; + } + waitTask = _changed.Task; + } + + await waitTask.WaitAsync(drainCancellation.Token); + } + } + catch (OperationCanceledException) when (drainCancellation.IsCancellationRequested) + { + return false; + } + } + + private void Release(bool exclusive) + { + lock (_sync) + { + if (exclusive) + { + _exclusiveActive = false; + _exclusiveAllowsObservations = false; + } + else + { + _activeOperations--; + } + SignalChange(); + } + } + + private void SignalChange() + { + var old = _changed; + _changed = NewSignal(); + old.TrySetResult(); + } + + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public sealed class Lease : IAsyncDisposable, IDisposable + { + private RuntimeOperationCoordinator? _owner; + private readonly bool _exclusive; + private readonly CancellationTokenSource _linked; + + internal Lease(RuntimeOperationCoordinator owner, bool exclusive, CancellationTokenSource linked) + { + _owner = owner; + _exclusive = exclusive; + _linked = linked; + } + + public CancellationToken Token => _linked.Token; + + public void Dispose() + { + var owner = Interlocked.Exchange(ref _owner, null); + if (owner is null) + { + return; + } + _linked.Dispose(); + owner.Release(_exclusive); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs new file mode 100644 index 00000000..31968609 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeRequestOperationPolicy.cs @@ -0,0 +1,51 @@ +namespace v2rayN.WebAPI.Services; + +internal enum RuntimeRequestOperationKind +{ + Shared, + Observation, + Exclusive, + ExclusiveReadOnly, + Background, +} + +internal static class RuntimeRequestOperationPolicy +{ + public static RuntimeRequestOperationKind Classify(string method, string path) + { + var normalizedPath = path.TrimEnd('/'); + var isPost = string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase); + var isGet = string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase); + + if (isPost + && (normalizedPath is "/api/core/xray/update" or "/api/core/geo/update" or "/api/web-updates/update" or "/api/speedtests" + or "/api/subscriptions/update" or "/api/backup/restore" or "/api/backup/webdav/restore" + || normalizedPath.StartsWith("/api/settings/regional-presets/", StringComparison.Ordinal) + || normalizedPath.StartsWith("/api/core-updates/", StringComparison.Ordinal) + && normalizedPath.EndsWith("/update", StringComparison.Ordinal) + || (normalizedPath.StartsWith("/api/subscriptions/", StringComparison.Ordinal) + && normalizedPath.EndsWith("/update", StringComparison.Ordinal)))) + { + return RuntimeRequestOperationKind.Background; + } + + if (isGet + && normalizedPath is "/api/status" or "/api/operations" or "/api/logs" or "/api/logs/page") + { + return RuntimeRequestOperationKind.Observation; + } + + if (!isGet && normalizedPath.StartsWith("/api/settings/", StringComparison.Ordinal)) + { + return RuntimeRequestOperationKind.ExclusiveReadOnly; + } + + if ((isGet && normalizedPath == "/api/backup/download") + || (isPost && normalizedPath == "/api/backup/webdav")) + { + return RuntimeRequestOperationKind.Exclusive; + } + + return RuntimeRequestOperationKind.Shared; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs b/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs new file mode 100644 index 00000000..286e6b6b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/RuntimeRestartRecovery.cs @@ -0,0 +1,105 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +internal sealed record RuntimeRestartIntent(bool WasRunning, string? PreferredProfileId) +{ + public string Reason { get; init; } = "Restore"; +} + +internal enum RuntimeRestartRecoverySource +{ + NotRequested, + PreferredProfile, + SelectedProfile, + DefaultProfile, + NoProfile, + StartFailed, +} + +internal sealed record RuntimeRestartRecoveryResult( + RuntimeRestartRecoverySource Source, + string? ProfileId = null, + string? FailureCode = null); + +internal static class RuntimeRestartRecovery +{ + public static bool ShouldAutoStart(RuntimeRestartIntent? intent, bool configured, bool setupRequired = false) => + !setupRequired && intent is null && configured; + + public static bool ShouldRecoverCore(RuntimeRestartIntent? intent, bool setupRequired) => + !setupRequired && intent?.WasRunning == true; + + public static async Task RecoverAsync( + RuntimeRestartIntent intent, + string? selectedProfileId, + string? defaultProfileId, + Func> profileExists, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + if (!intent.WasRunning) + { + return new(RuntimeRestartRecoverySource.NotRequested); + } + + var reason = string.IsNullOrWhiteSpace(intent.Reason) ? "Runtime restart" : intent.Reason; + log($"{reason} requested Core runtime recovery."); + var preferredId = Normalize(intent.PreferredProfileId); + if (preferredId is not null && await profileExists(preferredId, cancellationToken)) + { + return await StartAsync(preferredId, reason, RuntimeRestartRecoverySource.PreferredProfile, startCore, log, cancellationToken); + } + + if (preferredId is not null) + { + log($"Previous profile {preferredId} no longer exists after {reason.ToLowerInvariant()}."); + } + + var selectedId = Normalize(selectedProfileId); + if (selectedId is not null && selectedId != preferredId) + { + if (await profileExists(selectedId, cancellationToken)) + { + log($"Falling back to selected profile {selectedId}."); + return await StartAsync(selectedId, reason, RuntimeRestartRecoverySource.SelectedProfile, startCore, log, cancellationToken); + } + + log($"Selected profile {selectedId} does not exist after {reason.ToLowerInvariant()}."); + } + + var fallbackId = Normalize(defaultProfileId); + if (fallbackId is not null && fallbackId != preferredId && fallbackId != selectedId + && await profileExists(fallbackId, cancellationToken)) + { + log($"Falling back to the ServiceLib default profile {fallbackId}."); + return await StartAsync(fallbackId, reason, RuntimeRestartRecoverySource.DefaultProfile, startCore, log, cancellationToken); + } + + log($"{reason} requested Core runtime recovery, but no valid profile exists in the current configuration. Core remains stopped."); + return new(RuntimeRestartRecoverySource.NoProfile); + } + + private static async Task StartAsync( + string profileId, + string reason, + RuntimeRestartRecoverySource source, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + var result = await startCore(profileId, cancellationToken); + if (result.Success) + { + log($"Core runtime restored successfully using {profileId}."); + return new(source, profileId); + } + + log($"{reason} requested Core runtime recovery, but Core failed to start using {profileId} (code: {result.Code}). No further restart will be attempted."); + return new(RuntimeRestartRecoverySource.StartFailed, profileId, result.Code); + } + + private static string? Normalize(string? profileId) => + string.IsNullOrWhiteSpace(profileId) ? null : profileId; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs b/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs new file mode 100644 index 00000000..03a26a60 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/ShutdownCleanupSequence.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; + +namespace v2rayN.WebAPI.Services; + +internal static class RuntimeShutdownBudgets +{ + // Runtime cleanup owns 20 seconds; the host retains 5 seconds, systemd is + // configured to 30 seconds, and the native --stop launcher waits 5 seconds more. + public static readonly TimeSpan RuntimeCleanup = TimeSpan.FromSeconds(20); + public static readonly TimeSpan HostShutdown = TimeSpan.FromSeconds(25); + public static readonly TimeSpan LauncherWait = TimeSpan.FromSeconds(35); + // Used when the host shutdown budget was already exhausted before cleanup + // started, for example while the web server drained long-lived connections. + public static readonly TimeSpan LateCleanup = TimeSpan.FromSeconds(5); +} + +internal static class ShutdownDiagnostics +{ + private static string? _currentStage; + + public static string? CurrentStage => Volatile.Read(ref _currentStage); + + public static void SetStage(string? stage) => Volatile.Write(ref _currentStage, stage); +} + +internal sealed class ShutdownDeadline : IDisposable +{ + private readonly TimeSpan _budget; + private readonly Stopwatch _elapsed = Stopwatch.StartNew(); + private readonly CancellationTokenSource _deadlineCancellation; + private readonly CancellationTokenSource _linkedCancellation; + + public ShutdownDeadline(TimeSpan budget, CancellationToken cancellationToken) + { + if (budget <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(budget)); + } + + _budget = budget; + _deadlineCancellation = new CancellationTokenSource(budget); + _linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _deadlineCancellation.Token); + } + + public CancellationToken Token => _linkedCancellation.Token; + + public TimeSpan Remaining + { + get + { + var remaining = _budget - _elapsed.Elapsed; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + } + + public void Dispose() + { + _linkedCancellation.Dispose(); + _deadlineCancellation.Dispose(); + } +} + +internal sealed record ShutdownCleanupStep( + string Name, + Func> ExecuteAsync); + +internal static class ShutdownCleanupSequence +{ + public static async Task RunAsync( + IEnumerable steps, + TimeSpan budget, + CancellationToken cancellationToken, + Action log, + Action? stageChanged = null) + { + if (cancellationToken.IsCancellationRequested) + { + // The host token can already be canceled when cleanup starts, for + // example while the web server drains long-lived stream connections. + // Cleanup is still worth a short, fresh window: it stops the Core + // child and saves shared state instead of skipping both. + log("Shutdown started after the host shutdown budget was exhausted; running a reduced cleanup with a fresh budget."); + budget = RuntimeShutdownBudgets.LateCleanup; + cancellationToken = CancellationToken.None; + } + + using var deadline = new ShutdownDeadline(budget, cancellationToken); + foreach (var step in steps) + { + var remaining = deadline.Remaining; + if (remaining <= TimeSpan.Zero) + { + log($"Shutdown deadline expired before '{step.Name}'; all later cleanup was skipped."); + return false; + } + + stageChanged?.Invoke(step.Name); + try + { + // Run the synchronous prefix on the thread pool too, so a blocking cleanup + // implementation cannot prevent the overall deadline from being enforced. + var execution = Task.Run(async () => await step.ExecuteAsync(deadline)); + var completed = await execution.WaitAsync(remaining, deadline.Token); + if (!completed) + { + log($"Shutdown stopped at '{step.Name}'; all later cleanup was skipped."); + return false; + } + stageChanged?.Invoke(null); + } + catch (TimeoutException) + { + log($"Shutdown deadline expired during '{step.Name}'; all later cleanup was skipped."); + return false; + } + catch (OperationCanceledException) when (deadline.Token.IsCancellationRequested) + { + log($"Shutdown was canceled during '{step.Name}'; all later cleanup was skipped."); + return false; + } + catch (Exception exception) + { + log($"Shutdown cleanup failed at '{step.Name}': {exception.Message}; all later cleanup was skipped."); + return false; + } + } + + stageChanged?.Invoke(null); + return true; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs new file mode 100644 index 00000000..c7b40901 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayHostedService.cs @@ -0,0 +1,11 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Services; + +public sealed class V2rayHostedService(V2rayRuntime runtime, WebAuthService webAuth) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => + runtime.InitializeAsync(cancellationToken, suppressCoreAutostart: webAuth.SetupRequired); + + public Task StopAsync(CancellationToken cancellationToken) => runtime.ShutdownAsync(cancellationToken); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs new file mode 100644 index 00000000..e117e82e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Backup.cs @@ -0,0 +1,848 @@ +using System.IO.Compression; +using System.Text.Json; +using SQLite; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Handler; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private readonly object _restoreShutdownGate = new(); + private Task? _restoreShutdownTask; + private const long MaxBackupArchiveBytes = 64L * 1024 * 1024; + private const long MaxBackupExpandedBytes = 256L * 1024 * 1024; + private const int MaxBackupEntries = 2048; + internal const string WebAuthFileName = "WebAPI-auth.json"; + private const string RestoreRuntimeStateFileName = "v2rayn-WebAPI-restore-state.json"; + private NativeWebRestoreReplacement? _nativeRestoreReplacement; + + public NativeWebRestoreReplacement? NativeRestoreReplacement => _nativeRestoreReplacement; + + public WebDavSettingsView GetWebDavSettings() => new( + Config.WebDavItem.Url, + Config.WebDavItem.UserName, + Config.WebDavItem.DirName, + !string.IsNullOrEmpty(Config.WebDavItem.Password)); + + public async Task UpdateWebDavSettingsAsync(WebDavSettingsInput input) + { + await _mutations.RunAsync(async () => + { + Config.WebDavItem.Url = input.Url?.Trim(); + Config.WebDavItem.UserName = input.UserName?.Trim(); + if (input.Password is not null) + { + Config.WebDavItem.Password = input.Password; + } + Config.WebDavItem.DirName = input.DirName?.Trim(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return OperationView.Ok(ApiMessageKeys.WebDavSettingsSaved); + } + + public async Task CheckWebDavAsync() + { + var success = await WebDavManager.Instance.CheckConnection(); + if (!success) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + } + return success + ? OperationView.Ok(ApiMessageKeys.WebDavCheckSucceeded) + : OperationView.Fail("webdav_check_failed", ApiMessageKeys.WebDavCheckFailed); + } + + public async Task<(OperationView Result, string? FilePath)> CreateBackupArchiveAsync() + { + await _mutations.RunAsync(async () => + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await ProfileExManager.Instance.SaveTo(); + await StatisticsManager.Instance.SaveTo(); + }); + + var archivePath = Utils.GetBackupPath($"backup_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.zip"); + var tempRoot = Utils.GetTempPath($"backup_{Utils.GetGuid(false)}"); + var tempConfigPath = Path.Combine(tempRoot, "guiConfigs"); + try + { + CopyConfigForBackup(Utils.GetConfigPath(), tempConfigPath); + if (!FileUtils.CreateFromDirectory(tempRoot, archivePath)) + { + return (OperationView.Fail("backup_create_failed", ApiMessageKeys.BackupArchiveInvalid), null); + } + if (new FileInfo(archivePath).Length > MaxBackupArchiveBytes) + { + File.Delete(archivePath); + return (OperationView.Fail("backup_archive_too_large", ApiMessageKeys.BackupArchiveInvalid), null); + } + return (OperationView.Ok(ApiMessageKeys.BackupCreated, new { fileName = Path.GetFileName(archivePath) }), archivePath); + } + catch + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + return (OperationView.Fail("backup_create_failed", ApiMessageKeys.BackupArchiveInvalid), null); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + } + + public async Task BackupToWebDavAsync() + { + var (result, archivePath) = await CreateBackupArchiveAsync(); + if (!result.Success || archivePath is null) + { + return result; + } + + try + { + if (!await WebDavManager.Instance.PutFile(archivePath)) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + return OperationView.Fail("webdav_backup_failed", ApiMessageKeys.WebDavCheckFailed); + } + return OperationView.Ok(ApiMessageKeys.WebDavBackupSucceeded, new { fileName = Path.GetFileName(archivePath) }); + } + finally + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + + public async Task RestoreFromWebDavAsync(CancellationToken cancellationToken) + { + var archivePath = Utils.GetTempPath($"restore_{Utils.GetGuid(false)}.zip"); + if (!await WebDavManager.Instance.GetRawFile(archivePath)) + { + AddLog("backup", WebDavManager.Instance.GetLastError()); + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + return OperationView.Fail("webdav_restore_download_failed", ApiMessageKeys.WebDavRestoreFailed); + } + return await RestoreBackupArchiveAsync(archivePath, cancellationToken); + } + + public async Task RestoreFromUploadAsync(Stream archive, CancellationToken cancellationToken) + { + var archivePath = Utils.GetTempPath($"restore_{Utils.GetGuid(false)}.zip"); + try + { + await using (var output = new FileStream(archivePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + await CopyWithLimitAsync(archive, output, MaxBackupArchiveBytes, cancellationToken); + } + return await RestoreBackupArchiveAsync(archivePath, cancellationToken); + } + catch + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + throw; + } + } + + private async Task RestoreBackupArchiveAsync(string archivePath, CancellationToken cancellationToken) + { + var stagingRoot = Utils.GetTempPath($"restore_stage_{Utils.GetGuid(false)}"); + var databaseClosed = false; + RuntimeOperationCoordinator.Lease? operation = null; + var restoreStateWritten = false; + RuntimeRestartIntent? restoreState = null; + try + { + operation = await _operations.EnterExclusiveAsync(cancellationToken); + cancellationToken = operation.Token; + if (!IsSafeBackupArchive(archivePath)) + { + return OperationView.Fail("backup_archive_invalid", ApiMessageKeys.BackupArchiveInvalid); + } + + Directory.CreateDirectory(stagingRoot); + if (!TryExtractBackupConfig(archivePath, stagingRoot) + || !IsValidBackupConfig(Path.Combine(stagingRoot, Global.ConfigFileName))) + { + return OperationView.Fail("backup_archive_invalid", ApiMessageKeys.BackupArchiveInvalid); + } + var databasePath = Path.Combine(stagingRoot, "guiNDB.db"); + if (File.Exists(databasePath) && !BackupDatabaseCompatibility.IsCompatible(databasePath, out var databaseError)) + { + AddLog("backup", $"Restore preflight rejected guiNDB.db: {databaseError}"); + return OperationView.Fail("backup_database_incompatible", ApiMessageKeys.BackupDatabaseIncompatible); + } + + _nativeRestoreReplacement = null; + var preflight = EvaluateNativeRestorePreflight(); + return await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + _nativeRestoreReplacement = preflight.Replacement; + var (backupResult, safetyBackupPath) = await CreateBackupArchiveAsync(); + if (!backupResult.Success || safetyBackupPath is null) + { + return OperationView.Fail("backup_safety_copy_failed", ApiMessageKeys.BackupRestoreFailed); + } + + var previousRuntime = CurrentCoreRuntime; + restoreState = new RuntimeRestartIntent( + previousRuntime.State == CoreRuntimeState.Running, + previousRuntime.ProfileId); + await WriteRestoreRuntimeStateAsync(restoreState); + restoreStateWritten = true; + SetCoreRuntime(previousRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + await ProfileExManager.Instance.SaveTo(); + await StatisticsManager.Instance.SaveTo(); + StatisticsManager.Instance.Close(); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)), cancellationToken); + await SQLiteHelper.Instance.DisposeDbConnectionAsync(); + databaseClosed = true; + + var configPath = Utils.GetConfigPath(); + var configParent = Path.GetDirectoryName(configPath) + ?? throw new InvalidOperationException("The configuration directory has no parent directory."); + var candidatePath = Path.Combine(configParent, $".guiConfigs-restore-{Guid.NewGuid():N}"); + var displacedPath = Path.Combine(configParent, $".guiConfigs-before-restore-{Guid.NewGuid():N}"); + try + { + PrepareRestoredConfigDirectory(stagingRoot, configPath, candidatePath); + ReplaceConfigDirectory(candidatePath, configPath, displacedPath); + } + finally + { + TryDeleteRestoreDirectory(candidatePath, "restore candidate"); + TryDeleteRestoreDirectory(displacedPath, "displaced configuration"); + } + + _restoring = true; + _operations.RejectNewOperations(); + RequestHostShutdownForRestore(); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted, + new { restartRequired = true, safetyBackup = Path.GetFileName(safetyBackupPath) }); + }); + } + catch (Exception exception) + { + AddLog("backup", $"Restore failed: {exception.Message}"); + if (databaseClosed) + { + _restoring = true; + _operations.RejectNewOperations(); + RequestHostShutdownForRestore(); + } + else if (restoreStateWritten) + { + TryDeleteRestoreRuntimeState(); + if (restoreState is not null) + { + await RecoverRuntimeAfterFailedRestoreAsync( + restoreState, + GetActiveCoreProcessIds(), + (processIds, failure) => SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }), + StartCoreAsync, + message => AddLog("backup", message), + CancellationToken.None); + } + } + return OperationView.Fail("backup_restore_failed", ApiMessageKeys.BackupRestoreFailed); + } + finally + { + if (operation is not null) + { + await operation.DisposeAsync(); + } + try + { + if (Directory.Exists(stagingRoot)) + { + Directory.Delete(stagingRoot, true); + } + } + catch (Exception exception) + { + AddLog("backup", $"Restore staging cleanup failed: {exception.Message}"); + } + finally + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + } + + private NativeWebRestorePreflightResult EvaluateNativeRestorePreflight() + { + var processPath = Environment.ProcessPath; + var processArguments = Environment.GetCommandLineArgs(); + return NativeWebRestorePreflight.Evaluate( + _configuration, + isLinux: OperatingSystem.IsLinux(), + isWindows: OperatingSystem.IsWindows(), + externalSupervisor: LauncherEnvironment.IsSystemdManagedDeployment(), + containerEnvironment: LauncherEnvironment.IsCurrentContainerEnvironment(), + setsidPath: OperatingSystem.IsLinux() ? LinuxXdgBrowserOpener.FindExecutable("setsid") : null, + processPath: processPath, + managedEntryPoint: WebReplacementCommand.GetManagedEntryPoint(processPath, processArguments), + hostArguments: GetCurrentHostArguments(), + coreWasRunning: CurrentCoreRuntime.State == CoreRuntimeState.Running); + } + + // Restore failed before database replacement: bring the pre-restore Core runtime intent + // back. Only Web-captured Core process identities prove that the previous Core still + // exists; listener or port occupancy is never used as process ownership. When no tracked + // process remains, the normal start path attempts the restart and its launch preflight + // reports a port conflict (proxy_port_in_use) if another program now owns the port. + internal static async Task RecoverRuntimeAfterFailedRestoreAsync( + RuntimeRestartIntent restoreState, + int[] remainingCoreProcessIds, + Action markRuntimeFaulted, + Func> startCore, + Action log, + CancellationToken cancellationToken) + { + if (!restoreState.WasRunning) + { + return null; + } + + if (remainingCoreProcessIds.Length > 0) + { + const string failure = "Restore was canceled before database replacement; the Web runtime could not confirm that the previous Core had stopped."; + markRuntimeFaulted(remainingCoreProcessIds, failure); + log(failure); + return null; + } + + var restart = await startCore(restoreState.PreferredProfileId, cancellationToken); + if (!restart.Success) + { + log($"Restore was canceled before database replacement, and the previous Core runtime intent could not be restored: {restart.Code}"); + } + return restart; + } + + private void TryDeleteRestoreDirectory(string path, string description) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception exception) + { + AddLog("backup", $"Restore {description} cleanup failed: {exception.Message}"); + } + } + + private void RequestHostShutdownForRestore() + { + Interlocked.Exchange(ref _restoreAndRestartRequested, 1); + lock (_restoreShutdownGate) + { + if (_restoreShutdownTask is { IsCompleted: false }) + { + return; + } + var stoppingToken = _lifetime.ApplicationStopping; + _restoreShutdownTask = Task.Run(async () => + { + try + { + // Give the restore API response time to flush; process handoff below + // waits for the actual instance lock, never for this delay. + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + if (!stoppingToken.IsCancellationRequested) + { + _lifetime.StopApplication(); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // The host is already stopping. + } + }); + } + } + + private static string RestoreRuntimeStatePath => Path.Combine(Utils.StartupPath(), RestoreRuntimeStateFileName); + internal static string WebUpdateRuntimeStatePath => Utils.GetTempPath("WebAPI-update-runtime-state.json"); + + private static async Task WriteRestoreRuntimeStateAsync(RuntimeRestartIntent state) + { + await WriteRuntimeIntentAsync(RestoreRuntimeStatePath, state); + } + + internal static async Task WriteRuntimeIntentAsync(string path, RuntimeRestartIntent state) + { + var temporary = path + $".{Guid.NewGuid():N}.tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(state)); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static Task LoadRestoreRuntimeStateAsync(CancellationToken cancellationToken) => + LoadRuntimeRestartIntentAsync(RestoreRuntimeStatePath, cancellationToken); + + internal static async Task LoadRuntimeRestartIntentAsync(string path, CancellationToken cancellationToken) + { + RequeueAbandonedRestoreRuntimeClaim(path); + try + { + if (!File.Exists(path)) return null; + var json = await File.ReadAllTextAsync(path, cancellationToken); + return JsonSerializer.Deserialize(json); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (Exception exception) when (exception is IOException or JsonException) + { + Logging.SaveLog("Restore runtime-state marker could not be read: " + exception.Message); + return null; + } + } + + internal static Task CommitRestoreRuntimeStateAsync() => CommitRestoreRuntimeStateAsync(RestoreRuntimeStatePath); + + internal static Task CommitRestoreRuntimeStateAsync(string path) + { + try + { + // Keep the primary marker until every abandoned claim has been removed. If + // cleanup fails or the process crashes here, startup still has a recoverable + // authoritative marker and can retry cleanup. + DeleteRestoreRuntimeClaims(path, requireComplete: true); + if (File.Exists(path)) File.Delete(path); + if (File.Exists(path)) + { + throw new IOException("The committed restore runtime-state marker still exists."); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Committed restore runtime-state marker could not be removed: " + exception.Message); + return Task.FromException(exception); + } + return Task.CompletedTask; + } + + private static void RequeueAbandonedRestoreRuntimeClaim(string path) + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) return; + try + { + var pattern = Path.GetFileName(path) + ".consuming-*"; + var claims = Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly) + .OrderBy(claim => claim, StringComparer.Ordinal) + .Select(ReadRestoreRuntimeClaim) + .ToArray(); + + // A valid primary marker is authoritative when an interrupted consume left + // duplicate claims behind. An invalid primary must not hide the only valid + // recoverable claim. + if (TryReadRestoreRuntimeIntent(path) is not null) + { + DeleteRestoreRuntimeClaims(path); + return; + } + + // A consuming claim is a renamed copy of the marker. Prefer the most recently + // written valid intent; ties prefer the more informative running/profile state, + // then the ordinal filename. Never rely on directory enumeration order. + var selected = claims + .Where(claim => claim.Intent is not null) + .OrderByDescending(claim => claim.LastWriteTimeUtc) + .ThenByDescending(claim => claim.Intent!.WasRunning) + .ThenByDescending(claim => !string.IsNullOrWhiteSpace(claim.Intent!.PreferredProfileId)) + .ThenBy(claim => claim.Path, StringComparer.Ordinal) + .FirstOrDefault(); + + if (selected is not null) + { + try + { + File.Move(selected.Path, path, overwrite: true); + Logging.SaveLog("Recovered the newest valid interrupted restore runtime-state marker claim."); + DeleteRestoreRuntimeClaims(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (TryReadRestoreRuntimeIntent(path) is not null) + { + // Another writer restored a valid primary marker; it is authoritative. + DeleteRestoreRuntimeClaims(path); + return; + } + + // Keep all claims when replacement failed: one of them may be the only + // valid copy needed by a later startup. + Logging.SaveLog("Abandoned restore runtime-state claim could not be moved to the primary marker: " + exception.Message); + return; + } + + return; + } + + DeleteRestoreRuntimeClaims(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Abandoned restore runtime-state claim could not be recovered: " + exception.Message); + } + } + + private static RuntimeRestartIntent? TryReadRestoreRuntimeIntent(string path) + { + try + { + if (!File.Exists(path)) return null; + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + private static RestoreRuntimeClaim ReadRestoreRuntimeClaim(string claimPath) + { + RuntimeRestartIntent? intent = null; + try + { + intent = JsonSerializer.Deserialize(File.ReadAllText(claimPath)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + Logging.SaveLog($"Ignoring invalid abandoned restore runtime-state claim {Path.GetFileName(claimPath)}: {exception.Message}"); + } + + DateTime lastWriteTimeUtc; + try { lastWriteTimeUtc = File.GetLastWriteTimeUtc(claimPath); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + lastWriteTimeUtc = DateTime.MinValue; + } + return new RestoreRuntimeClaim(claimPath, intent, lastWriteTimeUtc); + } + + private static void DeleteRestoreRuntimeClaims(string path, bool requireComplete = false) + { + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) return; + try + { + var pattern = Path.GetFileName(path) + ".consuming-*"; + foreach (var claim in Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly)) + { + try + { + File.Delete(claim); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (requireComplete) throw; + Logging.SaveLog("Committed abandoned restore runtime-state claim could not be removed: " + exception.Message); + } + } + if (requireComplete && Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly).Any()) + { + throw new IOException("One or more abandoned restore runtime-state claims still exist."); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (requireComplete) throw; + Logging.SaveLog("Committed abandoned restore runtime-state claims could not be removed: " + exception.Message); + } + } + + private sealed record RestoreRuntimeClaim(string Path, RuntimeRestartIntent? Intent, DateTime LastWriteTimeUtc); + + private static void TryDeleteRestoreRuntimeState() => + _ = CommitRestoreRuntimeStateAsync(RestoreRuntimeStatePath); + + internal static Task DeleteRuntimeIntentAsync(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Logging.SaveLog("Runtime restart intent could not be removed: " + exception.Message); + } + return Task.CompletedTask; + } + + private async Task WaitForRestoreShutdownRequestAsync(CancellationToken cancellationToken) + { + Task? restartTask; + lock (_restoreShutdownGate) + { + restartTask = _restoreShutdownTask; + } + if (restartTask is not null) + { + await restartTask.WaitAsync(cancellationToken); + } + } + + internal static bool IsSafeBackupArchive(string archivePath) + { + try + { + var archiveInfo = new FileInfo(archivePath); + if (!archiveInfo.Exists || archiveInfo.Length is <= 0 or > MaxBackupArchiveBytes) + { + return false; + } + + using var archive = ZipFile.OpenRead(archivePath); + var configFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + if (archive.Entries.Count is 0 or > MaxBackupEntries) + { + return false; + } + + long expandedBytes = 0; + foreach (var entry in archive.Entries) + { + if (entry.Length < 0 || entry.Length > MaxBackupExpandedBytes) + { + return false; + } + expandedBytes = checked(expandedBytes + entry.Length); + if (expandedBytes > MaxBackupExpandedBytes) + { + return false; + } + } + + foreach (var entry in archive.Entries) + { + var relative = entry.FullName.Replace('\\', '/'); + var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(relative) + || segments.Any(segment => segment is "." or "..") + || relative.Contains(':') + || ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) + { + return false; + } + + if (entry.Length == 0) + { + continue; + } + + var guiConfigIndex = Array.IndexOf(segments, "guiConfigs"); + if (guiConfigIndex < 0 || segments.Length != guiConfigIndex + 2) + { + return false; + } + if (!configFiles.Add(segments[^1])) + { + return false; + } + } + + return configFiles.Contains(Global.ConfigFileName); + } + catch + { + return false; + } + } + + internal static bool TryExtractBackupConfig(string archivePath, string destinationDirectory) + { + try + { + using var archive = ZipFile.OpenRead(archivePath); + Directory.CreateDirectory(destinationDirectory); + foreach (var entry in archive.Entries) + { + if (entry.Length == 0) + { + continue; + } + + var segments = entry.FullName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var guiConfigIndex = Array.IndexOf(segments, "guiConfigs"); + if (guiConfigIndex < 0 || segments.Length != guiConfigIndex + 2) + { + return false; + } + + var fileName = segments[^1]; + if (string.Equals(fileName, WebAuthFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + using var source = entry.Open(); + using var target = new FileStream( + Path.Combine(destinationDirectory, fileName), + FileMode.CreateNew, + FileAccess.Write, + FileShare.None); + source.CopyTo(target); + } + + return File.Exists(Path.Combine(destinationDirectory, Global.ConfigFileName)); + } + catch + { + return false; + } + } + + internal static void CopyConfigForBackup(string sourceDirectory, string destinationDirectory) => + FileUtils.CopyDirectory(sourceDirectory, destinationDirectory, recursive: false, overwrite: true, ignoredName: WebAuthFileName); + + internal static void PrepareRestoredConfigDirectory( + string extractedConfigDirectory, + string currentConfigDirectory, + string candidateDirectory) + { + if (Directory.Exists(currentConfigDirectory)) + { + CopyConfigDirectory(currentConfigDirectory, candidateDirectory); + } + else + { + Directory.CreateDirectory(candidateDirectory); + } + + foreach (var file in Directory.EnumerateFiles(extractedConfigDirectory, "*", SearchOption.TopDirectoryOnly)) + { + if (string.Equals(Path.GetFileName(file), WebAuthFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + File.Copy(file, Path.Combine(candidateDirectory, Path.GetFileName(file)), overwrite: true); + } + } + + private static void CopyConfigDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var sourceFile in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + File.Copy(sourceFile, Path.Combine(destinationDirectory, Path.GetFileName(sourceFile)), overwrite: true); + } + foreach (var sourceSubdirectory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + CopyConfigDirectory(sourceSubdirectory, Path.Combine(destinationDirectory, Path.GetFileName(sourceSubdirectory))); + } + } + + internal static void ReplaceConfigDirectory(string candidateDirectory, string configDirectory, string displacedDirectory) + { + var hadCurrentDirectory = Directory.Exists(configDirectory); + if (hadCurrentDirectory) + { + Directory.Move(configDirectory, displacedDirectory); + } + + try + { + Directory.Move(candidateDirectory, configDirectory); + } + catch + { + if (hadCurrentDirectory && Directory.Exists(displacedDirectory) && !Directory.Exists(configDirectory)) + { + Directory.Move(displacedDirectory, configDirectory); + } + throw; + } + } + + private static bool IsValidBackupConfig(string configPath) + { + try + { + using var document = JsonDocument.Parse( + File.ReadAllText(configPath), + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); + return document.RootElement.ValueKind == JsonValueKind.Object + && JsonSerializer.Deserialize(document.RootElement.GetRawText(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }) is not null; + } + catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static async Task CopyWithLimitAsync(Stream input, Stream output, long limit, CancellationToken cancellationToken) + { + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + return; + } + total += read; + if (total > limit) + { + throw new InvalidDataException("The backup archive exceeds the upload size limit."); + } + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs new file mode 100644 index 00000000..57849d93 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.CoreUpdate.cs @@ -0,0 +1,1473 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Manager; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Resx; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const string GeoFilesUpdateTarget = "GeoFiles"; + private const string WebUpdateTarget = "v2rayN.WebAPI"; + internal static string NormalizeWebUpdateTarget(string target) => target == "v2rayN.Web" ? WebUpdateTarget : target; + + internal static List? NormalizeWebUpdateTargets(IEnumerable? targets) => + targets?.Select(NormalizeWebUpdateTarget).Distinct(StringComparer.Ordinal).ToList(); + private readonly object _updateTaskGate = new(); + private readonly GeoFilesUpdateGate _geoUpdateGate = new(); + private readonly ConcurrentDictionary _coreUpdateTasks = new(); + private readonly ConcurrentDictionary _updateProgress = new(StringComparer.Ordinal); + private Task? _geoUpdateTask; + private Task? _coreUpdateBatchTask; + + internal bool GetUpdatePreRelease(string target, bool? preRelease = null) => + preRelease ?? (Config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(target, StringComparer.Ordinal) ?? false); + + public CoreUpdateSettingsView GetCoreUpdateSettings() + { + var manager = CoreInfoManager.Instance; + var selectedTypes = Config.CheckUpdateItem.SelectedCoreTypes; + var targets = GetAvailableWebCoreUpdateTypes() + .Select(coreType => + { + var isSupported = manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } info + && IsCurrentPlatformDownloadSupported(info); + var canInstall = isSupported + && coreType != ECoreType.v2rayN + && CoreUpdatePackageStager.SupportsCore(coreType); + var unsupportedReason = coreType == ECoreType.v2rayN + ? "maintenance.webUpdateUnsupported" + : canInstall ? null : "maintenance.updateUnsupported"; + var storageName = coreType.ToString(); + return new CoreUpdateTargetView( + storageName, + GetCoreUpdateNameKey(coreType), + isSupported, + canInstall, + manager.GetCheckPreRelease(coreType, preRelease: true), + selectedTypes?.Contains(storageName, StringComparer.Ordinal) ?? true, + unsupportedReason); + }) + .ToArray(); + + var preReleaseNames = GetAvailablePreReleaseUpdateTargets(); + return new CoreUpdateSettingsView( + targets, + selectedTypes?.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal) ?? true, + GetUpdatePreRelease(WebUpdateTarget), + Config.CheckUpdateItem.UpdateViaProxy, + Config.CheckUpdateItem.CheckPreReleaseCoreTypes? + .Where(preReleaseNames.Contains) + .Distinct(StringComparer.Ordinal) + .ToArray() ?? []); + } + + public IReadOnlyList GetCoreUpdateProgress() => + _updateProgress.Values + .Append(GetWebUpdateProgress()) + .Where(item => item is not null) + .Select(item => item!) + .GroupBy(item => item.CoreType, StringComparer.Ordinal) + .Select(group => group.Last()) + .OrderBy(item => item.CoreType, StringComparer.Ordinal) + .ToArray(); + + public async Task SaveCoreUpdateSettingsAsync(CoreUpdateSettingsInput input) + { + var supportedNames = GetAvailableWebCoreUpdateTypes() + .Select(type => type.ToString()) + .Append(WebUpdateTarget) + .ToHashSet(StringComparer.Ordinal); + var requestedNames = (input.SelectedCoreTypes ?? []).Select(NormalizeWebUpdateTarget) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var preReleaseNames = GetAvailablePreReleaseUpdateTargets(); + // Older clients still submit a single switch. An explicit per-target list takes + // precedence, including an empty list, and is independent of update selections. + var requestedPreReleaseNames = NormalizeWebUpdateTargets(input.CheckPreReleaseCoreTypes)?.ToArray() + ?? (input.PreRelease ? preReleaseNames.ToArray() : []); + if (requestedNames.Any(name => name != GeoFilesUpdateTarget && !supportedNames.Contains(name)) + || requestedPreReleaseNames.Any(name => !preReleaseNames.Contains(name))) + { + return OperationView.Fail("core_update_settings_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await _mutations.RunAsync(async () => + { + var previous = NormalizeWebUpdateTargets(Config.CheckUpdateItem.SelectedCoreTypes) ?? []; + // Keep selections from backups that this platform/Web updater cannot display, + // just as the desktop updater keeps hidden Core configuration untouched. + var hiddenSelections = previous.Where(name => name != GeoFilesUpdateTarget && !supportedNames.Contains(name)); + Config.CheckUpdateItem.SelectedCoreTypes = hiddenSelections + .Concat(requestedNames) + .Distinct(StringComparer.Ordinal) + .ToList(); + var hiddenPreReleaseSelections = (NormalizeWebUpdateTargets(Config.CheckUpdateItem.CheckPreReleaseCoreTypes) ?? []) + .Where(name => !preReleaseNames.Contains(name)); + Config.CheckUpdateItem.CheckPreReleaseCoreTypes = hiddenPreReleaseSelections + .Concat(requestedPreReleaseNames) + .Distinct(StringComparer.Ordinal) + .ToList(); + Config.CheckUpdateItem.UpdateViaProxy = input.UseProxy; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return OperationView.Ok(ApiMessageKeys.CoreUpdateSettingsSaved); + } + + public async Task> CheckCoreUpdateAsync( + ECoreType coreType, + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + if (!CanCheckCoreUpdate(coreType)) + { + return ApiEnvelope.Fail( + "core_update_unsupported", + ApiMessageKeys.CoreUpdateUnsupported); + } + + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, preRelease, useProxy, cancellationToken); + var result = checkedUpdate.Result; + if (result.Success && result.Version is not null) + { + return ApiEnvelope.Ok( + new CoreUpdateCheckView(true, result.Version.ToString()), + ApiMessageKeys.CoreUpdateAvailable); + } + if (checkedUpdate.IsUpToDate) + { + return ApiEnvelope.Ok( + new CoreUpdateCheckView(false, result.Version?.ToString(), IsUpToDate: true, Detail: result.Msg), + ApiMessageKeys.CoreUpdateCurrent); + } + + AddLog("update", $"{coreType} update check failed: {result.Msg}"); + return ApiEnvelope.Fail( + "core_update_check_failed", + ApiMessageKeys.CoreUpdateCheckFailed, + new CoreUpdateCheckView(false, null, Detail: result.Msg)); + } + + public Task> CheckXrayUpdateAsync( + bool preRelease, + bool useProxy, + CancellationToken cancellationToken) => + CheckCoreUpdateAsync(ECoreType.Xray, preRelease, useProxy, cancellationToken); + + public OperationView StartCoreUpdate(ECoreType coreType, bool? preRelease = null, bool? useProxy = null) + { + if (!CanInstallCoreUpdate(coreType)) + { + return OperationView.Fail("core_update_unsupported", ApiMessageKeys.CoreUpdateUnsupported); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + if (selected is not null && !selected.Contains(coreType.ToString(), StringComparer.Ordinal)) + { + return OperationView.Fail("core_update_not_selected", ApiMessageKeys.CoreUpdateUnsupported); + } + + var task = Task.Run(() => RunCoreUpdateAsync( + coreType, + GetUpdatePreRelease(coreType.ToString(), preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy)); + _coreUpdateTasks[coreType] = task; + } + + return OperationView.Ok(ApiMessageKeys.CoreUpdateStarted, new { coreType = coreType.ToString() }); + } + + public bool StartXrayUpdate(bool preRelease, bool useProxy) => + StartCoreUpdate(ECoreType.Xray, preRelease, useProxy).Success; + + public OperationView StartSelectedCoreUpdateBatch(bool apply) + { + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + var webSupported = WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid); + var selectedNames = selected is null + ? GetAvailableWebCoreUpdateTypes().Select(coreType => coreType.ToString()) + .Append(GeoFilesUpdateTarget) + .Concat(webSupported ? [WebUpdateTarget] : []) + .ToArray() + : selected.ToArray(); + var targets = GetAvailableWebCoreUpdateTypes() + .Where(coreType => selectedNames.Contains(coreType.ToString(), StringComparer.Ordinal)) + .ToArray(); + var includeGeoFiles = selectedNames.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal); + var includeWeb = webSupported && selectedNames.Contains(WebUpdateTarget, StringComparer.Ordinal); + if (targets.Length == 0 && !includeGeoFiles && !includeWeb) + { + return OperationView.Fail("core_update_batch_empty", ApiMessageKeys.CommonInvalidInput); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + + _coreUpdateBatchTask = Task.Run(() => RunSelectedCoreUpdateBatchAsync( + targets, + includeGeoFiles, + includeWeb, + apply, + Config.CheckUpdateItem.UpdateViaProxy)); + } + + var targetNames = targets.Select(coreType => coreType.ToString()).ToList(); + if (includeGeoFiles) targetNames.Add(GeoFilesUpdateTarget); + if (includeWeb) targetNames.Add(WebUpdateTarget); + return OperationView.Ok(ApiMessageKeys.CoreUpdateBatchStarted, new { apply, targets = targetNames }); + } + + public OperationView StartGeoUpdate(bool? useProxy = null) + { + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + if (selected is not null && !selected.Contains(GeoFilesUpdateTarget, StringComparer.Ordinal)) + { + return OperationView.Fail("geo_update_not_selected", ApiMessageKeys.GeoUpdateNotSelected); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("geo_update_busy", ApiMessageKeys.GeoUpdateBusy); + } + + var proxy = useProxy ?? Config.CheckUpdateItem.UpdateViaProxy; + _geoUpdateTask = Task.Run(() => RunGeoUpdateAsync(proxy)); + } + return OperationView.Ok(ApiMessageKeys.GeoUpdateStarted); + } + + internal IReadOnlyList GetRunningCoreUpdateOperations() + { + lock (_updateTaskGate) + { + var operations = _coreUpdateTasks + .Where(pair => !pair.Value.IsCompleted) + .Select(pair => GetCoreUpdateOperationName(pair.Key)) + .ToList(); + if (_geoUpdateTask is { IsCompleted: false }) + { + operations.Add("geo-update"); + } + if (_webUpdateTask is { IsCompleted: false }) + { + operations.Add("web-update"); + } + if (_coreUpdateBatchTask is { IsCompleted: false }) + { + operations.Add("core-update-batch"); + } + return operations; + } + } + + private bool IsUpdateRunningLocked() => + _coreUpdateTasks.Values.Any(task => !task.IsCompleted) + || _geoUpdateTask is { IsCompleted: false } + || _webUpdateTask is { IsCompleted: false } + || _coreUpdateBatchTask is { IsCompleted: false }; + + private async Task RunSelectedCoreUpdateBatchAsync( + IReadOnlyList coreTypes, + bool includeGeoFiles, + bool includeWeb, + bool apply, + bool useProxy) + { + var staged = new List(); + var overallSuccess = true; + var currentTarget = string.Empty; + WebUpdateStage? webStage = null; + try + { + // Holding the runtime's exclusive mutation lease prevents a restore or profile + // mutation from invalidating the checked/staged targets. Read-only status and log + // requests remain available. No Core process is stopped during this stage. + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + var token = maintenance.Token; + var checks = new List(coreTypes.Count); + + foreach (var coreType in coreTypes) + { + currentTarget = coreType.ToString(); + var wasRunning = IsCoreTypeRunning(coreType); + PublishCoreUpdateProgress(coreType, "checking", false, false, wasRunning, null, null, batch: true); + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, null, useProxy, token); + var check = checkedUpdate.Result; + if (checkedUpdate.IsUpToDate) + { + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: true)); + PublishCoreUpdateProgress(coreType, "completed", true, true, wasRunning, + check.Version?.ToString(), check.Msg ?? "Already up to date.", batch: true); + continue; + } + + if (!check.Success || check.Version is null || string.IsNullOrWhiteSpace(check.Url)) + { + overallSuccess = false; + PublishCoreUpdateProgress(coreType, "failed", true, false, wasRunning, null, check.Msg, batch: true); + AddLog("update", $"Batch update check failed for {coreType}: {check.Msg}"); + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: false, CheckFailed: true)); + continue; + } + + checks.Add(new CoreUpdateBatchCheck(coreType, check, IsUpToDate: false)); + if (!apply) + { + PublishCoreUpdateProgress(coreType, "completed", true, true, wasRunning, + check.Version.ToString(), check.Msg ?? "An update is available.", batch: true); + } + } + + if (includeGeoFiles) + { + PublishGeoUpdateProgress("checking", false, false, + "ServiceLib has no read-only GeoFiles check; the transactional GeoFiles update runs only during batch apply.", batch: true); + if (!apply) + { + PublishGeoUpdateProgress("completed", true, true, + "GeoFiles will be downloaded, validated, and applied only when batch apply is enabled.", batch: true); + } + } + + WebUpdateReleaseCheck? webCheck = null; + if (includeWeb) + { + currentTarget = WebUpdateTarget; + PublishWebUpdateProgress("checking", false, false, null, batch: true); + webCheck = await CheckWebUpdateReleaseAsync(GetUpdatePreRelease(WebUpdateTarget), useProxy, token); + Volatile.Write(ref _latestWebUpdateVersion, webCheck.Version); + if (!string.IsNullOrEmpty(webCheck.Error)) + { + overallSuccess = false; + PublishWebUpdateProgress("failed", true, false, webCheck.Error, webCheck.Version, batch: true); + } + else if (!webCheck.IsUpdateAvailable) + { + PublishWebUpdateProgress("completed", true, true, webCheck.Detail ?? "The Web application is up to date.", + webCheck.Version, batch: true); + } + else if (!apply) + { + var detail = CanInstallWebUpdate() + ? "An update is available." + : $"An update is available; {GetWebUpdateInstallReasonKey()}"; + PublishWebUpdateProgress("completed", true, true, detail, webCheck.Version, batch: true); + } + else if (!CanInstallWebUpdate()) + { + overallSuccess = false; + PublishWebUpdateProgress("failed", true, false, GetWebUpdateInstallReasonKey(), webCheck.Version, batch: true); + } + } + + if (!apply) + { + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = false }); + return; + } + + if (checks.Any(item => item.CheckFailed) || webCheck is { Error: { Length: > 0 } }) + { + overallSuccess = false; + foreach (var item in checks.Where(item => !item.CheckFailed && !item.IsUpToDate)) + { + PublishCoreUpdateProgress(item.CoreType, "failed", true, false, IsCoreTypeRunning(item.CoreType), + item.Check.Version?.ToString(), "No targets were installed because at least one selected update check failed.", batch: true); + } + if (includeGeoFiles) + { + PublishGeoUpdateProgress("failed", true, false, "No updates were installed because a selected update check failed.", batch: true); + } + if (webCheck is { Error: { Length: > 0 } }) + { + PublishWebUpdateProgress("failed", true, false, "No updates were installed because a selected update check failed.", + webCheck.Version, batch: true); + } + _events.Publish("core-update-batch-completed", new { success = false, apply = true }); + return; + } + + var targetsToStage = checks.Where(item => !item.IsUpToDate).ToArray(); + var unsupportedTargets = targetsToStage.Where(item => !CanInstallCoreUpdate(item.CoreType)).ToArray(); + if (unsupportedTargets.Length > 0) + { + overallSuccess = false; + foreach (var item in unsupportedTargets) + { + PublishCoreUpdateProgress(item.CoreType, "failed", true, false, IsCoreTypeRunning(item.CoreType), + item.Check.Version?.ToString(), "This Web deployment can check but cannot safely install this target.", batch: true); + } + targetsToStage = targetsToStage.Where(item => CanInstallCoreUpdate(item.CoreType)).ToArray(); + } + var shouldInstallWeb = includeWeb && webCheck is { IsUpdateAvailable: true } && CanInstallWebUpdate(); + if (targetsToStage.Length == 0 && !includeGeoFiles && !shouldInstallWeb) + { + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = true }); + return; + } + + // Complete the GeoFiles transaction while the serving Core remains available. + // The Web layer snapshots/validates/rolls back files around the upstream updater. + async Task PrepareAdditionalUpdatesBeforeCoreApplyAsync() + { + if (includeGeoFiles) + { + currentTarget = GeoFilesUpdateTarget; + PublishGeoUpdateProgress("downloading", false, false, null, batch: true); + await ApplyGeoFilesUpdateAsync(useProxy, token); + PublishGeoUpdateProgress("completed", true, true, null, batch: true); + } + if (shouldInstallWeb && webCheck is { } availableWebCheck) + { + currentTarget = WebUpdateTarget; + webStage = await StageWebUpdateAsync(availableWebCheck, useProxy, token, batch: true); + } + } + + await CoreUpdateWorkflow.StageAllThenApplyAsync( + targetsToStage, + async target => + { + currentTarget = target.CoreType.ToString(); + var result = await DownloadAndVerifyCoreUpdateAsync(target.CoreType, target.Check, useProxy, token, batch: true); + staged.Add(result); + return result; + }, + async completeStage => + { + // This callback is reached only after every selected package has been + // downloaded, extracted, and version-verified successfully. + foreach (var stage in completeStage) + { + PublishCoreUpdateProgress(stage.CoreType, "waiting", false, false, + IsCoreTypeRunning(stage.CoreType), stage.VersionOutput, "All selected Core packages are staged and verified.", batch: true); + } + + foreach (var stage in completeStage) + { + currentTarget = stage.CoreType.ToString(); + var result = await ApplyCoreUpdateAsync(stage, token, batch: true); + overallSuccess &= result.Success; + PublishCoreUpdateProgress(stage.CoreType, + result.Success ? "completed" : "failed", + isComplete: true, + success: result.Success, + result.CoreWasRunning, + result.Version ?? stage.VersionOutput, + result.Detail, + batch: true); + } + return true; + }, + includeGeoFiles || shouldInstallWeb ? PrepareAdditionalUpdatesBeforeCoreApplyAsync : null); + + if (webStage is not null) + { + currentTarget = WebUpdateTarget; + await ApplyStagedWebUpdateAsync(webStage, token, batch: true); + // The helper takes over only after every selected Core/Geo package has + // completed its apply phase; no remaining batch work follows the restart. + return; + } + + _events.Publish("core-update-batch-completed", new { success = overallSuccess, apply = true }); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + overallSuccess = false; + if (currentTarget == GeoFilesUpdateTarget) + { + PublishGeoUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", batch: true); + } + else if (currentTarget == WebUpdateTarget) + { + PublishWebUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", batch: true); + } + else if (Enum.TryParse(currentTarget, out var coreType)) + { + PublishCoreUpdateProgress(coreType, "failed", true, false, IsCoreTypeRunning(coreType), null, + "Canceled during graceful shutdown.", batch: true); + } + _events.Publish("core-update-batch-completed", new { success = false, apply }); + } + catch (Exception exception) + { + overallSuccess = false; + AddLog("update", $"Core update batch failed at {currentTarget}: {exception}"); + if (Enum.TryParse(currentTarget, out var coreType)) + { + PublishCoreUpdateProgress(coreType, "failed", true, false, IsCoreTypeRunning(coreType), null, exception.Message, batch: true); + } + else if (currentTarget == GeoFilesUpdateTarget) + { + PublishGeoUpdateProgress("failed", true, false, exception.Message, batch: true); + } + else if (currentTarget == WebUpdateTarget) + { + PublishWebUpdateProgress("failed", true, false, exception.Message, batch: true); + } + foreach (var notApplied in staged) + { + var progress = _updateProgress.GetValueOrDefault(notApplied.CoreType.ToString()); + if (progress is { IsComplete: false }) + { + PublishCoreUpdateProgress(notApplied.CoreType, "failed", true, false, + IsCoreTypeRunning(notApplied.CoreType), notApplied.VersionOutput, + "The staged package was not applied because another selected target failed.", batch: true); + } + } + _events.Publish("core-update-batch-completed", new { success = false, apply }); + } + finally + { + CleanupWebUpdateStage(webStage); + foreach (var stage in staged) CleanupCoreUpdateStage(stage); + lock (_updateTaskGate) + { + _coreUpdateBatchTask = null; + } + } + } + + private HashSet GetAvailablePreReleaseUpdateTargets() => + GetAvailableWebCoreUpdateTypes() + .Where(coreType => CoreInfoManager.Instance.GetCheckPreRelease(coreType, true)) + .Select(coreType => coreType.ToString()) + .Append(WebUpdateTarget) + .ToHashSet(StringComparer.Ordinal); + + private IReadOnlyList GetAvailableWebCoreUpdateTypes() + { + var manager = CoreInfoManager.Instance; + return manager.GetCheckUpdateCoreTypes() + .Where(coreType => coreType != ECoreType.v2rayN + && manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } coreInfo + && IsCurrentPlatformDownloadSupported(coreInfo)) + .ToArray(); + } + + private bool CanCheckCoreUpdate(ECoreType coreType) + { + var manager = CoreInfoManager.Instance; + return coreType != ECoreType.v2rayN + && manager.GetCheckUpdateCoreTypes().Contains(coreType) + && manager.IsCheckUpdateSupported(coreType) + && manager.GetCoreInfo(coreType) is { } coreInfo + && IsCurrentPlatformDownloadSupported(coreInfo); + } + + private bool CanInstallCoreUpdate(ECoreType coreType) => + coreType != ECoreType.v2rayN + && CanCheckCoreUpdate(coreType) + && CoreUpdatePackageStager.SupportsCore(coreType); + + private async Task CheckCoreUpdateResultAsync( + ECoreType coreType, + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); + var result = await updateService.CheckHasUpdateOnly( + coreType, + GetUpdatePreRelease(coreType.ToString(), preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy, + cancellationToken); + return new CoreUpdateCheckResult(result, IsUpToDateResult(coreType, result.Msg)); + } + + private async Task RunCoreUpdateAsync(ECoreType coreType, bool preRelease, bool useProxy) + { + CoreUpdateStage? stage = null; + var wasRunning = IsCoreTypeRunning(coreType); + try + { + var result = await CoreUpdateWorkflow.StageThenApplyAsync( + async () => + { + await using var operation = await _operations.EnterOperationAsync(_operations.ShutdownToken); + PublishCoreUpdateProgress(coreType, "checking", isComplete: false, success: false, wasRunning, null, null); + var checkedUpdate = await CheckCoreUpdateResultAsync(coreType, preRelease, useProxy, operation.Token); + var check = checkedUpdate.Result; + if (checkedUpdate.IsUpToDate) + { + return new CoreUpdatePrepareResult(null, check); + } + if (!check.Success || check.Version is null || string.IsNullOrWhiteSpace(check.Url)) + { + throw new InvalidOperationException(check.Msg ?? $"Could not check {coreType} updates."); + } + + stage = await DownloadAndVerifyCoreUpdateAsync(coreType, check, useProxy, operation.Token); + return new CoreUpdatePrepareResult(stage, check); + }, + async prepared => + { + if (prepared.Stage is null) + { + return new CoreUpdateApplyResult(true, false, wasRunning, prepared.Check.Msg ?? "Already up to date.", prepared.Check.Version?.ToString()); + } + + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + return await ApplyCoreUpdateAsync(prepared.Stage, maintenance.Token); + }); + + PublishCoreUpdateProgress( + coreType, + "completed", + isComplete: true, + success: result.Success, + result.CoreWasRunning, + result.Version ?? stage?.VersionOutput, + result.Detail); + if (!result.Success) + { + AddLog("update", $"{coreType} update failed: {result.Detail}"); + } + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishCoreUpdateProgress(coreType, "failed", isComplete: true, success: false, wasRunning, stage?.VersionOutput, "Canceled during graceful shutdown."); + } + catch (Exception exception) + { + AddLog("update", $"{coreType} update failed: {exception.Message}"); + PublishCoreUpdateProgress(coreType, "failed", isComplete: true, success: false, wasRunning, stage?.VersionOutput, exception.Message); + } + finally + { + CleanupCoreUpdateStage(stage); + lock (_updateTaskGate) + { + _coreUpdateTasks.TryRemove(coreType, out _); + } + } + } + + private async Task DownloadAndVerifyCoreUpdateAsync( + ECoreType coreType, + UpdateResult check, + bool useProxy, + CancellationToken cancellationToken, + bool batch = false) + { + var installPath = Path.GetFullPath(Utils.GetBinPath(string.Empty, coreType.ToString())); + var installParent = Path.GetDirectoryName(installPath) + ?? throw new InvalidOperationException($"The {coreType} installation path has no parent directory."); + var archivePath = Utils.GetTempPath($"core-update-{coreType}-{Guid.NewGuid():N}{GetArchiveSuffix(check.Url!)}"); + var packagePath = Path.Combine(installParent, $".{coreType}-package-{Guid.NewGuid():N}"); + Directory.CreateDirectory(packagePath); + + try + { + PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), check.Url, batch); + var download = new DownloadService(); + var downloadFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + download.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); + download.UpdateCompleted += (_, progress) => + { + PublishCoreUpdateProgress(coreType, "downloading", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), progress.Msg, batch); + }; + await download.DownloadFileAsync(new FileDownloadRequest + { + FileUrl = check.Url!, + FilePath = archivePath, + DisplayFileName = Path.GetFileName(archivePath), + }, + useProxy, + cancellationToken); + + if (downloadFailure.Task.IsCompletedSuccessfully) + { + throw new IOException("The core package download failed.", await downloadFailure.Task); + } + if (!File.Exists(archivePath)) + { + throw new IOException("The core package download did not produce an archive."); + } + + PublishCoreUpdateProgress(coreType, "verifying", isComplete: false, success: false, IsCoreTypeRunning(coreType), check.Version?.ToString(), null, batch); + await CoreUpdatePackageStager.ExtractAsync(coreType, archivePath, packagePath, cancellationToken); + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType) + ?? throw new InvalidOperationException($"{coreType} version metadata is unavailable."); + var stagedExecutable = FindCoreExecutable(coreInfo, packagePath) + ?? throw new InvalidDataException($"The downloaded {coreType} archive does not contain a usable executable."); + await Utils.SetLinuxChmod(stagedExecutable); + var versionOutput = await VerifyCoreExecutableAsync(coreType, coreInfo, stagedExecutable, cancellationToken); + + return new CoreUpdateStage(coreType, installPath, archivePath, packagePath, versionOutput); + } + catch + { + CleanupCoreUpdateStageFiles(archivePath, packagePath); + throw; + } + } + + private async Task ApplyCoreUpdateAsync(CoreUpdateStage stage, CancellationToken cancellationToken, bool batch = false) + { + await _coreGate.WaitAsync(cancellationToken); + var installPath = stage.InstallPath; + var parentDirectory = Path.GetDirectoryName(installPath) + ?? throw new InvalidOperationException("The Core installation path has no parent directory."); + var candidatePath = Path.Combine(parentDirectory, $".{stage.CoreType}-candidate-{Guid.NewGuid():N}"); + var backupPath = Path.Combine(parentDirectory, $".{stage.CoreType}-backup-{Guid.NewGuid():N}"); + var hadInstalledCore = Directory.Exists(installPath); + var originalCoreWasRunning = IsCoreTypeRunning(stage.CoreType); + ProfileItem? runningProfile = null; + var oldCoreWasStopped = false; + var candidateInstalled = false; + + try + { + if (originalCoreWasRunning) + { + runningProfile = await AppManager.Instance.GetProfileItem(CurrentCoreRuntime.ProfileId ?? string.Empty); + if (runningProfile is null) + { + return new CoreUpdateApplyResult(false, false, true, "The active profile could not be loaded; the running Core was left unchanged."); + } + } + + // Prepare and verify the complete replacement while the serving Core is still + // alive. This phase performs no network access; proxy-backed downloads and all + // archive verification have already completed in DownloadAndVerifyCoreUpdateAsync. + if (hadInstalledCore) + { + CopyCoreDirectory(installPath, candidatePath); + } + else + { + Directory.CreateDirectory(candidatePath); + } + foreach (var packageFile in Directory.EnumerateFiles(stage.PackagePath, "*", SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(packageFile, Path.Combine(candidatePath, Path.GetFileName(packageFile)), overwrite: true); + } + if (stage.CoreType == ECoreType.Xray) + { + CopyLatestGeoFilesForApply(installPath, candidatePath); + } + + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(stage.CoreType) + ?? throw new InvalidOperationException($"{stage.CoreType} version metadata is unavailable."); + var candidateExecutable = FindCoreExecutable(coreInfo, candidatePath) + ?? throw new InvalidDataException($"The prepared {stage.CoreType} directory has no executable."); + await Utils.SetLinuxChmod(candidateExecutable); + await VerifyCoreExecutableAsync(stage.CoreType, coreInfo, candidateExecutable, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + PublishCoreUpdateProgress(stage.CoreType, "stopping-core", isComplete: false, success: false, originalCoreWasRunning, stage.VersionOutput, + originalCoreWasRunning ? null : "The updated Core was not running and will remain stopped.", batch); + if (originalCoreWasRunning) + { + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + oldCoreWasStopped = true; + } + + PublishCoreUpdateProgress(stage.CoreType, "installing", isComplete: false, success: false, originalCoreWasRunning, stage.VersionOutput, null, batch); + if (hadInstalledCore) + { + Directory.Move(installPath, backupPath); + } + try + { + Directory.Move(candidatePath, installPath); + candidateInstalled = true; + } + catch + { + if (hadInstalledCore && Directory.Exists(backupPath) && !Directory.Exists(installPath)) + { + Directory.Move(backupPath, installPath); + } + throw; + } + + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (stage.CoreType == ECoreType.Xray) + { + _xrayPath = FindXrayExecutable(out var missingXrayMessage); + if (_xrayPath is null) + { + throw new InvalidDataException(missingXrayMessage); + } + } + + if (originalCoreWasRunning && runningProfile is not null) + { + PublishCoreUpdateProgress(stage.CoreType, "restarting-core", isComplete: false, success: false, true, stage.VersionOutput, null, batch); + var restart = await StartCoreLockedAsync(runningProfile, cancellationToken); + if (!restart.Success) + { + throw new InvalidOperationException($"The updated {stage.CoreType} failed to restart ({restart.Code})."); + } + } + + if (Directory.Exists(backupPath)) + { + try + { + Directory.Delete(backupPath, recursive: true); + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} updated; previous Core cleanup deferred: {exception.Message}"); + } + } + + return new CoreUpdateApplyResult(true, false, originalCoreWasRunning, "Update installed and verified.", stage.VersionOutput); + } + catch (OperationCanceledException) + { + if (candidateInstalled || oldCoreWasStopped || Directory.Exists(backupPath)) + { + var rollback = await RollBackCoreUpdateAsync( + stage.CoreType, + installPath, + backupPath, + candidateInstalled, + hadInstalledCore, + oldCoreWasStopped, + originalCoreWasRunning, + runningProfile, + batch); + if (!rollback.Completed) + { + AddLog("update", rollback.FilesRestored + ? $"{stage.CoreType} previous files were restored, but the old Core process could not be relaunched." + : $"{stage.CoreType} rollback did not complete; previous files remain at {backupPath}."); + } + } + throw; + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} apply failed: {exception.Message}"); + if (CurrentCoreRuntime.State is CoreRuntimeState.Stopping or CoreRuntimeState.Restarting or CoreRuntimeState.Starting) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + } + var rollback = new CoreUpdateRollbackResult(FilesRestored: true, CoreRestarted: true); + if (candidateInstalled || oldCoreWasStopped || Directory.Exists(backupPath)) + { + rollback = await RollBackCoreUpdateAsync( + stage.CoreType, + installPath, + backupPath, + candidateInstalled, + hadInstalledCore, + oldCoreWasStopped, + originalCoreWasRunning, + runningProfile, + batch); + } + var detail = rollback.Completed + ? $"{exception.Message} The previous Core was restored." + : rollback.FilesRestored + ? $"{exception.Message} The previous Core files were restored, but its process could not be restarted." + : $"{exception.Message} Rollback did not complete; see the runtime log for the retained backup path."; + return new CoreUpdateApplyResult(false, rollback.Completed, originalCoreWasRunning, detail, stage.VersionOutput); + } + finally + { + try + { + if (Directory.Exists(candidatePath)) + { + Directory.Delete(candidatePath, recursive: true); + } + } + catch (Exception exception) + { + AddLog("update", $"{stage.CoreType} candidate cleanup failed: {exception.Message}"); + } + _coreGate.Release(); + } + } + + private async Task RollBackCoreUpdateAsync( + ECoreType coreType, + string installPath, + string backupPath, + bool candidateInstalled, + bool hadInstalledCore, + bool oldCoreWasStopped, + bool wasRunning, + ProfileItem? runningProfile, + bool batch = false) + { + var filesRestored = false; + try + { + return await CoreUpdateWorkflow.RollBackAndRestoreAsync( + async () => + { + // Do not stop an unrelated active Core when the target being updated was idle. + if (oldCoreWasStopped && wasRunning) + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(CancellationToken.None); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + CoreUpdateWorkflow.RestorePreviousDirectory( + installPath, backupPath, candidateInstalled, hadInstalledCore); + filesRestored = true; + }, + async () => + { + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (coreType == ECoreType.Xray) + { + _xrayPath = FindXrayExecutable(out _); + } + }, + wasRunning && runningProfile is not null, + async () => + { + PublishCoreUpdateProgress(coreType, "restarting-core", isComplete: false, success: false, + true, null, "Restarting the restored Core.", batch); + return (await StartCoreLockedAsync(runningProfile!, CancellationToken.None)).Success; + }); + } + catch (Exception exception) + { + AddLog("update", $"{coreType} rollback failed: {exception.Message}"); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + return new CoreUpdateRollbackResult(filesRestored, CoreRestarted: false); + } + } + + private async Task VerifyCoreExecutableAsync( + ECoreType coreType, + CoreInfo coreInfo, + string executablePath, + CancellationToken cancellationToken) + { + var versionArgument = coreInfo.VersionArg + ?? throw new InvalidDataException($"{coreType} has no version-check command configured."); + var versionOutput = await Utils.GetCliWrapOutput(executablePath, versionArgument, cancellationToken); + var expectedName = coreInfo.Match ?? coreType.ToString(); + if (!CoreExecutableVersionCheck.IsValid(versionOutput, expectedName)) + { + throw new InvalidDataException($"The staged {coreType} executable did not pass its version check."); + } + return versionOutput!.Trim(); + } + + private static string? FindCoreExecutable(CoreInfo coreInfo, string directory) => + (coreInfo.CoreExes ?? []) + .Select(name => Path.Combine(directory, Utils.GetExeName(name))) + .FirstOrDefault(File.Exists); + + private static bool IsCurrentPlatformDownloadSupported(CoreInfo coreInfo) + { + var downloadUrl = Utils.IsWindows() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlWinArm64, + Architecture.X64 => coreInfo.DownloadUrlWin64, + _ => null, + } + : Utils.IsLinux() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlLinuxArm64, + Architecture.RiscV64 => coreInfo.DownloadUrlLinuxRiscV64, + Architecture.LoongArch64 => coreInfo.DownloadUrlLinuxLoong64, + Architecture.X64 => coreInfo.DownloadUrlLinux64, + _ => null, + } + : Utils.IsMacOS() + ? RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => coreInfo.DownloadUrlOSXArm64, + Architecture.X64 => coreInfo.DownloadUrlOSX64, + _ => null, + } + : null; + + return !string.IsNullOrWhiteSpace(downloadUrl); + } + + internal static bool IsUpToDateResult(ECoreType coreType, string? message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + const string typeMarker = "__V2RAYN_CORE_TYPE__"; + const string versionMarker = "__V2RAYN_CORE_VERSION__"; + var template = string.Format(ResUI.IsLatestCore, typeMarker, versionMarker); + var typePosition = template.IndexOf(typeMarker, StringComparison.Ordinal); + var versionPosition = template.IndexOf(versionMarker, StringComparison.Ordinal); + if (typePosition < 0 || versionPosition <= typePosition) + { + return false; + } + + var expectedPrefix = template[..typePosition] + + coreType + + template[(typePosition + typeMarker.Length)..versionPosition]; + var suffix = template[(versionPosition + versionMarker.Length)..]; + var normalizedMessage = message.Trim(); + if (!normalizedMessage.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase) + || !normalizedMessage.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var versionLength = normalizedMessage.Length - expectedPrefix.Length - suffix.Length; + return versionLength > 0 + && normalizedMessage.Substring(expectedPrefix.Length, versionLength).All(character => !char.IsWhiteSpace(character)); + } + + private bool IsCoreTypeRunning(ECoreType coreType) => + CurrentCoreRuntime.State is CoreRuntimeState.Running or CoreRuntimeState.Faulted + && CurrentCoreRuntime.CoreType == coreType + && HasTrackedCoreProcesses; + + private static string GetArchiveSuffix(string downloadUrl) + { + var path = Uri.TryCreate(downloadUrl, UriKind.Absolute, out var uri) ? uri.AbsolutePath : downloadUrl; + return path.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + ? ".tar.gz" + : Path.GetExtension(path); + } + + private static void CopyCoreDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var sourceFile in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + File.Copy(sourceFile, Path.Combine(destinationDirectory, Path.GetFileName(sourceFile)), overwrite: true); + } + foreach (var sourceSubdirectory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.TopDirectoryOnly)) + { + CopyCoreDirectory(sourceSubdirectory, Path.Combine(destinationDirectory, Path.GetFileName(sourceSubdirectory))); + } + } + + private void CleanupCoreUpdateStage(CoreUpdateStage? stage) + { + if (stage is not null) + { + CleanupCoreUpdateStageFiles(stage.ArchivePath, stage.PackagePath); + } + } + + private void CleanupCoreUpdateStageFiles(string archivePath, string packagePath) + { + try + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, recursive: true); + } + } + catch (Exception exception) + { + AddLog("update", $"Core package staging cleanup failed: {exception.Message}"); + } + try + { + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + catch (Exception exception) + { + AddLog("update", $"Core archive cleanup failed: {exception.Message}"); + } + } + + private async Task RunGeoUpdateAsync(bool useProxy) + { + try + { + PublishGeoUpdateProgress("downloading", isComplete: false, success: false, null); + await using var operation = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + await ApplyGeoFilesUpdateAsync(useProxy, operation.Token); + PublishGeoUpdateProgress("completed", isComplete: true, success: true, null); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishGeoUpdateProgress("failed", isComplete: true, success: false, "Canceled during graceful shutdown."); + } + catch (Exception exception) + { + AddLog("update", $"GeoFiles update failed: {exception.Message}"); + PublishGeoUpdateProgress("failed", isComplete: true, success: false, exception.Message); + } + finally + { + lock (_updateTaskGate) + { + _geoUpdateTask = null; + } + } + } + + private async Task ApplyGeoFilesUpdateAsync( + bool useProxy, + CancellationToken cancellationToken, + bool publishProgress = true, + Func, CancellationToken, Task>? beforeCommit = null) + { + await _geoUpdateGate.RunAsync(async () => + { + var completion = new GeoFilesUpdateCompletion(); + var updater = new UpdateService(Config, (success, message) => + { + AddLog("update", message); + completion.Report(success, message, IsGeoDownloadProgressMessage(message)); + if (publishProgress) + { + _events.Publish("geo-update-progress", new + { + success, + code = success ? "ok" : "geo_update_progress", + messageKey = success ? ApiMessageKeys.CommonCompleted : ApiMessageKeys.GeoUpdateProgress, + rawLog = message, + }); + } + return Task.CompletedTask; + }); + + var requiredFiles = GetRequiredGeoFiles(); + var managedFiles = await GetManagedGeoFilesAsync(); + await GeoFilesUpdateTransaction.ApplyAsync( + managedFiles, + requiredFiles, + async token => + { + // Keep download selection, URLs, and installation behavior in the upstream + // public API. Its legacy callback reports progress/errors separately from its + // final success notification, so reject non-progress failures before commit. + await updater.UpdateGeoFileAll(useProxy, token); + completion.EnsureSuccessful(); + }, + cancellationToken, + beforeCommit); + }, cancellationToken); + } + + private async Task GetManagedGeoFilesAsync() + { + var files = new HashSet(GetRequiredGeoFiles(), StringComparerForPaths); + var geoipRules = new List(); + var geositeRules = new List(); + + var routingItems = await AppManager.Instance.RoutingItems(); + foreach (var routing in routingItems ?? []) + { + var rules = JsonUtils.Deserialize>(routing.RuleSet); + foreach (var rule in rules ?? []) + { + AddPrefixedItems(rule.Ip, Global.GeoIPPrefix, geoipRules); + AddPrefixedItems(rule.Domain, Global.GeoSitePrefix, geositeRules); + } + } + + var dnsItem = await AppManager.Instance.GetDNSItem(ECoreType.sing_box); + if (dnsItem is not null) + { + ExtractDnsRuleSets(dnsItem.NormalDNS, geoipRules, geositeRules); + ExtractDnsRuleSets(dnsItem.TunDNS, geoipRules, geositeRules); + } + + // Match ServiceLib.UpdateService.GetSrsFileAllRequest exactly: configured rule sets, + // plus its default geosite downloads. Do not treat every .srs or geo*-named file in + // the Core directory as owned by this transaction. + geositeRules.AddRange(["google", "cn", "geolocation-cn", "category-ads-all"]); + var srsDirectory = Path.GetFullPath(Utils.GetBinPath("srss")); + foreach (var (type, names) in new[] { ("geoip", geoipRules), ("geosite", geositeRules) }) + { + foreach (var name in names.Distinct(StringComparer.Ordinal)) + { + files.Add(GetManagedSrsPath(srsDirectory, type, name)); + } + } + + return files.OrderBy(path => path, StringComparerForPaths).ToArray(); + } + + private static string GetManagedSrsPath(string srsDirectory, string type, string name) + { + if (string.IsNullOrWhiteSpace(name) + || name is "." or ".." + || name.Contains('/') + || name.Contains('\\') + || name.Contains(':') + || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || !string.Equals(name, name.TrimEnd(' ', '.'), StringComparison.Ordinal) + || Path.GetFileName(name) != name) + { + throw new InvalidDataException("A GeoFiles rule-set name is not a safe file name."); + } + + var directory = Path.GetFullPath(srsDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var path = Path.GetFullPath(Path.Combine(directory, $"{type}-{name}.srs")); + if (!string.Equals(Path.GetDirectoryName(path), directory, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + throw new InvalidDataException("A GeoFiles rule-set path escapes the managed rules directory."); + } + return path; + } + + private static void AddPrefixedItems(IEnumerable? items, string prefix, ICollection output) + { + if (items is null) return; + foreach (var item in items) + { + if (item.StartsWith(prefix, StringComparison.Ordinal)) + { + output.Add(item[prefix.Length..]); + } + } + } + + private static void ExtractDnsRuleSets(string? dnsJson, ICollection geoipRules, ICollection geositeRules) + { + if (string.IsNullOrEmpty(dnsJson)) return; + try + { + var dns = JsonUtils.Deserialize(dnsJson); + foreach (var rule in dns?.rules ?? []) + { + ExtractSrsRuleSets(rule, geoipRules, geositeRules); + } + } + catch + { + // Match the updater: malformed optional DNS JSON does not stop GeoFiles updates. + } + } + + private static void ExtractSrsRuleSets(Rule4Sbox? rule, ICollection geoipRules, ICollection geositeRules) + { + if (rule is null) return; + AddPrefixedItems(rule.rule_set, "geosite-", geositeRules); + AddPrefixedItems(rule.rule_set, "geoip-", geoipRules); + foreach (var nestedRule in rule.rules ?? []) + { + ExtractSrsRuleSets(nestedRule, geoipRules, geositeRules); + } + } + + private string[] GetRequiredGeoFiles() + { + var binDirectory = Utils.GetBinPath(string.Empty); + var required = new List + { + Path.Combine(binDirectory, "geosite.dat"), + Path.Combine(binDirectory, "geoip.dat"), + }; + if (string.IsNullOrEmpty(Config.ConstItem.GeoSourceUrl)) + { + required.AddRange(Global.OtherGeoUrls.Select(url => Path.Combine(binDirectory, Path.GetFileName(url)))); + } + return required.Distinct(StringComparerForPaths).ToArray(); + } + + private static bool IsGeoDownloadProgressMessage(string message) + { + var normalized = message.TrimStart(); + return normalized.StartsWith(ResUI.Downloading, StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(normalized, @"^\d+/\d+\s*\|", RegexOptions.CultureInvariant); + } + + private static StringComparer StringComparerForPaths => OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private void PublishCoreUpdateProgress( + ECoreType coreType, + string phase, + bool isComplete, + bool success, + bool coreWasRunning, + string? version, + string? detail, + bool batch = false) + { + var progress = new CoreUpdateProgressView(coreType.ToString(), phase, isComplete, success, coreWasRunning, version, detail, batch); + if (!_updateProgress.TryGetValue(coreType.ToString(), out var previous) || previous.Phase != phase) + { + AddLog("update", $"{coreType} update phase: {phase}"); + } + _updateProgress[coreType.ToString()] = progress; + _events.Publish("core-update-progress", progress); + if (coreType == ECoreType.Xray && isComplete) + { + _events.Publish("xray-update-completed", success + ? OperationView.Ok(ApiMessageKeys.XrayUpdateCompleted, new { version, coreRestarted = coreWasRunning }) + : OperationView.Fail("xray_update_failed", ApiMessageKeys.XrayUpdateFailed, new { coreWasRunning, detail })); + } + } + + private void PublishGeoUpdateProgress(string phase, bool isComplete, bool success, string? detail, bool batch = false) + { + var progress = new CoreUpdateProgressView(GeoFilesUpdateTarget, phase, isComplete, success, false, null, detail, batch); + if (!_updateProgress.TryGetValue(GeoFilesUpdateTarget, out var previous) || previous.Phase != phase) + { + AddLog("update", $"GeoFiles update phase: {phase}"); + } + _updateProgress[GeoFilesUpdateTarget] = progress; + _events.Publish("core-update-progress", progress); + if (isComplete) + { + _events.Publish("geo-update-completed", success + ? OperationView.Ok(ApiMessageKeys.CommonCompleted, new { batch }) + : OperationView.Fail("geo_update_failed", ApiMessageKeys.CoreUpdateFailed, new { batch })); + } + } + + private static string GetCoreUpdateOperationName(ECoreType coreType) => + $"core-update-{coreType.ToString().ToLowerInvariant()}"; + + private static string GetCoreUpdateNameKey(ECoreType coreType) => coreType switch + { + ECoreType.Xray => "maintenance.coreNames.xray", + ECoreType.mihomo => "maintenance.coreNames.mihomo", + ECoreType.sing_box => "maintenance.coreNames.singBox", + ECoreType.v2rayN => "maintenance.coreNames.v2rayN", + _ => "maintenance.coreNames.other", + }; + + internal static void CopyLatestGeoFilesForApply(string installPath, string stagingPath) + { + if (!Directory.Exists(installPath)) + { + return; + } + foreach (var source in Directory.EnumerateFiles(installPath, "geo*", SearchOption.TopDirectoryOnly)) + { + File.Copy(source, Path.Combine(stagingPath, Path.GetFileName(source)), overwrite: true); + } + } + + internal sealed record CoreUpdateStage(ECoreType CoreType, string InstallPath, string ArchivePath, string PackagePath, string VersionOutput); + private sealed record CoreUpdateCheckResult(UpdateResult Result, bool IsUpToDate); + private sealed record CoreUpdateBatchCheck(ECoreType CoreType, UpdateResult Check, bool IsUpToDate, bool CheckFailed = false); + private sealed record CoreUpdatePrepareResult(CoreUpdateStage? Stage, UpdateResult Check); + private sealed record CoreUpdateApplyResult(bool Success, bool RolledBack, bool CoreWasRunning, string Detail, string? Version = null); +} + +internal static class CoreUpdateRuntimePolicy +{ + public static bool IsTargetRunning(ECoreType target, ECoreType runningCore, DateTimeOffset? startedAt) => + startedAt is not null && target == runningCore; +} + +internal static class CoreUpdateWorkflow +{ + public static void RestorePreviousDirectory( + string installPath, + string backupPath, + bool candidateInstalled, + bool hadInstalledCore) + { + if (candidateInstalled && Directory.Exists(installPath)) + { + Directory.Delete(installPath, recursive: true); + } + if (hadInstalledCore && Directory.Exists(backupPath) && !Directory.Exists(installPath)) + { + Directory.Move(backupPath, installPath); + } + } + + public static async Task RollBackAndRestoreAsync( + Func restoreFilesAsync, + Func initializePreviousCoreAsync, + bool wasRunning, + Func> restartPreviousCoreAsync) + { + await restoreFilesAsync(); + await initializePreviousCoreAsync(); + var restarted = !wasRunning || await restartPreviousCoreAsync(); + return new CoreUpdateRollbackResult(FilesRestored: true, CoreRestarted: restarted); + } + + public static async Task StageThenApplyAsync( + Func> stageAsync, + Func> applyAsync) + { + var staged = await stageAsync(); + return await applyAsync(staged); + } + + public static async Task StageAllThenApplyAsync( + IReadOnlyList targets, + Func> stageAsync, + Func, Task> applyAsync, + Func? beforeApplyAsync = null) + { + var staged = new List(targets.Count); + foreach (var target in targets) + { + staged.Add(await stageAsync(target)); + } + if (beforeApplyAsync is not null) + { + await beforeApplyAsync(); + } + return await applyAsync(staged); + } +} + +internal sealed record CoreUpdateRollbackResult(bool FilesRestored, bool CoreRestarted) +{ + public bool Completed => FilesRestored && CoreRestarted; +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs new file mode 100644 index 00000000..8440b1a3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Profiles.cs @@ -0,0 +1,572 @@ +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Handler.Builder; +using ServiceLib.Handler.Fmt; +using ServiceLib.Manager; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + public async Task> GetProfileGroupsAsync() + { + var subscriptions = await AppManager.Instance.SubItems() ?? []; + var profiles = await AppManager.Instance.ProfileItems(string.Empty) ?? []; + var counts = profiles.GroupBy(item => item.Subid).ToDictionary(group => group.Key, group => group.Count()); + var groups = new List + { + new(string.Empty, "all", null, ApiMessageKeys.ProfilesAllGroup, profiles.Count, string.IsNullOrEmpty(Config.SubIndexId)), + }; + + groups.AddRange(subscriptions.Select(item => new ProfileGroupView( + item.Id, + "subscription", + item.Remarks, + null, + counts.GetValueOrDefault(item.Id), + item.Id == Config.SubIndexId))); + + return groups; + } + + public async Task SelectProfileGroupAsync(string? subscriptionId) + { + if (!string.IsNullOrWhiteSpace(subscriptionId) + && await AppManager.Instance.GetSubItem(subscriptionId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + await _mutations.RunAsync(async () => + { + Config.SubIndexId = subscriptionId ?? string.Empty; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.CommonCompleted, new { subscriptionId = Config.SubIndexId }); + } + + public async Task ImportProfilesAsync(ProfileImportRequest request) + { + if (string.IsNullOrWhiteSpace(request.Content)) + { + return OperationView.Fail("profile_content_required", ApiMessageKeys.CommonInvalidInput, new { imported = 0 }); + } + + var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty; + if (!string.IsNullOrEmpty(groupId) && await AppManager.Instance.GetSubItem(groupId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound, new { imported = 0 }); + } + + var count = await _mutations.RunAsync(async () => + { + var imported = await ConfigHandler.AddBatchServers(Config, request.Content, groupId, request.IsSubscription); + if (imported > 0) + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + return imported; + }); + if (count <= 0) + { + return OperationView.Fail("profile_import_empty", ApiMessageKeys.ProfileInvalid, new { imported = Math.Max(count, 0) }); + } + + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + AddLog("profile", $"Imported {count} profile(s)."); + return OperationView.Ok(ApiMessageKeys.ProfileImported, new { imported = count }); + } + + public async Task SaveProfileAsync(ProfileItem profile, string? profileId = null) + { + if (profile is null) + { + return OperationView.Fail("profile_data_required", ApiMessageKeys.CommonInvalidInput); + } + + var isNew = string.IsNullOrWhiteSpace(profileId); + if (!isNew) + { + var existing = await AppManager.Instance.GetProfileItem(profileId!); + if (existing is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + if (!IsConfigTypeUnchanged(profile, existing)) + { + return OperationView.Fail("profile_config_type_immutable", ApiMessageKeys.ProfileInvalid); + } + profile.IndexId = profileId!; + profile.Subid = existing.Subid; + profile.IsSub = existing.IsSub; + } + else + { + profile.IndexId = string.Empty; + profile.Subid = Config.SubIndexId ?? string.Empty; + profile.IsSub = false; + if (!string.IsNullOrEmpty(profile.Subid) + && await AppManager.Instance.GetSubItem(profile.Subid) is null) + { + AddLog("profile", $"Rejected profile creation for missing subscription group {profile.Subid}."); + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound, + new { subscriptionId = profile.Subid }); + } + } + + if (profile.ConfigType.IsGroupType()) + { + profile.CoreType ??= ECoreType.Xray; + var protocolExtra = profile.GetProtocolExtra(); + var childIds = Utils.String2List(protocolExtra.ChildItems) ?? []; + if (string.IsNullOrWhiteSpace(profile.Remarks) + || (childIds.Count == 0 && string.IsNullOrWhiteSpace(protocolExtra.SubChildItems)) + || profile.CoreType is not (ECoreType.Xray or ECoreType.sing_box)) + { + return OperationView.Fail("profile_group_invalid", ApiMessageKeys.ProfileInvalid); + } + if (!string.IsNullOrWhiteSpace(protocolExtra.SubChildItems) + && await AppManager.Instance.GetSubItem(protocolExtra.SubChildItems) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + var children = await AppManager.Instance.GetProfileItemsByIndexIds(childIds); + if (children.Count != childIds.Distinct(StringComparer.Ordinal).Count() + || await GroupProfileManager.HasCycle(profile)) + { + return OperationView.Fail("profile_group_children_invalid", ApiMessageKeys.ProfileInvalid); + } + } + else if (profile.ConfigType is EConfigType.Custom or EConfigType.Outbound) + { + if (string.IsNullOrWhiteSpace(profile.Remarks) || string.IsNullOrWhiteSpace(profile.Address)) + { + return OperationView.Fail("profile_validation_failed", ApiMessageKeys.ProfileInvalid); + } + } + else if (profile.ConfigType.IsComplexType()) + { + return OperationView.Fail("profile_complex_type_requires_import", ApiMessageKeys.CommonInvalidInput); + } + else if (!TryValidateProfile(profile, out var code, out var messageKey)) + { + return OperationView.Fail(code, messageKey); + } + + var wasRunning = !isNew + && profile.IndexId == CurrentCoreRuntime.ProfileId + && CurrentCoreRuntime.State == CoreRuntimeState.Running; + int result; + try + { + result = await _mutations.RunAsync(() => profile.ConfigType.IsGroupType() + ? ConfigHandler.AddServerCommon(Config, profile) + : profile.ConfigType switch + { + EConfigType.Custom when isNew => ConfigHandler.AddCustomServer(Config, profile, blDelete: false), + EConfigType.Outbound when isNew => ConfigHandler.AddCustomOutboundServer(Config, profile, blDelete: false, toFile: true), + EConfigType.Custom or EConfigType.Outbound => ConfigHandler.EditCustomServer(Config, profile), + _ => ConfigHandler.AddServer(Config, profile), + }); + } + catch (Exception exception) + { + AddLog("profile", $"Profile save failed for {profile.ConfigType} ({profile.IndexId}): {exception}"); + return OperationView.Fail("profile_persistence_failed", ApiMessageKeys.ProfileSaveFailed, + new { stage = "servicelib_or_sqlite_write", exceptionType = exception.GetType().Name, detail = exception.Message }); + } + if (result != 0) + { + AddLog("profile", $"ServiceLib rejected profile save for {profile.ConfigType} ({profile.IndexId}); result={result}."); + return OperationView.Fail("profile_save_rejected", ApiMessageKeys.ProfileSaveFailed, + new { stage = "servicelib_validation_or_persistence", result }); + } + + if (wasRunning) + { + var restart = await RestartCoreAsync(CancellationToken.None); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return restart.Success + ? OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = true }) + : OperationView.Fail("profile_saved_core_restart_failed", ApiMessageKeys.ProfileSaved, + new { profileId = profile.IndexId, coreRestartRequired = true, coreResultCode = restart.Code }); + } + + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileSaved, new { profileId = profile.IndexId, coreRestarted = false }); + } + + internal static bool TryValidateProfile(ProfileItem profile, out string code, out string messageKey) + { + if (string.IsNullOrEmpty(profile.Remarks) + || string.IsNullOrEmpty(profile.Address) + || profile.Port <= 0 + || profile.Port >= Global.MaxPort + || (profile.ConfigType is not EConfigType.SOCKS and not EConfigType.HTTP + && string.IsNullOrEmpty(profile.Password))) + { + code = "profile_validation_failed"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + var protocolExtra = profile.GetProtocolExtra(); + if (profile.ConfigType == EConfigType.Shadowsocks && string.IsNullOrEmpty(protocolExtra.SsMethod)) + { + code = "profile_validation_failed"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + if (profile.ConfigType == EConfigType.HTTP + && !string.IsNullOrEmpty(protocolExtra.HttpHeaders) + && JsonUtils.ParseJson(protocolExtra.HttpHeaders) is null) + { + code = "profile_http_headers_invalid"; + messageKey = ApiMessageKeys.ProfileInvalid; + return false; + } + + code = "ok"; + messageKey = string.Empty; + return true; + } + + internal static bool IsConfigTypeUnchanged(ProfileItem input, ProfileItem existing) => + input.ConfigType == existing.ConfigType; + + public async Task DeleteProfilesAsync(IEnumerable profileIds) + { + var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray(); + if (ids.Length == 0) + { + return OperationView.Fail("profile_selection_required", ApiMessageKeys.ProfileSelectRequired); + } + + var selected = await AppManager.Instance.GetProfileItemsByIndexIds(ids); + if (selected.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + var runningProfileId = CurrentCoreRuntime.ProfileId; + var removesCurrent = ids.Contains(runningProfileId ?? Config.IndexId, StringComparer.Ordinal); + var wasRunning = CurrentCoreRuntime.State == CoreRuntimeState.Running; + if (removesCurrent && wasRunning) + { + var stop = await StopCoreAsync(CancellationToken.None); + if (!stop.Success) + { + return stop; + } + } + + await _mutations.RunAsync(async () => + { + if (await ConfigHandler.RemoveServers(Config, selected) != 0) + { + throw new IOException("ServiceLib could not remove the selected profiles."); + } + if (removesCurrent) + { + _ = await ConfigHandler.GetDefaultServer(Config); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + }); + + if (removesCurrent && wasRunning && !string.IsNullOrEmpty(Config.IndexId)) + { + var restart = await StartCoreAsync(null, CancellationToken.None); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return restart.Success + ? OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count, coreRestarted = true }) + : OperationView.Fail("profiles_deleted_core_restart_failed", ApiMessageKeys.ProfileDeleted, + new { deleted = selected.Count, coreRestartRequired = true, coreResultCode = restart.Code }); + } + + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileDeleted, new { deleted = selected.Count }); + } + + public async Task CopyProfilesAsync(IEnumerable profileIds) + { + var ids = profileIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray(); + var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(ids); + if (items.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + if (await _mutations.RunAsync(() => ConfigHandler.CopyServer(Config, items)) != 0) + { + return OperationView.Fail("profile_copy_failed", ApiMessageKeys.CommonInvalidInput); + } + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileCopied, new { copied = items.Count }); + } + + public async Task MoveProfilesToGroupAsync(MoveProfilesRequest request) + { + if (!string.IsNullOrEmpty(request.SubscriptionId) + && await AppManager.Instance.GetSubItem(request.SubscriptionId) is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + var items = await AppManager.Instance.GetProfileItemsByIndexIds(request.ProfileIds); + if (items.Count == 0) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + if (await _mutations.RunAsync(() => ConfigHandler.MoveToGroup(Config, items, request.SubscriptionId ?? string.Empty)) != 0) + { + return OperationView.Fail("profile_move_failed", ApiMessageKeys.CommonInvalidInput); + } + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileMoved, new { moved = items.Count, subscriptionId = request.SubscriptionId }); + } + + public async Task MoveProfileAsync(MoveProfileRequest request) + { + var groupId = Config.SubIndexId ?? string.Empty; + var profiles = await GetProfilesAsync(groupId, null); + var orderedIds = profiles.Select(item => item.IndexId).ToList(); + var index = orderedIds.IndexOf(request.ProfileId); + if (index < 0) + { + return OperationView.Fail("profile_not_in_group", ApiMessageKeys.ProfileNotInGroup); + } + + var result = await _mutations.RunAsync(() => ConfigHandler.MoveServer(Config, orderedIds, index, request.Direction, request.Position)); + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + return result == 0 + ? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved) + : OperationView.Fail("profile_order_failed", ApiMessageKeys.CommonInvalidInput); + } + + public async Task SortProfilesAsync(SortProfilesRequest request) + { + var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty; + if (!Enum.TryParse(request.Column, true, out _)) + { + return OperationView.Fail("profile_sort_column_invalid", ApiMessageKeys.CommonInvalidInput); + } + + var result = await _mutations.RunAsync(() => ConfigHandler.SortServers(Config, groupId, request.Column, request.Ascending)); + _events.Publish("profiles-changed", new { subscriptionId = groupId }); + return result == 0 + ? OperationView.Ok(ApiMessageKeys.ProfileOrderSaved) + : OperationView.Fail("profile_sort_failed", ApiMessageKeys.CommonInvalidInput); + } + + public async Task RemoveDuplicateProfilesAsync(string? subscriptionId) + { + var result = await _mutations.RunAsync(() => ConfigHandler.DedupServerList(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty)); + _events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.ProfileDeduplicated, new { removed = result.Item1 - result.Item2 }); + } + + public async Task RemoveInvalidProfilesAsync(string? subscriptionId) + { + var count = await _mutations.RunAsync(() => ConfigHandler.RemoveInvalidServerResult(Config, subscriptionId ?? Config.SubIndexId ?? string.Empty)); + _events.Publish("profiles-changed", new { subscriptionId = subscriptionId ?? Config.SubIndexId }); + return count < 0 + ? OperationView.Ok(ApiMessageKeys.CommonCompleted, new { removed = 0 }) + : OperationView.Ok(ApiMessageKeys.ProfileInvalidRemoved, new { removed = count }); + } + + public async Task GenerateProfileGroupsAsync(string? subscriptionId, bool byRegion) + { + SubItem? subscription = null; + if (!string.IsNullOrEmpty(subscriptionId)) + { + subscription = await AppManager.Instance.GetSubItem(subscriptionId); + if (subscription is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + } + + var result = await _mutations.RunAsync(() => byRegion + ? ConfigHandler.AddGroupRegionServer(Config, subscription) + : ConfigHandler.AddGroupAllServer(Config, subscription)); + if (!result.Success) + { + return OperationView.Fail("profile_group_empty", ApiMessageKeys.ProfileGrouped, result.Data); + } + + _events.Publish("profiles-changed", new { subscriptionId = subscription?.Id ?? string.Empty }); + return OperationView.Ok(ApiMessageKeys.ProfileGrouped, result.Data); + } + + public async Task> ExportProfileDataAsync(ProfileExportRequest request) + { + var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds); + var output = new List(); + + if (request.IncludeShareUris) + { + var uris = items.Select(FmtHandler.GetShareUri).Where(uri => !string.IsNullOrWhiteSpace(uri)).ToArray(); + if (uris.Length > 0) + { + var content = string.Join(Environment.NewLine, uris) + Environment.NewLine; + output.Add(new(string.Empty, string.Empty, request.Base64ShareUris ? "share-uri-base64" : "share-uri", + request.Base64ShareUris ? Utils.Base64Encode(content) : content)); + } + } + + if (request.IncludeInnerUri && items.Count > 0) + { + var innerUri = InnerFmt.ToUri(items); + if (!string.IsNullOrWhiteSpace(innerUri)) + { + output.Add(new(string.Empty, string.Empty, "inner-uri", innerUri)); + } + } + + if (request.IncludeClientConfig) + { + foreach (var item in items) + { + var (context, validation) = await CoreConfigContextBuilder.Build(Config, item); + if (validation.Success) + { + var generated = await CoreConfigHandler.GenerateClientConfig(context, null); + if (generated.Success && generated.Data is string configText) + { + output.Add(new(item.IndexId, item.Remarks, "client-config", configText)); + } + } + } + } + return output; + } + + public async Task GetSubscriptionShareAsync(string id) + { + return (await AppManager.Instance.GetSubItem(id))?.Url; + } + + public async Task StartSpeedTestAsync(SpeedTestRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var action = request.Action == ESpeedActionType.FastRealping ? ESpeedActionType.Realping : request.Action; + var profiles = request.ProfileIds is { Length: > 0 } + ? await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds) + : await AppManager.Instance.ProfileItems(Config.SubIndexId ?? string.Empty) ?? []; + if (profiles.Count == 0) + { + return OperationView.Fail("speedtest_profile_selection_required", ApiMessageKeys.SpeedTestSelectRequired); + } + + if (action != ESpeedActionType.Tcping + && profiles.Where(item => !item.ConfigType.IsComplexType()) + .Any(item => AppManager.Instance.GetCoreType(item, item.ConfigType) is not (ECoreType.Xray or ECoreType.sing_box))) + { + return OperationView.Fail("speedtest_core_unsupported", ApiMessageKeys.SpeedTestUnsupportedCore); + } + + lock (_speedtestGate) + { + if (_speedtestTask is { IsCompleted: false }) + { + return OperationView.Fail("speedtest_busy", ApiMessageKeys.SpeedTestBusy); + } + var speedtestService = _speedtestService ??= CreateSpeedtestService(); + var speedtestCancellation = new CancellationTokenSource(); + _speedtestCancellation = speedtestCancellation; + _speedtestTask = Task.Run(async () => + { + try + { + using var requested = CancellationTokenSource.CreateLinkedTokenSource(_operations.ShutdownToken, speedtestCancellation.Token); + await using var operation = await _operations.EnterOperationAsync(requested.Token); + operation.Token.ThrowIfCancellationRequested(); + await speedtestService.RunLoop(action, profiles, operation.Token); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + // Expected during graceful service shutdown. + } + catch (OperationCanceledException) when (speedtestCancellation.IsCancellationRequested) + { + // Expected when the caller stops the active speed test. + } + catch (Exception ex) + { + AddLog("speedtest", ex.Message); + } + finally + { + lock (_speedtestGate) + { + if (ReferenceEquals(_speedtestCancellation, speedtestCancellation)) + { + _speedtestCancellation = null; + } + } + speedtestCancellation.Dispose(); + } + }); + } + _events.Publish("speedtest-started", new + { + code = "speedtest_started", + messageKey = ApiMessageKeys.SpeedTestStarted, + action = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(action.ToString()), + profileIds = profiles.Select(item => item.IndexId), + }); + return OperationView.Ok(ApiMessageKeys.SpeedTestStarted, new { action = action.ToString(), profileCount = profiles.Count }); + } + + private SpeedtestService CreateSpeedtestService() => new(Config, result => + { + int? delay = int.TryParse(result.Delay, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedDelay) + ? parsedDelay + : null; + decimal? speed = decimal.TryParse(result.Speed, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed) + ? parsedSpeed + : null; + _events.Publish("speedtest-result", new + { + code = ApiMessageKeys.SpeedTestResult, + messageKey = ApiMessageKeys.SpeedTestResult, + indexId = result.IndexId, + delay, + speed, + ipInfo = result.IpInfo, + rawResult = delay is null && speed is null ? (result.Delay ?? result.Speed) : null, + }); + if (!string.IsNullOrEmpty(result.IndexId)) + { + AddLog("speedtest", $"{result.IndexId}: delay={result.Delay}, speed={result.Speed}"); + } + return Task.CompletedTask; + }); + + public OperationView StopSpeedTests() + { + lock (_speedtestGate) + { + _speedtestCancellation?.Cancel(); + _speedtestService?.ExitLoop(); + } + return OperationView.Ok(ApiMessageKeys.CommonCompleted); + } + + public IReadOnlyList GetRunningSubscriptionUpdates() + { + lock (_subscriptionGate) + { + return _subscriptionTasks.Where(pair => !pair.Value.IsCompleted).Select(pair => pair.Key).ToArray(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs new file mode 100644 index 00000000..f76d4bc3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Scheduling.cs @@ -0,0 +1,174 @@ +using ServiceLib.Common; +using ServiceLib.Handler; +using ServiceLib.Manager; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private CancellationTokenSource? _scheduledOperationsCancellation; + private Task? _scheduledOperationsTask; + + private void StartScheduledOperations(CancellationToken hostToken) + { + _scheduledOperationsCancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken, _operations.ShutdownToken); + var cancellationToken = _scheduledOperationsCancellation.Token; + _scheduledOperationsTask = Task.Run(() => RunScheduledOperationsAsync(cancellationToken)); + } + + private async Task StopScheduledOperationsAsync(CancellationToken shutdownToken) + { + var cancellation = _scheduledOperationsCancellation; + var task = _scheduledOperationsTask; + if (cancellation is null || task is null) + { + return true; + } + + cancellation.Cancel(); + try + { + await task.WaitAsync(shutdownToken); + } + catch (OperationCanceledException) when (task.IsCompleted && cancellation.IsCancellationRequested) + { + // Expected during graceful service shutdown. + } + finally + { + if (task.IsCompleted) + { + cancellation.Dispose(); + _scheduledOperationsCancellation = null; + _scheduledOperationsTask = null; + } + } + + return true; + } + + private async Task RunScheduledOperationsAsync(CancellationToken cancellationToken) + { + var elapsedMinutes = 1; + while (true) + { + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); + await RunScheduledStepAsync("subscription", RunScheduledSubscriptionUpdatesAsync, cancellationToken); + + if (elapsedMinutes % 20 == 0) + { + await RunScheduledStepAsync("save", async token => + { + await using var operation = await _operations.EnterOperationAsync(token); + await _mutations.RunAsync(async () => + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await ProfileExManager.Instance.SaveTo(); + }, operation.Token); + }, cancellationToken); + } + + if (elapsedMinutes % 60 == 0) + { + await RunScheduledStepAsync("cleanup", async token => + { + await using var operation = await _operations.EnterOperationAsync(token); + FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1), "Test"); + FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddDays(-7)); + FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddDays(-7)); + }, cancellationToken); + + var elapsedHours = elapsedMinutes / 60; + if (Config.GuiItem.AutoUpdateInterval > 0 && elapsedHours % Config.GuiItem.AutoUpdateInterval == 0) + { + await RunScheduledStepAsync("geo-update", RunScheduledGeoUpdateAsync, cancellationToken); + } + } + + if (elapsedMinutes % 1440 == 1) + { + await RunScheduledStepAsync("core-update-check", RunScheduledCoreUpdateCheckAsync, cancellationToken); + } + elapsedMinutes++; + } + } + + private async Task RunScheduledStepAsync(string name, Func step, CancellationToken cancellationToken) + { + try + { + await step(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AddLog("task", $"{name}: {exception.Message}"); + } + } + + private async Task RunScheduledSubscriptionUpdatesAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + IReadOnlyList subscriptions; + await using (var operation = await _operations.EnterOperationAsync(cancellationToken)) + { + subscriptions = (await AppManager.Instance.SubItems() ?? []) + .Where(item => item.AutoUpdateInterval > 0 + && now - item.UpdateTime >= item.AutoUpdateInterval * 60L) + .ToArray(); + } + + foreach (var subscription in subscriptions) + { + cancellationToken.ThrowIfCancellationRequested(); + var update = StartSubscriptionUpdateTask(subscription.Id, useProxy: true, cancellationToken); + if (update is not null) + { + await update.WaitAsync(cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + } + + private async Task RunScheduledGeoUpdateAsync(CancellationToken cancellationToken) + { + await using var operation = await _operations.EnterExclusiveAsync( + cancellationToken, + allowReadOnlyObservations: true); + await new UpdateService(Config, (success, message) => + { + AddLog("update", message); + _events.Publish("geo-update-progress", new + { + success, + code = success ? "ok" : "geo_update_progress", + messageKey = success ? ApiMessageKeys.CommonCompleted : ApiMessageKeys.GeoUpdateProgress, + rawLog = message, + }); + if (success) + { + _events.Publish("geo-update-completed", OperationView.Ok(ApiMessageKeys.CommonCompleted)); + } + return Task.CompletedTask; + }).UpdateGeoFileAll(blProxy: true, cancellationToken: operation.Token); + } + + private async Task RunScheduledCoreUpdateCheckAsync(CancellationToken cancellationToken) + { + await using var operation = await _operations.EnterOperationAsync(cancellationToken); + var updateService = new UpdateService(Config, (_, _) => Task.CompletedTask); + var messages = await updateService.CheckHasUpdateOnlyAll( + Config.CheckUpdateItem.UpdateViaProxy, + operation.Token); + foreach (var message in messages) + { + AddLog("update", message); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs new file mode 100644 index 00000000..b0f35980 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.Settings.cs @@ -0,0 +1,1784 @@ +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const int DefaultWireGuardMtu = 1280; + private static readonly Lazy ProfileEditorOptionsValue = new(CreateProfileEditorOptions); + + private static readonly EConfigType[] WebEditableCoreTypes = + [ + EConfigType.VMess, + EConfigType.Custom, + EConfigType.Shadowsocks, + EConfigType.SOCKS, + EConfigType.VLESS, + EConfigType.Trojan, + EConfigType.Hysteria2, + EConfigType.WireGuard, + ]; + + public Task GetSettingsAsync() + { + var inbound = Config.Inbound[0]; + return Task.FromResult(new WebSettingsView( + new InboundSettingsView( + inbound.LocalPort, + inbound.SecondLocalPortEnabled, + inbound.UdpEnabled, + inbound.SniffingEnabled, + inbound.DestOverride?.ToArray() ?? [], + inbound.RouteOnly, + inbound.AllowLANConn, + inbound.NewPort4LAN, + inbound.User, + inbound.Pass), + new CoreSettingsView( + Config.CoreBasicItem.LogEnabled, + Config.CoreBasicItem.Loglevel, + Config.CoreBasicItem.DefFingerprint, + Config.CoreBasicItem.DefUserAgent, + Config.CoreBasicItem.SendThrough, + Config.CoreBasicItem.BindInterface, + Config.Mux4RayItem.Concurrency, + Config.Mux4RayItem.XudpConcurrency, + Config.Mux4RayItem.XudpProxyUDP443, + Config.Mux4SboxItem.Protocol, + Config.Mux4SboxItem.Padding, + Config.CoreBasicItem.EnableCacheFile4Sbox, + Config.HysteriaItem.UpMbps, + Config.HysteriaItem.DownMbps, + Config.CoreBasicItem.EnableFragment, + Config.CoreBasicItem.EnableFinalFragment, + Config.Fragment4RayItem?.Packets, + Config.Fragment4RayItem?.Lengths?.ToArray() ?? [], + Config.Fragment4RayItem?.Delays?.ToArray() ?? [], + Config.Fragment4RayItem?.MaxSplit), + new AppSettingsView( + Config.GuiItem.EnableStatistics, + Config.GuiItem.DisplayRealTimeSpeed, + Config.GuiItem.KeepOlderDedupl, + Config.GuiItem.AutoUpdateInterval, + Config.GuiItem.RootCertProvider, + Config.ConstItem.GeoSourceUrl, + Config.ConstItem.SrsSourceUrl, + Config.ConstItem.RouteRulesTemplateSourceUrl, + Config.ConstItem.SubConvertUrl), + new SpeedTestSettingsView( + Config.SpeedTestItem.SpeedTestTimeout, + Config.SpeedTestItem.SpeedTestUrl, + Config.SpeedTestItem.SpeedPingTestUrl, + Config.SpeedTestItem.MixedConcurrencyCount, + Config.SpeedTestItem.IPAPIUrl, + Config.SpeedTestItem.UdpTestTarget), + Config.RoutingBasicItem.DomainStrategy, + Config.RoutingBasicItem.DomainStrategy4Singbox, + Config.CoreTypeItem + .Where(item => WebEditableCoreTypes.Contains(item.ConfigType)) + .Select(item => new CoreTypeMapping(item.ConfigType, item.CoreType)) + .ToArray(), + GetWebSettingsOptions(), + ShouldShowIpInfoColumn(Config.SpeedTestItem.IPAPIUrl, Config.UiItem.HideColumnIpInfo))); + } + + public static WebSettingsOptionsView GetWebSettingsOptions() => new( + Global.LogLevels.ToArray(), + Global.RootCertProviders.ToArray(), + Global.Fingerprints.ToArray(), + Global.UserAgent.ToArray(), + Global.SingboxMuxs.ToArray(), + ["reject", "skip"], + Global.FragmentPacketsOptions.ToArray(), + Global.destOverrideProtocols.ToArray(), + Global.DomainStrategies.ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Global.DomainStrategies.AppendEmpty().ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20).ToArray(), + Enumerable.Range(2, 5).Select(value => value * 5).ToArray(), + Global.SpeedTestUrls.ToArray(), + Global.SpeedPingTestUrls.ToArray(), + Global.UdpTestTargets.ToArray(), + Global.IPAPIUrls.ToArray(), + Global.SubConvertUrls.ToArray(), + Global.GeoFilesSources.ToArray(), + Global.SingboxRulesetSources.ToArray(), + Global.RoutingRulesSources.ToArray()); + + public static WebEditorOptionsView GetEditorOptions() => new( + new SubscriptionEditorOptionsView( + Global.SubConvertTargets.ToArray(), + Enum.GetValues().Where(coreType => coreType != ECoreType.v2rayN).Select(coreType => coreType.ToString()).ToArray()), + ProfileEditorOptionsValue.Value, + GetDnsEditorOptions()); + + private static ProfileEditorOptionsView CreateProfileEditorOptions() + { + var shadowsocksMethods = Enum.GetValues() + .Where(coreType => coreType != ECoreType.v2rayN) + .ToDictionary( + coreType => coreType.ToString(), + coreType => coreType switch + { + ECoreType.v2fly => Global.SsSecurities.ToArray(), + ECoreType.Xray => Global.SsSecuritiesInXray.ToArray(), + _ => Global.SsSecuritiesInSingbox.ToArray(), + }, + StringComparer.OrdinalIgnoreCase); + + return new ProfileEditorOptionsView( + Global.CoreTypes.ToArray(), + Enum.GetValues().Select(configType => configType.ToString()).ToArray(), + Global.SingboxOnlyConfigType.Select(configType => configType.ToString()).ToArray(), + Global.Networks.ToArray(), + Global.DefaultNetwork, + new ProfileItem().StreamSecurity, + Global.DefaultSecurity, + Global.None, + Global.DefaultXhttpMode, + Global.GrpcGunMode, + DefaultWireGuardMtu, + [Global.None, Global.RawHeaderHttp], + Global.XhttpMode.ToArray(), + [Global.None, .. Global.KcpHeaderTypes], + [Global.GrpcGunMode, Global.GrpcMultiMode], + Global.VmessSecurities.ToArray(), + Global.Flows.ToArray(), + Global.TuicCongestionControls.ToArray(), + Global.NaiveCongestionControls.ToArray(), + Global.Fingerprints.ToArray(), + Global.Alpns.ToArray(), + shadowsocksMethods, + [string.Empty, Global.StreamSecurity, Global.StreamSecurityReality], + Enum.GetNames()); + } + + public static DnsEditorOptionsView GetDnsEditorOptions() => new( + Global.CoreTypes.ToArray(), + Global.DomainStrategy.ToArray(), + Global.DomainStrategies4Sbox.ToArray(), + Global.DomainPureIPDNSAddress.ToArray(), + Global.DomainDirectDNSAddress.ToArray(), + Global.DomainRemoteDNSAddress.ToArray(), + Global.DomainPureIPDNSAddress.ToArray(), + Global.FakeIPRanges.ToArray(), + Global.ExpectedIPs.ToArray()); + + public IReadOnlyList GetDefaultDnsProfiles() => + [ + new(ECoreType.Xray, + EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName), + EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)), + new(ECoreType.sing_box, + EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName), + EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName)), + ]; + + public async Task ApplySettingsAsync(SettingsApplyInput input) + { + var inbound = input.Inbound; + var core = input.Core; + var application = input.Application; + var speed = input.SpeedTest; + var highestPortOffset = inbound.SecondLocalPortEnabled || (inbound.AllowLANConn && inbound.NewPort4LAN) ? 2 : 0; + if (inbound.LocalPort <= 0 || inbound.LocalPort > 65535 - highestPortOffset + || !AreDestOverrideProtocolsValid(inbound.DestOverride)) + { + return OperationView.Fail("inbound_port_invalid", ApiMessageKeys.SettingsInvalidPort); + } + if (!ValidateCoreSettingsInput(core)) + { + return OperationView.Fail("core_setting_invalid", ApiMessageKeys.SettingsInvalidCoreValue); + } + if (application.GeoAutoUpdateInterval < 0 + || !IsRootCertProviderValid(application.RootCertProvider) + || speed.SpeedTestTimeout <= 0 || speed.MixedConcurrencyCount <= 0 + || !IsHttpUrl(speed.SpeedTestUrl) || !IsHttpUrl(speed.SpeedPingTestUrl)) + { + return OperationView.Fail("settings_apply_invalid", ApiMessageKeys.SettingsInvalidSpeedTest); + } + if (!AreWebCoreTypeMappingsValid(input.CoreTypes) + || !Global.DomainStrategies.Contains(input.DomainStrategy ?? string.Empty) + || !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Singbox ?? string.Empty)) + { + return OperationView.Fail("settings_apply_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var coreFingerprintBefore = GetCoreConfigurationFingerprint(); + var oldConfig = applyContext.OldConfig; + var statisticsChanged = Config.GuiItem.EnableStatistics != application.EnableStatistics + || Config.GuiItem.DisplayRealTimeSpeed != application.DisplayRealTimeSpeed; + try + { + await _mutations.RunAsync(async () => + { + ApplyInboundSettings(Config.Inbound[0], inbound); + + ApplyCoreSettingsToConfig(core); + Config.GuiItem.EnableStatistics = application.EnableStatistics; + Config.GuiItem.DisplayRealTimeSpeed = application.DisplayRealTimeSpeed; + Config.GuiItem.KeepOlderDedupl = application.KeepOlderDedupl; + Config.GuiItem.AutoUpdateInterval = Math.Max(0, application.GeoAutoUpdateInterval); + Config.GuiItem.RootCertProvider = application.RootCertProvider; + Config.ConstItem.GeoSourceUrl = application.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = application.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = application.RouteRulesTemplateSourceUrl; + Config.ConstItem.SubConvertUrl = application.SubConvertUrl; + + Config.SpeedTestItem.SpeedTestTimeout = speed.SpeedTestTimeout; + Config.SpeedTestItem.SpeedTestUrl = speed.SpeedTestUrl; + Config.SpeedTestItem.SpeedPingTestUrl = speed.SpeedPingTestUrl; + Config.SpeedTestItem.MixedConcurrencyCount = Math.Max(speed.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin); + Config.SpeedTestItem.IPAPIUrl = speed.IPAPIUrl; + Config.SpeedTestItem.UdpTestTarget = speed.UdpTestTarget; + + EnsureCoreTypeMappings(); + foreach (var mapping in input.CoreTypes) + { + var existing = Config.CoreTypeItem.FirstOrDefault(item => item.ConfigType == mapping.ConfigType); + if (existing is not null) + { + existing.CoreType = mapping.CoreType; + } + } + Config.RoutingBasicItem.DomainStrategy = input.DomainStrategy!; + Config.RoutingBasicItem.DomainStrategy4Singbox = input.DomainStrategy4Singbox!; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + } + catch (Exception exception) + { + RestoreConfigValues(Config, oldConfig); + try + { + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + } + catch (Exception rollbackException) + { + AddLog("settings", $"Settings rollback persistence failed: {rollbackException}"); + } + AddLog("settings", $"Settings transaction failed before Core apply: {exception}"); + return OperationView.Fail("settings_transaction_failed", ApiMessageKeys.CommonInternal, + new { stage = "config_save", exceptionType = exception.GetType().Name, detail = exception.Message }); + } + + if (statisticsChanged) + { + StatisticsManager.Instance.Close(); + _latestTraffic = null; + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + try + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + catch (Exception exception) + { + AddLog("settings", $"Statistics reinitialization failed after settings were saved: {exception}"); + } + } + } + + var coreChanged = coreFingerprintBefore != GetCoreConfigurationFingerprint(); + return await CompleteCoreAffectingChangeAsync("all-settings", coreChanged, applyContext); + } + + public async Task UpdateInboundSettingsAsync(InboundSettingsInput input) + { + var highestPortOffset = input.SecondLocalPortEnabled || (input.AllowLANConn && input.NewPort4LAN) ? 2 : 0; + if (input.LocalPort <= 0 || input.LocalPort > 65535 - highestPortOffset) + { + return OperationView.Fail("inbound_port_invalid", ApiMessageKeys.SettingsInvalidPort); + } + if (!AreDestOverrideProtocolsValid(input.DestOverride)) + { + return OperationView.Fail("inbound_dest_override_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + ApplyInboundSettings(Config.Inbound[0], input); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return await CompleteCoreAffectingChangeAsync("inbound", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task UpdateCoreSettingsAsync(CoreSettingsInput input) + { + if (!AreCoreSettingsOptionsValid(input)) + { + return OperationView.Fail("core_setting_option_invalid", ApiMessageKeys.SettingsInvalidCoreValue); + } + if (!Global.LogLevels.Contains(input.Loglevel ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + { + return OperationView.Fail("core_log_level_invalid", ApiMessageKeys.SettingsInvalidCoreLogLevel); + } + if (input.Mux4RayConcurrency is < 0 or > 1024 || input.Mux4RayXudpConcurrency is < 0 or > 1024 + || input.Hy2UpMbps < 0 || input.Hy2DownMbps < 0) + { + return OperationView.Fail("core_setting_out_of_range", ApiMessageKeys.SettingsInvalidCoreValue); + } + var fragmentLengths = input.FragmentLengths ?? []; + var fragmentDelays = input.FragmentDelays ?? []; + if (fragmentLengths.Any(value => !Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + || fragmentDelays.Any(value => !Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + || (!string.IsNullOrWhiteSpace(input.FragmentMaxSplit) + && !Utils.TryParseMaxSplit(input.FragmentMaxSplit, 0, 10000, out _, out _))) + { + return OperationView.Fail("fragment_setting_invalid", ApiMessageKeys.SettingsInvalidFragment); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.CoreBasicItem.LogEnabled = input.LogEnabled; + Config.CoreBasicItem.Loglevel = input.Loglevel ?? string.Empty; + Config.CoreBasicItem.DefFingerprint = input.DefFingerprint?.Trim() ?? string.Empty; + Config.CoreBasicItem.DefUserAgent = input.DefUserAgent?.Trim() ?? string.Empty; + Config.CoreBasicItem.SendThrough = input.SendThrough?.Trim(); + Config.CoreBasicItem.BindInterface = input.BindInterface?.Trim(); + Config.Mux4RayItem.Concurrency = input.Mux4RayConcurrency is > 0 ? input.Mux4RayConcurrency : null; + Config.Mux4RayItem.XudpConcurrency = input.Mux4RayXudpConcurrency is > 0 ? input.Mux4RayXudpConcurrency : null; + Config.Mux4RayItem.XudpProxyUDP443 = input.Mux4RayXudpProxyUDP443; + Config.Mux4SboxItem.Protocol = input.Mux4SboxProtocol ?? string.Empty; + Config.Mux4SboxItem.Padding = input.Mux4SboxPadding; + Config.CoreBasicItem.EnableCacheFile4Sbox = input.EnableCacheFile4Sbox; + Config.HysteriaItem.UpMbps = input.Hy2UpMbps; + Config.HysteriaItem.DownMbps = input.Hy2DownMbps; + Config.CoreBasicItem.EnableFragment = input.EnableFragment; + Config.CoreBasicItem.EnableFinalFragment = input.EnableFinalFragment; + Config.Fragment4RayItem ??= new(); + Config.Fragment4RayItem.Packets = input.FragmentPackets; + Config.Fragment4RayItem.Lengths = fragmentLengths.ToList(); + Config.Fragment4RayItem.Delays = fragmentDelays.ToList(); + Config.Fragment4RayItem.MaxSplit = input.FragmentMaxSplit; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + return await CompleteCoreAffectingChangeAsync("core", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task UpdateAppSettingsAsync(AppSettingsInput input) + { + if (input.GeoAutoUpdateInterval < 0 || !IsRootCertProviderValid(input.RootCertProvider)) + { + return OperationView.Fail("app_setting_invalid", ApiMessageKeys.CommonInvalidInput); + } + + var statisticsChanged = Config.GuiItem.EnableStatistics != input.EnableStatistics + || Config.GuiItem.DisplayRealTimeSpeed != input.DisplayRealTimeSpeed; + + await _mutations.RunAsync(async () => + { + Config.GuiItem.EnableStatistics = input.EnableStatistics; + Config.GuiItem.DisplayRealTimeSpeed = input.DisplayRealTimeSpeed; + Config.GuiItem.KeepOlderDedupl = input.KeepOlderDedupl; + Config.GuiItem.AutoUpdateInterval = Math.Max(0, input.GeoAutoUpdateInterval); + Config.GuiItem.RootCertProvider = input.RootCertProvider; + Config.ConstItem.GeoSourceUrl = input.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = input.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = input.RouteRulesTemplateSourceUrl; + Config.ConstItem.SubConvertUrl = input.SubConvertUrl; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + + if (statisticsChanged) + { + StatisticsManager.Instance.Close(); + _latestTraffic = null; + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + } + + _events.Publish("settings-changed", new { section = "application", restartRequired = false }); + return OperationView.Ok(ApiMessageKeys.CommonSaved); + } + + public async Task UpdateSpeedTestSettingsAsync(SpeedTestSettingsInput input) + { + if (input.SpeedTestTimeout <= 0 || input.MixedConcurrencyCount <= 0 + || !IsHttpUrl(input.SpeedTestUrl) || !IsHttpUrl(input.SpeedPingTestUrl)) + { + return OperationView.Fail("speedtest_settings_invalid", ApiMessageKeys.SettingsInvalidSpeedTest); + } + + await _mutations.RunAsync(async () => + { + Config.SpeedTestItem.SpeedTestTimeout = input.SpeedTestTimeout; + Config.SpeedTestItem.SpeedTestUrl = input.SpeedTestUrl; + Config.SpeedTestItem.SpeedPingTestUrl = input.SpeedPingTestUrl; + Config.SpeedTestItem.MixedConcurrencyCount = Math.Max(input.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin); + Config.SpeedTestItem.IPAPIUrl = input.IPAPIUrl; + Config.SpeedTestItem.UdpTestTarget = input.UdpTestTarget; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + _events.Publish("settings-changed", new { section = "speedtest", restartRequired = false }); + return OperationView.Ok(ApiMessageKeys.SpeedTestSettingsSaved); + } + + public async Task UpdateCoreTypeMappingsAsync(IEnumerable mappings) + { + var requestedMappings = mappings.ToArray(); + if (!AreWebCoreTypeMappingsValid(requestedMappings)) + { + return OperationView.Fail("core_type_mapping_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + EnsureCoreTypeMappings(); + foreach (var mapping in requestedMappings) + { + var item = Config.CoreTypeItem.FirstOrDefault(entry => entry.ConfigType == mapping.ConfigType); + if (item is not null) + { + item.CoreType = mapping.CoreType; + } + } + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("core-types", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + private void EnsureCoreTypeMappings() + { + Config.CoreTypeItem ??= []; + foreach (var configType in Enum.GetValues()) + { + if (Config.CoreTypeItem.All(item => item.ConfigType != configType)) + { + Config.CoreTypeItem.Add(new CoreTypeItem { ConfigType = configType, CoreType = ECoreType.Xray }); + } + } + } + + public async Task UpdateRoutingStrategiesAsync(RoutingSettingsInput input) + { + if (!Global.DomainStrategies.Contains(input.DomainStrategy ?? string.Empty) + || !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Singbox ?? string.Empty)) + { + return OperationView.Fail("routing_strategy_invalid", ApiMessageKeys.SettingsInvalidRoutingStrategy); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.RoutingBasicItem.DomainStrategy = input.DomainStrategy!; + Config.RoutingBasicItem.DomainStrategy4Singbox = input.DomainStrategy4Singbox!; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("routing", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public Task GetSimpleDNSAsync() => Task.FromResult(Config.SimpleDNSItem); + + public async Task UpdateSimpleDNSAsync(SimpleDNSItem input) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var fingerprint = GetCoreConfigurationFingerprint(); + await _mutations.RunAsync(async () => + { + Config.SimpleDNSItem = input; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + return await CompleteCoreAffectingChangeAsync("dns", fingerprint != GetCoreConfigurationFingerprint(), applyContext); + } + + public async Task> GetDnsProfilesAsync() => + (await AppManager.Instance.DNSItems() ?? []) + .Select(ToDnsProfileView) + .ToArray(); + + public async Task UpdateDnsProfileAsync(ECoreType coreType, DnsProfileInput input) + { + if (!IsValidCustomDns(coreType, input.NormalDNS) + || !IsValidCustomDns(coreType, input.TunDNS) + || (coreType == ECoreType.Xray && !Global.DomainStrategy.Contains(input.DomainStrategy4Freedom ?? string.Empty)) + || (coreType == ECoreType.sing_box && !Global.DomainStrategies4Sbox.Contains(input.DomainStrategy4Freedom ?? string.Empty))) + { + return OperationView.Fail("dns_profile_invalid", ApiMessageKeys.CommonInvalidInput); + } + + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var result = await _mutations.RunAsync(async () => + { + var current = await AppManager.Instance.GetDNSItem(coreType); + if (current is null) + { + return (Result: -2, Changed: false); + } + var changed = current.Enabled != input.Enabled + || current.UseSystemHosts != input.UseSystemHosts + || current.NormalDNS != input.NormalDNS + || current.TunDNS != input.TunDNS + || current.DomainStrategy4Freedom != input.DomainStrategy4Freedom + || current.DomainDNSAddress != input.DomainDNSAddress + || current.Remarks != (input.Remarks ?? current.Remarks); + var item = ApplyDnsProfileInput(current, coreType, input); + return (Result: await ConfigHandler.SaveDNSItems(Config, item), Changed: changed); + }); + if (result.Result == -2) + { + return OperationView.Fail("dns_profile_not_found", ApiMessageKeys.DnsProfileNotFound, new { coreType = coreType.ToString() }); + } + if (result.Result != 0) + { + return OperationView.Fail("dns_profile_save_failed", ApiMessageKeys.DnsSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("dns", result.Changed, applyContext, data: new { coreType = coreType.ToString() }); + } + + internal static DnsProfileView ToDnsProfileView(DNSItem item) => new( + item.Id, + item.Remarks, + item.Enabled, + item.CoreType, + item.UseSystemHosts, + item.NormalDNS, + item.TunDNS, + item.DomainStrategy4Freedom, + item.DomainDNSAddress); + + internal static DNSItem ApplyDnsProfileInput(DNSItem current, ECoreType coreType, DnsProfileInput input) => new() + { + Id = current.Id, + Remarks = input.Remarks ?? current.Remarks, + Enabled = input.Enabled, + CoreType = coreType, + UseSystemHosts = input.UseSystemHosts, + NormalDNS = input.NormalDNS, + TunDNS = input.TunDNS, + DomainStrategy4Freedom = input.DomainStrategy4Freedom, + DomainDNSAddress = input.DomainDNSAddress, + }; + + internal static bool IsValidCustomDns(ECoreType coreType, string? content) + { + if (string.IsNullOrEmpty(content)) return true; + if (coreType == ECoreType.sing_box) + { + var parsed = JsonUtils.Deserialize(content); + return parsed?.servers is { Count: > 0 } + && parsed.servers.All(server => !string.IsNullOrEmpty(server.type)); + } + + if (!content.TrimStart().StartsWith('{')) + { + return !content.Contains('{') && !content.Contains('}'); + } + var json = JsonUtils.ParseJson(content); + return json is not null && json["servers"] is not null; + } + + public async Task> GetRoutingProfilesAsync() => await AppManager.Instance.RoutingItems() ?? []; + + public async Task SaveRoutingProfileAsync(RoutingItem input, string? routingId = null) + { + if (string.IsNullOrWhiteSpace(input.Remarks)) + { + return OperationView.Fail("routing_name_required", ApiMessageKeys.RoutingNameRequired); + } + if (!AreRoutingProfileStrategiesValid(input.DomainStrategy, input.DomainStrategy4Singbox)) + { + return OperationView.Fail("routing_strategy_invalid", ApiMessageKeys.SettingsInvalidRoutingStrategy); + } + + var ruleJson = string.IsNullOrWhiteSpace(input.RuleSet) ? "[]" : input.RuleSet; + var parsedRules = JsonUtils.Deserialize>(ruleJson); + if (parsedRules is null) + { + return OperationView.Fail("routing_rules_json_invalid", ApiMessageKeys.RoutingRulesInvalid); + } + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var save = await _mutations.RunAsync(async () => + { + RoutingItem item; + var changed = string.IsNullOrEmpty(routingId); + if (string.IsNullOrEmpty(routingId)) + { + item = input; + item.Id = string.Empty; + item.IsActive = false; + var existing = await AppManager.Instance.RoutingItems() ?? []; + item.Sort = item.Sort > 0 ? item.Sort : (existing.MaxBy(route => route.Sort)?.Sort ?? 0) + 1; + } + else + { + var existingItem = await AppManager.Instance.GetRoutingItem(routingId); + if (existingItem is null) + { + return (Result: -2, Item: (RoutingItem?)null, Changed: false); + } + item = existingItem; + changed = item.Remarks != input.Remarks + || item.Url != input.Url + || item.Enabled != input.Enabled + || item.Locked != input.Locked + || item.CustomIcon != input.CustomIcon + || item.CustomRulesetPath4Singbox != input.CustomRulesetPath4Singbox + || item.DomainStrategy != input.DomainStrategy + || item.DomainStrategy4Singbox != input.DomainStrategy4Singbox + || item.RuleSet != ruleJson; + item.Remarks = input.Remarks; + item.Url = input.Url; + item.Enabled = input.Enabled; + item.Locked = input.Locked; + item.CustomIcon = input.CustomIcon; + item.CustomRulesetPath4Singbox = input.CustomRulesetPath4Singbox; + item.DomainStrategy = input.DomainStrategy; + item.DomainStrategy4Singbox = input.DomainStrategy4Singbox; + } + + if (parsedRules.Count == 0) + { + item.RuleSet = "[]"; + item.RuleNum = 0; + } + else + { + item.RuleNum = parsedRules.Count; + } + var result = parsedRules.Count == 0 + ? await ConfigHandler.SaveRoutingItem(Config, item) + : await ConfigHandler.AddBatchRoutingRules(item, JsonUtils.Serialize(parsedRules, false)); + return (Result: result, Item: (RoutingItem?)item, Changed: changed); + }); + if (save.Result == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (save.Result != 0 || save.Item is null) + { + return OperationView.Fail("routing_profile_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + + return await CompleteCoreAffectingChangeAsync("routing-profiles", save.Changed && save.Item.IsActive, applyContext, + data: new { routingId = save.Item.Id }); + } + + public async Task DeleteRoutingProfileAsync(string id) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var removed = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(id); + if (item is null) + { + return (Removed: false, AffectsCore: false); + } + await ConfigHandler.RemoveRoutingItem(item); + if (item.IsActive) + { + var remaining = await AppManager.Instance.RoutingItems() ?? []; + var fallback = remaining.FirstOrDefault(); + if (fallback is not null) + { + if (await ConfigHandler.SetDefaultRouting(Config, fallback) != 0) + { + throw new IOException("ServiceLib could not select the fallback routing profile."); + } + } + else + { + Config.RoutingBasicItem.RoutingIndexId = string.Empty; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + } + return (Removed: true, AffectsCore: item.IsActive); + }); + if (!removed.Removed) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", removed.AffectsCore, applyContext, + ApiMessageKeys.CommonDeleted, new { routingId = id }); + } + + public async Task ActivateRoutingProfileAsync(string id) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(id); + if (item is null) + { + return (Result: -2, Changed: false); + } + var changed = !item.IsActive; + return (Result: changed ? await ConfigHandler.SetDefaultRouting(Config, item) : 0, Changed: changed); + }); + if (result.Result == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (result.Result != 0) + { + return OperationView.Fail("routing_profile_activate_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", result.Changed, applyContext, + ApiMessageKeys.CommonCompleted, new { routingId = id }); + } + + public async Task> GetRoutingRulesAsync(string routingId) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + return JsonUtils.Deserialize>(item?.RuleSet ?? "[]") ?? []; + } + + public async Task SaveRoutingRulesAsync(string routingId, IEnumerable rules) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var items = rules.ToArray(); + var before = await AppManager.Instance.GetRoutingItem(routingId); + var changed = before is not null && before.IsActive + && before.RuleSet != JsonUtils.Serialize(items, false); + var saved = await _mutations.RunAsync(() => SaveRoutingRulesLockedAsync(routingId, items)); + if (saved == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (saved != 0) + { + return OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-rules", changed, applyContext, data: new { routingId }); + } + + private static async Task SaveRoutingRulesLockedAsync(string routingId, IReadOnlyList rules) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + return await ConfigHandler.AddBatchRoutingRules(item, JsonUtils.Serialize(rules, false)); + } + + private async Task GetActiveRoutingSignatureAsync() + { + var active = (await AppManager.Instance.RoutingItems() ?? []).FirstOrDefault(item => item.IsActive); + return active is null + ? string.Empty + : System.Text.Json.JsonSerializer.Serialize(new + { + active.Id, + active.RuleSet, + active.Enabled, + active.DomainStrategy, + active.DomainStrategy4Singbox, + active.CustomRulesetPath4Singbox, + }); + } + + public async Task ImportRoutingRulesAsync(string routingId, RouteRulesImportInput request) + { + var incoming = JsonUtils.Deserialize>(request.Content); + if (incoming is null) + { + return OperationView.Fail("routing_rules_json_invalid", ApiMessageKeys.RoutingRulesInvalid); + } + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var activeBefore = await AppManager.Instance.GetRoutingItem(routingId); + var beforeRuleSet = activeBefore?.RuleSet; + var saved = await _mutations.RunAsync(async () => + { + if (request.Append) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var existing = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + existing.AddRange(incoming); + incoming = existing; + } + return await SaveRoutingRulesLockedAsync(routingId, incoming); + }); + if (saved == -2) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (saved != 0) + { + return OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed); + } + var activeAfter = await AppManager.Instance.GetRoutingItem(routingId); + var changed = activeBefore?.IsActive == true && activeAfter?.RuleSet != beforeRuleSet; + return await CompleteCoreAffectingChangeAsync("routing-rules", changed, applyContext, data: new { routingId }); + } + + public async Task ImportRoutingRulesFromUrlAsync(string routingId, bool append, CancellationToken cancellationToken) + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound); + } + if (!Uri.TryCreate(item.Url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return OperationView.Fail("routing_url_invalid", "routing.urlInvalid"); + } + + var download = new DownloadService(); + var content = await download.TryDownloadString(uri.AbsoluteUri, true, string.Empty, cancellationToken); + if (string.IsNullOrWhiteSpace(content)) + { + return OperationView.Fail("routing_url_download_failed", "routing.urlImportFailedGeneric"); + } + + return await ImportRoutingRulesAsync(routingId, new RouteRulesImportInput(content, append)); + } + + public async Task MoveRoutingRuleAsync(string routingId, string ruleId, EMove direction, int position) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var routeWasActive = (await AppManager.Instance.GetRoutingItem(routingId))?.IsActive == true; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var rules = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + var index = rules.FindIndex(rule => rule.Id == ruleId); + if (index < 0) + { + return -3; + } + if (await ConfigHandler.MoveRoutingRule(rules, index, direction, position) != 0) + { + return -4; + } + return await SaveRoutingRulesLockedAsync(routingId, rules); + }); + if (result != 0) + { + return result switch + { + -2 => OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound), + -3 => OperationView.Fail("routing_rule_not_found", ApiMessageKeys.RoutingRuleNotFound), + -4 => OperationView.Fail("routing_rule_move_failed", ApiMessageKeys.CommonInvalidInput), + _ => OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed), + }; + } + return await CompleteCoreAffectingChangeAsync("routing-rules", routeWasActive, applyContext, data: new { routingId }); + } + + public async Task DeleteRoutingRuleAsync(string routingId, string ruleId) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var routeWasActive = (await AppManager.Instance.GetRoutingItem(routingId))?.IsActive == true; + var result = await _mutations.RunAsync(async () => + { + var item = await AppManager.Instance.GetRoutingItem(routingId); + if (item is null) + { + return -2; + } + var rules = JsonUtils.Deserialize>(item.RuleSet ?? "[]") ?? []; + if (rules.RemoveAll(rule => rule.Id == ruleId) == 0) + { + return -3; + } + return await SaveRoutingRulesLockedAsync(routingId, rules); + }); + if (result != 0) + { + return result switch + { + -2 => OperationView.Fail("routing_profile_not_found", ApiMessageKeys.RoutingProfileNotFound), + -3 => OperationView.Fail("routing_rule_not_found", ApiMessageKeys.RoutingRuleNotFound), + _ => OperationView.Fail("routing_rules_save_failed", ApiMessageKeys.RoutingSaveFailed), + }; + } + return await CompleteCoreAffectingChangeAsync("routing-rules", routeWasActive, applyContext, data: new { routingId }); + } + + public async Task ImportRoutingProfilesAsync() + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var before = await GetActiveRoutingSignatureAsync(); + var result = await _mutations.RunAsync(() => ConfigHandler.InitRouting(Config, true)); + if (result != 0) + { + return OperationView.Fail("routing_import_failed", ApiMessageKeys.RoutingSaveFailed); + } + return await CompleteCoreAffectingChangeAsync("routing-profiles", before != await GetActiveRoutingSignatureAsync(), applyContext, + ApiMessageKeys.CommonCompleted); + } + + public async Task> GetFullConfigTemplatesAsync() => + (await AppManager.Instance.FullConfigTemplateItem() ?? []) + .Select(ToCoreConfigTemplateView) + .ToArray(); + + internal static CoreConfigTemplateView ToCoreConfigTemplateView(FullConfigTemplateItem item) => new( + item.Id, item.Remarks, item.Enabled, item.CoreType, item.Config, item.TunConfig, item.AddProxyOnly, item.ProxyDetour); + + internal static FullConfigTemplateItem ApplyCoreConfigTemplateInput( + FullConfigTemplateItem current, + ECoreType coreType, + CoreConfigTemplateInput input) => new() + { + Id = current.Id, + Remarks = input.Remarks ?? current.Remarks, + Enabled = input.Enabled, + CoreType = coreType, + Config = input.Config, + TunConfig = input.TunConfig, + AddProxyOnly = input.AddProxyOnly, + ProxyDetour = input.ProxyDetour, + }; + + public async Task SaveFullConfigTemplateAsync(ECoreType coreType, CoreConfigTemplateInput input) + { + await using var applyContext = await BeginCoreSettingsApplyAsync(); + if (applyContext.Failure is { } applyFailure) return applyFailure; + var existingBefore = await AppManager.Instance.GetFullConfigTemplateItem(coreType); + var changed = existingBefore is not null + && (existingBefore.Remarks != (input.Remarks ?? existingBefore.Remarks) + || existingBefore.Enabled != input.Enabled + || existingBefore.Config != input.Config + || existingBefore.AddProxyOnly != input.AddProxyOnly + || existingBefore.ProxyDetour != input.ProxyDetour); + var result = await _mutations.RunAsync(async () => + { + var current = await AppManager.Instance.GetFullConfigTemplateItem(coreType); + if (current is null) + { + return -2; + } + var item = ApplyCoreConfigTemplateInput(current, coreType, input); + return await ConfigHandler.SaveFullConfigTemplate(Config, item); + }); + if (result == -2) + { + return OperationView.Fail("core_template_not_found", ApiMessageKeys.CommonNotFound, new { coreType = coreType.ToString() }); + } + if (result != 0) + { + return OperationView.Fail("core_template_save_failed", ApiMessageKeys.CommonInvalidInput); + } + return await CompleteCoreAffectingChangeAsync("core-template", + changed && CurrentCoreRuntime.CoreType == coreType, + applyContext, + data: new { coreType = coreType.ToString() }); + } + + public async Task ApplyRegionalPresetAsync(EPresetType preset, CancellationToken cancellationToken = default) + { + if (preset is not (EPresetType.Default or EPresetType.Russia or EPresetType.Iran)) + { + return OperationView.Fail("regional_preset_invalid", ApiMessageKeys.RegionalPresetInvalid); + } + + await _regionalPresetGate.WaitAsync(cancellationToken); + try + { + var existingDnsItems = await AppManager.Instance.DNSItems() ?? []; + var existingRoutingItems = await AppManager.Instance.RoutingItems() ?? []; + var proxy = preset == EPresetType.Default + ? null + : await GetRegionalPresetProxyAsync(cancellationToken); + var stager = new RegionalPresetStager(async (url, webProxy, token) => + { + var download = new DownloadService(); + return await download.TryDownloadString(url, webProxy, string.Empty, token); + }); + + return await RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync( + preset, + existingDnsItems, + existingRoutingItems, + proxy, + token, + Config.RoutingBasicItem.RoutingIndexId), + ApplyStagedRegionalPresetAsync, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (RegionalPresetStagingException exception) + { + AddLog("settings", $"Regional preset staging failed ({preset}); no settings were changed: {exception.InnerException}"); + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (Exception exception) + { + AddLog("settings", $"Regional preset workflow failed ({preset}): {exception}"); + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + finally + { + _regionalPresetGate.Release(); + } + } + + private async Task ApplyStagedRegionalPresetAsync( + RegionalPresetStage stage, + CancellationToken cancellationToken) + { + await using var maintenance = await _operations.EnterExclusiveAsync( + cancellationToken, + allowReadOnlyObservations: true); + var operationToken = maintenance.Token; + await using var applyContext = await BeginCoreSettingsApplyAsync(operationToken); + if (applyContext.Failure is { } applyFailure) return applyFailure; + + RegionalPresetApplyResult? applyResult = null; + OperationView? coreApplyResult = null; + CoreSettingsRollbackResult? presetRollback = null; + try + { + await RegionalPresetApplyWorkflow.RunAsync( + applyConfiguration: async token => + { + applyResult = await RegionalPresetTransaction.RunAsync( + () => _mutations.RunAsync(async () => + { + token.ThrowIfCancellationRequested(); + Config.ConstItem.GeoSourceUrl = stage.GeoSourceUrl; + Config.ConstItem.SrsSourceUrl = stage.SrsSourceUrl; + Config.ConstItem.RouteRulesTemplateSourceUrl = stage.RouteRulesTemplateSourceUrl; + Config.SimpleDNSItem = stage.SimpleDnsItem; + + if (stage.Preset == EPresetType.Default) + { + await SQLiteHelper.Instance.DeleteAllAsync(); + foreach (var item in stage.DnsItems) + { + if (await ConfigHandler.SaveDNSItems(Config, item) != 0) + { + throw new IOException($"The {item.CoreType} DNS profile could not be saved."); + } + } + + // Default routing uses embedded data only; this call performs no network I/O + // because the route template source was cleared in the staged plan. + if (await ConfigHandler.InitRouting(Config, false) != 0) + { + throw new IOException("The built-in routing profiles could not be initialized."); + } + } + else + { + foreach (var item in stage.DnsItems) + { + if (await ConfigHandler.SaveDNSItems(Config, item) != 0) + { + throw new IOException($"The {item.CoreType} DNS profile could not be saved."); + } + } + + foreach (var itemId in stage.Routing.RemoveItemIds ?? []) + { + var item = await AppManager.Instance.GetRoutingItem(itemId); + if (item is not null) + { + await ConfigHandler.RemoveRoutingItem(item); + } + } + + if (stage.Routing.ClearLegacyRoutingIndex) + { + Config.RoutingBasicItem.RoutingIndexId = string.Empty; + } + + var currentRoutes = await AppManager.Instance.RoutingItems() ?? []; + var hasVersion = stage.Routing.TemplateVersion is { Length: > 0 } version + && currentRoutes.Any(item => item.Remarks?.StartsWith(version, StringComparison.Ordinal) == true); + var builtinFallbackAlreadyInitialized = stage.Routing.IsBuiltinFallback && currentRoutes.Count > 0; + if (!hasVersion && !builtinFallbackAlreadyInitialized) + { + var nextSort = currentRoutes.Count; + foreach (var item in stage.Routing.Items) + { + item.Sort = ++nextSort; + if (await ConfigHandler.AddBatchRoutingRules(item, item.RuleSet ?? "[]") != 0) + { + throw new IOException($"The routing profile '{item.Remarks}' could not be saved."); + } + } + + var defaultItem = stage.Routing.DefaultRemarks is { } remarks + ? stage.Routing.Items.FirstOrDefault(item => item.Remarks == remarks) + : null; + if (defaultItem is not null && await ConfigHandler.SetDefaultRouting(Config, defaultItem) != 0) + { + throw new IOException("The first staged routing profile could not be activated."); + } + } + + if (stage.Routing.ExistingDefaultItemId is { } existingDefaultId) + { + var existingDefault = currentRoutes.FirstOrDefault(item => item.Id == existingDefaultId); + if (existingDefault is not null && !existingDefault.IsActive + && await ConfigHandler.SetDefaultRouting(Config, existingDefault) != 0) + { + throw new IOException("The migrated default routing profile could not be activated."); + } + } + } + + token.ThrowIfCancellationRequested(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + token.ThrowIfCancellationRequested(); + }, token), + () => RestoreRegionalPresetSnapshotAsync(applyContext)); + + if (!applyResult.Success) + { + AddLog("settings", $"Regional preset config apply failed ({stage.Preset}); rollback={(applyResult.RollbackSucceeded ? "succeeded" : "failed")}: {applyResult.Error}"); + if (token.IsCancellationRequested) + { + if (!applyResult.RollbackSucceeded) + { + _ = await RollBackCoreSettingsAsync(applyContext, "regional_preset_config_cancelled"); + } + throw new OperationCanceledException(token); + } + if (!applyResult.RollbackSucceeded) + { + var fallbackRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_config_apply_failed"); + AddLog("settings", $"Regional preset config rollback fallback ({stage.Preset}) completed={fallbackRollback.RolledBack}; runtimeRestored={fallbackRollback.OldRuntimeRestored}."); + } + throw new RegionalPresetConfigApplyException(); + } + + }, + updateGeoFilesTransaction: (beforeCommit, token) => ApplyGeoFilesUpdateAsync( + Config.CheckUpdateItem.UpdateViaProxy, + token, + publishProgress: false, + beforeCommit: beforeCommit), + completeCoreApply: async (rollbackGeoFiles, token) => + { + // From this point, finish Core apply/recovery even if the HTTP client disconnects. + // A failed Core apply first restores GeoFiles through this transaction scope, + // then the existing settings rollback can relaunch the previous runtime safely. + token.ThrowIfCancellationRequested(); + coreApplyResult = await CompleteCoreAffectingChangeAsync("regional-preset", true, + applyContext, + ApiMessageKeys.CommonCompleted, + new { preset = stage.Preset.ToString() }, + beforeRollback: rollbackGeoFiles); + if (!coreApplyResult.Success) + { + AddLog("settings", $"Regional preset Core restart failed ({stage.Preset}); Core settings rollback was requested."); + throw new RegionalPresetCoreApplyFailedException(); + } + }, + rollbackConfigurationAndRuntime: async () => + { + if (coreApplyResult is { Success: false }) + { + return; + } + + presetRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_geofiles_failed"); + if (!presetRollback.RolledBack) + { + AddLog("settings", $"Regional preset rollback failed ({stage.Preset}): {presetRollback.Detail}"); + } + }, + cancellationToken: operationToken); + + return coreApplyResult ?? OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (RegionalPresetConfigApplyException) + { + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (RegionalPresetCoreApplyFailedException) + { + // The enclosing GeoFiles transaction restored its snapshot before this result is returned. + return coreApplyResult ?? OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed); + } + catch (OperationCanceledException) when (operationToken.IsCancellationRequested) + { + if (presetRollback is not null) + { + AddLog("settings", $"Regional preset GeoFiles update canceled ({stage.Preset}); config/runtime rollback={presetRollback.RolledBack}."); + } + throw; + } + catch (Exception exception) + { + if (coreApplyResult is { Success: false }) + { + // CompleteCoreAffectingChangeAsync already restored config/runtime. This means + // the GeoFiles transaction itself also reported an incomplete file rollback. + AddLog("settings", $"Regional preset GeoFiles rollback failed after Core restart failure ({stage.Preset}): {exception}"); + return coreApplyResult; + } + + AddLog("settings", $"Regional preset GeoFiles update failed ({stage.Preset}); restoring config, DNS, routing, and runtime: {exception}"); + if (presetRollback is null) + { + presetRollback = await RollBackCoreSettingsAsync(applyContext, "regional_preset_geofiles_failed"); + } + var geoFilesRolledBack = exception is not GeoFilesUpdateRollbackException; + return OperationView.Fail("regional_preset_failed", ApiMessageKeys.RegionalPresetFailed, + new + { + preset = stage.Preset.ToString(), + rolledBack = presetRollback.RolledBack && geoFilesRolledBack, + configAndRuntimeRolledBack = presetRollback.RolledBack, + geoFilesRolledBack, + oldRuntimeRestored = presetRollback.OldRuntimeRestored, + }); + } + } + + private async Task RestoreRegionalPresetSnapshotAsync(CoreSettingsApplyContext context) + { + try + { + await _mutations.RunAsync(async () => + { + RestoreConfigValues(Config, context.OldConfig); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + + await SQLiteHelper.Instance.DeleteAllAsync(); + if (context.OldRoutingItems.Length > 0 + && await SQLiteHelper.Instance.InsertAllAsync(context.OldRoutingItems) != context.OldRoutingItems.Length) + { + throw new IOException("Routing profiles could not be restored from the regional preset snapshot."); + } + await SQLiteHelper.Instance.DeleteAllAsync(); + if (context.OldDnsItems.Length > 0 + && await SQLiteHelper.Instance.InsertAllAsync(context.OldDnsItems) != context.OldDnsItems.Length) + { + throw new IOException("DNS profiles could not be restored from the regional preset snapshot."); + } + AppManager.Instance.Reset(); + }); + return true; + } + catch (Exception exception) + { + AddLog("settings", $"Regional preset rollback failed: {exception}"); + return false; + } + } + + internal static bool ShouldShowIpInfoColumn(string? ipApiUrl, bool hideColumnIpInfo) => + !string.IsNullOrEmpty(ipApiUrl) && !hideColumnIpInfo; + + internal static void ApplyInboundSettings(InItem target, InboundSettingsInput input) + { + var destOverride = NormalizeDestOverride(input.SniffingEnabled, input.DestOverride); + target.LocalPort = input.LocalPort; + target.SecondLocalPortEnabled = input.SecondLocalPortEnabled; + target.UdpEnabled = input.UdpEnabled; + target.SniffingEnabled = input.SniffingEnabled; + target.DestOverride = destOverride.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + target.RouteOnly = input.RouteOnly; + target.AllowLANConn = input.AllowLANConn; + target.NewPort4LAN = input.AllowLANConn && input.NewPort4LAN; + target.User = input.User?.Trim() ?? string.Empty; + target.Pass = input.Pass?.Trim() ?? string.Empty; + } + + internal static string[] NormalizeDestOverride(bool willEnableSniffing, IEnumerable? destOverride) + { + var protocols = destOverride?.ToArray() ?? []; + if (willEnableSniffing && protocols.Length == 0) + { + // Use ServiceLib's defaults so Web stays aligned when upstream changes them. + return new InItem().DestOverride?.ToArray() ?? []; + } + + return protocols; + } + + public async Task ClearStatisticsAsync() + { + await _mutations.RunAsync(() => StatisticsManager.Instance.ClearAllServerStatistics()); + _latestTraffic = null; + _events.Publish("traffic", null!); + _events.Publish("profiles-changed", new { subscriptionId = Config.SubIndexId }); + return OperationView.Ok(ApiMessageKeys.CommonDeleted); + } + + public Task> GetRunningOperationsAsync() + { + var operations = GetRunningSubscriptionUpdates().ToList(); + if (_speedtestTask is { IsCompleted: false }) + { + operations.Add("speedtest"); + } + operations.AddRange(GetRunningCoreUpdateOperations()); + if (CurrentCoreRuntime.State != CoreRuntimeState.Stopped) + { + operations.Add("core"); + } + return Task.FromResult>(operations); + } + + private static bool IsHttpUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; + + internal static bool IsWebEditableCoreType(EConfigType configType) => WebEditableCoreTypes.Contains(configType); + + internal static bool AreWebCoreTypeMappingsValid(IReadOnlyCollection mappings) => + mappings.All(mapping => IsWebEditableCoreType(mapping.ConfigType) && Enum.IsDefined(mapping.CoreType)) + && mappings.Select(mapping => mapping.ConfigType).Distinct().Count() == mappings.Count; + + internal static bool AreDestOverrideProtocolsValid(IEnumerable? protocols) => + protocols is null || protocols.All(Global.destOverrideProtocols.Contains); + + internal static bool IsRootCertProviderValid(string? provider) => + string.IsNullOrEmpty(provider) || Global.RootCertProviders.Contains(provider); + + internal static bool AreCoreSettingsOptionsValid(CoreSettingsInput input) => + Global.Fingerprints.Contains(input.DefFingerprint ?? string.Empty) + && (string.IsNullOrEmpty(input.DefUserAgent) || Global.UserAgent.Contains(input.DefUserAgent)) + && Global.SingboxMuxs.Contains(input.Mux4SboxProtocol ?? string.Empty) + && (string.IsNullOrEmpty(input.Mux4RayXudpProxyUDP443) + || input.Mux4RayXudpProxyUDP443 is "reject" or "skip") + && (string.IsNullOrEmpty(input.FragmentPackets) + || Global.FragmentPacketsOptions.Contains(input.FragmentPackets)); + + internal static bool AreRoutingProfileStrategiesValid(string? domainStrategy, string? domainStrategy4Singbox) => + Global.DomainStrategies.AppendEmpty().Contains(domainStrategy ?? string.Empty) + && Global.DomainStrategies4Sbox.Contains(domainStrategy4Singbox ?? string.Empty); + + private string GetCoreConfigurationFingerprint() => System.Text.Json.JsonSerializer.Serialize(new + { + Inbound = Config.Inbound.Select(item => new + { + item.LocalPort, + Protocol = item.Protocol ?? string.Empty, + item.UdpEnabled, + item.SniffingEnabled, + DestOverride = item.DestOverride ?? [], + item.RouteOnly, + item.AllowLANConn, + item.NewPort4LAN, + User = item.User ?? string.Empty, + Pass = item.Pass ?? string.Empty, + item.SecondLocalPortEnabled, + }), + CoreBasic = new + { + Config.CoreBasicItem.LogEnabled, + Loglevel = Config.CoreBasicItem.Loglevel ?? string.Empty, + DefFingerprint = Config.CoreBasicItem.DefFingerprint ?? string.Empty, + DefUserAgent = Config.CoreBasicItem.DefUserAgent ?? string.Empty, + SendThrough = Config.CoreBasicItem.SendThrough ?? string.Empty, + BindInterface = Config.CoreBasicItem.BindInterface ?? string.Empty, + Config.CoreBasicItem.EnableFragment, + Config.CoreBasicItem.EnableFinalFragment, + Config.CoreBasicItem.EnableCacheFile4Sbox, + }, + Mux4Ray = new + { + Config.Mux4RayItem.Concurrency, + Config.Mux4RayItem.XudpConcurrency, + XudpProxyUDP443 = Config.Mux4RayItem.XudpProxyUDP443 ?? string.Empty, + }, + Mux4Sbox = new + { + Protocol = Config.Mux4SboxItem.Protocol ?? string.Empty, + Config.Mux4SboxItem.MaxConnections, + Config.Mux4SboxItem.Padding, + }, + Config.HysteriaItem.UpMbps, + Config.HysteriaItem.DownMbps, + Fragment = new + { + Packets = Config.Fragment4RayItem?.Packets ?? string.Empty, + Lengths = Config.Fragment4RayItem?.Lengths ?? [], + Delays = Config.Fragment4RayItem?.Delays ?? [], + MaxSplit = Config.Fragment4RayItem?.MaxSplit ?? string.Empty, + }, + Config.SimpleDNSItem, + Routing = new + { + DomainStrategy = Config.RoutingBasicItem.DomainStrategy ?? string.Empty, + DomainStrategy4Singbox = Config.RoutingBasicItem.DomainStrategy4Singbox ?? string.Empty, + }, + CoreTypeMapping = Config.CoreTypeItem ?? [], + }); + + private async Task CompleteCoreAffectingChangeAsync( + string section, + bool changed, + CoreSettingsApplyContext context, + string successMessageKey = ApiMessageKeys.CommonSaved, + object? data = null, + Func? beforeRollback = null) + { + var runtime = CurrentCoreRuntime; + var hasActiveChild = HasTrackedCoreProcesses; + var execution = await CoreSettingsApplyExecutor.ExecuteAsync( + runtime.State, + hasActiveChild, + changed, + () => RestartCoreLockedAsync(CancellationToken.None)); + var action = execution.Action; + if (action is CoreSettingsApplyAction.Busy or CoreSettingsApplyAction.Inconsistent) + { + var failure = action == CoreSettingsApplyAction.Busy + ? OperationView.Fail("core_runtime_busy", ApiMessageKeys.CoreRuntimeBusy, + new { runtimeState = runtime.State.ToString().ToLowerInvariant() }) + : OperationView.Fail("core_runtime_faulted", ApiMessageKeys.SettingsCoreApplyFailed, + new { runtimeState = runtime.State.ToString().ToLowerInvariant(), processIds = GetActiveCoreProcessIds() }); + var rollback = await RollBackCoreSettingsAsync(context, failure.Code, beforeRollback); + return CreateSettingsApplyFailure(section, data, failure.Code, ApiMessageKeys.SettingsCoreApplyFailed, + rollback.ConfigRestored, rollback.RolledBack, rollback.OldRuntimeRestored, rollback.Detail); + } + + if (action == CoreSettingsApplyAction.SaveOnly && runtime.State == CoreRuntimeState.Faulted) + { + // A faulted runtime with no child is not serving stale configuration. Normalize + // the empty runtime to Stopped so the UI does not imply an active Core. + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + + var restarted = false; + if (action == CoreSettingsApplyAction.Restart) + { + var restart = execution.RestartResult!; + if (!restart.Success) + { + AddLog("settings", $"Settings for {section} failed to apply: {restart.Code}; {CurrentCoreRuntime.LastFailure}"); + var rollback = await RollBackCoreSettingsAsync(context, restart.Code, beforeRollback); + return CreateSettingsApplyFailure(section, data, restart.Code, ApiMessageKeys.SettingsCoreApplyFailed, + rollback.ConfigRestored, rollback.RolledBack, rollback.OldRuntimeRestored, rollback.Detail); + } + restarted = true; + } + + _events.Publish("settings-changed", new { section, restartRequired = false, coreRestarted = restarted }); + var resultData = ToResultDictionary(data); + resultData["changed"] = changed; + resultData["coreRestarted"] = restarted; + resultData["restartRequired"] = false; + return OperationView.Ok(restarted ? ApiMessageKeys.CoreRestarted : successMessageKey, resultData); + } + + private async Task BeginCoreSettingsApplyAsync(CancellationToken cancellationToken = default) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + var oldConfig = JsonUtils.DeepCopy(Config) + ?? throw new InvalidOperationException("The current configuration could not be copied for a Core settings apply."); + var oldRuntime = CurrentCoreRuntime; + var hasActiveChild = HasTrackedCoreProcesses; + var action = CoreSettingsApplyPolicy.Decide(oldRuntime.State, hasActiveChild, changed: false); + var failure = action switch + { + CoreSettingsApplyAction.Busy => OperationView.Fail("core_runtime_busy", ApiMessageKeys.CoreRuntimeBusy, + new { runtimeState = oldRuntime.State.ToString().ToLowerInvariant() }), + CoreSettingsApplyAction.Inconsistent => OperationView.Fail("core_runtime_faulted", ApiMessageKeys.SettingsCoreApplyFailed, + new { runtimeState = oldRuntime.State.ToString().ToLowerInvariant(), processIds = GetActiveCoreProcessIds() }), + _ => null, + }; + var oldRoutingItems = failure is null ? await AppManager.Instance.RoutingItems() ?? [] : []; + var oldDnsItems = failure is null ? await AppManager.Instance.DNSItems() ?? [] : []; + var oldFullConfigTemplates = failure is null ? await AppManager.Instance.FullConfigTemplateItem() ?? [] : []; + return new CoreSettingsApplyContext(_coreGate, oldConfig, oldRuntime, hasActiveChild, + oldRoutingItems.ToArray(), oldDnsItems.ToArray(), oldFullConfigTemplates.ToArray(), failure); + } + catch + { + _coreGate.Release(); + throw; + } + } + + private async Task RollBackCoreSettingsAsync( + CoreSettingsApplyContext context, + string failureCode, + Func? beforeRuntimeRestore = null) + { + var configRestored = false; + Exception? additionalRollbackFailure = null; + if (beforeRuntimeRestore is not null) + { + try + { + await beforeRuntimeRestore(); + } + catch (Exception exception) + { + additionalRollbackFailure = exception; + AddLog("settings", $"Additional state rollback failed before Core recovery ({failureCode}): {exception}"); + } + } + + try + { + RestoreConfigValues(Config, context.OldConfig); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + configRestored = true; + await _mutations.RunAsync(async () => + { + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldRoutingItems) != context.OldRoutingItems.Length) + throw new IOException("Routing profiles could not be restored from the settings snapshot."); + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldDnsItems) != context.OldDnsItems.Length) + throw new IOException("DNS profiles could not be restored from the settings snapshot."); + await SQLiteHelper.Instance.DeleteAllAsync(); + if (await SQLiteHelper.Instance.InsertAllAsync(context.OldFullConfigTemplates) != context.OldFullConfigTemplates.Length) + throw new IOException("Core templates could not be restored from the settings snapshot."); + }); + AppManager.Instance.Reset(); + + var current = CurrentCoreRuntime; + if (context.HadActiveChild + && current.State == CoreRuntimeState.Running + && HasTrackedCoreProcesses) + { + if (additionalRollbackFailure is not null) + { + AddLog("settings", $"Core settings were restored and the original Core remained healthy, but additional state rollback failed ({failureCode})."); + return new(false, true, true, additionalRollbackFailure.Message); + } + AddLog("settings", $"Core settings apply failed ({failureCode}); the original Core remained healthy and its previous configuration was restored."); + return new(true, true, true, null); + } + + if (HasTrackedCoreProcesses || current.State != CoreRuntimeState.Stopped) + { + var stop = await StopCoreLockedAsync(CancellationToken.None); + if (!stop.Success) + { + throw new InvalidOperationException($"Could not stop the failed Core before rollback: {stop.Code}."); + } + } + + if (!context.HadActiveChild) + { + if (additionalRollbackFailure is not null) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = $"Settings rollback failed ({failureCode}); additional state rollback failed: {additionalRollbackFailure.Message}", + }); + return new(false, false, configRestored, additionalRollbackFailure.Message); + } + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + AddLog("settings", $"Core settings apply failed ({failureCode}); the original stopped runtime intent was restored."); + return new(true, true, true, null); + } + + if (additionalRollbackFailure is not null) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = $"Settings rollback failed ({failureCode}); the previous Core was not relaunched because additional state rollback failed: {additionalRollbackFailure.Message}", + }); + return new(false, false, configRestored, additionalRollbackFailure.Message); + } + + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + var oldProfileId = context.OldRuntime.ProfileId ?? Config.IndexId; + var oldProfile = await AppManager.Instance.GetProfileItem(oldProfileId) + ?? throw new InvalidOperationException($"The previous Core profile {oldProfileId} no longer exists."); + var restored = await StartCoreLockedAsync(oldProfile, CancellationToken.None); + if (!restored.Success) + { + throw new InvalidOperationException($"The previous Core runtime could not be restored ({restored.Code})."); + } + + AddLog("settings", $"Core settings apply failed ({failureCode}); the previous configuration and Core runtime were restored."); + return new(true, true, true, null); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = $"Settings apply failed ({failureCode}); rollback failed: {exception.Message}", + }); + AddLog("settings", $"Settings rollback failed after {failureCode}: {exception}"); + return new(false, false, configRestored, exception.Message); + } + } + + private OperationView CreateSettingsApplyFailure( + string section, + object? data, + string? failureCode, + string messageKey, + bool configRestored, + bool rolledBack, + bool oldRuntimeRestored, + string? rollbackDetail) + { + var runtime = CurrentCoreRuntime; + var failureData = ToResultDictionary(data); + failureData["section"] = section; + failureData["configSaved"] = !configRestored; + failureData["configRestored"] = configRestored; + failureData["restartRequired"] = true; + failureData["rolledBack"] = rolledBack; + failureData["oldRuntimeRestored"] = oldRuntimeRestored; + failureData["runtimeState"] = runtime.State.ToString().ToLowerInvariant(); + failureData["configuredProxyPort"] = Config.Inbound.FirstOrDefault()?.LocalPort; + failureData["runningProxyPort"] = runtime.ProxyPort; + failureData["coreResultCode"] = failureCode; + failureData["detail"] = runtime.LastFailure; + failureData["rollbackDetail"] = rollbackDetail; + _events.Publish("settings-changed", new { section, restartRequired = true, restartFailed = true, rolledBack, oldRuntimeRestored }); + return OperationView.Fail("settings_core_apply_failed", messageKey, failureData); + } + + private static Dictionary ToResultDictionary(object? data) + { + if (data is IReadOnlyDictionary readOnly) + { + return new Dictionary(readOnly, StringComparer.Ordinal); + } + if (data is IDictionary mutable) + { + return new Dictionary(mutable, StringComparer.Ordinal); + } + + var result = new Dictionary(StringComparer.Ordinal); + if (data is not null) + { + foreach (var property in data.GetType().GetProperties()) + { + result[property.Name] = property.GetValue(data); + } + } + return result; + } + + private static bool ValidateCoreSettingsInput(CoreSettingsInput input) + { + var fragmentLengths = input.FragmentLengths ?? []; + var fragmentDelays = input.FragmentDelays ?? []; + return AreCoreSettingsOptionsValid(input) + && Global.LogLevels.Contains(input.Loglevel ?? string.Empty, StringComparer.OrdinalIgnoreCase) + && input.Mux4RayConcurrency is >= 0 and <= 1024 + && input.Mux4RayXudpConcurrency is >= 0 and <= 1024 + && input.Hy2UpMbps >= 0 + && input.Hy2DownMbps >= 0 + && fragmentLengths.All(value => Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + && fragmentDelays.All(value => Utils.TryParseRange(value, 0, int.MaxValue, out _, out _)) + && (string.IsNullOrWhiteSpace(input.FragmentMaxSplit) + || Utils.TryParseMaxSplit(input.FragmentMaxSplit, 0, 10000, out _, out _)); + } + + private void ApplyCoreSettingsToConfig(CoreSettingsInput input) + { + Config.CoreBasicItem.LogEnabled = input.LogEnabled; + Config.CoreBasicItem.Loglevel = input.Loglevel ?? string.Empty; + Config.CoreBasicItem.DefFingerprint = input.DefFingerprint?.Trim() ?? string.Empty; + Config.CoreBasicItem.DefUserAgent = input.DefUserAgent?.Trim() ?? string.Empty; + Config.CoreBasicItem.SendThrough = input.SendThrough?.Trim(); + Config.CoreBasicItem.BindInterface = input.BindInterface?.Trim(); + Config.Mux4RayItem.Concurrency = input.Mux4RayConcurrency is > 0 ? input.Mux4RayConcurrency : null; + Config.Mux4RayItem.XudpConcurrency = input.Mux4RayXudpConcurrency is > 0 ? input.Mux4RayXudpConcurrency : null; + Config.Mux4RayItem.XudpProxyUDP443 = input.Mux4RayXudpProxyUDP443; + Config.Mux4SboxItem.Protocol = input.Mux4SboxProtocol ?? string.Empty; + Config.Mux4SboxItem.Padding = input.Mux4SboxPadding; + Config.CoreBasicItem.EnableCacheFile4Sbox = input.EnableCacheFile4Sbox; + Config.HysteriaItem.UpMbps = input.Hy2UpMbps; + Config.HysteriaItem.DownMbps = input.Hy2DownMbps; + Config.CoreBasicItem.EnableFragment = input.EnableFragment; + Config.CoreBasicItem.EnableFinalFragment = input.EnableFinalFragment; + Config.Fragment4RayItem ??= new(); + Config.Fragment4RayItem.Packets = input.FragmentPackets; + Config.Fragment4RayItem.Lengths = input.FragmentLengths?.ToList() ?? []; + Config.Fragment4RayItem.Delays = input.FragmentDelays?.ToList() ?? []; + Config.Fragment4RayItem.MaxSplit = input.FragmentMaxSplit; + } + + private static void RestoreConfigValues(Config target, Config snapshot) + { + foreach (var property in typeof(Config).GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) + { + if (property.CanRead && property.CanWrite) + { + property.SetValue(target, property.GetValue(snapshot)); + } + } + } + + private sealed class CoreSettingsApplyContext( + SemaphoreSlim gate, + Config oldConfig, + CoreRuntimeSnapshot oldRuntime, + bool hadActiveChild, + RoutingItem[] oldRoutingItems, + DNSItem[] oldDnsItems, + FullConfigTemplateItem[] oldFullConfigTemplates, + OperationView? failure) : IAsyncDisposable + { + private int _disposed; + + public Config OldConfig { get; } = oldConfig; + public CoreRuntimeSnapshot OldRuntime { get; } = oldRuntime; + public bool HadActiveChild { get; } = hadActiveChild; + public RoutingItem[] OldRoutingItems { get; } = oldRoutingItems; + public DNSItem[] OldDnsItems { get; } = oldDnsItems; + public FullConfigTemplateItem[] OldFullConfigTemplates { get; } = oldFullConfigTemplates; + public OperationView? Failure { get; } = failure; + + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) gate.Release(); + return ValueTask.CompletedTask; + } + } + + private sealed record CoreSettingsRollbackResult(bool RolledBack, bool OldRuntimeRestored, bool ConfigRestored, string? Detail); + + private sealed class RegionalPresetConfigApplyException : Exception { } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs new file mode 100644 index 00000000..d28fb99c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.WebUpdate.cs @@ -0,0 +1,656 @@ +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Text.Json; +using ServiceLib.Common; +using ServiceLib.Manager; +using ServiceLib.Models.Dto; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime +{ + private const string WebUpdateProgressFile = "WebAPI-update-progress.json"; + private Task? _webUpdateTask; + private string? _latestWebUpdateVersion; + + public WebUpdateTargetView GetWebUpdateTarget() + { + var deployment = GetWebUpdateDeployment(); + var build = WebBuildIdentity.Current; + var selected = Config.CheckUpdateItem.SelectedCoreTypes; + var supportedRid = WebUpdatePackageStager.IsSupportedRid(build.Rid); + var runtimeInstallReason = GetWebUpdateRuntimeInstallReason(); + var canInstall = deployment.CanInstall && runtimeInstallReason is null; + return new WebUpdateTargetView( + WebUpdateTarget, + build.Version, + build.Commit, + build.BuildDate, + build.Rid, + ToDeploymentName(deployment.Kind), + supportedRid, + deployment.CanCheck, + canInstall, + supportedRid && (selected?.Contains(WebUpdateTarget, StringComparer.Ordinal) ?? true), + !supportedRid ? "maintenance.webUpdateUnsupported" : deployment.InstallReasonKey ?? runtimeInstallReason, + Volatile.Read(ref _latestWebUpdateVersion) ?? _updateProgress.GetValueOrDefault(WebUpdateTarget)?.Version); + } + + public async Task> CheckWebUpdateAsync( + bool? preRelease, + bool? useProxy, + CancellationToken cancellationToken) + { + var check = await CheckWebUpdateReleaseAsync( + GetUpdatePreRelease(WebUpdateTarget, preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy, + cancellationToken); + Volatile.Write(ref _latestWebUpdateVersion, check.Version); + var deployment = GetWebUpdateDeployment(); + var view = new WebUpdateCheckView( + check.IsUpdateAvailable, + WebBuildIdentity.Current.Version, + WebBuildIdentity.Current.Commit, + WebBuildIdentity.Current.Rid, + check.Version, + check.Commit, + deployment.CanInstall && GetWebUpdateRuntimeInstallReason() is null, + deployment.InstallReasonKey ?? GetWebUpdateRuntimeInstallReason(), + check.Error ?? check.Detail); + if (!string.IsNullOrWhiteSpace(check.Error)) + { + AddLog("update", $"v2rayN.WebAPI update check failed: {check.Error}"); + return ApiEnvelope.Fail("web_update_check_failed", ApiMessageKeys.WebUpdateCheckFailed, view); + } + return ApiEnvelope.Ok(view, + check.IsUpdateAvailable ? ApiMessageKeys.CoreUpdateAvailable : ApiMessageKeys.CoreUpdateCurrent); + } + + public OperationView StartWebUpdate(bool? preRelease = null, bool? useProxy = null) + { + if (!GetWebUpdateDeployment().CanInstall) + { + return OperationView.Fail("web_update_install_unsupported", GetWebUpdateDeployment().InstallReasonKey + ?? ApiMessageKeys.CoreUpdateUnsupported); + } + if (GetWebUpdateRuntimeInstallReason() is { } runtimeReason) + return OperationView.Fail("web_update_runtime_unavailable", runtimeReason); + if (Config.CheckUpdateItem.SelectedCoreTypes is { } selected + && !selected.Contains(WebUpdateTarget, StringComparer.Ordinal)) + { + return OperationView.Fail("web_update_not_selected", ApiMessageKeys.CoreUpdateUnsupported); + } + + lock (_updateTaskGate) + { + if (IsUpdateRunningLocked()) + { + return OperationView.Fail("core_update_busy", ApiMessageKeys.CoreUpdateBusy); + } + _webUpdateTask = Task.Run(() => RunWebUpdateAsync( + GetUpdatePreRelease(WebUpdateTarget, preRelease), + useProxy ?? Config.CheckUpdateItem.UpdateViaProxy)); + } + return OperationView.Ok(ApiMessageKeys.CoreUpdateStarted, new { target = WebUpdateTarget }); + } + + internal CoreUpdateProgressView? GetWebUpdateProgress() + { + var inMemory = _updateProgress.GetValueOrDefault(WebUpdateTarget); + if (inMemory is { IsComplete: false } || inMemory?.Phase == "completed") return inMemory; + try + { + var path = Utils.GetTempPath(WebUpdateProgressFile); + if (!File.Exists(path)) return inMemory; + var persisted = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions); + if (persisted is null) return inMemory; + var progress = new CoreUpdateProgressView( + WebUpdateTarget, + persisted.Phase, + persisted.IsComplete, + persisted.Success, + persisted.CoreWasRunning, + persisted.Version, + persisted.Detail, + false, + persisted.RollbackSucceeded); + // In-flight persisted progress belongs to the detached helper. Caching + // it as local progress would prevent subsequent reads of its result. + if (progress.IsComplete) _updateProgress[WebUpdateTarget] = progress; + return progress; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + AddLog("update", $"Could not read the native Web update progress: {exception.Message}"); + return inMemory; + } + } + + private async Task RunWebUpdateAsync(bool preRelease, bool useProxy) + { + WebUpdateStage? stage = null; + try + { + await using var maintenance = await _operations.EnterExclusiveAsync( + _operations.ShutdownToken, + allowReadOnlyObservations: true); + PublishWebUpdateProgress("checking", false, false, null); + var check = await CheckWebUpdateReleaseAsync(preRelease, useProxy, maintenance.Token); + Volatile.Write(ref _latestWebUpdateVersion, check.Version); + if (!string.IsNullOrWhiteSpace(check.Error)) + { + PublishWebUpdateProgress("failed", true, false, check.Error, check.Version); + return; + } + if (!check.IsUpdateAvailable) + { + PublishWebUpdateProgress("completed", true, true, check.Detail ?? "The Web application is up to date.", check.Version); + return; + } + + stage = await StageWebUpdateAsync(check, useProxy, maintenance.Token, batch: false); + await ApplyStagedWebUpdateAsync(stage, maintenance.Token, batch: false); + } + catch (OperationCanceledException) when (_operations.IsStopping) + { + PublishWebUpdateProgress("failed", true, false, "Canceled during graceful shutdown.", stage?.Manifest.Version); + } + catch (Exception exception) + { + AddLog("update", $"v2rayN.WebAPI update failed: {exception}"); + PublishWebUpdateProgress("failed", true, false, exception.Message, stage?.Manifest.Version); + } + finally + { + CleanupWebUpdateStage(stage); + lock (_updateTaskGate) _webUpdateTask = null; + } + } + + private async Task CheckWebUpdateReleaseAsync( + bool allowPrerelease, + bool useProxy, + CancellationToken cancellationToken) + { + try + { + var repository = WebBuildIdentity.Current.Repository; + var downloader = new DownloadService(); + var releaseJson = await downloader.TryDownloadString( + WebReleaseChannel.BuildReleaseIndexUrl(repository), + useProxy, + $"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}", + cancellationToken); + if (string.IsNullOrWhiteSpace(releaseJson) || releaseJson.Length > 8 * 1024 * 1024) + return WebUpdateReleaseCheck.Failed("The GitHub Web release index could not be downloaded or exceeded its size limit."); + + var candidates = ParseWebReleaseCandidates(releaseJson, allowPrerelease); + if (candidates.Count == 0) + return WebUpdateReleaseCheck.Failed("No v2rayN releases with Web update packages were found for this channel."); + candidates.Sort((left, right) => new ServiceLib.Models.Dto.SemanticVersion(right.Version) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(left.Version))); + var selected = candidates[0]; + if (!WebReleaseChannel.IsTrustedAssetUrl(selected.ManifestUrl, repository, selected.Tag, WebReleaseChannel.ManifestAssetName)) + return WebUpdateReleaseCheck.Failed("The Web release manifest URL did not match the trusted release channel."); + + var manifestJson = await downloader.TryDownloadString( + selected.ManifestUrl, + useProxy, + $"v2rayN.WebAPI/{WebBuildIdentity.Current.Version}", + cancellationToken); + if (string.IsNullOrWhiteSpace(manifestJson) || manifestJson.Length > 1024 * 1024) + return WebUpdateReleaseCheck.Failed("The Web release manifest could not be downloaded or exceeded its size limit."); + var manifest = WebUpdatePackageStager.ParseManifest(manifestJson); + if (manifest.Version != selected.Version) + return WebUpdateReleaseCheck.Failed("The Web release manifest version does not match its release tag."); + + var currentRid = WebBuildIdentity.Current.Rid; + var package = manifest.Packages.FirstOrDefault(item => item.Rid == currentRid); + if (package is null) + { + return new WebUpdateReleaseCheck(false, manifest.Version, manifest.Commit, manifest, null, + "This v2rayN.WebAPI release does not include a package for the current runtime identifier.", + "No package matches runtime " + currentRid + "."); + } + var expectedAsset = WebUpdatePackageStager.AppOnlyAssetName(package.Rid); + if (string.IsNullOrEmpty(expectedAsset) + || package.Asset != expectedAsset + || !selected.Assets.TryGetValue(package.Asset, out var releaseAsset) + || releaseAsset.Url != package.Url + || releaseAsset.Size != package.Size + || !WebReleaseChannel.IsTrustedAssetUrl(package.Url, repository, selected.Tag, package.Asset)) + { + return WebUpdateReleaseCheck.Failed("The Web package entry does not match a trusted asset attached to this release."); + } + + var currentVersion = WebBuildIdentity.Current.Version; + var updateAvailable = WebUpdatePackageStager.IsUpdateAvailable( + currentVersion, manifest.Version, allowPrerelease, WebBuildIdentity.Current.Commit, manifest.Commit); + var detail = updateAvailable + ? "A newer v2rayN.WebAPI release is available." + : new ServiceLib.Models.Dto.SemanticVersion(currentVersion) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(manifest.Version)) > 0 + ? "This Web build is newer than the latest release in the selected channel." + : "The v2rayN.WebAPI build is up to date."; + return new WebUpdateReleaseCheck(updateAvailable, manifest.Version, manifest.Commit, manifest, package, null, detail); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is HttpRequestException or IOException or JsonException or InvalidDataException or ArgumentException) + { + return WebUpdateReleaseCheck.Failed(exception.Message); + } + } + + /// + /// Extracts official release candidates that carry a Web update manifest from a GitHub + /// "list releases" response. Legacy web-v* tags are intentionally ignored. + /// + internal static List ParseWebReleaseCandidates(string releaseJson, bool allowPrerelease) + { + using var document = JsonDocument.Parse(releaseJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) + throw new InvalidDataException("The GitHub Web release index has an invalid format."); + + var candidates = new List(); + foreach (var release in document.RootElement.EnumerateArray()) + { + if (release.TryGetProperty("draft", out var draft) && draft.GetBoolean()) continue; + var isPrerelease = release.TryGetProperty("prerelease", out var prerelease) && prerelease.GetBoolean(); + if (!WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease, allowPrerelease)) continue; + var tag = release.TryGetProperty("tag_name", out var tagElement) ? tagElement.GetString() : null; + if (!WebReleaseChannel.TryParseReleaseTag(tag, out var version)) continue; + var assets = new Dictionary(StringComparer.Ordinal); + if (release.TryGetProperty("assets", out var assetsElement) && assetsElement.ValueKind == JsonValueKind.Array) + { + foreach (var asset in assetsElement.EnumerateArray()) + { + var name = asset.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null; + var url = asset.TryGetProperty("browser_download_url", out var urlElement) ? urlElement.GetString() : null; + var size = asset.TryGetProperty("size", out var sizeElement) && sizeElement.TryGetInt64(out var parsedSize) + ? parsedSize : 0; + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(url)) assets[name] = (url, size); + } + } + if (assets.TryGetValue(WebReleaseChannel.ManifestAssetName, out var manifestAsset)) + candidates.Add(new WebReleaseCandidate(tag!, version, isPrerelease, manifestAsset.Url, assets)); + } + return candidates; + } + + private async Task StageWebUpdateAsync( + WebUpdateReleaseCheck check, + bool useProxy, + CancellationToken cancellationToken, + bool batch) + { + if (check.Manifest is null || check.Package is null) + throw new InvalidDataException("The v2rayN.WebAPI update manifest or runtime package was not checked."); + var deployment = GetWebUpdateDeployment(); + if (!deployment.CanInstall) throw new InvalidOperationException(deployment.InstallReasonKey ?? "Web update installation is unavailable."); + + var id = Guid.NewGuid().ToString("N"); + var archivePath = Utils.GetTempPath($"web-update-{id}.zip"); + var packageDirectory = Utils.GetTempPath($"web-update-stage-{id}"); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("The current Web executable path is unavailable."); + var installDirectory = Path.GetDirectoryName(Path.GetFullPath(executablePath)) + ?? throw new InvalidOperationException("The current Web executable has no install directory."); + var installParent = Path.GetDirectoryName(installDirectory) + ?? throw new InvalidOperationException("The current Web install directory has no parent."); + var candidateDirectory = Path.Combine(installParent, $".v2rayn-WebAPI-candidate-{id}"); + var backupDirectory = Path.Combine(installParent, $".v2rayn-WebAPI-backup-{id}"); + + try + { + PublishWebUpdateProgress("downloading", false, false, null, check.Manifest.Version, batch); + var downloader = new DownloadService(); + var downloadFailure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + downloader.Error += (_, args) => downloadFailure.TrySetResult(args.GetException()); + downloader.UpdateCompleted += (_, progress) => + PublishWebUpdateProgress("downloading", false, false, progress.Msg, check.Manifest.Version, batch); + await downloader.DownloadFileAsync(new FileDownloadRequest + { + FileUrl = check.Package.Url, + FilePath = archivePath, + DisplayFileName = Path.GetFileName(archivePath), + }, useProxy, cancellationToken); + if (downloadFailure.Task.IsCompletedSuccessfully) + throw new IOException("The v2rayN.WebAPI package download failed.", await downloadFailure.Task); + if (!File.Exists(archivePath)) throw new IOException("The v2rayN.WebAPI package download did not produce an archive."); + + PublishWebUpdateProgress("verifying", false, false, null, check.Manifest.Version, batch); + var identity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archivePath, packageDirectory, check.Manifest, check.Package, cancellationToken); + if (identity.Rid != WebBuildIdentity.Current.Rid) + throw new InvalidDataException("The staged v2rayN.WebAPI package has the wrong runtime identifier."); + + PublishWebUpdateProgress("staged", false, false, "The app-only package passed SHA-256, RID, identity, and archive checks.", + check.Manifest.Version, batch); + CopyVerifiedWebApp(packageDirectory, candidateDirectory, check.Package.Rid); + return new WebUpdateStage(archivePath, packageDirectory, candidateDirectory, backupDirectory, + installDirectory, check.Manifest, check.Package); + } + catch + { + CleanupWebUpdateStageFiles(archivePath, packageDirectory, candidateDirectory); + throw; + } + } + + private async Task ApplyStagedWebUpdateAsync(WebUpdateStage stage, CancellationToken cancellationToken, bool batch) + { + cancellationToken.ThrowIfCancellationRequested(); + var current = CurrentCoreRuntime; + var wasRunning = HasTrackedCoreProcesses + && current.State is CoreRuntimeState.Running or CoreRuntimeState.Faulted; + var runtimeIntent = new RuntimeRestartIntent(wasRunning, wasRunning ? current.ProfileId : null) + { + Reason = "Web application update", + }; + var runtimeIntentPath = WebUpdateRuntimeStatePath; + var progressPath = Utils.GetTempPath(WebUpdateProgressFile); + var planPath = Utils.GetTempPath($"web-update-plan-{Guid.NewGuid():N}.json"); + var hostArguments = GetCurrentHostArguments(); + // Never stop the current instance before its listener configuration has a + // loopback HTTP probe: the detached helper verifies the replacement (and its + // rollback) through that exact endpoint. + if (!WebProbeUriResolver.TryResolve(_configuration, out var probeEndpoints)) + { + throw new InvalidOperationException( + "The current listener configuration has no loopback HTTP health endpoint, so the update cannot be verified safely."); + } + var plan = new NativeWebUpdatePlan( + stage.InstallDirectory, + stage.CandidateDirectory, + stage.BackupDirectory, + Path.Combine(Utils.StartupPath(), "v2rayN.WebAPI.instance.lock"), + probeEndpoints.HealthUri.ToString(), + hostArguments, + stage.Manifest.Version, + stage.Manifest.Commit, + stage.Package.Rid, + WebBuildIdentity.Current.Version, + runtimeIntentPath, + progressPath, + wasRunning); + + await WriteRuntimeIntentAsync(runtimeIntentPath, runtimeIntent); + try + { + await WriteNativeWebUpdatePlanAsync(planPath, plan); + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("The native Web executable path is unavailable."); + ProcessStartInfo startInfo; + string? isolatedWindowsHelper = null; + if (OperatingSystem.IsLinux()) + { + var setsid = LinuxXdgBrowserOpener.FindExecutable("setsid") + ?? throw new InvalidOperationException("The setsid executable is required for a native Web update."); + startInfo = new ProcessStartInfo + { + FileName = setsid, + WorkingDirectory = stage.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(executablePath); + } + else if (OperatingSystem.IsWindows()) + { + // The Windows process image is locked while running. Use a sibling copy as the + // detached update worker so it can replace the installed executable after the + // Web host releases its instance lock. + isolatedWindowsHelper = NativeWebUpdateHelper.CreateIsolatedHelperExecutable(executablePath); + startInfo = new ProcessStartInfo + { + FileName = isolatedWindowsHelper, + WorkingDirectory = stage.InstallDirectory, + UseShellExecute = false, + CreateNoWindow = true, + }; + } + else + { + throw new PlatformNotSupportedException("Native Web self-update is supported on Linux and Windows x64."); + } + startInfo.ArgumentList.Add("--apply-WebAPI-update"); + startInfo.ArgumentList.Add(planPath); + Process helper; + try + { + helper = Process.Start(startInfo) + ?? throw new InvalidOperationException("The native Web update helper could not be started."); + } + catch + { + if (isolatedWindowsHelper is not null) TryDeleteFile(isolatedWindowsHelper); + throw; + } + using (helper) + { + stage.TransferredToHelper = true; + PublishWebUpdateProgress("stopping", false, false, + "All network operations and package verification completed before Web/Core shutdown.", + stage.Manifest.Version, batch); + AddLog("update", $"Handing off the v2rayN.WebAPI update to helper process {helper.Id}."); + _lifetime.StopApplication(); + } + } + catch + { + await DeleteRuntimeIntentAsync(runtimeIntentPath); + TryDeleteFile(planPath); + throw; + } + } + + private WebUpdateDeployment GetWebUpdateDeployment() + { + var isContainer = File.Exists("/.dockerenv") || File.Exists("/run/.containerenv") + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("container")) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")); + var isSystemd = LauncherEnvironment.IsSystemdManagedDeployment(); + var executable = Environment.ProcessPath; + var expectedExecutableName = WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid) + ? WebUpdatePackageStager.ExecutableNameForRid(WebBuildIdentity.Current.Rid) + : null; + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var nativeSingleFile = expectedExecutableName is not null && !string.IsNullOrWhiteSpace(executable) + && Path.GetFileName(executable).Equals(expectedExecutableName, pathComparison) + && File.Exists(executable) + && string.Equals(AppContext.BaseDirectory, + Path.GetDirectoryName(Path.GetFullPath(executable)) + Path.DirectorySeparatorChar, + pathComparison); + var installDirectory = nativeSingleFile && executable is not null ? Path.GetDirectoryName(executable)! : string.Empty; + var writable = nativeSingleFile && !isSystemd && IsWritableInstallDirectory(installDirectory, executable!); + var supportedNativePlatform = OperatingSystem.IsLinux() || OperatingSystem.IsWindows(); + return WebUpdateDeploymentPolicy.Evaluate(supportedNativePlatform, isContainer, isSystemd, nativeSingleFile, writable); + } + + private bool CanInstallWebUpdate() => + WebUpdatePackageStager.IsSupportedRid(WebBuildIdentity.Current.Rid) + && GetWebUpdateDeployment().CanInstall + && GetWebUpdateRuntimeInstallReason() is null; + + internal string? GetWebUpdateRuntimeInstallReason() + { + if (!WebProbeUriResolver.TryResolve(_configuration, out _)) + { + // The update helper verifies both the replacement and its rollback through + // the loopback health endpoint; an unprobeable listener would strand the + // service, so installing is refused before anything is stopped. + return "maintenance.webUpdateProbeUnavailable"; + } + + var snapshot = CurrentCoreRuntime; + var activeChild = HasTrackedCoreProcesses; + if (snapshot.State is CoreRuntimeState.Starting or CoreRuntimeState.Stopping or CoreRuntimeState.Restarting) + return ApiMessageKeys.CoreRuntimeBusy; + if (snapshot.State == CoreRuntimeState.Faulted && activeChild) + return "maintenance.webUpdateRuntimeFaulted"; + if (snapshot.State == CoreRuntimeState.Running && !activeChild + || snapshot.State == CoreRuntimeState.Stopped && activeChild) + return "maintenance.webUpdateRuntimeFaulted"; + return null; + } + + private static bool IsWritableInstallDirectory(string installDirectory, string executablePath) + { + try + { + if (new DirectoryInfo(installDirectory).LinkTarget is not null + || new FileInfo(executablePath).LinkTarget is not null) + return false; + var probe = Path.Combine(installDirectory, $".v2rayn-WebAPI-write-probe-{Guid.NewGuid():N}"); + using (new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { } + File.Delete(probe); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return false; + } + } + + private string GetWebUpdateInstallReasonKey() => GetWebUpdateDeployment().InstallReasonKey + ?? GetWebUpdateRuntimeInstallReason() + ?? "maintenance.webUpdateUnsupported"; + + private static string ToDeploymentName(WebDeploymentKind kind) => kind switch + { + WebDeploymentKind.NativeWritable => "native-writable", + WebDeploymentKind.SystemdManaged => "systemd-managed", + WebDeploymentKind.Container => "container", + WebDeploymentKind.ReadOnly => "native-read-only", + _ => "unsupported", + }; + + private static async Task WriteNativeWebUpdatePlanAsync(string planPath, NativeWebUpdatePlan plan) + { + var temporary = planPath + $".{Guid.NewGuid():N}.tmp"; + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(plan, JsonOptions)); + File.Move(temporary, planPath); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(planPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + private static string[] GetCurrentHostArguments() + { + var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); + return WebLaunchOptions.Parse( + args, + OperatingSystem.IsLinux(), + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false).HostArguments; + } + + private static void CopyVerifiedWebApp(string source, string destination, string rid) + { + Directory.CreateDirectory(destination); + var executableName = WebUpdatePackageStager.ExecutableNameForRid(rid); + File.Copy(Path.Combine(source, executableName), Path.Combine(destination, executableName)); + File.Copy(Path.Combine(source, "v2rayN.WebAPI.build.json"), Path.Combine(destination, "v2rayN.WebAPI.build.json")); + if (OperatingSystem.IsLinux() && rid.StartsWith("linux-", StringComparison.Ordinal)) + { + File.SetUnixFileMode(Path.Combine(destination, executableName), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + + private static void CleanupWebUpdateStage(WebUpdateStage? stage) + { + if (stage is null) return; + CleanupWebUpdateStageFiles(stage.ArchivePath, stage.PackageDirectory, + stage.TransferredToHelper ? null : stage.CandidateDirectory); + } + + private static void CleanupWebUpdateStageFiles(string archive, string packageDirectory, string? candidateDirectory) + { + TryDeleteFile(archive); + TryDeleteDirectory(packageDirectory); + if (candidateDirectory is not null) TryDeleteDirectory(candidateDirectory); + } + + private void PublishWebUpdateProgress( + string phase, + bool complete, + bool success, + string? detail, + string? version = null, + bool batch = false) + { + var progress = new CoreUpdateProgressView(WebUpdateTarget, phase, complete, success, + HasTrackedCoreProcesses, version, detail, batch); + _updateProgress[WebUpdateTarget] = progress; + _events.Publish("core-update-progress", progress); + AddLog("update", $"v2rayN.WebAPI update phase: {phase}"); + try + { + var path = Utils.GetTempPath(WebUpdateProgressFile); + var temporary = path + $".{Guid.NewGuid():N}.tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(progress, JsonOptions)); + File.Move(temporary, path, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + AddLog("update", $"Could not persist Web update progress: {exception.Message}"); + } + } + + private static void TryDeleteFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } + } + + private static void TryDeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { } + } + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + internal sealed record WebUpdateReleaseCheck( + bool IsUpdateAvailable, + string? Version, + string? Commit, + WebUpdateManifest? Manifest, + WebUpdatePackage? Package, + string? Error, + string? Detail) + { + public static WebUpdateReleaseCheck Failed(string detail) => new(false, null, null, null, null, detail, detail); + } + + internal sealed record WebUpdateStage( + string ArchivePath, + string PackageDirectory, + string CandidateDirectory, + string BackupDirectory, + string InstallDirectory, + WebUpdateManifest Manifest, + WebUpdatePackage Package) + { + public bool TransferredToHelper { get; set; } + } + + internal sealed record WebReleaseCandidate( + string Tag, + string Version, + bool IsPrerelease, + string ManifestUrl, + IReadOnlyDictionary Assets); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs new file mode 100644 index 00000000..9dbb9031 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/V2rayRuntime.cs @@ -0,0 +1,1535 @@ +using System.Net.Sockets; +using System.Net; +using System.Net.NetworkInformation; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Handler.Builder; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using NLog; +using NLog.Config; +using NLog.Targets; +using v2rayN.WebAPI.Adapters; +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Services; + +public sealed partial class V2rayRuntime( + EventHub events, + LogBuffer logs, + IConfiguration configuration, + IHostApplicationLifetime lifetime, + RuntimeOperationCoordinator operations) +{ + private readonly EventHub _events = events; + private readonly LogBuffer _logs = logs; + private readonly IConfiguration _configuration = configuration; + private readonly IHostApplicationLifetime _lifetime = lifetime; + private readonly RuntimeOperationCoordinator _operations = operations; + private readonly RuntimeMutationGate _mutations = new(); + private readonly SemaphoreSlim _coreGate = new(1, 1); + private readonly SemaphoreSlim _regionalPresetGate = new(1, 1); + private readonly SemaphoreSlim _coreMonitorGate = new(1, 1); + private readonly object _subscriptionGate = new(); + private readonly object _speedtestGate = new(); + private readonly Dictionary _subscriptionTasks = new(StringComparer.Ordinal); + private SpeedtestService? _speedtestService; + private Task? _speedtestTask; + private CancellationTokenSource? _speedtestCancellation; + private CoreRuntimeSnapshot _coreRuntime = CoreRuntimeSnapshot.Stopped; + private CancellationTokenSource? _coreMonitorCancellation; + private Task? _coreMonitorTask; + private CoreProcessIdentity[] _trackedCoreProcesses = []; + private string? _xrayPath; + private ServerSpeedItem? _latestTraffic; + private bool _initialized; + private bool _restoring; + private int _restoreAndRestartRequested; + + private Config Config => AppManager.Instance.Config; + private CoreRuntimeSnapshot CurrentCoreRuntime => Volatile.Read(ref _coreRuntime); + public bool RestoreAndRestartRequested => Volatile.Read(ref _restoreAndRestartRequested) != 0; + + public int[] GetCoreProcessIds() => GetActiveCoreProcessIds(); + + public string? GetCoreRuntimeProfileId() => CurrentCoreRuntime.ProfileId; + + public string GetCoreRuntimeState() => CurrentCoreRuntime.State.ToString().ToLowerInvariant(); + + private void SetCoreRuntime(CoreRuntimeSnapshot snapshot) + { + snapshot = snapshot with { ProcessIds = GetActiveCoreProcessIds() }; + Volatile.Write(ref _coreRuntime, snapshot); + if (snapshot.State != CoreRuntimeState.Running) + { + _coreMonitorCancellation?.Cancel(); + } + _events.Publish("core-state", new + { + state = snapshot.State.ToString().ToLowerInvariant(), + profileId = snapshot.ProfileId, + coreType = snapshot.CoreType is { } coreType + ? System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(coreType.ToString()) + : null, + startedAt = snapshot.StartedAt, + proxyPort = snapshot.ProxyPort, + apiPort = snapshot.ApiPort, + processIds = snapshot.ProcessIds, + lastFailure = snapshot.LastFailure, + }); + } + + private async Task StartCoreMonitorAsync() + { + await StopCoreMonitorAsync(); + var cancellation = new CancellationTokenSource(); + _coreMonitorCancellation = cancellation; + _coreMonitorTask = Task.Run(() => MonitorCoreRuntimeAsync(cancellation.Token)); + } + + private async Task StopCoreMonitorAsync() + { + await _coreMonitorGate.WaitAsync(); + try + { + var cancellation = _coreMonitorCancellation; + var task = _coreMonitorTask; + _coreMonitorCancellation = null; + _coreMonitorTask = null; + if (cancellation is null) return; + cancellation.Cancel(); + if (task is not null) + { + try { await task.WaitAsync(TimeSpan.FromSeconds(2)); } + catch (OperationCanceledException) { } + catch (TimeoutException) { AddLog("core", "Core runtime monitor did not stop within its cleanup budget."); } + } + cancellation.Dispose(); + } + finally + { + _coreMonitorGate.Release(); + } + } + + private CoreProcessIdentity[] GetActiveCoreProcesses() => + CoreProcessTracker.GetActiveProcesses(Volatile.Read(ref _trackedCoreProcesses)); + + private int[] GetActiveCoreProcessIds() => + GetActiveCoreProcesses().Select(process => process.ProcessId).ToArray(); + + private bool HasTrackedCoreProcesses => GetActiveCoreProcesses().Length > 0; + + private void SetTrackedCoreProcesses(IEnumerable processes) => + Volatile.Write(ref _trackedCoreProcesses, processes.DistinctBy(process => process.ProcessId).ToArray()); + + private async Task StopCoreAndConfirmAsync(CancellationToken cancellationToken) + { + await CoreManager.Instance.CoreStop(); + + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var activeProcesses = GetActiveCoreProcesses(); + // Port occupancy is not process ownership: another program may bind a just-released + // TCP or UDP port before this check. Only Web's captured process identities can + // keep stop pending or cause a stop failure. + if (activeProcesses.Length == 0) + { + SetTrackedCoreProcesses([]); + return; + } + + if (DateTimeOffset.UtcNow >= deadline) + { + var details = new List(); + if (activeProcesses.Length > 0) + { + details.Add($"Core processes remain: {string.Join(",", activeProcesses.Select(process => process.ProcessId))}."); + } + throw new TimeoutException(string.Join(" ", details)); + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + } + } + + private async Task MonitorCoreRuntimeAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(750), cancellationToken); + var snapshot = CurrentCoreRuntime; + if (snapshot.State != CoreRuntimeState.Running || snapshot.ProxyPort is not int port) + { + continue; + } + + var processIds = GetActiveCoreProcessIds(); + var listening = await IsListeningAsync(port, cancellationToken); + if (!ReferenceEquals(snapshot, CurrentCoreRuntime) || CurrentCoreRuntime.State != CoreRuntimeState.Running) + { + continue; + } + if (processIds.Length > 0 && listening) + { + continue; + } + + var failure = processIds.Length == 0 + ? "The tracked Core child process exited unexpectedly." + : "The running Core no longer owns its expected proxy listener."; + SetCoreRuntime(snapshot with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }); + AddLog("core", failure); + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Expected when the Core is stopped, restarted, or the Web host shuts down. + } + catch (Exception exception) + { + AddLog("core", $"Core runtime monitor failed: {exception}"); + } + } + + public async Task InitializeAsync(CancellationToken cancellationToken, bool suppressCoreAutostart = false) + { + var restoreState = await LoadRestoreRuntimeStateAsync(cancellationToken); + var updateStatePath = WebUpdateRuntimeStatePath; + var updateRuntimeState = restoreState is null + ? await LoadRuntimeRestartIntentAsync(updateStatePath, cancellationToken) + : null; + var startupRuntimeIntent = restoreState ?? updateRuntimeState; + if (!AppManager.Instance.InitApp()) + { + throw new InvalidOperationException("ServiceLib could not load its configuration."); + } + ConfigureServiceLibConsoleLogging(); + + AppManager.Instance.WindowDialog = new HeadlessWindowDialog(); + if (!AppManager.Instance.InitComponents()) + { + throw new InvalidOperationException("ServiceLib component initialization failed."); + } + + await _mutations.RunAsync(async () => + { + EnsureInboundDefaults(Config); + EnsureCoreTypeMappings(); + // Migrate only API-owned preference identifiers; no ServiceLib code changes. + Config.CheckUpdateItem.SelectedCoreTypes = NormalizeWebUpdateTargets(Config.CheckUpdateItem.SelectedCoreTypes); + Config.CheckUpdateItem.CheckPreReleaseCoreTypes = NormalizeWebUpdateTargets(Config.CheckUpdateItem.CheckPreReleaseCoreTypes); + var proxyPortOverride = _configuration.GetValue("V2RAYN_WEB_PROXY_PORT"); + if (proxyPortOverride is <= 0 or > 65535) + { + throw new InvalidOperationException("V2RAYN_WEB_PROXY_PORT must be a valid TCP/UDP port (1-65535)."); + } + if (proxyPortOverride is int configuredProxyPort) + { + Config.Inbound[0].LocalPort = configuredProxyPort; + } + if (_configuration.GetValue("V2RAYN_WEB_PROXY_LISTEN_ALL") is bool allowProxyFromLan) + { + Config.Inbound[0].AllowLANConn = allowProxyFromLan; + } + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + await ConfigHandler.InitBuiltinDNS(Config); + await BuiltinDnsProfileBootstrap.EnsureAsync( + Config, + async () => await AppManager.Instance.DNSItems() ?? [], + ConfigHandler.SaveDNSItems); + await ConfigHandler.InitBuiltinFullConfigTemplate(Config); + // Keep startup aligned with Desktop's StatusBarViewModel. In particular, + // InitBuiltinRouting owns the RoutingIndexId -> IsActive migration and must + // run even when a restored Desktop database already has routing rows. + await ConfigHandler.InitBuiltinRouting(Config); + _ = await ConfigHandler.GetDefaultRouting(Config); + var restoredSubscriptions = await AppManager.Instance.SubItems() ?? []; + var normalizedSubIndexId = NormalizeSelectedSubscriptionId( + Config.SubIndexId, + restoredSubscriptions.Select(item => item.Id).ToArray()); + if (Config.SubIndexId != normalizedSubIndexId) + { + AddLog("restore", "The selected subscription group no longer exists; normalized the selection to all profiles."); + Config.SubIndexId = normalizedSubIndexId; + } + var selectedProfileId = Config.IndexId; + var defaultProfile = await ConfigHandler.GetDefaultServer(Config); + await _mutations.RunAsync(() => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config))); + await ProfileExManager.Instance.Init(); + await CertPemManager.Instance.Init(Config); + // The Web frontend owns its runtime and rejects generated launch contexts that enable TUN. + await CoreManager.Instance.Init(Config, OnCoreMessageAsync); + if (Config.GuiItem.EnableStatistics || Config.GuiItem.DisplayRealTimeSpeed) + { + await StatisticsManager.Instance.Init(Config, OnStatisticsUpdateAsync); + } + _xrayPath = FindXrayExecutable(out var missingXrayMessage); + if (_xrayPath is null) + { + AddLog("core", missingXrayMessage); + } + + _initialized = true; + AddLog("web", "serviceLib.initialized"); + StartScheduledOperations(cancellationToken); + + if (startupRuntimeIntent is not null) + { + if (!RuntimeRestartRecovery.ShouldRecoverCore(startupRuntimeIntent, suppressCoreAutostart)) + { + if (suppressCoreAutostart && startupRuntimeIntent.WasRunning) + { + AddLog("core", "Core startup was suppressed until first-run Management Key setup is complete."); + } + } + else + { + await RuntimeRestartRecovery.RecoverAsync( + startupRuntimeIntent, + selectedProfileId, + defaultProfile?.IndexId, + async (profileId, _) => await AppManager.Instance.GetProfileItem(profileId) is not null, + StartCoreAsync, + message => AddLog(restoreState is not null ? "restore" : "update", message), + cancellationToken); + } + if (restoreState is not null) + { + await CommitRestoreRuntimeStateAsync(); + } + } + else if (RuntimeRestartRecovery.ShouldAutoStart( + startupRuntimeIntent, + _configuration.GetValue("V2RAYN_WEB_AUTOSTART", false), + suppressCoreAutostart)) + { + var result = await StartCoreAsync(null, cancellationToken); + if (!result.Success) + { + AddLog("core", result.MessageKey); + } + } + else if (suppressCoreAutostart) + { + AddLog("core", "Core autostart was suppressed until first-run Management Key setup is complete."); + } + } + + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + var steps = new List + { + new("restore shutdown request", async deadline => + { + await WaitForRestoreShutdownRequestAsync(deadline.Token); + return true; + }), + new("scheduled operations stop", deadline => StopScheduledOperationsAsync(deadline.Token)), + new("operation drain", async deadline => + { + var drained = await _operations.StopAndDrainAsync(deadline.Token, deadline.Remaining); + if (!drained) + { + AddLog("web", "Shutdown operation drain timed out; skipped all Core, state-save, and SQLite cleanup."); + } + return drained; + }), + }; + + var saveServiceLibState = _initialized && !_restoring; + if (saveServiceLibState) + { + steps.Add(new("Core stop", async deadline => + { + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(deadline.Token); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + return true; + } + catch (Exception exception) + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }); + throw; + } + })); + steps.Add(new("profile save", async _ => + { + await ProfileExManager.Instance.SaveTo(); + return true; + })); + steps.Add(new("statistics save", async _ => + { + await StatisticsManager.Instance.SaveTo(); + return true; + })); + steps.Add(new("statistics close", _ => + { + StatisticsManager.Instance.Close(); + return Task.FromResult(true); + })); + steps.Add(new("configuration save", async deadline => + { + await _mutations.RunAsync( + () => EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)), + deadline.Token); + return true; + })); + steps.Add(new("database close", async _ => + { + await SQLiteHelper.Instance.DisposeDbConnectionAsync(); + return true; + })); + } + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + RuntimeShutdownBudgets.RuntimeCleanup, + cancellationToken, + message => AddLog("web", message), + ShutdownDiagnostics.SetStage); + if (completed && saveServiceLibState) + { + AddLog("web", "serviceLib.stopped"); + } + } + + internal static async Task EnsureConfigSaveSucceededAsync(Func> save) + { + if (await save() != 0) + { + throw new IOException("ServiceLib could not persist the configuration."); + } + } + + public async Task> GetSubscriptionsAsync() + { + var items = await AppManager.Instance.SubItems() ?? []; + return items.Select(ToSubscriptionView).ToArray(); + } + + public async Task AddSubscriptionAsync(SubscriptionInput input) + { + if (!TryValidateSubscription(input, out var code, out var messageKey)) + { + return new(false, code, messageKey, null); + } + + var item = ToSubItem(input, null); + var result = await _mutations.RunAsync(() => ConfigHandler.AddSubItem(Config, item)); + if (result != 0) + { + return new(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + } + + var saved = await AppManager.Instance.GetSubItem(item.Id); + return saved is null + ? new(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null) + : new(true, "ok", ApiMessageKeys.SubscriptionAdded, ToSubscriptionView(saved)); + } + + public async Task UpdateSubscriptionAsync(string id, SubscriptionInput input) + { + if (!TryValidateSubscription(input, out var code, out var messageKey)) + { + return new(false, code, messageKey, null); + } + + return await _mutations.RunAsync(async () => + { + var existing = await AppManager.Instance.GetSubItem(id); + if (existing is null) + { + return new SubscriptionMutationResult(false, "subscription_not_found", ApiMessageKeys.SubscriptionNotFound, null); + } + + var item = ToSubItem(input, existing); + item.Id = id; + var result = await ConfigHandler.AddSubItem(Config, item); + if (result != 0) + { + return new SubscriptionMutationResult(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + } + + var saved = await AppManager.Instance.GetSubItem(id); + return saved is not null + ? new SubscriptionMutationResult(true, "ok", ApiMessageKeys.SubscriptionSaved, ToSubscriptionView(saved)) + : new SubscriptionMutationResult(false, "subscription_save_failed", ApiMessageKeys.SubscriptionSaveFailed, null); + }); + } + + public async Task DeleteSubscriptionAsync(string id) + { + var subscription = await AppManager.Instance.GetSubItem(id); + if (subscription is null) + { + return OperationView.Fail("subscription_not_found", ApiMessageKeys.SubscriptionNotFound); + } + + var selectedProfile = await AppManager.Instance.GetProfileItem(Config.IndexId); + var removesCurrentProfile = selectedProfile?.Subid == id; + if (removesCurrentProfile) + { + await StopCoreAsync(CancellationToken.None); + } + + await _mutations.RunAsync(async () => + { + if (await ConfigHandler.DeleteSubItem(Config, id) != 0) + { + throw new IOException("ServiceLib could not delete the subscription."); + } + if (removesCurrentProfile) + { + _ = await ConfigHandler.GetDefaultServer(Config); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + }); + + AddLog("subscription", ApiMessageKeys.SubscriptionDeleted); + return OperationView.Ok(ApiMessageKeys.SubscriptionDeleted); + } + + public bool StartSubscriptionUpdate(string id, bool useProxy) + { + return StartSubscriptionUpdateTask(id, useProxy, _operations.ShutdownToken) is not null; + } + + private Task? StartSubscriptionUpdateTask(string id, bool useProxy, CancellationToken operationToken) + { + lock (_subscriptionGate) + { + var hasConflictingUpdate = _subscriptionTasks.Any(pair => + !pair.Value.IsCompleted + && (pair.Key.Length == 0 || id.Length == 0 || pair.Key == id)); + if (hasConflictingUpdate) + { + return null; + } + + var task = Task.Run(async () => + { + try + { + await using var operation = await _operations.EnterOperationAsync(operationToken); + await _mutations.RunAsync(async () => + { + await SubscriptionHandler.UpdateProcess(Config, id, useProxy, (success, message) => + { + var payload = new + { + subscriptionId = id, + success, + code = success ? "ok" : "subscription_update_progress", + messageKey = success ? ApiMessageKeys.SubscriptionSaved : ApiMessageKeys.SubscriptionUpdateProgress, + rawLog = message, + }; + _events.Publish("subscription-progress", payload); + AddLog("subscription", message); + return Task.CompletedTask; + }); + + operation.Token.ThrowIfCancellationRequested(); + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + await UpdateSubscriptionTimestampLockedAsync(id); + }, operation.Token); + _events.Publish("profiles-changed", new { subscriptionId = id }); + } + catch (OperationCanceledException) when (_operations.IsStopping || operationToken.IsCancellationRequested) + { + // Expected during graceful service shutdown. + } + catch (Exception ex) + { + AddLog("subscription", ex.Message); + } + finally + { + lock (_subscriptionGate) + { + _subscriptionTasks.Remove(id); + } + } + }); + + _subscriptionTasks[id] = task; + return task; + } + } + + public async Task> GetProfilesAsync(string? subscriptionId, string? filter) + { + var groupId = subscriptionId ?? Config.SubIndexId ?? string.Empty; + var profiles = await AppManager.Instance.ProfileItems(groupId) ?? []; + var profileModels = await AppManager.Instance.ProfileModels(groupId, string.Empty) ?? []; + var modelMap = profileModels.ToDictionary(item => item.IndexId); + var subscriptions = (await AppManager.Instance.SubItems() ?? []).ToDictionary(item => item.Id, item => item.Remarks); + var extensions = await ProfileExManager.Instance.GetProfileExs(); + var extensionMap = extensions.ToDictionary(item => item.IndexId); + var statistics = (Config.GuiItem.EnableStatistics ? StatisticsManager.Instance.ServerStat : null) ?? []; + var statisticsMap = statistics.ToDictionary(item => item.IndexId); + var query = filter?.Trim(); + + return profiles + .Where(item => string.IsNullOrEmpty(query) + || Utils.IsRegexMatch(item.Remarks, query) + || Utils.IsRegexMatch(item.Address, query)) + .Select(item => + { + extensionMap.TryGetValue(item.IndexId, out var extension); + modelMap.TryGetValue(item.IndexId, out var model); + statisticsMap.TryGetValue(item.IndexId, out var statistic); + var delay = extension?.Delay ?? 0; + var speed = extension?.Speed ?? 0; + return new ProfileView( + item.IndexId, + item.Remarks, + System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(item.ConfigType.ToString()), + item.Address, + item.Port, + item.GetNetwork(), + item.StreamSecurity, + item.Subid, + model?.SubRemarks ?? subscriptions.GetValueOrDefault(item.Subid), + System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(AppManager.Instance.GetCoreType(item, item.ConfigType).ToString()), + extension?.Sort ?? 0, + delay, + speed, + extension?.IpInfo, + statistic?.TodayUp ?? 0, + statistic?.TodayDown ?? 0, + statistic?.TotalUp ?? 0, + statistic?.TotalDown ?? 0, + item.IndexId == Config.IndexId, + !item.IsComplex() && item.Port > 0); + }) + .OrderBy(item => item.Sort) + .ToArray(); + } + + public async Task GetProfileDetailsAsync(string profileId) => + await AppManager.Instance.GetProfileItem(profileId); + + public async Task GetStatusAsync() + { + var selectedProfile = await AppManager.Instance.GetProfileItem(Config.IndexId); + var profileItems = await AppManager.Instance.ProfileItems(string.Empty) ?? []; + var subscriptions = await AppManager.Instance.SubItems() ?? []; + var snapshot = CurrentCoreRuntime; + var processIds = GetActiveCoreProcessIds(); + var listenerDefinitions = snapshot.ProxyPort.HasValue && snapshot.State != CoreRuntimeState.Stopped + ? snapshot.Listeners + : GetConfiguredListenerSnapshots(); + var listeners = new List(listenerDefinitions.Length); + foreach (var listener in listenerDefinitions) + { + listeners.Add(new ListenerView( + listener.Name, + listener.Protocols, + listener.ListenAddress, + listener.Port, + await IsListeningAsync(listener.Port, CancellationToken.None))); + } + + var mainListener = listeners.FirstOrDefault(listener => listener.Name == "local"); + var listening = mainListener?.Listening == true; + var inbound = Config.Inbound.FirstOrDefault(); + var sniffingEnabled = inbound?.SniffingEnabled ?? false; + var sniffingTypes = NormalizeDestOverride(sniffingEnabled, inbound?.DestOverride); + var coreRunning = snapshot.State == CoreRuntimeState.Running && processIds.Length > 0 && listening; + if (snapshot.State == CoreRuntimeState.Running && !coreRunning) + { + snapshot = snapshot with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = processIds.Length == 0 + ? "The tracked Core child process exited unexpectedly." + : "The running Core no longer owns its expected proxy listener.", + }; + SetCoreRuntime(snapshot); + } + + var runningProfile = string.IsNullOrEmpty(snapshot.ProfileId) + ? null + : await AppManager.Instance.GetProfileItem(snapshot.ProfileId); + var configuredProxyPort = Config.Inbound.FirstOrDefault()?.LocalPort; + return new StatusView( + coreRunning, + snapshot.CoreType is { } coreType + ? System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(coreType.ToString()) + : null, + selectedProfile?.IndexId, + selectedProfile?.Remarks, + snapshot.StartedAt, + listeners.ToArray(), + !string.IsNullOrEmpty(_xrayPath), + Utils.GetRuntimeInfo(), + profileItems.Count, + subscriptions.Count, + DateTimeOffset.UtcNow, + Config.GuiItem.EnableStatistics, + _latestTraffic is null ? null : new TrafficView( + _latestTraffic.ProxyUp, + _latestTraffic.ProxyDown, + _latestTraffic.DirectUp, + _latestTraffic.DirectDown), + snapshot.State.ToString().ToLowerInvariant(), + configuredProxyPort, + snapshot.State == CoreRuntimeState.Stopped ? null : snapshot.ProxyPort, + snapshot.ProfileId, + runningProfile?.Remarks, + snapshot.ApiPort, + processIds, + snapshot.LastFailure, + WebBuildIdentity.Current.Version, + WebBuildIdentity.Current.Commit, + WebBuildIdentity.Current.Rid, + WebBuildIdentity.Current.BuildDate, + sniffingEnabled, + sniffingTypes, + WebApiCapabilities.Current); + + } + + public async Task SelectProfileAsync(string profileId, CancellationToken cancellationToken) + { + var profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + + return await StartCoreAsync(profile, cancellationToken, selectProfile: true); + } + + public async Task StartCoreAsync(string? profileId, CancellationToken cancellationToken) + { + ProfileItem? profile = null; + if (!string.IsNullOrWhiteSpace(profileId)) + { + profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound); + } + } + + return await StartCoreAsync(profile, cancellationToken); + } + + private async Task StartCoreAsync(ProfileItem? requestedProfile, CancellationToken cancellationToken, bool selectProfile = false) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await StartCoreLockedAsync(requestedProfile, cancellationToken, selectProfile); + } + finally + { + _coreGate.Release(); + } + } + + private async Task StartCoreLockedAsync(ProfileItem? requestedProfile, CancellationToken cancellationToken, bool selectProfile = false) + { + var profile = requestedProfile ?? await GetDefaultProfileAsync(); + if (profile is null) + { + return OperationView.Fail("profile_not_selected", ApiMessageKeys.ProfileNoneSelected); + } + + var current = CurrentCoreRuntime; + if (!selectProfile + && current.State == CoreRuntimeState.Running + && current.ProfileId == profile.IndexId + && HasTrackedCoreProcesses) + { + return OperationView.Ok(ApiMessageKeys.CoreStarted, new { profileId = profile.IndexId, alreadyRunning = true }); + } + + var preflight = await BuildAndValidateCoreLaunchAsync(profile); + if (preflight.Failure is { } preflightFailure) + { + return preflightFailure; + } + + if (selectProfile) + { + await _mutations.RunAsync(async () => + { + Config.IndexId = profile.IndexId; + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + }); + } + + return await LaunchPreflightedCoreLockedAsync(preflight, cancellationToken); + } + + private async Task GetDefaultProfileAsync() => await _mutations.RunAsync(async () => + { + var selected = await ConfigHandler.GetDefaultServer(Config); + if (selected is not null) + { + await EnsureConfigSaveSucceededAsync(() => ConfigHandler.SaveConfig(Config)); + } + return selected; + }); + + private async Task BuildAndValidateCoreLaunchAsync(ProfileItem profile) + { + var preflight = await CoreLaunchPreflight.BuildAndValidateAsync(Config, profile, coreType => + { + var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType); + var executable = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var missingCoreMessage); + if (!string.IsNullOrEmpty(executable)) + { + return null; + } + + AddLog("core", missingCoreMessage); + return OperationView.Fail("core_binary_missing", ApiMessageKeys.CoreBinaryMissing, + new { coreType = coreType.ToString() }); + }); + + if (preflight.Failure?.Code == "profile_validation_failed") + { + foreach (var message in preflight.BuiltContext.CombinedValidatorResult.Errors + .Concat(preflight.BuiltContext.CombinedValidatorResult.Warnings)) + { + AddLog("core-validation", message); + } + } + + if (preflight.Success) + { + RuntimeListenerSnapshot[] currentlyOwnedListeners = HasTrackedCoreProcesses + ? CurrentCoreRuntime.Listeners + : []; + foreach (var listener in GetConfiguredListenerSnapshots()) + { + if (await IsProjectedListenerUnavailableAsync(listener, currentlyOwnedListeners, CancellationToken.None)) + { + return new CoreLaunchPreflightResult( + preflight.Profile, + preflight.BuiltContext, + OperationView.Fail("proxy_port_in_use", ApiMessageKeys.CorePortInUse, + new { port = listener.Port, listener = listener.Name })); + } + } + } + + return preflight; + } + + private async Task LaunchPreflightedCoreLockedAsync( + CoreLaunchPreflightResult preflight, + CancellationToken cancellationToken) + { + var profile = preflight.Profile; + var built = preflight.BuiltContext; + var port = Config.Inbound.FirstOrDefault()?.LocalPort ?? 0; + if (!HasTrackedCoreProcesses && CurrentCoreRuntime.State == CoreRuntimeState.Stopped + && await IsListeningAsync(port, cancellationToken)) + { + return OperationView.Fail("proxy_port_in_use", ApiMessageKeys.CorePortInUse, new { port }); + } + + if (CurrentCoreRuntime.State != CoreRuntimeState.Stopped || HasTrackedCoreProcesses) + { + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + } + catch (Exception exception) + { + var failedStop = CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = GetActiveCoreProcessIds(), + LastFailure = exception.Message, + }; + SetCoreRuntime(failedStop); + AddLog("core", $"Core stop before launch failed: {exception}"); + return OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds = failedStop.ProcessIds }); + } + } + + var expectedCoreType = built.PreSocksResult?.Context.RunCoreType ?? built.MainResult.Context.RunCoreType; + var listeners = GetConfiguredListenerSnapshots(); + SetCoreRuntime(new CoreRuntimeSnapshot( + CoreRuntimeState.Starting, + profile.IndexId, + expectedCoreType, + null, + listeners.FirstOrDefault(listener => listener.Name == "local")?.Port, + AppManager.Instance.StatePort, + listeners, + [], + null)); + + try + { + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var expectedExecutablePaths = GetExpectedCoreExecutablePaths(built); + var expectedConfigPaths = GetExpectedCoreConfigPaths(built); + try + { + await CoreManager.Instance.LoadCore(built.MainResult.Context, built.PreSocksResult?.Context); + } + finally + { + SetTrackedCoreProcesses(CoreProcessTracker.CaptureStartedProcesses( + existingProcessIds, + expectedExecutablePaths, + expectedConfigPaths)); + } + var started = await WaitForListenerAsync(port, cancellationToken); + var processIds = GetActiveCoreProcessIds(); + if (!started || processIds.Length == 0) + { + var failure = !started + ? "The Core process did not open its configured proxy listener." + : "The Web runtime could not identify a live Core child process."; + try + { + await StopCoreAndConfirmAsync(CancellationToken.None); + processIds = GetActiveCoreProcessIds(); + } + catch (Exception stopException) + { + failure += $" Cleanup also failed: {stopException.Message}"; + } + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = failure, + }); + AddLog("core", failure); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_start_failed", ApiMessageKeys.CoreStartFailed, + new { profileId = profile.IndexId, detail = failure, processIds }); + } + + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Running, + CoreType = AppManager.Instance.RunningCoreType, + StartedAt = DateTimeOffset.UtcNow, + ProcessIds = processIds, + LastFailure = null, + }); + await StartCoreMonitorAsync(); + AddLog("core", ApiMessageKeys.CoreStarted); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Ok(ApiMessageKeys.CoreStarted, new { profileId = profile.IndexId, processIds }); + } + catch (Exception exception) + { + AddLog("core", $"Core start failed: {exception}"); + var processIds = GetActiveCoreProcessIds(); + if (CurrentCoreRuntime.State == CoreRuntimeState.Starting || processIds.Length > 0) + { + try + { + await StopCoreAndConfirmAsync(CancellationToken.None); + processIds = GetActiveCoreProcessIds(); + } + catch (Exception stopException) + { + AddLog("core", $"Core start cleanup failed: {stopException}"); + } + } + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_start_failed", ApiMessageKeys.CoreStartFailed, + new { profileId = profile.IndexId, detail = exception.Message, processIds }); + } + } + + internal static OperationView? GetTunLaunchRejection(CoreConfigContextBuilderAllResult built) => + CoreLaunchPreflight.GetTunLaunchRejection(built); + + internal static string NormalizeSelectedSubscriptionId(string? selectedId, IReadOnlyCollection availableIds) => + string.IsNullOrEmpty(selectedId) || availableIds.Contains(selectedId, StringComparer.Ordinal) + ? selectedId ?? string.Empty + : string.Empty; + + public async Task StopCoreAsync(CancellationToken cancellationToken) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await StopCoreLockedAsync(cancellationToken); + } + finally + { + _coreGate.Release(); + } + } + + private async Task StopCoreLockedAsync(CancellationToken cancellationToken) + { + if (CurrentCoreRuntime.State == CoreRuntimeState.Stopped && !HasTrackedCoreProcesses) + { + return OperationView.Ok(ApiMessageKeys.CoreStopped); + } + + SetCoreRuntime(CurrentCoreRuntime with { State = CoreRuntimeState.Stopping, LastFailure = null }); + try + { + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + AddLog("core", ApiMessageKeys.CoreStopped); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Ok(ApiMessageKeys.CoreStopped); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + AddLog("core", $"Core stop failed: {exception}"); + _events.Publish("status", await GetStatusAsync()); + return OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds }); + } + } + + public async Task RestartCoreAsync(CancellationToken cancellationToken) + { + await _coreGate.WaitAsync(cancellationToken); + try + { + return await RestartCoreLockedAsync(cancellationToken); + } + finally + { + _coreGate.Release(); + } + } + + private async Task RestartCoreLockedAsync(CancellationToken cancellationToken) + { + var previous = CurrentCoreRuntime; + if (previous.State is not (CoreRuntimeState.Running or CoreRuntimeState.Faulted) + || string.IsNullOrEmpty(previous.ProfileId)) + { + return OperationView.Fail("core_not_running", ApiMessageKeys.CoreNotRunning, + new { runtimeState = previous.State.ToString(), processIds = GetActiveCoreProcessIds() }); + } + + var profile = await AppManager.Instance.GetProfileItem(previous.ProfileId); + if (profile is null) + { + return OperationView.Fail("profile_not_found", ApiMessageKeys.ProfileNotFound, + new { profileId = previous.ProfileId }); + } + + AppManager.Instance.Reset(); + var restartStage = "preflight"; + try + { + return await CoreRestartFlow.ExecuteAsync( + () => BuildAndValidateCoreLaunchAsync(profile), + async () => + { + restartStage = "stop"; + SetCoreRuntime(previous with { State = CoreRuntimeState.Restarting, LastFailure = null }); + await StopCoreMonitorAsync(); + await StopCoreAndConfirmAsync(cancellationToken); + }, + () => + { + SetCoreRuntime(CoreRuntimeSnapshot.Stopped); + restartStage = "start"; + }, + preflight => LaunchPreflightedCoreLockedAsync(preflight, cancellationToken)); + } + catch (Exception exception) + { + var processIds = GetActiveCoreProcessIds(); + if (restartStage != "preflight") + { + SetCoreRuntime(CurrentCoreRuntime with + { + State = CoreRuntimeState.Faulted, + ProcessIds = processIds, + LastFailure = exception.Message, + }); + } + AddLog("core", $"Core restart failed during {restartStage}: {exception}"); + return restartStage == "stop" + ? OperationView.Fail("core_stop_failed", ApiMessageKeys.CoreStopFailed, + new { detail = exception.Message, processIds }) + : OperationView.Fail("core_restart_failed", ApiMessageKeys.CoreStartFailed, + new { stage = restartStage, detail = exception.Message, processIds }); + } + } + + public async Task StartLatencyTestAsync(string profileId) + { + var profile = await AppManager.Instance.GetProfileItem(profileId); + if (profile is null || profile.IsComplex() || profile.Port <= 0) + { + return OperationView.Fail("speedtest_invalid_profile", ApiMessageKeys.SpeedTestInvalidProfile); + } + + return await StartSpeedTestAsync(new SpeedTestRequest(ESpeedActionType.Tcping, [profileId]), CancellationToken.None); + } + + public OperationView StopLatencyTests() + { + _speedtestService?.ExitLoop(); + return OperationView.Ok(ApiMessageKeys.CommonCompleted); + } + + public IReadOnlyList GetRecentLogs(int limit, string? filter = null) => _logs.Recent(limit, filter); + + public LogPageView GetRecentLogsPage(int page, int pageSize, string? filter = null) => _logs.RecentPage(page, pageSize, filter); + + public long ClearLogs() + { + var generation = _logs.Clear(); + _events.Publish("logs-cleared", new { generation }); + return generation; + } + + private async Task OnCoreMessageAsync(bool notify, string message) + { + AddLog("core", message); + await Task.CompletedTask; + } + + private void AddLog(string source, string message) + { + var entry = _logs.Add(source, message); + _events.Publish("log", entry); + Console.Out.WriteLine($"[{source}] {message.TrimEnd()}"); + } + + private static void ConfigureServiceLibConsoleLogging() + { + var logging = LogManager.Configuration; + if (logging is null) + { + return; + } + + var target = new ConsoleTarget("web-console") + { + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message}", + }; + logging.AddTarget(target); + logging.LoggingRules.Add(new LoggingRule("*", NLog.LogLevel.Debug, target)); + LogManager.Configuration = logging; + } + + private Task OnStatisticsUpdateAsync(ServerSpeedItem update) + { + _latestTraffic = update; + _events.Publish("traffic", update); + return Task.CompletedTask; + } + + // The subscription update owns the mutation gate while calling this helper. + private async Task UpdateSubscriptionTimestampLockedAsync(string subscriptionId) + { + var updateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (subscriptionId.Length > 0) + { + var item = await AppManager.Instance.GetSubItem(subscriptionId); + if (item is not null) + { + item.UpdateTime = updateTime; + if (await ConfigHandler.AddSubItem(Config, item) != 0) + { + throw new IOException("ServiceLib could not save the subscription timestamp."); + } + } + return; + } + + foreach (var item in await AppManager.Instance.SubItems() ?? []) + { + if (item.Enabled) + { + item.UpdateTime = updateTime; + if (await ConfigHandler.AddSubItem(Config, item) != 0) + { + throw new IOException("ServiceLib could not save the subscription timestamp."); + } + } + } + } + + internal static bool TryValidateSubscription(SubscriptionInput input, out string code, out string messageKey) + { + if (string.IsNullOrWhiteSpace(input.Remarks)) + { + code = "subscription_name_required"; + messageKey = ApiMessageKeys.SubscriptionNameRequired; + return false; + } + if (!string.IsNullOrWhiteSpace(input.Url) + && (!Uri.TryCreate(input.Url, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https"))) + { + code = "subscription_url_invalid"; + messageKey = ApiMessageKeys.SubscriptionInvalidUrl; + return false; + } + if (!HttpRequestHeadersHelper.TryParse(input.RequestHeaders, out _)) + { + code = "subscription_headers_invalid"; + messageKey = ApiMessageKeys.CommonInvalidInput; + return false; + } + + code = "ok"; + messageKey = string.Empty; + return true; + } + + internal static SubItem ToSubItem(SubscriptionInput input, SubItem? existing) => new() + { + Id = existing?.Id ?? string.Empty, + Remarks = input.Remarks.Trim(), + Url = input.Url?.Trim() ?? string.Empty, + MoreUrl = input.MoreUrl?.Trim() ?? existing?.MoreUrl ?? string.Empty, + Enabled = input.Enabled ?? existing?.Enabled ?? true, + UserAgent = input.UserAgent?.Trim() ?? existing?.UserAgent ?? string.Empty, + RequestHeaders = input.RequestHeaders ?? existing?.RequestHeaders, + Filter = input.Filter ?? existing?.Filter, + AutoUpdateInterval = Math.Max(input.AutoUpdateInterval ?? existing?.AutoUpdateInterval ?? 0, 0), + ConvertTarget = input.ConvertTarget ?? existing?.ConvertTarget, + Memo = input.Memo ?? existing?.Memo, + Sort = input.Sort ?? existing?.Sort ?? 0, + UpdateTime = existing?.UpdateTime ?? 0, + PrevProfile = input.PrevProfile ?? existing?.PrevProfile, + NextProfile = input.NextProfile ?? existing?.NextProfile, + PreSocksPort = input.PreSocksPort, + CustomCoreType = input.CustomCoreType, + }; + + internal static SubscriptionView ToSubscriptionView(SubItem item) => new( + item.Id, + item.Remarks, + item.Url, + item.Enabled, + item.MoreUrl, + item.Filter, + item.AutoUpdateInterval, + item.UpdateTime, + item.UserAgent, + item.RequestHeaders, + item.ConvertTarget, + item.Memo, + item.Sort, + item.PrevProfile, + item.NextProfile, + item.PreSocksPort, + item.CustomCoreType is null ? null : System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(item.CustomCoreType.Value.ToString())); + + private static void EnsureInboundDefaults(Config config) + { + if (config.Inbound.Count == 0) + { + config.Inbound.Add(new InItem + { + Protocol = nameof(EInboundProtocol.socks), + LocalPort = 10808, + UdpEnabled = true, + SniffingEnabled = true, + }); + } + + if (config.Inbound[0].LocalPort is <= 0 or > 65535) + { + config.Inbound[0].LocalPort = 10808; + } + } + + private RuntimeListenerSnapshot[] GetConfiguredListenerSnapshots() + { + var inbound = Config.Inbound.FirstOrDefault(); + if (inbound is null || inbound.LocalPort is <= 0 or > 65535) + { + return []; + } + + var listeners = new List(); + string[] localProtocols = inbound.UdpEnabled ? ["http", "socks", "udp"] : ["http", "socks"]; + var localAddress = inbound.AllowLANConn && !inbound.NewPort4LAN ? "0.0.0.0" : "127.0.0.1"; + listeners.Add(new RuntimeListenerSnapshot("local", localProtocols, localAddress, inbound.LocalPort)); + if (inbound.SecondLocalPortEnabled) + { + var secondaryPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks2); + if (secondaryPort is > 0 and <= 65535) + { + listeners.Add(new RuntimeListenerSnapshot("local-secondary", localProtocols, "127.0.0.1", secondaryPort)); + } + } + if (inbound.AllowLANConn && inbound.NewPort4LAN) + { + var lanPort = AppManager.Instance.GetLocalPort(EInboundProtocol.socks3); + if (lanPort is > 0 and <= 65535) + { + listeners.Add(new RuntimeListenerSnapshot("lan", localProtocols, "0.0.0.0", lanPort)); + } + } + return listeners.ToArray(); + } + + private static string[] GetExpectedCoreExecutablePaths(CoreConfigContextBuilderAllResult built) + { + var coreTypes = new ECoreType?[] + { + built.MainResult.Context.RunCoreType, + built.PreSocksResult?.Context.RunCoreType, + } + .Where(coreType => coreType.HasValue) + .Select(coreType => coreType!.Value) + .Distinct(); + + return coreTypes + .Select(coreType => CoreInfoManager.Instance.GetCoreInfo(coreType)) + .Select(info => info is null ? string.Empty : CoreInfoManager.Instance.GetCoreExecFile(info, out _)) + .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) + .Select(Path.GetFullPath) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .ToArray(); + } + + private static string[] GetExpectedCoreConfigPaths(CoreConfigContextBuilderAllResult built) + { + var fileNames = built.PreSocksResult is null + ? new[] { Global.CoreConfigFileName } + : new[] { Global.CoreConfigFileName, Global.CorePreConfigFileName }; + return fileNames + .Select(Utils.GetBinConfigPath) + .Select(Path.GetFullPath) + .Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + .ToArray(); + } + + private static string? FindXrayExecutable(out string message) + { + var info = CoreInfoManager.Instance.GetCoreInfo(ECoreType.Xray); + foreach (var name in info?.CoreExes ?? []) + { + var path = Utils.GetBinPath(Utils.GetExeName(name), ECoreType.Xray.ToString()); + if (File.Exists(path)) + { + message = string.Empty; + return path; + } + } + + message = $"Xray-core executable was not found under {Utils.GetBinPath(string.Empty, ECoreType.Xray.ToString())}."; + return null; + } + + private static async Task WaitForListenerAsync(int port, CancellationToken cancellationToken) + { + if (port is <= 0 or > 65535) + { + return false; + } + + var deadline = DateTimeOffset.UtcNow.AddSeconds(3); + while (DateTimeOffset.UtcNow < deadline) + { + if (await IsListeningAsync(port, cancellationToken)) + { + return true; + } + await Task.Delay(100, cancellationToken); + } + + return false; + } + + private static async Task IsListeningAsync(int port, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMilliseconds(250)); + await client.ConnectAsync("127.0.0.1", port, timeout.Token); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private async Task GetRegionalPresetProxyAsync(CancellationToken cancellationToken) + { + var runtime = CurrentCoreRuntime; + var inbound = Config.Inbound.FirstOrDefault(); + if (runtime.State != CoreRuntimeState.Running + || !HasTrackedCoreProcesses + || runtime.ProxyPort is not int port + || inbound is null + || port != inbound.LocalPort + || !runtime.Listeners.Any(listener => listener.Name == "local" + && listener.Port == port + && listener.Protocols.Contains("socks", StringComparer.OrdinalIgnoreCase))) + { + return null; + } + + // IsListeningAsync has a 250 ms connect budget. If the live mixed listener is not + // already present, do not probe a configured-but-stopped SOCKS port via DownloadService. + if (!await IsListeningAsync(port, cancellationToken) + || !ReferenceEquals(runtime, CurrentCoreRuntime)) + { + return null; + } + + return new WebProxy($"socks5://{Global.Loopback}:{port}"); + } + + internal static async Task IsProjectedListenerUnavailableAsync( + RuntimeListenerSnapshot target, + IReadOnlyCollection runtimeOwned, + CancellationToken cancellationToken) + { + foreach (var address in GetProbeAddresses(target.ListenAddress)) + { + var ownsTcp = runtimeOwned.Any(listener => listener.Port == target.Port + && ListenerOwnsAddress(listener.ListenAddress, address)); + if (!ownsTcp + && (await IsListeningOnAddressAsync(address, target.Port, cancellationToken) + || !CanBind(address, target.Port, SocketType.Stream, ProtocolType.Tcp))) + { + return true; + } + var targetUsesUdp = target.Protocols.Contains("udp", StringComparer.OrdinalIgnoreCase); + var ownsUdp = runtimeOwned.Any(listener => listener.Port == target.Port + && listener.Protocols.Contains("udp", StringComparer.OrdinalIgnoreCase) + && ListenerOwnsAddress(listener.ListenAddress, address)); + if (targetUsesUdp && !ownsUdp + && !CanBind(address, target.Port, SocketType.Dgram, ProtocolType.Udp)) + { + return true; + } + } + return false; + } + + private static IPAddress[] GetProbeAddresses(string listenAddress) + { + if (IPAddress.TryParse(listenAddress, out var explicitAddress) + && !explicitAddress.Equals(IPAddress.Any)) + { + return [explicitAddress]; + } + + var addresses = new HashSet { IPAddress.Loopback }; + foreach (var network in NetworkInterface.GetAllNetworkInterfaces()) + { + if (network.OperationalStatus != OperationalStatus.Up) continue; + foreach (var address in network.GetIPProperties().UnicastAddresses + .Select(item => item.Address) + .Where(address => address.AddressFamily == AddressFamily.InterNetwork)) + { + addresses.Add(address); + } + } + return addresses.ToArray(); + } + + private static bool ListenerOwnsAddress(string listenAddress, IPAddress address) => + IPAddress.TryParse(listenAddress, out var parsed) + && (parsed.Equals(IPAddress.Any) || parsed.Equals(address)); + + private static async Task IsListeningOnAddressAsync(IPAddress address, int port, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(address.AddressFamily); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMilliseconds(250)); + await client.ConnectAsync(address, port, timeout.Token); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return false; + } + } + + private static bool CanBind(IPAddress address, int port, SocketType socketType, ProtocolType protocolType) + { + try + { + using var socket = new Socket(address.AddressFamily, socketType, protocolType) + { + ExclusiveAddressUse = true, + }; + socket.Bind(new IPEndPoint(address, port)); + if (socketType == SocketType.Stream) socket.Listen(1); + return true; + } + catch (SocketException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs b/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs new file mode 100644 index 00000000..21f828b2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebBuildIdentity.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace v2rayN.WebAPI.Services; + +public sealed record WebBuildIdentity(string Version, string Commit, string BuildDate, string Rid, string Repository) +{ + public static WebBuildIdentity Current { get; } = Load(); + + private static WebBuildIdentity Load() + { + var metadata = Assembly.GetEntryAssembly()?.GetCustomAttributes() + .Where(item => item.Key is "WebVersion" or "WebCommit" or "WebBuildDate" or "WebRepository") + .ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var repository = metadata.GetValueOrDefault("WebRepository"); + if (!WebReleaseChannel.IsValidRepository(repository)) + repository = WebReleaseChannel.DefaultRepository; + return new WebBuildIdentity( + metadata.GetValueOrDefault("WebVersion") ?? "0.0.0-dev", + metadata.GetValueOrDefault("WebCommit") ?? "unknown", + metadata.GetValueOrDefault("WebBuildDate") ?? "unknown", + RuntimeInformation.RuntimeIdentifier, + repository!); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs b/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs new file mode 100644 index 00000000..a5903fcd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebReleaseAssets.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace v2rayN.WebAPI.Services; + +/// +/// The single source of truth for v2rayN Web release asset names. +/// Assets/web-assets.json is embedded here and read by the packaging and manifest +/// scripts, so the updater, the manifest, and the release ZIPs never disagree about names. +/// +internal static class WebReleaseAssets +{ + private const string ResourceName = "v2rayN.WebAPI.Assets.web-assets.json"; + private static readonly IReadOnlyDictionary Map = Load(); + + public static string? ArtifactArch(string? rid) => Find(rid)?.Arch; + + public static string? FullAssetName(string? rid) => Find(rid)?.Full; + + public static string? UpdateAssetName(string? rid) => Find(rid)?.Update; + + private static Entry? Find(string? rid) => + rid is not null && Map.TryGetValue(rid, out var entry) ? entry : null; + + private sealed record Entry(string Arch, string Full, string Update); + + private static IReadOnlyDictionary Load() + { + using var stream = typeof(WebReleaseAssets).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidDataException($"The embedded Web release asset map is missing: {ResourceName}"); + using var document = JsonDocument.Parse(stream); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var element in document.RootElement.GetProperty("assets").EnumerateArray()) + { + var rid = element.GetProperty("rid").GetString(); + var arch = element.GetProperty("arch").GetString(); + var full = element.GetProperty("full").GetString(); + var update = element.GetProperty("update").GetString(); + if (string.IsNullOrWhiteSpace(rid) + || string.IsNullOrWhiteSpace(arch) + || string.IsNullOrWhiteSpace(full) + || string.IsNullOrWhiteSpace(update) + || !map.TryAdd(rid, new Entry(arch, full, update))) + { + throw new InvalidDataException("The embedded Web release asset map is invalid."); + } + } + return map; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs b/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs new file mode 100644 index 00000000..9f4f1d14 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebReleaseChannel.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; + +namespace v2rayN.WebAPI.Services; + +/// +/// Describes the official v2rayN release channel used by the Web self-update. +/// Official releases use the upstream release tag (for example 7.25.3) and carry +/// WebAPI-update.json plus the app-only ZIP archives as additional release assets. +/// +internal static partial class WebReleaseChannel +{ + public const string DefaultRepository = "2dust/v2rayN"; + public const string ManifestAssetName = "WebAPI-update.json"; + private const string ApiBase = "https://api.github.com/repos"; + + public static bool IsValidRepository(string? value) => + !string.IsNullOrWhiteSpace(value) && RepositoryPattern().IsMatch(value); + + public static string BuildReleaseIndexUrl(string repository) + { + if (!IsValidRepository(repository)) + throw new ArgumentException("The release repository identifier is invalid.", nameof(repository)); + return $"{ApiBase}/{repository}/releases?per_page=100"; + } + + public static bool TryParseReleaseTag(string? tag, out string version) + { + version = string.Empty; + if (string.IsNullOrWhiteSpace(tag) || !ReleaseTagPattern().IsMatch(tag)) return false; + version = tag; + return true; + } + + public static bool IsTrustedAssetUrl(string? value, string repository, string tag, string assetName) + { + if (!IsValidRepository(repository) + || string.IsNullOrWhiteSpace(tag) + || string.IsNullOrWhiteSpace(assetName) + || !Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttps + || uri.Host != "github.com") + { + return false; + } + var expectedPath = $"/{repository}/releases/download/{Uri.EscapeDataString(tag)}/{Uri.EscapeDataString(assetName)}"; + return uri.AbsolutePath.Equals(expectedPath, StringComparison.Ordinal); + } + + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$", RegexOptions.CultureInvariant)] + private static partial Regex RepositoryPattern(); + + [GeneratedRegex("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", RegexOptions.CultureInvariant)] + private static partial Regex ReleaseTagPattern(); +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs b/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs new file mode 100644 index 00000000..fc20d34a --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebUpdateDeploymentPolicy.cs @@ -0,0 +1,33 @@ +namespace v2rayN.WebAPI.Services; + +internal enum WebDeploymentKind +{ + NativeWritable, + SystemdManaged, + Container, + ReadOnly, + Unsupported, +} + +internal sealed record WebUpdateDeployment(WebDeploymentKind Kind, bool CanCheck, bool CanInstall, string? InstallReasonKey); + +internal static class WebUpdateDeploymentPolicy +{ + public static WebUpdateDeployment Evaluate( + bool isSupportedNativePlatform, + bool isContainer, + bool isSystemdManaged, + bool isNativeSingleFile, + bool installDirectoryWritable) + { + if (isContainer) + return new(WebDeploymentKind.Container, true, false, "maintenance.webUpdateContainerImage"); + if (isSystemdManaged) + return new(WebDeploymentKind.SystemdManaged, true, false, "maintenance.webUpdateSystemdAdmin"); + if (!isSupportedNativePlatform || !isNativeSingleFile) + return new(WebDeploymentKind.Unsupported, true, false, "maintenance.webUpdateUnsupported"); + if (!installDirectoryWritable) + return new(WebDeploymentKind.ReadOnly, true, false, "maintenance.webUpdateReadOnly"); + return new(WebDeploymentKind.NativeWritable, true, true, null); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs b/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs new file mode 100644 index 00000000..81a56198 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Services/WebUpdatePackageStager.cs @@ -0,0 +1,329 @@ +using System.IO.Compression; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using ServiceLib.Models.Dto; + +namespace v2rayN.WebAPI.Services; + +public sealed record WebUpdatePackage(string Rid, string Asset, string Url, string Sha256, long Size); + +public sealed record WebUpdateManifest( + string Product, + string Version, + string Commit, + string BuildDate, + IReadOnlyList Packages); + +public sealed record WebUpdatePackageIdentity(string Product, string Version, string Commit, string BuildDate, string Rid); + +internal static partial class WebUpdatePackageStager +{ + internal const long MaximumArchiveBytes = 256L * 1024 * 1024; + internal const long MaximumExpandedBytes = 1024L * 1024 * 1024; + private const int MaximumEntries = 10000; + private const string LinuxExecutableName = "v2rayN.WebAPI"; + private const string WindowsExecutableName = "v2rayN.WebAPI.exe"; + + public static bool IsValidVersion(string? value) => + !string.IsNullOrWhiteSpace(value) && VersionPattern().IsMatch(value); + + public static bool IsUpdateAvailable( + string currentVersion, + string candidateVersion, + bool allowPrerelease, + string? currentCommit = null, + string? candidateCommit = null) + { + if (!IsValidVersion(currentVersion) || !IsValidVersion(candidateVersion)) return false; + var current = new SemanticVersion(currentVersion); + var candidate = new SemanticVersion(candidateVersion); + var comparison = candidate.CompareTo(current); + if (comparison > 0) return true; + return comparison == 0 + && !string.IsNullOrWhiteSpace(candidateCommit) + && !string.IsNullOrWhiteSpace(currentCommit) + && !string.Equals(candidateCommit, currentCommit, StringComparison.OrdinalIgnoreCase); + } + + public static bool ShouldConsiderRelease(bool isPrerelease, bool allowPrerelease) => !isPrerelease || allowPrerelease; + + public static WebUpdateManifest ParseManifest(string json) + { + var manifest = JsonSerializer.Deserialize(json, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The Web release manifest is empty."); + if (manifest.Product == "v2rayN.Web") + throw new InvalidDataException("Legacy v2rayN.Web packages require a one-time manual installation of v2rayN.WebAPI."); + if (manifest.Product != "v2rayN.WebAPI" || !IsValidVersion(manifest.Version) + || string.IsNullOrWhiteSpace(manifest.Commit) || manifest.Packages is null || manifest.Packages.Count is 0 or > 8) + { + throw new InvalidDataException("The Web release manifest has an invalid product identity."); + } + if (manifest.Packages.Select(package => package.Rid).Distinct(StringComparer.Ordinal).Count() != manifest.Packages.Count) + { + throw new InvalidDataException("The Web release manifest contains duplicate runtime identifiers."); + } + foreach (var package in manifest.Packages) + { + if (!IsSupportedRid(package.Rid)) + { + // Forward compatibility: a newer release can carry packages for runtime + // identifiers this build does not support. They are ignored here and are + // never downloaded or installed by this instance. + continue; + } + if (string.IsNullOrWhiteSpace(package.Asset) + || !Uri.TryCreate(package.Url, UriKind.Absolute, out var assetUri) + || assetUri.Scheme != Uri.UriSchemeHttps + || string.IsNullOrWhiteSpace(package.Sha256) + || !Regex.IsMatch(package.Sha256, "^[a-fA-F0-9]{64}$", RegexOptions.CultureInvariant) + || package.Size is <= 0 or > MaximumArchiveBytes) + { + throw new InvalidDataException("The Web release manifest contains an unsafe or unsupported package entry."); + } + } + return manifest; + } + + public static WebUpdatePackage RequirePackage(WebUpdateManifest manifest, string rid) + { + if (!IsSupportedRid(rid)) + throw new InvalidDataException($"The Web release does not support runtime identifier {rid}."); + return manifest.Packages.FirstOrDefault(package => package.Rid == rid) + ?? throw new InvalidDataException($"The Web release has no package for runtime identifier {rid}."); + } + + /// + /// Maps a supported .NET runtime identifier to the release archive architecture suffix + /// (linux-x64/win-x64 become 64; linux-arm64 stays arm64). + /// + public static string? ArtifactArch(string? rid) => WebReleaseAssets.ArtifactArch(rid); + + public static string? FullInstallAssetName(string? rid) => WebReleaseAssets.FullAssetName(rid); + + public static string? AppOnlyAssetName(string? rid) => WebReleaseAssets.UpdateAssetName(rid); + + public static async Task VerifyAndExtractAsync( + string archivePath, + string destinationPath, + WebUpdateManifest manifest, + WebUpdatePackage package, + CancellationToken cancellationToken) + { + var archive = new FileInfo(archivePath); + if (!archive.Exists || archive.Length != package.Size || archive.Length is <= 0 or > MaximumArchiveBytes) + throw new InvalidDataException("The downloaded Web package size does not match its manifest."); + + await using (var stream = File.OpenRead(archivePath)) + { + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + var expectedHash = Convert.FromHexString(package.Sha256); + if (!CryptographicOperations.FixedTimeEquals(hash, expectedHash)) + throw new InvalidDataException("The downloaded Web package SHA-256 does not match its manifest."); + } + + if (Directory.Exists(destinationPath) || File.Exists(destinationPath)) + throw new InvalidDataException("The Web package staging directory already exists."); + Directory.CreateDirectory(destinationPath); + var root = Path.GetFullPath(destinationPath); + if (new DirectoryInfo(root).LinkTarget is not null) + throw new InvalidDataException("The Web package staging root must not be a symbolic link."); + var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar; + long expandedBytes = 0; + var entryCount = 0; + var extractedFiles = new HashSet(StringComparer.Ordinal); + using (var zip = ZipFile.OpenRead(archivePath)) + { + foreach (var entry in zip.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (++entryCount > MaximumEntries) + throw new InvalidDataException("The Web package has too many archive entries."); + var relative = NormalizeEntryName(entry.FullName); + var target = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + if (!target.StartsWith(rootPrefix, StringComparison.Ordinal)) + throw new InvalidDataException("The Web package contains a path outside its staging directory."); + if (IsEnvironmentConfigurationPath(relative)) + throw new InvalidDataException("The Web package must not contain .env or .env.example files."); + if (!IsAllowedPath(relative, package.Rid)) + throw new InvalidDataException($"The Web package contains an unexpected file: {relative}."); + + var unixType = (entry.ExternalAttributes >> 16) & 0xF000; + if (unixType is not (0 or 0x4000 or 0x8000)) + throw new InvalidDataException("The Web package contains a link or special archive entry."); + if (unixType == 0x4000 || entry.FullName.EndsWith('/')) + { + Directory.CreateDirectory(target); + continue; + } + if (!extractedFiles.Add(relative)) + throw new InvalidDataException("The Web package contains duplicate file paths."); + if (entry.Length < 0 || entry.Length > MaximumExpandedBytes - expandedBytes) + throw new InvalidDataException("The expanded Web package exceeds the configured size limit."); + expandedBytes += entry.Length; + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await using var input = entry.Open(); + await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true); + await CopyWithLimitAsync(input, output, entry.Length, cancellationToken); + } + } + + var executableName = ExecutableNameForRid(package.Rid); + var executable = Path.Combine(root, executableName); + var identityPath = Path.Combine(root, "v2rayN.WebAPI.build.json"); + if (!File.Exists(executable) || new FileInfo(executable).Length == 0 + || !File.Exists(identityPath)) + { + throw new InvalidDataException("The Web package is missing its executable or build identity."); + } + RequireNativeExecutable(executable, package.Rid); + + var identity = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(identityPath, cancellationToken), + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidDataException("The Web package build identity is invalid."); + if (identity.Product != "v2rayN.WebAPI" || identity.Version != manifest.Version + || identity.Commit != manifest.Commit || identity.BuildDate != manifest.BuildDate || identity.Rid != package.Rid) + { + throw new InvalidDataException("The Web package identity does not match the verified release manifest."); + } + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(executable, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + return identity; + } + + private static string NormalizeEntryName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Contains('\\') || name.Contains('\0') || name.StartsWith('/')) + throw new InvalidDataException("The Web package contains an invalid archive path."); + var segments = name.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(segment => segment is "." or ".." || segment.Contains(':'))) + throw new InvalidDataException("The Web package contains a path traversal entry."); + var normalized = string.Join('/', segments); + if (normalized.Length == 0) throw new InvalidDataException("The Web package contains an empty archive path."); + return normalized; + } + + private static bool IsAllowedPath(string path, string rid) => + path == ExecutableNameForRid(rid) || path == "v2rayN.WebAPI.build.json"; + + // JSON RID alone cannot prove executable identity. Reject wrong-architecture, + // truncated and script payloads before shutdown or process replacement. + internal static bool IsSupportedRid(string? rid) => + rid is "linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64" or "win-x64" or "win-arm64"; + + internal static string ExecutableNameForRid(string rid) => rid switch + { + "linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64" => LinuxExecutableName, + "win-x64" or "win-arm64" => WindowsExecutableName, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + + internal static void RequireNativeExecutable(string path, string rid) + { + using var stream = File.OpenRead(path); + if (rid is "win-x64" or "win-arm64") + { + RequireWindowsExecutable(stream, rid); + return; + } + if (rid is not ("linux-x64" or "linux-arm64" or "linux-riscv64" or "linux-loongarch64")) + throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."); + + var expectedMachine = rid switch + { + "linux-x64" => 62, + "linux-arm64" => 183, + "linux-riscv64" => 243, + "linux-loongarch64" => 258, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + Span header = stackalloc byte[64]; + if (stream.Read(header) != header.Length + || !header[..7].SequenceEqual(new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }) + || BinaryPrimitives.ReadUInt16LittleEndian(header[16..]) is not (2 or 3) + || BinaryPrimitives.ReadUInt16LittleEndian(header[18..]) != expectedMachine + || BinaryPrimitives.ReadUInt32LittleEndian(header[20..]) != 1 + || BinaryPrimitives.ReadUInt16LittleEndian(header[52..]) != 64) + throw new InvalidDataException("The WebAPI package executable must be a Linux ELF64 image for its declared runtime identifier."); + var offset = BinaryPrimitives.ReadUInt64LittleEndian(header[32..]); + var entrySize = BinaryPrimitives.ReadUInt16LittleEndian(header[54..]); + var count = BinaryPrimitives.ReadUInt16LittleEndian(header[56..]); + if (offset < 64 || offset > (ulong)stream.Length || entrySize != 56 || count == 0 + || (ulong)count * entrySize > (ulong)stream.Length - offset) + throw new InvalidDataException("The WebAPI package executable has a truncated or invalid ELF program table."); + } + + private static void RequireWindowsExecutable(Stream stream, string rid) + { + var expectedMachine = rid switch + { + "win-x64" => 0x8664, + "win-arm64" => 0xAA64, + _ => throw new InvalidDataException($"Unsupported WebAPI runtime identifier: {rid}."), + }; + Span dosHeader = stackalloc byte[64]; + if (stream.Length < dosHeader.Length || stream.Read(dosHeader) != dosHeader.Length + || BinaryPrimitives.ReadUInt16LittleEndian(dosHeader) != 0x5A4D) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + + var peOffset = BinaryPrimitives.ReadUInt32LittleEndian(dosHeader[0x3C..]); + if (peOffset < dosHeader.Length || peOffset > stream.Length - 26) + throw new InvalidDataException("The WebAPI package executable has a truncated Windows PE header."); + + stream.Position = peOffset; + Span peHeader = stackalloc byte[26]; + if (stream.Read(peHeader) != peHeader.Length + || !peHeader[..4].SequenceEqual(new byte[] { (byte)'P', (byte)'E', 0, 0 }) + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[4..]) != expectedMachine + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[6..]) == 0 + || BinaryPrimitives.ReadUInt16LittleEndian(peHeader[20..]) < 2 + || (BinaryPrimitives.ReadUInt16LittleEndian(peHeader[22..]) & 0x0002) == 0) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + + var optionalHeaderSize = BinaryPrimitives.ReadUInt16LittleEndian(peHeader[20..]); + if ((long)peOffset + 24 + optionalHeaderSize > stream.Length) + throw new InvalidDataException("The WebAPI package executable has a truncated Windows optional header."); + Span optionalHeaderMagic = stackalloc byte[2]; + stream.Position = (long)peOffset + 24; + if (stream.Read(optionalHeaderMagic) != optionalHeaderMagic.Length + || BinaryPrimitives.ReadUInt16LittleEndian(optionalHeaderMagic) != 0x20B) + { + throw new InvalidDataException("The WebAPI package executable must be a Windows PE32+ image for its declared runtime identifier."); + } + } + + private static bool IsEnvironmentConfigurationPath(string path) => + path.Split('/').Any(segment => segment is ".env" or ".env.example"); + + private static async Task CopyWithLimitAsync(Stream input, Stream output, long expectedLength, CancellationToken cancellationToken) + { + var buffer = new byte[65536]; + long copied = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + copied += read; + if (copied > expectedLength || copied > MaximumExpandedBytes) + throw new InvalidDataException("A Web package archive entry exceeded its declared size."); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + if (copied != expectedLength) throw new InvalidDataException("A Web package archive entry was truncated."); + } + + [GeneratedRegex("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$", RegexOptions.CultureInvariant)] + private static partial Regex VersionPattern(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/.gitignore b/v2rayN/v2rayN.WebAPI/Tests/.gitignore new file mode 100644 index 00000000..18c6a9e8 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/obj/ +/TestResults/ diff --git a/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs new file mode 100644 index 00000000..030ed3f2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BackupArchiveValidationTests.cs @@ -0,0 +1,209 @@ +using System.IO.Compression; +using SQLite; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BackupArchiveValidationTests +{ + [Test] + public async Task SafeServiceLibBackupIsAccepted() + { + var archivePath = CreateArchive(("guiConfigs/guiNConfig.json", "{}")); + try + { + await V2rayRuntime.IsSafeBackupArchive(archivePath).Should().BeTrue(); + } + finally + { + File.Delete(archivePath); + } + } + + [Test] + public async Task ArchiveTraversalPathIsRejected() + { + var archivePath = CreateArchive( + ("guiConfigs/guiNConfig.json", "{}"), + ("guiConfigs/../../outside.txt", "not safe")); + try + { + await V2rayRuntime.IsSafeBackupArchive(archivePath).Should().BeFalse(); + } + finally + { + File.Delete(archivePath); + } + } + + [Test] + public async Task DesktopBackupSchemaWithOlderNullableColumnsIsAccepted() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "desktop.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?);", "legacy-sub", "Legacy", "https://example.test"); + } + + var archive = CreateArchive( + ("v2rayN_20260925/guiConfigs/guiNConfig.json", "{}"), + ("v2rayN_20260925/guiConfigs/guiNDB.db", await File.ReadAllBytesAsync(database))); + var extracted = Path.Combine(directory.Path, "desktop-extracted"); + try + { + await V2rayRuntime.IsSafeBackupArchive(archive).Should().BeTrue(); + await V2rayRuntime.TryExtractBackupConfig(archive, extracted).Should().BeTrue(); + await BackupDatabaseCompatibility.IsCompatible(Path.Combine(extracted, "guiNDB.db"), out _).Should().BeTrue(); + } + finally + { + File.Delete(archive); + } + } + + [Test] + public async Task ServiceLibAddsMissingColumnsWithoutDroppingDesktopRowsOrUnknownColumns() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "upgrade.db"); + using var connection = new SQLiteConnection(database); + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar, DesktopOnly varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url, DesktopOnly) VALUES (?, ?, ?, ?);", + "legacy-sub", "Desktop subscription", "https://example.test", "preserve-me"); + + connection.CreateTable(); + + var restored = connection.Table().First(); + var columns = connection.Query("PRAGMA table_info(\"SubItem\");"); + await (restored.Id == "legacy-sub" && restored.Remarks == "Desktop subscription").Should().BeTrue(); + await columns.Any(column => column.Name == "RequestHeaders").Should().BeTrue(); + await columns.Any(column => column.Name == "DesktopOnly").Should().BeTrue(); + } + + [Test] + public async Task WebBackupSchemaAndEmbeddedAuthAreCompatibleButCannotReplaceLocalAuth() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "web.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Id varchar primary key not null, Remarks varchar, Url varchar, RequestHeaders varchar);"); + connection.Execute("CREATE TABLE ProfileExItem (IndexId varchar primary key not null, Delay integer, IpInfo varchar);"); + connection.Execute("INSERT INTO SubItem (Id, Remarks, Url, RequestHeaders) VALUES (?, ?, ?, ?);", "web-sub", "Web", "https://example.test", "{}"); + } + + var archive = CreateArchive( + ("backup_WebAPI/guiConfigs/guiNConfig.json", "{}"), + ("backup_WebAPI/guiConfigs/guiNDB.db", await File.ReadAllBytesAsync(database)), + ("backup_WebAPI/guiConfigs/WebAPI-auth.json", "attacker-provided-auth")); + var extracted = Path.Combine(directory.Path, "WebAPI-extracted"); + var current = Path.Combine(directory.Path, "current-config"); + var candidate = Path.Combine(directory.Path, "candidate-config"); + var backupCopy = Path.Combine(directory.Path, "backup-config"); + Directory.CreateDirectory(current); + await File.WriteAllTextAsync(Path.Combine(current, "WebAPI-auth.json"), "local-auth-verifier"); + await File.WriteAllTextAsync(Path.Combine(current, "desktop-note"), "retain-local-config"); + V2rayRuntime.CopyConfigForBackup(current, backupCopy); + + try + { + await V2rayRuntime.IsSafeBackupArchive(archive).Should().BeTrue(); + await File.Exists(Path.Combine(backupCopy, "WebAPI-auth.json")).Should().BeFalse(); + await V2rayRuntime.TryExtractBackupConfig(archive, extracted).Should().BeTrue(); + await BackupDatabaseCompatibility.IsCompatible(Path.Combine(extracted, "guiNDB.db"), out _).Should().BeTrue(); + await File.Exists(Path.Combine(extracted, "WebAPI-auth.json")).Should().BeFalse(); + + V2rayRuntime.PrepareRestoredConfigDirectory(extracted, current, candidate); + + await (await File.ReadAllTextAsync(Path.Combine(candidate, "WebAPI-auth.json"))) + .Should().BeEqualTo("local-auth-verifier"); + await (await File.ReadAllTextAsync(Path.Combine(candidate, "desktop-note"))) + .Should().BeEqualTo("retain-local-config"); + await File.Exists(Path.Combine(candidate, "guiNDB.db")).Should().BeTrue(); + } + finally + { + File.Delete(archive); + } + } + + [Test] + public async Task DatabaseWithMissingPrimaryKeyIsRejectedBeforeRestore() + { + using var directory = new TemporaryDirectory(); + var database = Path.Combine(directory.Path, "invalid.db"); + using (var connection = new SQLiteConnection(database)) + { + connection.Execute("CREATE TABLE SubItem (Remarks varchar, Url varchar);"); + } + + await BackupDatabaseCompatibility.IsCompatible(database, out _).Should().BeFalse(); + } + + [Test] + public async Task FailedConfigDirectorySwapRestoresThePreviouslyActiveConfig() + { + using var directory = new TemporaryDirectory(); + var current = Path.Combine(directory.Path, "guiConfigs"); + var missingCandidate = Path.Combine(directory.Path, "missing-candidate"); + var displaced = Path.Combine(directory.Path, "guiConfigs-before-restore"); + Directory.CreateDirectory(current); + await File.WriteAllTextAsync(Path.Combine(current, "guiNConfig.json"), "old-config"); + + var failed = false; + try + { + V2rayRuntime.ReplaceConfigDirectory(missingCandidate, current, displaced); + } + catch (DirectoryNotFoundException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await Directory.Exists(current).Should().BeTrue(); + await Directory.Exists(displaced).Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(current, "guiNConfig.json"))) + .Should().BeEqualTo("old-config"); + } + + private static string CreateArchive(params (string Name, object Content)[] files) + { + var path = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-test-{Guid.NewGuid():N}.zip"); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var file in files) + { + using var entry = archive.CreateEntry(file.Name).Open(); + if (file.Content is byte[] bytes) + { + entry.Write(bytes); + } + else + { + using var writer = new StreamWriter(entry); + writer.Write((string)file.Content); + } + } + return path; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-backup-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class TableColumnRow + { + public string Name { get; set; } = string.Empty; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs new file mode 100644 index 00000000..586382b1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BackupRestorePreflightTests.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BackupRestorePreflightTests +{ + [Test] + [Arguments("https://127.0.0.1:5443", "backup.restoreProbeUnavailable")] + [Arguments("http://192.168.1.20:5080", "backup.restoreProbeUnavailable")] + public async Task UnprobeableListenersRefuseBeforeAnyRestoreMutation(string listener, string messageKey) + { + foreach (var wasRunning in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiNConfig.json"); + await File.WriteAllTextAsync(configPath, "original-config"); + var coreRunning = wasRunning; + var mutationsStarted = false; + var preflight = CreatePreflight(listener, setsidPath: "/usr/bin/setsid"); + + var result = await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + mutationsStarted = true; + coreRunning = false; + await File.WriteAllTextAsync(configPath, "restored-config"); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted); + }); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("backup_restore_health_probe_unavailable"); + await result.MessageKey.Should().BeEqualTo(messageKey); + await mutationsStarted.Should().BeFalse(); + await coreRunning.Should().BeEqualTo(wasRunning); + await (await File.ReadAllTextAsync(configPath)).Should().BeEqualTo("original-config"); + } + } + + [Test] + public async Task MissingExecutableSetsidRefusesBeforeStoppingCoreOrReplacingConfig() + { + foreach (var wasRunning in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiNConfig.json"); + await File.WriteAllTextAsync(configPath, "original-config"); + var coreRunning = wasRunning; + var mutationsStarted = false; + var preflight = CreatePreflight("http://127.0.0.1:5080", setsidPath: null); + + var result = await BackupRestorePreflightGate.ExecuteAsync(preflight, async () => + { + mutationsStarted = true; + coreRunning = false; + await File.WriteAllTextAsync(configPath, "restored-config"); + return OperationView.Ok(ApiMessageKeys.BackupRestoreStarted); + }); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("backup_restore_setsid_unavailable"); + await result.MessageKey.Should().BeEqualTo(ApiMessageKeys.BackupRestoreSetSidUnavailable); + await mutationsStarted.Should().BeFalse(); + await coreRunning.Should().BeEqualTo(wasRunning); + await (await File.ReadAllTextAsync(configPath)).Should().BeEqualTo("original-config"); + } + } + + [Test] + public async Task NativeWindowsRestoreBuildsVerifiedHiddenReplacementWithoutSetsid() + { + var preflight = NativeWebRestorePreflight.Evaluate( + Config(("urls", "http://localhost:5080")), + isLinux: false, + isWindows: true, + externalSupervisor: false, + containerEnvironment: false, + setsidPath: null, + processPath: @"C:\WebAPI\v2rayN.WebAPI.exe", + managedEntryPoint: null, + hostArguments: ["--urls", "http://localhost:5080"], + fileExists: _ => true); + + await preflight.Allowed.Should().BeTrue(); + await preflight.Owner.Should().BeEqualTo(WebLifecycleOwner.NativeProcess); + await preflight.Replacement!.HealthUri.ToString().Should().BeEqualTo("http://localhost:5080/api/health"); + await preflight.Replacement.Command.CreateNoWindow.Should().BeTrue(); + await preflight.Replacement.Command.LauncherPath.Should().BeEqualTo(Path.GetFullPath(@"C:\WebAPI\v2rayN.WebAPI.exe")); + await preflight.Replacement.Command.Arguments.SequenceEqual([ + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://localhost:5080", + ]).Should().BeTrue(); + await preflight.Replacement.Command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task ExternalSupervisorOwnsRestartAndDoesNotRequireANativeHealthProbeOrLauncher() + { + var preflight = NativeWebRestorePreflight.Evaluate( + Config(("urls", "https://127.0.0.1:5443")), + isLinux: true, + isWindows: false, + externalSupervisor: true, + containerEnvironment: false, + setsidPath: null, + processPath: null, + managedEntryPoint: null, + hostArguments: []); + + await preflight.Allowed.Should().BeTrue(); + await preflight.Owner.Should().BeEqualTo(WebLifecycleOwner.ExternalSupervisor); + await (preflight.Replacement is null).Should().BeTrue(); + } + + private static NativeWebRestorePreflightResult CreatePreflight(string listener, string? setsidPath) => + NativeWebRestorePreflight.Evaluate( + Config(("urls", listener)), + isLinux: true, + isWindows: false, + externalSupervisor: false, + containerEnvironment: false, + setsidPath, + processPath: "/opt/v2rayN.WebAPI", + managedEntryPoint: null, + hostArguments: [], + fileExists: _ => true, + isUnixExecutable: _ => true); + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)) + .Build(); + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-restore-preflight-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs b/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs new file mode 100644 index 00000000..f088b681 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/BuiltinDnsProfileBootstrapTests.cs @@ -0,0 +1,89 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class BuiltinDnsProfileBootstrapTests +{ + [Test] + public async Task EmptyDnsTableGetsExactlyOneDisabledXrayAndSingboxProfile() + { + var result = await Ensure([]); + await AssertBuiltinPair(result.Rows); + await result.SavedTypes.Count.Should().BeEqualTo(2); + await result.Rows.All(item => !item.Enabled).Should().BeTrue(); + await result.Rows.Single(item => item.CoreType == ECoreType.Xray).Remarks.Should().BeEqualTo("V2ray"); + await result.Rows.Single(item => item.CoreType == ECoreType.sing_box).Remarks.Should().BeEqualTo("sing-box"); + } + + [Test] + public async Task XrayOnlyStateAddsSingboxWithoutReplacingXray() + { + var xray = Existing(ECoreType.Xray, "xray-id", "preserved xray", enabled: true); + var result = await Ensure([xray]); + await AssertBuiltinPair(result.Rows); + await (result.Rows.Single(item => item.CoreType == ECoreType.Xray) == xray).Should().BeTrue(); + await result.SavedTypes.SequenceEqual(new[] { ECoreType.sing_box }).Should().BeTrue(); + await (xray.Remarks == "preserved xray" && xray.Enabled).Should().BeTrue(); + var singbox = result.Rows.Single(item => item.CoreType == ECoreType.sing_box); + await (!singbox.Enabled && singbox.Remarks == "sing-box").Should().BeTrue(); + } + + [Test] + public async Task SingboxOnlyStateAddsXrayWithoutReplacingSingbox() + { + var singbox = Existing(ECoreType.sing_box, "singbox-id", "preserved sing-box", enabled: true); + var result = await Ensure([singbox]); + await AssertBuiltinPair(result.Rows); + await (result.Rows.Single(item => item.CoreType == ECoreType.sing_box) == singbox).Should().BeTrue(); + await result.SavedTypes.SequenceEqual(new[] { ECoreType.Xray }).Should().BeTrue(); + await (singbox.Remarks == "preserved sing-box" && singbox.Enabled).Should().BeTrue(); + var xray = result.Rows.Single(item => item.CoreType == ECoreType.Xray); + await (!xray.Enabled && xray.Remarks == "V2ray").Should().BeTrue(); + } + + [Test] + public async Task CompleteDnsStateIsLeftUntouched() + { + var xray = Existing(ECoreType.Xray, "xray-id", "custom xray", enabled: true); + var singbox = Existing(ECoreType.sing_box, "singbox-id", "custom sing-box", enabled: true); + var result = await Ensure([xray, singbox]); + await AssertBuiltinPair(result.Rows); + await result.SavedTypes.Count.Should().BeEqualTo(0); + await (result.Rows[0] == xray && result.Rows[1] == singbox).Should().BeTrue(); + } + + private static async Task<(List Rows, List SavedTypes)> Ensure(List initial) + { + var rows = new List(initial); + var savedTypes = new List(); + await BuiltinDnsProfileBootstrap.EnsureAsync( + new Config(), + () => Task.FromResult>(rows.ToArray()), + (_, item) => + { + rows.Add(item); + savedTypes.Add(item.CoreType); + return Task.FromResult(0); + }); + return (rows, savedTypes); + } + + private static async Task AssertBuiltinPair(IReadOnlyList rows) + { + await rows.Count.Should().BeEqualTo(2); + await rows.Count(item => item.CoreType == ECoreType.Xray).Should().BeEqualTo(1); + await rows.Count(item => item.CoreType == ECoreType.sing_box).Should().BeEqualTo(1); + } + + private static DNSItem Existing(ECoreType coreType, string id, string remarks, bool enabled) => new() + { + Id = id, + CoreType = coreType, + Remarks = remarks, + Enabled = enabled, + NormalDNS = "user DNS data", + }; +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs new file mode 100644 index 00000000..202319d1 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreProcessTrackerTests.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreProcessTrackerTests +{ + [Test] + public async Task WebTrackerCapturesNewCoreProcessesAndPrunesExitedIdentities() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(executable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + await CoreProcessTracker.GetActiveProcesses(tracked) + .Any(identity => identity.ProcessId == process.Id) + .Should().BeTrue(); + + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + await CoreProcessTracker.GetActiveProcesses(tracked).Length.Should().BeEqualTo(0); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerDoesNotClaimProcessesThatPredateItsLaunchSnapshot() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + using var process = StartSleepProcess(executable); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + + try + { + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeFalse(); + } + finally + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + + [Test] + public async Task WebTrackerRecognizesAnExecutableRunningThroughItsInterpreter() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = Path.Combine(directory.Path, "test-core-shim"); + await File.WriteAllTextAsync(executable, "#!/bin/sh\nsleep 60\n"); + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = Process.Start(new ProcessStartInfo(executable) { UseShellExecute = false }) + ?? throw new InvalidOperationException("Could not start the Core shim test process."); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerDoesNotClaimSameNamedExecutableFromAnotherPath() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var unrelatedExecutable = Path.Combine(directory.Path, "sleep"); + File.Copy("/bin/sleep", unrelatedExecutable); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(unrelatedExecutable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, ["/bin/sleep"]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeFalse(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task LinuxTrackerRequiresTheWebCoreConfigPathWhenProvided() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = Path.Combine(directory.Path, "test-core-shim"); + var configPath = Path.Combine(directory.Path, "config.json"); + await File.WriteAllTextAsync(executable, "#!/bin/sh\nsleep 60\n"); + await File.WriteAllTextAsync(configPath, "{}"); + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false }; + startInfo.ArgumentList.Add(configPath); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the Core shim test process."); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable], [configPath]); + await tracked.Any(identity => identity.ProcessId == process.Id).Should().BeTrue(); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [Test] + public async Task WebTrackerRejectsReusedPidWithDifferentStartTimeOrExecutable() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var executable = CopySleepExecutable(directory.Path); + var existingProcessIds = CoreProcessTracker.CaptureExistingProcessIds(); + using var process = StartSleepProcess(executable); + try + { + var tracked = CoreProcessTracker.CaptureStartedProcesses(existingProcessIds, [executable]); + var identity = tracked.Single(item => item.ProcessId == process.Id); + + await CoreProcessTracker.GetActiveProcesses([identity with { StartTimeUtcTicks = identity.StartTimeUtcTicks + 1 }]) + .Length.Should().BeEqualTo(0); + await CoreProcessTracker.GetActiveProcesses([identity with { ExecutablePath = "/tmp/unrelated-core" }]) + .Length.Should().BeEqualTo(0); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + private static Process StartSleepProcess(string executable = "/bin/sleep") + { + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false }; + startInfo.ArgumentList.Add("60"); + return Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the test process."); + } + + private static string CopySleepExecutable(string directory) + { + var executable = Path.Combine(directory, "sleep-core-test"); + File.Copy("/bin/sleep", executable); + return executable; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-core-tracker-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs new file mode 100644 index 00000000..8f2c60cd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreRestartPreflightTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Sockets; +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreRestartPreflightTests +{ + [Test] + public async Task RestartWithTunContextKeepsRunningCore() + { + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(isTunEnabled: true), + _ => null); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "tun_not_supported"); + } + + [Test] + public async Task RestartWithInvalidContextKeepsRunningCore() + { + var binaryCheckCalled = false; + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(valid: false), + _ => + { + binaryCheckCalled = true; + return null; + }); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "profile_validation_failed"); + await binaryCheckCalled.Should().BeFalse(); + } + + [Test] + public async Task RestartWithMissingCoreBinaryKeepsRunningCore() + { + var preflight = CoreLaunchPreflight.Validate( + new ProfileItem(), + CreateBuiltContext(), + coreType => OperationView.Fail("core_binary_missing", ApiMessageKeys.CoreBinaryMissing, + new { coreType = coreType.ToString() })); + + await AssertRestartRejectedWithoutStoppingCore(preflight, "core_binary_missing"); + } + + [Test] + public async Task ValidRestartStopsThenLaunchesThePreflightedContext() + { + var profile = new ProfileItem { IndexId = "profile-1" }; + var builtContext = CreateBuiltContext(); + var preflight = CoreLaunchPreflight.Validate(profile, builtContext, _ => null); + var events = new List(); + var coreRunning = true; + CoreConfigContextBuilderAllResult? launchedContext = null; + + var result = await CoreRestartFlow.ExecuteAsync( + () => Task.FromResult(preflight), + () => + { + events.Add("stop"); + coreRunning = false; + return Task.CompletedTask; + }, + () => { }, + plan => + { + events.Add("launch"); + launchedContext = plan.BuiltContext; + coreRunning = true; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted, + new { profileId = plan.Profile.IndexId })); + }); + + await events.Count.Should().BeEqualTo(2); + await events[0].Should().BeEqualTo("stop"); + await events[1].Should().BeEqualTo("launch"); + await ReferenceEquals(launchedContext, builtContext).Should().BeTrue(); + await coreRunning.Should().BeTrue(); + await result.Success.Should().BeTrue(); + await result.MessageKey.Should().BeEqualTo(ApiMessageKeys.CoreRestarted); + } + + [Test] + public async Task UnrelatedListenerIsReportedUnavailableByTheNormalStartPreflightProbe() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + try + { + var target = new RuntimeListenerSnapshot("local", ["http", "socks"], "127.0.0.1", port); + + // With no Core owned by Web, the probe must reject the port before launch. + var unavailableForUnrelatedOwner = await V2rayRuntime.IsProjectedListenerUnavailableAsync( + target, + runtimeOwned: [], + CancellationToken.None); + await unavailableForUnrelatedOwner.Should().BeTrue(); + + // The same listener is fine while Web still owns the tracked Core process. + var unavailableForOwnedListener = await V2rayRuntime.IsProjectedListenerUnavailableAsync( + target, + runtimeOwned: [target], + CancellationToken.None); + await unavailableForOwnedListener.Should().BeFalse(); + } + finally + { + listener.Stop(); + } + } + + private static async Task AssertRestartRejectedWithoutStoppingCore( + CoreLaunchPreflightResult preflight, + string expectedCode) + { + var coreRunning = true; + var stopCalled = false; + var launchCalled = false; + + var result = await CoreRestartFlow.ExecuteAsync( + () => Task.FromResult(preflight), + () => + { + stopCalled = true; + coreRunning = false; + return Task.CompletedTask; + }, + () => { }, + _ => + { + launchCalled = true; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }); + + await result.Code.Should().BeEqualTo(expectedCode); + await stopCalled.Should().BeFalse(); + await launchCalled.Should().BeFalse(); + await coreRunning.Should().BeTrue(); + } + + private static CoreConfigContextBuilderAllResult CreateBuiltContext( + bool isTunEnabled = false, + bool valid = true) + { + var validator = valid + ? NodeValidatorResult.Empty() + : new NodeValidatorResult(["invalid"], []); + return new CoreConfigContextBuilderAllResult( + new CoreConfigContextBuilderResult( + new CoreConfigContext + { + Node = new ProfileItem(), + RunCoreType = ECoreType.Xray, + IsTunEnabled = isTunEnabled, + }, + validator), + null); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs new file mode 100644 index 00000000..afbc5339 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreSettingsApplyPolicyTests.cs @@ -0,0 +1,42 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreSettingsApplyPolicyTests +{ + [Test] + public async Task ApplyPolicyDistinguishesStoppedRunningFaultedAndTransitionStates() + { + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Stopped, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Faulted, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Faulted, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + + foreach (var transition in new[] + { + CoreRuntimeState.Starting, + CoreRuntimeState.Stopping, + CoreRuntimeState.Restarting, + }) + { + await CoreSettingsApplyPolicy.Decide(transition, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Busy); + } + + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: true, changed: false) + .Should().BeEqualTo(CoreSettingsApplyAction.SaveOnly); + } + + [Test] + public async Task AReportedRunningRuntimeWithoutAChildIsNotTreatedAsStopped() + { + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Running, hasActiveChild: false, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Inconsistent); + await CoreSettingsApplyPolicy.Decide(CoreRuntimeState.Stopped, hasActiveChild: true, changed: true) + .Should().BeEqualTo(CoreSettingsApplyAction.Inconsistent); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs b/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs new file mode 100644 index 00000000..da3a0eda --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/CoreUpdateWorkflowTests.cs @@ -0,0 +1,350 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Manager; +using ServiceLib.Resx; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class CoreUpdateWorkflowTests +{ + [Test] + public async Task CoreVersionCheckAcceptsVPrefixedMihomoVersion() + { + var output = "Mihomo Meta v1.19.31 linux amd64 with go1.26.8"; + + await CoreExecutableVersionCheck.IsValid(output, "Mihomo").Should().BeTrue(); + } + + [Test] + public async Task CoreVersionCheckStillRequiresExpectedNameAndSemanticVersion() + { + await CoreExecutableVersionCheck.IsValid("Xray 25.9.11", "Mihomo").Should().BeFalse(); + await CoreExecutableVersionCheck.IsValid("Mihomo Meta development build", "Mihomo").Should().BeFalse(); + } + + [Test] + public async Task XraySingBoxAndMihomoPackagesAreStagedUsingTheirNativeArchiveFormats() + { + using var directory = new TemporaryDirectory(); + + var xrayArchive = Path.Combine(directory.Path, "xray.zip"); + using (var zip = ZipFile.Open(xrayArchive, ZipArchiveMode.Create)) + { + await WriteZipFileAsync(zip, "Xray/xray", "xray binary"); + await WriteZipFileAsync(zip, "Xray/geoip.dat", "bundled geo data"); + } + var xrayStage = Path.Combine(directory.Path, "xray-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.Xray, xrayArchive, xrayStage, CancellationToken.None); + await File.Exists(Path.Combine(xrayStage, "xray")).Should().BeTrue(); + await File.Exists(Path.Combine(xrayStage, "geoip.dat")).Should().BeFalse(); + + var mihomoArchive = Path.Combine(directory.Path, "mihomo.gz"); + await WriteGzipAsync(mihomoArchive, "mihomo binary"); + var mihomoStage = Path.Combine(directory.Path, "mihomo-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.mihomo, mihomoArchive, mihomoStage, CancellationToken.None); + var mihomoExecutable = Utils.GetExeName(CoreInfoManager.Instance.GetCoreInfo(ECoreType.mihomo)!.CoreExes!.First()); + await File.Exists(Path.Combine(mihomoStage, mihomoExecutable)).Should().BeTrue(); + + var singBoxArchive = Path.Combine(directory.Path, "sing-box.tar.gz"); + await WriteTarGzipAsync(singBoxArchive, "sing-box-release/sing-box", "sing-box binary"); + var singBoxStage = Path.Combine(directory.Path, "sing-box-stage"); + await CoreUpdatePackageStager.ExtractAsync(ECoreType.sing_box, singBoxArchive, singBoxStage, CancellationToken.None); + await File.Exists(Path.Combine(singBoxStage, "sing-box")).Should().BeTrue(); + } + + [Test] + public async Task StagingAndVerificationAlwaysFinishBeforeApplyBegins() + { + var sequence = new List(); + var result = await CoreUpdateWorkflow.StageThenApplyAsync( + () => + { + sequence.Add("checking"); + sequence.Add("downloading"); + sequence.Add("verifying"); + return Task.FromResult("verified-package"); + }, + staged => + { + sequence.Add("stopping-core"); + sequence.Add("installing"); + return Task.FromResult(staged); + }); + + await (result == "verified-package").Should().BeTrue(); + await sequence.SequenceEqual(["checking", "downloading", "verifying", "stopping-core", "installing"]).Should().BeTrue(); + } + + [Test] + public async Task FailedStagingNeverInvokesApply() + { + var applied = false; + + var failed = false; + try + { + await CoreUpdateWorkflow.StageThenApplyAsync( + () => throw new InvalidDataException("package verification failed"), + _ => + { + applied = true; + return Task.FromResult(true); + }); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task SelectedBatchStagesEveryNetworkPackageBeforeAnyApply() + { + var sequence = new List(); + var results = await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box", "Mihomo" }, + target => + { + sequence.Add($"stage:{target}"); + return Task.FromResult($"verified:{target}"); + }, + stages => + { + sequence.Add("CoreStop"); + sequence.AddRange(stages.Select(stage => $"apply:{stage}")); + return Task.FromResult(stages.Count); + }, + () => + { + sequence.Add("prepare:GeoFiles"); + return Task.CompletedTask; + }); + + await results.Should().BeEqualTo(3); + await sequence.SequenceEqual(new[] + { + "stage:Xray", "stage:sing-box", "stage:Mihomo", "prepare:GeoFiles", "CoreStop", + "apply:verified:Xray", "apply:verified:sing-box", "apply:verified:Mihomo", + }).Should().BeTrue(); + } + + [Test] + public async Task FailedAdditionalNetworkPreparationPreventsEveryCoreApply() + { + var applied = false; + var failed = false; + try + { + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray" }, + target => Task.FromResult($"verified:{target}"), + _ => + { + applied = true; + return Task.FromResult(true); + }, + () => throw new InvalidDataException("GeoFiles preparation failed.")); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task FailedBatchStagingPreventsEveryCoreStopAndInstall() + { + var applied = false; + var failed = false; + try + { + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box" }, + target => target == "sing-box" + ? throw new InvalidDataException("package verification failed") + : Task.FromResult($"verified:{target}"), + stages => + { + applied = true; + return Task.FromResult(stages.Count); + }); + } + catch (InvalidDataException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await applied.Should().BeFalse(); + } + + [Test] + public async Task SelectedBatchStagesWebWithOtherTargetsAndAppliesItOnlyAfterCoreAndGeo() + { + var sequence = new List(); + await CoreUpdateWorkflow.StageAllThenApplyAsync( + new[] { "Xray", "sing-box" }, + target => + { + sequence.Add($"stage:{target}"); + return Task.FromResult(target); + }, + stages => + { + sequence.AddRange(stages.Select(stage => $"apply:{stage}")); + sequence.Add("apply:GeoFiles"); + return Task.FromResult(true); + }, + () => + { + sequence.Add("prepare:GeoFiles"); + sequence.Add("stage:v2rayN.WebAPI"); + return Task.CompletedTask; + }); + sequence.Add("apply:v2rayN.WebAPI"); + + await sequence.SequenceEqual(new[] + { + "stage:Xray", "stage:sing-box", "prepare:GeoFiles", "stage:v2rayN.WebAPI", + "apply:Xray", "apply:sing-box", "apply:GeoFiles", "apply:v2rayN.WebAPI", + }).Should().BeTrue(); + } + + [Test] + public async Task ApplyFailureRestoresThePreviousBinaryBeforeRestartingTheOldCore() + { + using var directory = new TemporaryDirectory(); + var install = Path.Combine(directory.Path, "xray"); + var backup = Path.Combine(directory.Path, ".xray-backup"); + Directory.CreateDirectory(install); + Directory.CreateDirectory(backup); + await File.WriteAllTextAsync(Path.Combine(install, "xray"), "new-binary"); + await File.WriteAllTextAsync(Path.Combine(backup, "xray"), "old-binary"); + var order = new List(); + + var rollback = await CoreUpdateWorkflow.RollBackAndRestoreAsync( + () => + { + CoreUpdateWorkflow.RestorePreviousDirectory(install, backup, candidateInstalled: true, hadInstalledCore: true); + order.Add("restore-files"); + return Task.CompletedTask; + }, + () => { order.Add("initialize-old"); return Task.CompletedTask; }, + wasRunning: true, + restartPreviousCoreAsync: () => { order.Add("restart-old"); return Task.FromResult(true); }); + + await rollback.Completed.Should().BeTrue(); + await (await File.ReadAllTextAsync(Path.Combine(install, "xray"))).Should().BeEqualTo("old-binary"); + await order.SequenceEqual(["restore-files", "initialize-old", "restart-old"]).Should().BeTrue(); + } + + [Test] + public async Task FailedOldCoreRestartIsReportedAfterItsFilesHaveBeenRestored() + { + using var directory = new TemporaryDirectory(); + var install = Path.Combine(directory.Path, "xray"); + var backup = Path.Combine(directory.Path, ".xray-backup"); + Directory.CreateDirectory(install); + Directory.CreateDirectory(backup); + await File.WriteAllTextAsync(Path.Combine(install, "xray"), "new-binary"); + await File.WriteAllTextAsync(Path.Combine(backup, "xray"), "old-binary"); + + var rollback = await CoreUpdateWorkflow.RollBackAndRestoreAsync( + () => + { + CoreUpdateWorkflow.RestorePreviousDirectory(install, backup, candidateInstalled: true, hadInstalledCore: true); + return Task.CompletedTask; + }, + () => Task.CompletedTask, + wasRunning: true, + restartPreviousCoreAsync: () => Task.FromResult(false)); + + await rollback.FilesRestored.Should().BeTrue(); + await rollback.Completed.Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(install, "xray"))).Should().BeEqualTo("old-binary"); + } + + [Test] + public async Task OnlyTheExactUpdatedCoreIsStoppedAndRestarted() + { + var now = DateTimeOffset.UtcNow; + foreach (var core in new[] { ECoreType.Xray, ECoreType.sing_box, ECoreType.mihomo }) + { + await CoreUpdateRuntimePolicy.IsTargetRunning(core, core, now).Should().BeTrue(); + await CoreUpdateRuntimePolicy.IsTargetRunning(core, ECoreType.v2fly, now).Should().BeFalse(); + await CoreUpdateRuntimePolicy.IsTargetRunning(core, core, null).Should().BeFalse(); + } + await CoreUpdatePackageStager.SupportsCore(ECoreType.Xray).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.sing_box).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.mihomo).Should().BeTrue(); + await CoreUpdatePackageStager.SupportsCore(ECoreType.v2rayN).Should().BeFalse(); + } + + [Test] + public async Task CoreUpdatePreReleaseChoicesMatchDesktopCoreInfoSemantics() + { + var manager = CoreInfoManager.Instance; + await manager.GetCheckPreRelease(ECoreType.Xray, true).Should().BeTrue(); + await manager.GetCheckPreRelease(ECoreType.v2rayN, true).Should().BeTrue(); + await manager.GetCheckPreRelease(ECoreType.mihomo, true).Should().BeFalse(); + await manager.GetCheckPreRelease(ECoreType.sing_box, true).Should().BeFalse(); + } + + [Test] + public async Task CoreUpdateCheckPreservesServiceLibCurrentVersionSemantics() + { + var latest = string.Format(ResUI.IsLatestCore, ECoreType.Xray, "v1.2.3"); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, latest).Should().BeTrue(); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, "Could not reach release API").Should().BeFalse(); + await V2rayRuntime.IsUpToDateResult(ECoreType.Xray, null).Should().BeFalse(); + } + + private static async Task WriteZipFileAsync(ZipArchive zip, string name, string content) + { + await using var stream = zip.CreateEntry(name).Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(content)); + } + + private static async Task WriteGzipAsync(string path, string content) + { + await using var file = File.Create(path); + await using var gzip = new GZipStream(file, CompressionLevel.SmallestSize); + await gzip.WriteAsync(Encoding.UTF8.GetBytes(content)); + } + + private static async Task WriteTarGzipAsync(string path, string entryName, string content) + { + await using var file = File.Create(path); + await using var gzip = new GZipStream(file, CompressionLevel.SmallestSize, leaveOpen: true); + using var writer = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true); + var entry = new PaxTarEntry(TarEntryType.RegularFile, entryName) + { + DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content)), + }; + writer.WriteEntry(entry); + await Task.CompletedTask; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-core-update-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs new file mode 100644 index 00000000..44b3b996 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/DnsEditorParityTests.cs @@ -0,0 +1,252 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Services.CoreConfig; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class DnsEditorParityTests +{ + [Test] + public async Task DnsEditorOptionsAreTheCurrentServiceLibOptions() + { + var options = V2rayRuntime.GetEditorOptions(); + var settings = V2rayRuntime.GetWebSettingsOptions(); + + await options.Subscriptions.ConvertTargets.SequenceEqual(Global.SubConvertTargets).Should().BeTrue(); + await options.Subscriptions.CustomCoreTypes.SequenceEqual( + Enum.GetValues().Where(value => value != ECoreType.v2rayN).Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.CoreTypes.SequenceEqual(Global.CoreTypes).Should().BeTrue(); + await options.Profiles.ConfigTypes.SequenceEqual(Enum.GetValues().Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.SingboxOnlyConfigTypes.ToHashSet().SetEquals(Global.SingboxOnlyConfigType.Select(value => value.ToString())).Should().BeTrue(); + await options.Profiles.Networks.SequenceEqual(Global.Networks).Should().BeTrue(); + await options.Profiles.DefaultNetwork.Should().BeEqualTo(Global.DefaultNetwork); + await options.Profiles.DefaultStreamSecurity.Should().BeEqualTo(new ProfileItem().StreamSecurity); + await options.Profiles.DefaultSecurity.Should().BeEqualTo(Global.DefaultSecurity); + await options.Profiles.DefaultVlessEncryption.Should().BeEqualTo(Global.None); + await options.Profiles.DefaultXhttpMode.Should().BeEqualTo(Global.DefaultXhttpMode); + await options.Profiles.DefaultGrpcMode.Should().BeEqualTo(Global.GrpcGunMode); + await options.Profiles.DefaultWireGuardMtu.Should().BeEqualTo(1280); + await options.Profiles.VmessSecurities.SequenceEqual(Global.VmessSecurities).Should().BeTrue(); + await options.Profiles.Flows.SequenceEqual(Global.Flows).Should().BeTrue(); + await options.Profiles.XhttpModes.SequenceEqual(Global.XhttpMode).Should().BeTrue(); + await options.Profiles.KcpHeaderTypes.SequenceEqual(new[] { Global.None }.Concat(Global.KcpHeaderTypes)).Should().BeTrue(); + await options.Profiles.TuicCongestionControls.SequenceEqual(Global.TuicCongestionControls).Should().BeTrue(); + await options.Profiles.NaiveCongestionControls.SequenceEqual(Global.NaiveCongestionControls).Should().BeTrue(); + await options.Profiles.Fingerprints.SequenceEqual(Global.Fingerprints).Should().BeTrue(); + await options.Profiles.Alpns.SequenceEqual(Global.Alpns).Should().BeTrue(); + foreach (var coreType in Enum.GetValues().Where(value => value != ECoreType.v2rayN)) + { + var expected = coreType switch + { + ECoreType.v2fly => Global.SsSecurities, + ECoreType.Xray => Global.SsSecuritiesInXray, + _ => Global.SsSecuritiesInSingbox, + }; + await options.Profiles.ShadowsocksSecurities[coreType.ToString()].SequenceEqual(expected).Should().BeTrue(); + } + await options.Profiles.MultipleLoadStrategies.ToHashSet().SetEquals(Enum.GetNames()).Should().BeTrue(); + await settings.LogLevels.SequenceEqual(Global.LogLevels).Should().BeTrue(); + await settings.RootCertProviders.SequenceEqual(Global.RootCertProviders).Should().BeTrue(); + await settings.Fingerprints.SequenceEqual(Global.Fingerprints).Should().BeTrue(); + await settings.UserAgents.SequenceEqual(Global.UserAgent).Should().BeTrue(); + await settings.Mux4SboxProtocols.SequenceEqual(Global.SingboxMuxs).Should().BeTrue(); + await settings.FragmentPacketsOptions.SequenceEqual(Global.FragmentPacketsOptions).Should().BeTrue(); + await settings.DestOverrideProtocols.SequenceEqual(Global.destOverrideProtocols).Should().BeTrue(); + await settings.MixedConcurrencyCounts.SequenceEqual(Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20)).Should().BeTrue(); + await settings.SpeedTestTimeouts.SequenceEqual(Enumerable.Range(2, 5).Select(value => value * 5)).Should().BeTrue(); + await settings.SpeedTestUrls.SequenceEqual(Global.SpeedTestUrls).Should().BeTrue(); + await settings.SpeedPingTestUrls.SequenceEqual(Global.SpeedPingTestUrls).Should().BeTrue(); + await settings.UdpTestTargets.SequenceEqual(Global.UdpTestTargets).Should().BeTrue(); + await settings.IPAPIUrls.SequenceEqual(Global.IPAPIUrls).Should().BeTrue(); + await settings.SubConvertUrls.SequenceEqual(Global.SubConvertUrls).Should().BeTrue(); + await settings.GeoFilesSources.SequenceEqual(Global.GeoFilesSources).Should().BeTrue(); + await settings.SingboxRulesetSources.SequenceEqual(Global.SingboxRulesetSources).Should().BeTrue(); + await settings.RoutingRulesSources.SequenceEqual(Global.RoutingRulesSources).Should().BeTrue(); + await options.Dns.CoreTypes.SequenceEqual(Global.CoreTypes).Should().BeTrue(); + await options.Dns.DomainStrategies4Freedom.SequenceEqual(Global.DomainStrategy).Should().BeTrue(); + await options.Dns.DomainStrategies4Singbox.SequenceEqual(Global.DomainStrategies4Sbox).Should().BeTrue(); + await options.Dns.DomainDnsAddresses.SequenceEqual(Global.DomainPureIPDNSAddress).Should().BeTrue(); + } + + [Test] + public async Task DnsDtoAndWebMappingRoundTripAllFieldsIncludingTunDns() + { + await (typeof(DnsProfileView).GetProperty(nameof(DnsProfileView.TunDNS)) is not null).Should().BeTrue(); + await (typeof(DnsProfileInput).GetProperty(nameof(DnsProfileInput.TunDNS)) is not null).Should().BeTrue(); + + var source = new DNSItem + { + Id = "xray-dns", + Remarks = "original", + CoreType = ECoreType.Xray, + Enabled = true, + UseSystemHosts = true, + NormalDNS = "original-normal", + TunDNS = "original-tun", + DomainStrategy4Freedom = Global.DomainStrategy.First(), + DomainDNSAddress = Global.DomainPureIPDNSAddress.First(), + }; + var loaded = V2rayRuntime.ToDnsProfileView(source); + var saved = V2rayRuntime.ApplyDnsProfileInput(source, ECoreType.Xray, new DnsProfileInput( + loaded.Remarks, + loaded.Enabled, + loaded.UseSystemHosts, + "edited-normal", + "edited-tun", + Global.DomainStrategy[1], + Global.DomainPureIPDNSAddress[1])); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + await reloaded.Id.Should().BeEqualTo("xray-dns"); + await reloaded.CoreType.Should().BeEqualTo(ECoreType.Xray); + await reloaded.Remarks.Should().BeEqualTo("original"); + await reloaded.Enabled.Should().BeTrue(); + await reloaded.UseSystemHosts.Should().BeTrue(); + await reloaded.NormalDNS.Should().BeEqualTo("edited-normal"); + await reloaded.TunDNS.Should().BeEqualTo("edited-tun"); + await reloaded.DomainStrategy4Freedom.Should().BeEqualTo(Global.DomainStrategy[1]); + await reloaded.DomainDNSAddress.Should().BeEqualTo(Global.DomainPureIPDNSAddress[1]); + } + + [Test] + public async Task DesktopDefaultDnsImportReadsEmbeddedXrayAndSingboxProfiles() + { + var defaults = new V2rayRuntime(null!, null!, null!, null!, null!).GetDefaultDnsProfiles(); + var xray = defaults.Single(item => item.CoreType == ECoreType.Xray); + var singbox = defaults.Single(item => item.CoreType == ECoreType.sing_box); + + await xray.NormalDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)); + await xray.TunDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSV2rayNormalFileName)); + await singbox.NormalDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.DNSSingboxNormalFileName)); + await singbox.TunDNS.Should().BeEqualTo(EmbedUtils.GetEmbedText(Global.TunSingboxDNSFileName)); + await string.IsNullOrWhiteSpace(xray.NormalDNS).Should().BeFalse(); + await string.IsNullOrWhiteSpace(singbox.TunDNS).Should().BeFalse(); + } + + [Test] + public async Task XrayNormalAndTunDnsAreUsedByGeneratedCoreConfigAfterWebMappingRoundTrip() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var initial = Existing(ECoreType.Xray); + var mapped = V2rayRuntime.ToDnsProfileView(initial); + var saved = V2rayRuntime.ApplyDnsProfileInput(initial, ECoreType.Xray, new DnsProfileInput( + mapped.Remarks, true, false, + "{\"servers\":[\"https://9.9.9.9/dns-query\"]}", + "{\"servers\":[\"tls://1.0.0.1\"]}", + Global.AsIs, + Global.DomainPureIPDNSAddress.First())); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + var normal = Generate(ECoreType.Xray, config, saved, tun: false); + var tun = Generate(ECoreType.Xray, config, saved, tun: true); + await reloaded.NormalDNS.Should().BeEqualTo("{\"servers\":[\"https://9.9.9.9/dns-query\"]}"); + await reloaded.TunDNS.Should().BeEqualTo("{\"servers\":[\"tls://1.0.0.1\"]}"); + await normal["dns"]!["servers"]!.ToJsonString().Contains("9.9.9.9").Should().BeTrue(); + await tun["dns"]!["servers"]!.ToJsonString().Contains("1.0.0.1").Should().BeTrue(); + } + + [Test] + public async Task SingboxNormalAndTunDnsAreUsedByGeneratedCoreConfigAfterWebMappingRoundTrip() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var initial = Existing(ECoreType.sing_box); + var mapped = V2rayRuntime.ToDnsProfileView(initial); + var saved = V2rayRuntime.ApplyDnsProfileInput(initial, ECoreType.sing_box, new DnsProfileInput( + mapped.Remarks, true, false, + "{\"servers\":[{\"tag\":\"web-normal\",\"type\":\"udp\",\"server\":\"9.9.9.9\"}],\"final\":\"web-normal\"}", + "{\"servers\":[{\"tag\":\"web-tun\",\"type\":\"udp\",\"server\":\"1.0.0.1\"}],\"final\":\"web-tun\"}", + Global.DomainStrategies4Sbox.First(), + Global.DomainPureIPDNSAddress.First())); + var reloaded = V2rayRuntime.ToDnsProfileView(saved); + + var normal = Generate(ECoreType.sing_box, config, saved, tun: false); + var tun = Generate(ECoreType.sing_box, config, saved, tun: true); + await reloaded.NormalDNS.Should().BeEqualTo(saved.NormalDNS); + await reloaded.TunDNS.Should().BeEqualTo(saved.TunDNS); + await normal["dns"]!["servers"]!.ToJsonString().Contains("9.9.9.9").Should().BeTrue(); + await tun["dns"]!["servers"]!.ToJsonString().Contains("1.0.0.1").Should().BeTrue(); + } + + [Test] + public async Task DnsValidationMatchesTheDesktopCoreEditorRules() + { + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "8.8.8.8,1.1.1.1").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "{\"servers\":[\"1.1.1.1\"]}").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.Xray, "{broken").Should().BeFalse(); + await V2rayRuntime.IsValidCustomDns(ECoreType.sing_box, + "{\"servers\":[{\"type\":\"udp\",\"server\":\"1.1.1.1\"}]}").Should().BeTrue(); + await V2rayRuntime.IsValidCustomDns(ECoreType.sing_box, + "{\"servers\":[{\"server\":\"1.1.1.1\"}]}").Should().BeFalse(); + } + + private static DNSItem Existing(ECoreType coreType) => new() + { + Id = $"{coreType}-dns", + Remarks = "custom DNS", + CoreType = coreType, + Enabled = true, + UseSystemHosts = false, + NormalDNS = "old-normal", + TunDNS = "old-tun", + DomainStrategy4Freedom = string.Empty, + DomainDNSAddress = Global.DomainPureIPDNSAddress.First(), + }; + + private static JsonNode Generate(ECoreType coreType, Config config, DNSItem dns, bool tun) + { + var node = new ProfileItem + { + IndexId = "dns-parity-node", + ConfigType = EConfigType.VMess, + CoreType = coreType, + Remarks = "DNS parity node", + Address = "example.test", + Port = 443, + Password = Guid.NewGuid().ToString(), + Network = Global.DefaultNetwork, + StreamSecurity = string.Empty, + Subid = string.Empty, + }; + node.SetProtocolExtra(new ProtocolExtraItem { AlterId = "0", VmessSecurity = Global.DefaultSecurity }); + var context = new CoreConfigContext + { + Node = node, + RunCoreType = coreType, + RoutingItem = new RoutingItem + { + Id = "dns-route", + Remarks = "DNS parity route", + RuleSet = "[]", + DomainStrategy = Global.AsIs, + DomainStrategy4Singbox = string.Empty, + }, + RawDnsItem = dns, + SimpleDnsItem = config.SimpleDNSItem, + AllProxiesMap = new Dictionary { [node.IndexId] = node }, + AppConfig = config, + IsTunEnabled = tun, + ProtectDomainList = [], + HasGlobalIPv6Address = true, + }; + var result = coreType == ECoreType.sing_box + ? new CoreConfigSingboxService(context).GenerateClientConfigContent() + : new CoreConfigV2rayService(context).GenerateClientConfigContent(); + if (!result.Success || result.Data is null) + { + throw new InvalidOperationException($"Core configuration generation failed: {result.Msg}"); + } + return JsonNode.Parse(result.Data.ToString()!)!; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs b/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs new file mode 100644 index 00000000..09bd1fe5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/EventHubTests.cs @@ -0,0 +1,52 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class EventHubTests +{ + [Test] + public async Task LogEventsAreNotQueuedForSubscribersWhenTheLogsPageIsInactive() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: false).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("log", new { message = "high-frequency core output" }); + hub.Publish("core-state", new { state = "running" }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("core-state"); + cancellation.Cancel(); + } + + [Test] + public async Task LogEventsAreDeliveredWhenTheLogsPageSubscribesForThem() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: true).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("log", new { message = "visible output" }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("log"); + cancellation.Cancel(); + } + + [Test] + public async Task LogsClearedControlEventsReachSubscribersWithoutTheLogsPayloadStream() + { + var hub = new EventHub(); + using var cancellation = new CancellationTokenSource(); + await using var subscription = hub.Subscribe(cancellation.Token, includeLogs: false).GetAsyncEnumerator(); + var next = subscription.MoveNextAsync().AsTask(); + + hub.Publish("logs-cleared", new { generation = 1 }); + + await next.WaitAsync(TimeSpan.FromSeconds(1)); + await subscription.Current.Type.Should().BeEqualTo("logs-cleared"); + cancellation.Cancel(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs b/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs new file mode 100644 index 00000000..a660697c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/GeoFilesUpdateTransactionTests.cs @@ -0,0 +1,322 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class GeoFilesUpdateTransactionTests +{ + [Test] + public async Task CallbackFailureIsRejectedEvenWhenUpdaterTaskCompletesNormally() + { + var completion = new GeoFilesUpdateCompletion(); + completion.Report(success: false, "download failed", isProgressMessage: false); + await Task.CompletedTask; + + var failed = false; + try + { + completion.EnsureSuccessful(); + } + catch (IOException exception) when (exception.Message.Contains("download failed", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + } + + [Test] + public async Task GeoFilesProgressDoesNotReplaceTheFinalSuccessNotification() + { + var completion = new GeoFilesUpdateCompletion(); + completion.Report(success: false, "Downloading geoip.dat", isProgressMessage: true); + + var missingCompletionRejected = false; + try + { + completion.EnsureSuccessful(); + } + catch (IOException exception) when (exception.Message.Contains("did not report successful completion", StringComparison.Ordinal)) + { + missingCompletionRejected = true; + } + + completion.Report(success: true, "GeoFiles updated", isProgressMessage: false); + completion.EnsureSuccessful(); + await missingCompletionRejected.Should().BeTrue(); + } + + [Test] + public async Task FailedGeoFilesUpdateRestoresReplacedFilesAndRemovesNewFiles() + { + using var directory = new TemporaryDirectory(); + var existing = Path.Combine(directory.Path, "geoip.dat"); + var created = Path.Combine(directory.Path, "geosite-custom.srs"); + await File.WriteAllTextAsync(existing, "old-geoip"); + + var failed = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, created], + [existing, created], + async _ => + { + await File.WriteAllTextAsync(existing, "partially-updated-geoip"); + await File.WriteAllTextAsync(created, "new-srs"); + throw new IOException("simulated later download/apply failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated later", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("old-geoip"); + await File.Exists(created).Should().BeFalse(); + } + + [Test] + public async Task MissingRequiredGeoFileFailsAndRestoresTheOriginalSet() + { + using var directory = new TemporaryDirectory(); + var existing = Path.Combine(directory.Path, "geosite.dat"); + var required = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(existing, "old-geosite"); + await File.WriteAllTextAsync(required, "old-geoip"); + + var failed = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, required], + [existing, required], + async _ => + { + await File.WriteAllTextAsync(existing, "new-geosite"); + File.Delete(required); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("did not produce", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("old-geosite"); + await (await File.ReadAllTextAsync(required)).Should().BeEqualTo("old-geoip"); + } + + [Test] + public async Task VerifiedGeoFilesUpdateKeepsNewFiles() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geosite.dat"); + + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + token => File.WriteAllTextAsync(target, "verified-update", token), + CancellationToken.None); + + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("verified-update"); + } + + [Test] + public async Task BeforeCommitFailureRestoresGeoFilesAfterCoreRestartFailure() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(target, "old-geoip"); + var coreCompletionCalled = false; + var coreSawRestoredGeo = false; + var failed = false; + + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + token => File.WriteAllTextAsync(target, "new-geoip", token), + CancellationToken.None, + async (rollbackGeoFiles, _) => + { + await rollbackGeoFiles(); + coreCompletionCalled = true; + coreSawRestoredGeo = await File.ReadAllTextAsync(target) == "old-geoip"; + throw new IOException("simulated Core restart failure"); + }); + } + catch (IOException exception) when (exception.Message.Contains("simulated Core restart failure", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await coreCompletionCalled.Should().BeTrue(); + await coreSawRestoredGeo.Should().BeTrue(); + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("old-geoip"); + } + + [Test] + public async Task ManualBatchAndRegionalPresetGeoFilesWorkShareOneTransactionGate() + { + var gate = new GeoFilesUpdateGate(); + var active = 0; + var maximumActive = 0; + var entrants = new System.Collections.Concurrent.ConcurrentBag(); + + await Task.WhenAll(new[] { "manual", "batch", "regional-preset" }.Select(name => gate.RunAsync(async () => + { + var nowActive = Interlocked.Increment(ref active); + UpdateMaximum(ref maximumActive, nowActive); + entrants.Add(name); + await Task.Delay(20); + Interlocked.Decrement(ref active); + }, CancellationToken.None))); + + await maximumActive.Should().BeEqualTo(1); + await entrants.OrderBy(item => item, StringComparer.Ordinal) + .SequenceEqual(new[] { "batch", "manual", "regional-preset" }) + .Should().BeTrue(); + } + + private static void UpdateMaximum(ref int maximum, int value) + { + while (true) + { + var current = Volatile.Read(ref maximum); + if (current >= value || Interlocked.CompareExchange(ref maximum, value, current) == current) + { + return; + } + } + } + + [Test] + public async Task FailedUpdateRemovesNewMmdbAndSrsTargetsButLeavesUnmanagedFilesAlone() + { + using var directory = new TemporaryDirectory(); + var required = Path.Combine(directory.Path, "geosite.dat"); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var newMmdb = Path.Combine(directory.Path, "Country.mmdb"); + var newSrs = Path.Combine(directory.Path, "geosite-custom.srs"); + var unrelatedSrs = Path.Combine(directory.Path, "locally-managed.srs"); + var unrelatedGeo = Path.Combine(directory.Path, "geography-not-managed.db"); + + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [required, geoip, newMmdb, newSrs], + [required, geoip], + async _ => + { + await File.WriteAllTextAsync(required, "new-geosite"); + await File.WriteAllTextAsync(geoip, "new-geoip"); + await File.WriteAllTextAsync(newMmdb, "new-mmdb"); + await File.WriteAllTextAsync(newSrs, "new-srs"); + await File.WriteAllTextAsync(unrelatedSrs, "keep-srs"); + await File.WriteAllTextAsync(unrelatedGeo, "keep-geo"); + throw new IOException("simulated callback-reported download failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated callback-reported", StringComparison.Ordinal)) + { + // The ServiceLib callback can report failure while its Task completes normally; + // the Web callback adapter converts that report to this transaction failure. + } + + await File.Exists(required).Should().BeFalse(); + await File.Exists(geoip).Should().BeFalse(); + await File.Exists(newMmdb).Should().BeFalse(); + await File.Exists(newSrs).Should().BeFalse(); + await (await File.ReadAllTextAsync(unrelatedSrs)).Should().BeEqualTo("keep-srs"); + await (await File.ReadAllTextAsync(unrelatedGeo)).Should().BeEqualTo("keep-geo"); + } + + [Test] + public async Task CancellationRollsBackChangesToTheManagedSet() + { + using var directory = new TemporaryDirectory(); + using var cancellation = new CancellationTokenSource(); + var existing = Path.Combine(directory.Path, "geoip.dat"); + var newFile = Path.Combine(directory.Path, "geosite-new.srs"); + await File.WriteAllTextAsync(existing, "before-cancel"); + + var canceled = false; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [existing, newFile], + [existing], + async token => + { + await File.WriteAllTextAsync(existing, "partial-update", token); + await File.WriteAllTextAsync(newFile, "partial-srs", token); + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + }, + cancellation.Token); + } + catch (OperationCanceledException) + { + canceled = true; + } + + await canceled.Should().BeTrue(); + await (await File.ReadAllTextAsync(existing)).Should().BeEqualTo("before-cancel"); + await File.Exists(newFile).Should().BeFalse(); + } + + [Test] + public async Task RollbackFailureRetainsItsBackupSetAndReportsBothErrors() + { + using var directory = new TemporaryDirectory(); + var target = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(target, "before-update"); + + IOException? failure = null; + try + { + await GeoFilesUpdateTransaction.ApplyAsync( + [target], + [target], + _ => + { + File.Delete(target); + Directory.CreateDirectory(target); + throw new IOException("simulated update failure"); + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("rollback is incomplete", StringComparison.Ordinal)) + { + failure = exception; + } + + await (failure is not null).Should().BeTrue(); + await (failure!.InnerException is AggregateException aggregate + && aggregate.InnerExceptions.Count == 2).Should().BeTrue(); + const string retainedPrefix = "backup set was retained at "; + var retainedStart = failure!.Message.IndexOf(retainedPrefix, StringComparison.Ordinal) + retainedPrefix.Length; + var retainedPath = failure.Message[retainedStart..].TrimEnd('.'); + await Directory.Exists(retainedPath).Should().BeTrue(); + Directory.Delete(retainedPath, recursive: true); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-geofiles-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 b/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 new file mode 100644 index 00000000..6fbc4283 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 @@ -0,0 +1,731 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('startup', 'update', 'restore')] + [string]$Mode, + [ValidateNotNullOrEmpty()] + [string]$PublishDirectory, + [ValidateNotNullOrEmpty()] + [string]$PreviousPublishDirectory, + [ValidateNotNullOrEmpty()] + [string]$CandidatePublishDirectory +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +if ($Mode -in @('startup', 'restore')) { + if (-not $PublishDirectory) { throw "$Mode mode requires -PublishDirectory." } + if ($Mode -eq 'restore') { + $xray = Join-Path $PublishDirectory 'bin\xray\xray.exe' + if (-not (Test-Path -LiteralPath $xray -PathType Leaf)) { + throw "restore mode requires the bundled Xray executable: $xray" + } + } +} elseif ($Mode -eq 'update') { + if (-not $PreviousPublishDirectory -or -not $CandidatePublishDirectory) { + throw 'update mode requires -PreviousPublishDirectory and -CandidatePublishDirectory.' + } +} + +$executableName = 'v2rayN.WebAPI.exe' +if (-not [Environment]::Is64BitOperatingSystem) { throw 'v2rayN.WebAPI Windows smoke requires 64-bit Windows.' } + +# Shared native process control: the app must run in its own process group so the smoke can +# send a graceful CTRL_BREAK_EVENT instead of killing it. +if (-not ('WebApiWindowsSmokeNative' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class WebApiWindowsSmokeNative +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct STARTUPINFO + { + public int cb; + public IntPtr lpReserved; + public IntPtr lpDesktop; + public IntPtr lpTitle; + public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess, hThread; + public uint dwProcessId, dwThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CreateProcess(string applicationName, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, + IntPtr environment, string currentDirectory, ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GenerateConsoleCtrlEvent(uint controlEvent, uint processGroupId); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); +} +'@ +} + +$testRoot = Join-Path $env:TEMP ('v2rayN.WebAPI-windows-' + $Mode + '-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot -Force | Out-Null + +# The union covers both modes so the finally block restores exactly the original state. +$environmentNames = @( + 'V2RAYN_DATA_HOME', 'V2RAYN_LOCAL_APPLICATION_DATA_V2', 'V2RAYN_WEB_API_KEY', + 'V2RAYN_WEB_AUTOSTART', 'ASPNETCORE_URLS', 'XDG_DATA_HOME', 'DOTNET_BUNDLE_EXTRACT_BASE_DIR', + 'INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET', 'DOTNET_RUNNING_IN_CONTAINER', 'container' +) +$oldEnvironment = @{} +foreach ($name in $environmentNames) { + $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} +$success = $false + +function Clear-TestEnvironment { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + } +} + +function Set-UpdateEnvironment([string]$Url, [string]$Key, [string]$BundleDirectory) { + Clear-TestEnvironment + [Environment]::SetEnvironmentVariable('V2RAYN_LOCAL_APPLICATION_DATA_V2', '0', 'Process') + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_API_KEY', $Key, 'Process') + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_AUTOSTART', 'false', 'Process') + [Environment]::SetEnvironmentVariable('ASPNETCORE_URLS', $Url, 'Process') + [Environment]::SetEnvironmentVariable('DOTNET_BUNDLE_EXTRACT_BASE_DIR', $BundleDirectory, 'Process') +} + +function Restore-TestEnvironment { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $oldEnvironment[$name], 'Process') + } +} + +function New-NoProxyHttpClient { + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.UseProxy = $false + return [System.Net.Http.HttpClient]::new($handler) +} + +function Get-FreeLoopbackPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + return $port +} + +function Start-NativeTestProcess([string]$Application, [string]$Arguments, [string]$WorkingDirectory) { + $commandLine = '"{0}" {1}' -f $Application, $Arguments + $startupInfo = [WebApiWindowsSmokeNative+STARTUPINFO]::new() + $startupInfo.cb = [Runtime.InteropServices.Marshal]::SizeOf([type][WebApiWindowsSmokeNative+STARTUPINFO]) + $processInfo = [WebApiWindowsSmokeNative+PROCESS_INFORMATION]::new() + $created = [WebApiWindowsSmokeNative]::CreateProcess( + $Application, [System.Text.StringBuilder]::new($commandLine), [IntPtr]::Zero, [IntPtr]::Zero, + $true, [uint32]0x00000200, [IntPtr]::Zero, $WorkingDirectory, + [ref]$startupInfo, [ref]$processInfo) + if (-not $created) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "Could not start $Application (Win32 error $code)." + } + try { + return [Diagnostics.Process]::GetProcessById([int]$processInfo.dwProcessId) + } finally { + if ($processInfo.hThread -ne [IntPtr]::Zero) { [void][WebApiWindowsSmokeNative]::CloseHandle($processInfo.hThread) } + if ($processInfo.hProcess -ne [IntPtr]::Zero) { [void][WebApiWindowsSmokeNative]::CloseHandle($processInfo.hProcess) } + } +} + +function Stop-NativeTestProcess([Diagnostics.Process]$Process, [int]$TimeoutMilliseconds = 15000) { + if ($null -eq $Process -or $Process.HasExited) { return } + if (-not [WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$Process.Id)) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "Could not send graceful CTRL_BREAK_EVENT (Win32 error $code)." + } + if (-not $Process.WaitForExit($TimeoutMilliseconds)) { + throw "Windows WebAPI process $($Process.Id) did not exit gracefully." + } +} + +function Stop-TestProcessAndWait([int]$ProcessId, [int]$TimeoutMilliseconds = 15000) { + if (-not $ProcessId -or -not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return } + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while ([DateTime]::UtcNow -lt $deadline) { + if (-not (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) { return } + Start-Sleep -Milliseconds 100 + } + if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { + throw "Windows process $ProcessId did not exit during smoke cleanup." + } +} + +function Wait-TestHealth { + param( + [string]$Url, + [string]$ExpectedVersion = '', + [Diagnostics.Process]$Process, + [int]$TimeoutSeconds = 35, + [int]$PreviousProcessId = 0 + ) + $client = New-NoProxyHttpClient + try { + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $lastError = $null + while ([DateTime]::UtcNow -lt $deadline) { + if ($null -ne $Process -and $Process.HasExited) { + throw "WebAPI exited during startup (code $($Process.ExitCode))." + } + try { + $response = $client.GetAsync("$Url/api/health").GetAwaiter().GetResult() + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if ([int]$response.StatusCode -eq 200) { + # The original startup smoke parsed the body and required status "ok"; + # keep that assertion for both modes instead of trusting the 200 alone. + $healthBody = $body | ConvertFrom-Json + if ($healthBody.status -ne 'ok') { throw "Unexpected health response: $body" } + if (-not $ExpectedVersion) { + return [pscustomobject]@{ Version = $null; ProcessId = $null; Body = $body } + } + $version = $response.Headers.GetValues('X-v2rayn-WebAPI-version') | Select-Object -First 1 + $ownerId = $response.Headers.GetValues('X-v2rayn-WebAPI-instance-pid') | Select-Object -First 1 + $isPreviousProcess = $PreviousProcessId -gt 0 -and $ownerId -and [int]$ownerId -eq $PreviousProcessId + if ($version -eq $ExpectedVersion -and $ownerId -and -not $isPreviousProcess) { + return [pscustomobject]@{ Version = $version; ProcessId = [int]$ownerId; Body = $body } + } + if ($isPreviousProcess) { + $lastError = "the previous process $PreviousProcessId is still serving health" + } else { + $lastError = "unexpected version ${version}: $body" + } + } else { + $lastError = "HTTP $([int]$response.StatusCode): $body" + } + $response.Dispose() + } catch { + $lastError = $_.Exception.Message + } + Start-Sleep -Milliseconds 250 + } + if ($ExpectedVersion) { + throw "Windows WebAPI health did not reach version $ExpectedVersion. Last error: $lastError" + } + throw "Health check failed at $Url/api/health. Last error: $lastError" + } finally { + $client.Dispose() + } +} + +function Write-TestJson([string]$Path, $Value) { + $json = $Value | ConvertTo-Json -Depth 8 -Compress + [IO.File]::WriteAllText($Path, $json, [Text.UTF8Encoding]::new($false)) +} + +function Invoke-WebApiJson { + param( + [System.Net.Http.HttpClient]$Client, + [string]$Url, + [string]$Path, + [string]$Method = 'GET', + [string]$Token, + [object]$Body, + [string]$UploadPath + ) + + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), "$Url$Path") + if ($Token) { + $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + } + if ($UploadPath) { + $multipart = [System.Net.Http.MultipartFormDataContent]::new() + $fileContent = [System.Net.Http.ByteArrayContent]::new([IO.File]::ReadAllBytes($UploadPath)) + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new('application/zip') + $multipart.Add($fileContent, 'file', [IO.Path]::GetFileName($UploadPath)) + $request.Content = $multipart + } elseif ($null -ne $Body) { + $json = $Body | ConvertTo-Json -Depth 16 -Compress + $request.Content = [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json') + } + + try { + $response = $Client.SendAsync($request).GetAwaiter().GetResult() + try { + $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + $parsed = if ($text) { $text | ConvertFrom-Json } else { $null } + return [pscustomobject]@{ StatusCode = [int]$response.StatusCode; Json = $parsed; Raw = $text } + } finally { + $response.Dispose() + } + } finally { + $request.Dispose() + } +} + +function Get-WebApiSessionToken { + param([System.Net.Http.HttpClient]$Client, [string]$Url, [string]$Key) + $login = Invoke-WebApiJson -Client $Client -Url $Url -Path '/api/auth/login' -Method 'POST' -Body @{ key = $Key } + if (-not $login.Json.success -or -not $login.Json.data.token) { + throw "Windows restore smoke login failed: $($login.Raw)" + } + return [string]$login.Json.data.token +} + +function Download-TestBackup { + param([System.Net.Http.HttpClient]$Client, [string]$Url, [string]$Token, [string]$Path) + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, "$Url/api/backup/download") + $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token) + try { + $response = $Client.SendAsync($request).GetAwaiter().GetResult() + try { + if (-not $response.IsSuccessStatusCode) { + throw "Backup download failed with HTTP $([int]$response.StatusCode)." + } + [IO.File]::WriteAllBytes($Path, $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()) + } finally { + $response.Dispose() + } + } finally { + $request.Dispose() + } +} + +function Add-RestoreMarkerToBackup([string]$ArchivePath) { + $archive = [System.IO.Compression.ZipFile]::Open($ArchivePath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + if ($null -ne $archive.GetEntry('guiConfigs/restore-smoke-marker.txt')) { + throw 'The generated backup unexpectedly contains the restore smoke marker already.' + } + $entry = $archive.CreateEntry('guiConfigs/restore-smoke-marker.txt') + $stream = $entry.Open() + try { + $writer = [IO.StreamWriter]::new($stream, [Text.UTF8Encoding]::new($false)) + try { $writer.Write('RESTORE_CONFIG_APPLIED') } finally { $writer.Dispose() } + } finally { + $stream.Dispose() + } + } finally { + $archive.Dispose() + } +} + +function Invoke-RestoreScenario([bool]$CoreWasRunning) { + $packageRoot = (Resolve-Path -LiteralPath $PublishDirectory).Path + $scenario = if ($CoreWasRunning) { 'running-core' } else { 'stopped-core' } + $caseRoot = Join-Path $testRoot $scenario + $install = Join-Path $caseRoot 'install' + New-Item -ItemType Directory -Path $install -Force | Out-Null + Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object { $_.Name -ne '.env' } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $install -Recurse -Force + } + + $identity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + if ($identity.rid -ne 'win-x64') { throw 'Windows restore smoke must run against the win-x64 package.' } + $guiDirectory = Join-Path $install 'guiConfigs' + $markerPath = Join-Path $guiDirectory 'restore-smoke-marker.txt' + $key = 'Windows-restore-' + [Guid]::NewGuid().ToString('N') + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $bundleRoot = Join-Path $caseRoot 'bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + $archivePath = Join-Path $caseRoot 'restore.zip' + $owner = $null + $replacementProcessId = $null + $client = New-NoProxyHttpClient + $token = $null + $success = $false + try { + Set-UpdateEnvironment $url $key $bundleRoot + try { + $executable = Join-Path $install $executableName + $owner = Start-NativeTestProcess $executable ('--foreground --no-open --urls "{0}"' -f $url) $install + } finally { + Restore-TestEnvironment + } + + $started = Wait-TestHealth -Url $url -ExpectedVersion $identity.version -Process $owner + if ($started.ProcessId -ne $owner.Id) { throw 'Initial Windows restore smoke lock/health PID mismatch.' } + $token = Get-WebApiSessionToken -Client $client -Url $url -Key $key + + if ($CoreWasRunning) { + $profile = @{ + configType = 'vless' + remarks = 'Windows backup restore smoke' + address = '127.0.0.1' + port = 65535 + password = [Guid]::NewGuid().ToString() + network = 'tcp' + streamSecurity = '' + } + $saved = Invoke-WebApiJson -Client $client -Url $url -Path '/api/profiles' -Method 'POST' -Token $token -Body $profile + if (-not $saved.Json.success -or -not $saved.Json.data.profileId) { + throw "Could not create the Windows Core restore smoke profile: $($saved.Raw)" + } + $profileId = [string]$saved.Json.data.profileId + $selected = Invoke-WebApiJson -Client $client -Url $url -Path "/api/profiles/$profileId/select" -Method 'POST' -Token $token + if (-not $selected.Json.success) { throw "Could not start the Windows restore smoke Core: $($selected.Raw)" } + } + + $beforeStatus = Invoke-WebApiJson -Client $client -Url $url -Path '/api/status' -Token $token + if ($CoreWasRunning) { + if (-not $beforeStatus.Json.data.coreRunning -or $beforeStatus.Json.data.runtimeState -ne 'running') { + throw "Windows restore smoke did not start Core before backup: $($beforeStatus.Raw)" + } + } elseif ($beforeStatus.Json.data.coreRunning -or $beforeStatus.Json.data.runtimeState -ne 'stopped') { + throw "Windows restore smoke expected Core to remain stopped before backup: $($beforeStatus.Raw)" + } + + Download-TestBackup -Client $client -Url $url -Token $token -Path $archivePath + Add-RestoreMarkerToBackup $archivePath + if (Test-Path -LiteralPath $markerPath) { throw 'Restore marker exists before the Windows backup restore.' } + + $restore = Invoke-WebApiJson -Client $client -Url $url -Path '/api/backup/restore' -Method 'POST' -Token $token -UploadPath $archivePath + if ($restore.StatusCode -ne 202 -or -not $restore.Json.success) { + throw "Windows backup restore was not accepted (HTTP $($restore.StatusCode)): $($restore.Raw)" + } + + $health = Wait-TestHealth -Url $url -ExpectedVersion $identity.version -TimeoutSeconds 90 -PreviousProcessId $owner.Id + $replacementProcessId = $health.ProcessId + if ($replacementProcessId -eq $owner.Id) { throw 'Windows backup restore reconnected to the old WebAPI process.' } + if (-not (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { + throw "Windows restored WebAPI process $replacementProcessId is not running." + } + if (-not $owner.WaitForExit(20000)) { throw 'The old Windows WebAPI process did not exit after verified restore handoff.' } + if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { throw 'The restored Windows config marker was not installed.' } + if ((Get-Content -LiteralPath $markerPath -Raw) -ne 'RESTORE_CONFIG_APPLIED') { throw 'The restored Windows config marker content is incorrect.' } + + # Backend restart invalidates in-memory sessions; reconnect with the retained local key. + $token = Get-WebApiSessionToken -Client $client -Url $url -Key $key + $afterStatus = Invoke-WebApiJson -Client $client -Url $url -Path '/api/status' -Token $token + $coreIds = @($afterStatus.Json.data.coreProcessIds | Where-Object { $_ }) + if ($CoreWasRunning) { + if (-not $afterStatus.Json.data.coreRunning -or $afterStatus.Json.data.runtimeState -ne 'running' -or $coreIds.Count -eq 0) { + throw "Windows restore did not recover the previously running Core: $($afterStatus.Raw)" + } + Write-Output 'Windows backup restore passed (new healthy Web PID, restored config, previously running Core restarted).' + } else { + if ($afterStatus.Json.data.coreRunning -or $afterStatus.Json.data.runtimeState -ne 'stopped' -or $coreIds.Count -ne 0) { + throw "Windows restore changed the previously stopped Core intent: $($afterStatus.Raw)" + } + Write-Output 'Windows backup restore passed (new healthy Web PID, restored config, previously stopped Core remains stopped).' + } + $success = $true + } finally { + $cleanupError = $null + if ($null -ne $client -and $replacementProcessId -and (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { + try { + if ($token) { [void](Invoke-WebApiJson -Client $client -Url $url -Path '/api/core/stop' -Method 'POST' -Token $token) } + } catch { } + try { Stop-Process -Id $replacementProcessId -Force -ErrorAction SilentlyContinue } catch { } + for ($attempt = 0; $attempt -lt 100; $attempt++) { + if (-not (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue)) { break } + Start-Sleep -Milliseconds 100 + } + if (Get-Process -Id $replacementProcessId -ErrorAction SilentlyContinue) { + $cleanupError = "Restored Windows WebAPI process $replacementProcessId did not exit during cleanup." + } + } + if ($null -ne $owner) { + try { + if (-not $owner.HasExited) { + try { [void][WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$owner.Id) } catch { } + if (-not $owner.WaitForExit(5000)) { Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue } + if (-not $owner.WaitForExit(10000)) { + if ($null -eq $cleanupError) { $cleanupError = "Old Windows WebAPI process $($owner.Id) did not exit during cleanup." } + } + } + } catch { try { Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue } catch { } } + $owner.Dispose() + } + if ($null -ne $client) { $client.Dispose() } + if ($cleanupError) { throw $cleanupError } + } +} + +function Invoke-RestoreSmoke { + Invoke-RestoreScenario $false + Invoke-RestoreScenario $true + Write-Output 'Windows native backup restore replacement and Core-intent smoke passed.' +} + +# First startup, health endpoint, first-run data paths and graceful shutdown. +function Invoke-StartupSmoke { + $packageRoot = (Resolve-Path -LiteralPath $PublishDirectory).Path + $sourceExe = Join-Path $packageRoot $executableName + if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) { throw "Missing executable: $sourceExe" } + + $runtimeDir = Join-Path $testRoot 'runtime' + New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null + Get-ChildItem -LiteralPath $packageRoot -Force | Where-Object { + $_.Name -notin @('README.txt', 'Test-Windows.ps1', '.env') + } | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $runtimeDir -Recurse -Force + } + + Clear-TestEnvironment + # Hosted Windows runners may export the variable with an empty value; the + # backend binds it as a Boolean, so make the first-startup smoke deterministic. + [Environment]::SetEnvironmentVariable('V2RAYN_WEB_AUTOSTART', 'false', 'Process') + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $webProcess = $null + $client = $null + try { + $exe = Join-Path $runtimeDir $executableName + $webProcess = Start-NativeTestProcess $exe ('--foreground --no-open --urls "{0}"' -f $url) $runtimeDir + $health = Wait-TestHealth -Url $url -Process $webProcess + + $lockPath = Join-Path $runtimeDir 'v2rayN.WebAPI.instance.lock' + $webApiDataDir = Join-Path $runtimeDir 'WebAPIData' + $tempDataDir = Join-Path $runtimeDir 'guiTemps' + foreach ($path in @($lockPath, $webApiDataDir, $tempDataDir)) { + if (-not (Test-Path -LiteralPath $path)) { throw "First-startup data path was not created: $path" } + } + + Stop-NativeTestProcess $webProcess + $webProcess.Refresh() + if (-not $webProcess.HasExited) { throw 'WebAPI process is still running after CTRL_BREAK_EVENT.' } + if (Get-Process -Id $webProcess.Id -ErrorAction SilentlyContinue) { throw 'WebAPI process remains listed after graceful shutdown.' } + + $lockOwner = (Get-Content -LiteralPath $lockPath -Raw).Trim() + if ($lockOwner -ne [string]$webProcess.Id) { throw "Instance lock owner mismatch: $lockOwner" } + + $client = New-NoProxyHttpClient + try { + $response = $client.GetAsync("$url/api/health").GetAwaiter().GetResult() + throw "Health endpoint remained reachable after shutdown (HTTP $([int]$response.StatusCode))." + } catch [System.Net.Http.HttpRequestException] { + # Expected: Kestrel has closed the listener after graceful shutdown. + } + + Write-Output "Windows smoke passed: win self-contained EXE, first-run data paths, $url, CTRL+BREAK graceful shutdown." + Write-Output "Windows: $([Environment]::OSVersion.VersionString); process exited cleanly after CTRL+BREAK." + } finally { + if ($null -ne $webProcess) { + try { + if (-not $webProcess.HasExited) { + try { [void][WebApiWindowsSmokeNative]::GenerateConsoleCtrlEvent(1, [uint32]$webProcess.Id) } catch { } + if (-not $webProcess.WaitForExit(5000)) { Stop-Process -Id $webProcess.Id -Force } + } + } catch { + try { Stop-Process -Id $webProcess.Id -Force -ErrorAction SilentlyContinue } catch { } + } + $webProcess.Dispose() + } + if ($null -ne $client) { $client.Dispose() } + } +} + +# Real helper replacement and health-mismatch rollback with two distinct win-x64 fixtures. +function Invoke-UpdateScenario([bool]$ExpectRollback) { + $previousRoot = (Resolve-Path -LiteralPath $PreviousPublishDirectory).Path + $candidateRoot = (Resolve-Path -LiteralPath $CandidatePublishDirectory).Path + $previousExe = Join-Path $previousRoot $executableName + $candidateExe = Join-Path $candidateRoot $executableName + $previousIdentityPath = Join-Path $previousRoot 'v2rayN.WebAPI.build.json' + $candidateIdentityPath = Join-Path $candidateRoot 'v2rayN.WebAPI.build.json' + foreach ($path in @($previousExe, $candidateExe, $previousIdentityPath, $candidateIdentityPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing Windows update fixture file: $path" } + } + $previousIdentity = Get-Content -LiteralPath $previousIdentityPath -Raw | ConvertFrom-Json + $candidateIdentity = Get-Content -LiteralPath $candidateIdentityPath -Raw | ConvertFrom-Json + if ($previousIdentity.rid -ne 'win-x64' -or $candidateIdentity.rid -ne 'win-x64') { throw 'Update fixtures must target win-x64.' } + if ($previousIdentity.version -eq $candidateIdentity.version) { throw 'Update fixture versions must differ.' } + + $caseRoot = Join-Path $testRoot ([Guid]::NewGuid().ToString('N')) + $install = Join-Path $caseRoot 'install' + $candidate = Join-Path $caseRoot ('.v2rayn-WebAPI-candidate-' + [Guid]::NewGuid().ToString('N')) + $backup = Join-Path $caseRoot ('.v2rayn-WebAPI-backup-' + [Guid]::NewGuid().ToString('N')) + $core = Join-Path $install 'bin' + $gui = Join-Path $install 'guiConfigs' + $temps = Join-Path $install 'guiTemps' + New-Item -ItemType Directory -Path $install, $candidate, $gui, $temps -Force | Out-Null + Copy-Item -LiteralPath $previousExe -Destination (Join-Path $install $executableName) + Copy-Item -LiteralPath $previousIdentityPath -Destination (Join-Path $install 'v2rayN.WebAPI.build.json') + $previousBin = Join-Path $previousRoot 'bin' + if (-not (Test-Path -LiteralPath $previousBin -PathType Container)) { throw "Previous update fixture is missing Core data: $previousBin" } + Copy-Item -LiteralPath $previousBin -Destination $install -Recurse -Force + Copy-Item -LiteralPath $candidateExe -Destination (Join-Path $candidate $executableName) + Copy-Item -LiteralPath $candidateIdentityPath -Destination (Join-Path $candidate 'v2rayN.WebAPI.build.json') + $previousAppIdentity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + $candidateAppIdentity = Get-Content -LiteralPath (Join-Path $candidate 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + $expectedVersion = if ($ExpectRollback) { '0.0.999' } else { $candidateAppIdentity.version } + if ($ExpectRollback) { + $candidateAppIdentity.version = $expectedVersion + Write-TestJson (Join-Path $candidate 'v2rayN.WebAPI.build.json') $candidateAppIdentity + } + + $key = 'Windows-update-' + [Guid]::NewGuid().ToString('N') + $envPath = Join-Path $install '.env' + [IO.File]::WriteAllText($envPath, "V2RAYN_WEB_API_KEY=$key`n", [Text.UTF8Encoding]::new($false)) + $userConfigMarker = Join-Path $gui 'web-update-test-marker.dat' + [IO.File]::WriteAllText($userConfigMarker, 'USER_CONFIG_KEEP') + [IO.File]::WriteAllText((Join-Path $core 'web-update-test-marker.dat'), 'CORE_FILES_KEEP') + $envHash = (Get-FileHash -LiteralPath $envPath -Algorithm SHA256).Hash + $configHash = (Get-FileHash -LiteralPath $userConfigMarker -Algorithm SHA256).Hash + $coreHash = (Get-FileHash -LiteralPath (Join-Path $core 'web-update-test-marker.dat') -Algorithm SHA256).Hash + + $url = "http://127.0.0.1:$(Get-FreeLoopbackPort)" + $bundleRoot = Join-Path $caseRoot 'bundle' + New-Item -ItemType Directory -Path $bundleRoot -Force | Out-Null + $owner = $null + $helper = $null + $updatedProcessId = $null + try { + Set-UpdateEnvironment $url $key $bundleRoot + try { + $exe = Join-Path $install $executableName + $owner = Start-NativeTestProcess $exe ('--foreground --no-open --urls "{0}"' -f $url) $install + } finally { + Restore-TestEnvironment + } + $previousHealth = Wait-TestHealth -Url $url -ExpectedVersion $previousAppIdentity.version + if ($previousHealth.ProcessId -ne $owner.Id) { throw 'Previous Windows WebAPI lock/health PID mismatch.' } + + $runtimeIntentPath = Join-Path $temps 'WebAPI-update-runtime-state.json' + $progressPath = Join-Path $temps 'WebAPI-update-progress.json' + $planPath = Join-Path $temps 'windows-update-plan.json' + Write-TestJson $runtimeIntentPath @{ wasRunning = $false; preferredProfileId = $null; reason = 'Windows update smoke' } + Write-TestJson $planPath @{ + installDirectory = $install + candidateDirectory = $candidate + backupDirectory = $backup + instanceLockPath = (Join-Path $install 'v2rayN.WebAPI.instance.lock') + healthUri = "$url/api/health" + hostArguments = @('--urls', $url) + expectedVersion = $expectedVersion + expectedCommit = $candidateAppIdentity.commit + rid = 'win-x64' + previousVersion = $previousAppIdentity.version + runtimeIntentPath = $runtimeIntentPath + progressPath = $progressPath + coreWasRunning = $false + } + + $helperPath = Join-Path $install ('.v2rayn-WebAPI-update-helper-' + [Guid]::NewGuid().ToString('N') + '.exe') + Copy-Item -LiteralPath (Join-Path $install $executableName) -Destination $helperPath + Set-UpdateEnvironment $url $key $bundleRoot + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $helperPath + $startInfo.WorkingDirectory = $install + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Arguments = '"--apply-WebAPI-update" "{0}"' -f $planPath.Replace('"', '\"') + $helperProcess = [Diagnostics.Process]::Start($startInfo) + $helperOutput = $helperProcess.StandardOutput.ReadToEndAsync() + $helperError = $helperProcess.StandardError.ReadToEndAsync() + } finally { + Restore-TestEnvironment + } + $helper = $helperProcess + + Stop-NativeTestProcess $owner + $observedHealth = [System.Collections.Generic.List[string]]::new() + $healthClient = New-NoProxyHttpClient + try { + $helperDeadline = [DateTime]::UtcNow.AddSeconds(180) + while (-not $helper.HasExited -and [DateTime]::UtcNow -lt $helperDeadline) { + try { + $response = $healthClient.GetAsync("$url/api/health").GetAwaiter().GetResult() + $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if ([int]$response.StatusCode -eq 200) { + $version = $response.Headers.GetValues('X-v2rayn-WebAPI-version') | Select-Object -First 1 + $instanceOwner = $response.Headers.GetValues('X-v2rayn-WebAPI-instance-pid') | Select-Object -First 1 + $coreState = $response.Headers.GetValues('X-v2rayn-WebAPI-core-state') | Select-Object -First 1 + $corePids = $response.Headers.GetValues('X-v2rayn-WebAPI-core-process-ids') | Select-Object -First 1 + $snapshot = "version=$version; pid=$instanceOwner; coreState=$coreState; corePids=$corePids; runtimeIntent=$(Test-Path -LiteralPath $runtimeIntentPath)" + if ($observedHealth.Count -eq 0 -or $observedHealth[$observedHealth.Count - 1] -ne $snapshot) { + if ($observedHealth.Count -lt 20) { $observedHealth.Add($snapshot) } + } + } + $response.Dispose() + } catch { } + Start-Sleep -Milliseconds 200 + } + if (-not $helper.HasExited) { throw 'Windows native update helper timed out.' } + } finally { + $healthClient.Dispose() + } + $expectedExit = if ($ExpectRollback) { 1 } else { 0 } + $helperOutputText = $helperOutput.GetAwaiter().GetResult() + $helperErrorText = $helperError.GetAwaiter().GetResult() + if ($helper.ExitCode -ne $expectedExit) { + $progressDump = if (Test-Path -LiteralPath $progressPath) { Get-Content -LiteralPath $progressPath -Raw } else { '' } + throw "Windows update helper exit $($helper.ExitCode), expected $expectedExit.`nhealth observations:`n$($observedHealth -join "`n")`nprogress:`n$progressDump`n$helperOutputText`n$helperErrorText" + } + + $expectedHealthVersion = if ($ExpectRollback) { $previousAppIdentity.version } else { $candidateAppIdentity.version } + $health = Wait-TestHealth -Url $url -ExpectedVersion $expectedHealthVersion + $updatedProcessId = $health.ProcessId + $progress = Get-Content -LiteralPath $progressPath -Raw | ConvertFrom-Json + if (-not $progress.isComplete -or $progress.success -eq $ExpectRollback) { throw "Unexpected Windows update progress: $($progress | ConvertTo-Json -Compress)" } + if ($ExpectRollback -and -not $progress.rollbackSucceeded) { throw 'Windows update rollback was not confirmed.' } + + $expectedExe = if ($ExpectRollback) { $previousExe } else { $candidateExe } + if ((Get-FileHash -LiteralPath (Join-Path $install $executableName) -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $expectedExe -Algorithm SHA256).Hash) { throw 'Windows update executable content is incorrect.' } + $installedIdentity = Get-Content -LiteralPath (Join-Path $install 'v2rayN.WebAPI.build.json') -Raw | ConvertFrom-Json + if ($installedIdentity.version -ne $expectedHealthVersion) { throw 'Windows update build identity is incorrect.' } + if ((Get-FileHash -LiteralPath $envPath -Algorithm SHA256).Hash -ne $envHash) { throw 'Windows update changed .env.' } + if ((Get-FileHash -LiteralPath $userConfigMarker -Algorithm SHA256).Hash -ne $configHash) { throw 'Windows update changed user config.' } + if ((Get-FileHash -LiteralPath (Join-Path $core 'web-update-test-marker.dat') -Algorithm SHA256).Hash -ne $coreHash) { throw 'Windows update changed Core files.' } + if (Test-Path -LiteralPath $backup) { throw 'Windows update did not clean its backup directory.' } + if (Test-Path -LiteralPath $candidate) { throw 'Windows update did not clean its candidate directory.' } + $helperDeadline = [DateTime]::UtcNow.AddSeconds(10) + while ((Test-Path -LiteralPath $helperPath) -and [DateTime]::UtcNow -lt $helperDeadline) { Start-Sleep -Milliseconds 200 } + if (Test-Path -LiteralPath $helperPath) { throw 'Windows update helper copy was not cleaned.' } + if ($ExpectRollback) { Write-Output 'Windows native update rollback passed.' } + else { Write-Output 'Windows native update replacement passed.' } + } finally { + foreach ($processId in @($updatedProcessId, $(if ($owner) { $owner.Id } else { $null }))) { + if ($processId) { Stop-TestProcessAndWait -ProcessId $processId } + } + if ($helper) { $helper.Dispose() } + if ($owner) { $owner.Dispose() } + } +} + +try { + if ($Mode -eq 'startup') { + Invoke-StartupSmoke + } elseif ($Mode -eq 'restore') { + Invoke-RestoreSmoke + } else { + Invoke-UpdateScenario $false | Out-Null + Invoke-UpdateScenario $true | Out-Null + Write-Output 'Windows native self-update replacement and rollback smoke passed.' + } + $success = $true +} finally { + Restore-TestEnvironment + if ($success) { + $removed = $false + $cleanupError = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction Stop + } + if (-not (Test-Path -LiteralPath $testRoot)) { + $removed = $true + break + } + throw 'The isolated Windows smoke directory still exists.' + } catch { + $cleanupError = $_.Exception.Message + if ($attempt -lt 19) { Start-Sleep -Milliseconds 500 } + } + } + if (-not $removed) { + Write-Warning "Windows $Mode smoke passed, but isolated temp cleanup was deferred at $testRoot : $cleanupError" + } + } else { + Write-Warning "Windows $Mode test failed; isolated files were retained at $testRoot" + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh new file mode 100644 index 00000000..3dd13eac --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-backup-restore-preflight.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +executable="$(realpath "${1:?Usage: test-backup-restore-preflight.sh }")" +script_path="$(realpath "${BASH_SOURCE[0]}")" +test -x "$executable" +command -v curl >/dev/null +command -v python3 >/dev/null +command -v openssl >/dev/null + +# A service-based Linux runner may keep this test in system.slice even after its +# environment markers are cleared. Move the entire test (and the WebAPI children +# it starts) into a transient scope so production supervisor detection stays intact. +if [[ "${V2RAYN_RESTORE_PREFLIGHT_TEST_SCOPE:-}" != "1" ]]; then + scope_environment=(V2RAYN_RESTORE_PREFLIGHT_TEST_SCOPE=1) + if [[ -n "${TMPDIR:-}" ]]; then scope_environment+=("TMPDIR=$TMPDIR"); fi + if command -v systemd-run >/dev/null && systemd-run --user --scope --quiet /usr/bin/true >/dev/null 2>&1; then + exec systemd-run --user --scope --quiet /usr/bin/env \ + "${scope_environment[@]}" /usr/bin/bash "$script_path" "$executable" + elif command -v sudo >/dev/null && sudo -n systemd-run --scope --quiet /usr/bin/true >/dev/null 2>&1; then + runuser_bin="$(command -v runuser)" + exec sudo -n systemd-run --scope --quiet "$runuser_bin" --preserve-environment -u "$(id -un)" -- \ + /usr/bin/env "${scope_environment[@]}" /usr/bin/bash "$script_path" "$executable" + elif grep -Eq 'system\.slice/.*\.service' /proc/self/cgroup; then + echo 'Backup restore preflight test is in a systemd service cgroup, but no transient scope is available.' >&2 + exit 1 + fi +fi + +temporary="$(mktemp -d "${TMPDIR:-/tmp}/v2rayn-backup-restore-preflight.XXXXXX")" +app_pid="" +cleanup() { + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null; then + kill -TERM "$app_pid" 2>/dev/null || true + for _ in {1..40}; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +port() { + python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +lan_address() { + python3 - <<'PY' +import socket +with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + print(sock.getsockname()[0]) +PY +} + +config_fingerprint() { + python3 - "$1" <<'PY' +import hashlib +import sys +from pathlib import Path +root = Path(sys.argv[1]) +for path in sorted(p for p in root.rglob("*") if p.is_file()): + print(hashlib.sha256(path.read_bytes()).hexdigest(), path.relative_to(root).as_posix()) +PY +} + +run_refusal_case() { + local name="$1" listener="$2" scheme="$3" expected_key="$4" missing_setsid="$5" + local case_root="$temporary/$name" data_home="$temporary/$name-data" key url pid lock_path token + local curl_tls=() process_env=() + mkdir -p "$case_root" "$data_home" + key="restore-preflight-$name-$(python3 -c 'import secrets; print(secrets.token_hex(12))')" + local listen_port + listen_port="$(port)" + if [[ "$scheme" == https ]]; then + url="https://127.0.0.1:$listen_port" + curl_tls=(--insecure) + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=localhost \ + -keyout "$case_root/key.pem" -out "$case_root/cert.pem" >/dev/null 2>&1 + openssl pkcs12 -export -out "$case_root/cert.pfx" \ + -inkey "$case_root/key.pem" -in "$case_root/cert.pem" \ + -passout pass:restore-smoke >/dev/null 2>&1 + process_env+=( + "Kestrel__Endpoints__Https__Url=https://127.0.0.1:$listen_port" + "Kestrel__Endpoints__Https__Certificate__Path=$case_root/cert.pfx" + 'Kestrel__Endpoints__Https__Certificate__Password=restore-smoke' + ) + else + url="http://$listener:$listen_port" + process_env+=("ASPNETCORE_URLS=$url") + fi + if [[ "$missing_setsid" == true ]]; then + mkdir -p "$case_root/empty-path" + process_env+=("PATH=$case_root/empty-path") + fi + + env \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "V2RAYN_WEB_API_KEY=$key" \ + V2RAYN_WEB_AUTOSTART=false \ + V2RAYN_WEB_INTERACTIVE_LAUNCH=1 \ + "V2RAYN_DATA_HOME=$data_home" \ + "DOTNET_BUNDLE_EXTRACT_BASE_DIR=$case_root/dotnet-bundle" \ + "${process_env[@]}" \ + "$executable" --foreground --no-open >"$case_root/runtime.log" 2>&1 & + app_pid=$! + + local ready=false + for _ in {1..100}; do + if ! kill -0 "$app_pid" 2>/dev/null; then + cat "$case_root/runtime.log" >&2 + echo "$name WebAPI exited before health became available." >&2 + return 1 + fi + if curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -D "$case_root/health.headers" \ + "$url/api/health" -o "$case_root/health.json" 2>/dev/null; then + ready=true + break + fi + sleep 0.2 + done + [[ "$ready" == true ]] || { cat "$case_root/runtime.log" >&2; echo "$name WebAPI failed to become healthy." >&2; return 1; } + + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "{\"key\":\"$key\"}" \ + "$url/api/auth/login" >"$case_root/login.json" + token="$(python3 - "$case_root/login.json" <<'PY' +import json, sys +response = json.load(open(sys.argv[1], encoding="utf-8")) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -H "Authorization: Bearer $token" \ + "$url/api/backup/download" -o "$case_root/backup.zip" + python3 - "$case_root/backup.zip" <<'PY' +from pathlib import Path +import sys +import zipfile +path = Path(sys.argv[1]) +with zipfile.ZipFile(path, "a", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("guiConfigs/restore-preflight-marker.txt", "must-not-be-installed") +PY + + local config_directory="$data_home/v2rayN/guiConfigs" + test -s "$config_directory/guiNConfig.json" + local before_config="$case_root/config.before" after_config="$case_root/config.after" + local lock_path="$data_home/v2rayN/v2rayN.WebAPI.instance.lock" + local owner_pid + owner_pid="$(tr -d '\r\n' <"$lock_path")" + [[ "$owner_pid" == "$app_pid" ]] || { + echo "$name instance lock PID does not match the launched process." >&2 + return 1 + } + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error \ + -H "Authorization: Bearer $token" \ + "$url/api/status" >"$case_root/status-before.json" + python3 - "$case_root/status-before.json" <<'PY' +import json, sys +status = json.load(open(sys.argv[1], encoding="utf-8"))["data"] +assert status["runtimeState"] == "stopped", status +assert status["coreRunning"] is False, status +assert not status["coreProcessIds"], status +PY + config_fingerprint "$config_directory" >"$before_config" + python3 - "$case_root/health.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +assert data == {"status": "ok"}, data +PY + test ! -e "$config_directory/restore-preflight-marker.txt" + + local status response_key + status="$(curl --noproxy '*' "${curl_tls[@]}" --silent --show-error \ + -o "$case_root/restore.json" -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -F "file=@$case_root/backup.zip;type=application/zip" \ + "$url/api/backup/restore")" + response_key="$(python3 - "$case_root/restore.json" <<'PY' +import json, sys +response = json.load(open(sys.argv[1], encoding="utf-8")) +assert response["success"] is False, response +print(response["messageKey"]) +PY +)" + [[ "$response_key" == "$expected_key" ]] || { + cat "$case_root/restore.json" >&2 + echo "$name restore refusal returned $response_key; expected $expected_key." >&2 + return 1 + } + + kill -0 "$app_pid" + config_fingerprint "$config_directory" >"$after_config" + cmp "$before_config" "$after_config" + test ! -e "$config_directory/restore-preflight-marker.txt" + test ! -e "$data_home/v2rayN/v2rayn-WebAPI-restore-state.json" + test "$(tr -d '\r\n' <"$lock_path")" = "$app_pid" + curl --noproxy '*' "${curl_tls[@]}" --silent --show-error --fail \ + -D "$case_root/health-after.headers" -o "$case_root/health-after.json" \ + "$url/api/health" + python3 - "$case_root/health-after.json" "$case_root/status-before.json" <<'PY' +import json, sys +assert json.load(open(sys.argv[1], encoding="utf-8")) == {"status": "ok"} +status = json.load(open(sys.argv[2], encoding="utf-8"))["data"] +assert status["runtimeState"] == "stopped" and not status["coreProcessIds"], status +PY + [[ "$(tr -d '\r\n' <"$lock_path")" == "$app_pid" ]] || { echo "$name refusal changed the healthy instance PID." >&2; return 1; } + printf '%s listener refusal passed (messageKey=%s; config, Core state, owner PID and health unchanged).\n' "$name" "$response_key" + + kill -TERM "$app_pid" + for _ in {1..100}; do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$app_pid" 2>/dev/null; then + cat "$case_root/runtime.log" >&2 + echo "$name WebAPI did not stop during cleanup." >&2 + return 1 + fi + wait "$app_pid" || true + app_pid="" +} + +https_port="$(port)" +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=localhost \ + -keyout "$temporary/key.pem" -out "$temporary/cert.pem" >/dev/null 2>&1 +openssl pkcs12 -export -out "$temporary/cert.pfx" \ + -inkey "$temporary/key.pem" -in "$temporary/cert.pem" \ + -passout pass:restore-smoke >/dev/null 2>&1 + +run_refusal_case \ + https-only "https://127.0.0.1:$https_port" https \ + backup.restoreProbeUnavailable false +run_refusal_case \ + explicit-lan "$(lan_address)" http \ + backup.restoreProbeUnavailable false +run_refusal_case \ + missing-setsid 127.0.0.1 http \ + backup.restoreSetSidUnavailable true + +printf 'Linux backup-restore preflight refusal tests passed.\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh new file mode 100644 index 00000000..bb7592d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +archive="${1:?Usage: test-full-zip-runtime.sh }" +archive="$(realpath "$archive")" +test -s "$archive" +command -v curl >/dev/null +command -v python3 >/dev/null +command -v unzip >/dev/null + +temporary="$(mktemp -d)" +web_pid="" +cleanup() { + if [[ -n "$web_pid" ]] && kill -0 "$web_pid" 2>/dev/null; then + kill -TERM "$web_pid" 2>/dev/null || true + for _ in {1..20}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + [[ -z "$state" || "$state" == *Z* ]] && break + sleep 0.1 + done + kill -KILL "$web_pid" 2>/dev/null || true + wait "$web_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +install_dir="$temporary/install" +data_home="$temporary/data" +mkdir -p "$install_dir" "$data_home" "$temporary/dotnet-bundle" "$temporary/other-working-directory" +unzip -q "$archive" -d "$install_dir" +executable="$install_dir/v2rayN.WebAPI" +test -x "$executable" +test -s "$install_dir/.env.example" +expected_version="$(python3 - "$archive" <<'PY' +import json +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as package: + identity = json.loads(package.read("v2rayN.WebAPI.build.json")) +assert identity["product"] == "v2rayN.WebAPI", identity +assert identity["rid"] == "linux-x64", identity +print(identity["version"]) +PY +)" + +port="$(python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +management_key="web-zip-smoke-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +cp "$install_dir/.env.example" "$install_dir/.env" +python3 - "$install_dir/.env" "$management_key" "$port" <<'PY' +import sys +from pathlib import Path + +path, key, port = sys.argv[1:] +content = Path(path).read_text(encoding="utf-8") +replacements = { + "V2RAYN_WEB_API_KEY=": f"V2RAYN_WEB_API_KEY={key}", + "ASPNETCORE_URLS=http://127.0.0.1:5080": f"ASPNETCORE_URLS=http://127.0.0.1:{port}", + "V2RAYN_WEB_AUTOSTART=true": "V2RAYN_WEB_AUTOSTART=false", +} +for original, replacement in replacements.items(): + assert content.count(original) == 1, original + content = content.replace(original, replacement, 1) +Path(path).write_text(content, encoding="utf-8") +PY +chmod 600 "$install_dir/.env" + +( + cd "$temporary/other-working-directory" + exec env \ + -u V2RAYN_WEB_API_KEY \ + -u V2RAYN_WEB_AUTOSTART \ + -u ASPNETCORE_URLS \ + -u ASPNETCORE_HTTP_PORTS \ + V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/dotnet-bundle" \ + "$executable" --foreground --no-open +) >"$temporary/web.log" 2>&1 & +web_pid=$! +health_url="http://127.0.0.1:$port/api/health" +healthy=false +for _ in {1..90}; do + if ! kill -0 "$web_pid" 2>/dev/null; then + cat "$temporary/web.log" >&2 + echo "Web process exited before /api/health became ready." >&2 + exit 1 + fi + if curl --noproxy '*' --silent --show-error --fail "$health_url" \ + -D "$temporary/health.headers" -o "$temporary/health.json" 2>/dev/null \ + && python3 - "$temporary/health.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response == {"status": "ok"}, response +PY + then + healthy=true + break + fi + sleep 1 +done +if [[ "$healthy" != true ]]; then + cat "$temporary/web.log" >&2 + echo "Full ZIP did not become healthy within 90 seconds." >&2 + exit 1 +fi +test "$(curl --noproxy '*' --silent --show-error -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/")" = 404 +grep -Fiq 'X-v2rayn-WebAPI-instance-pid:' "$temporary/health.headers" +grep -Fiq "X-v2rayn-WebAPI-version: $expected_version" "$temporary/health.headers" +grep -Fiq 'X-v2rayn-WebAPI-core-state: stopped' "$temporary/health.headers" +grep -Fiq 'X-v2rayn-WebAPI-core-process-ids:' "$temporary/health.headers" + +login_body="$(python3 -c 'import json,sys; print(json.dumps({"key": sys.argv[1]}))' "$management_key")" +curl --noproxy '*' --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "$login_body" \ + "http://127.0.0.1:$port/api/auth/login" >"$temporary/login.json" +session_token="$(python3 - "$temporary/login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" +curl --noproxy '*' --silent --show-error --fail \ + -H "Authorization: Bearer $session_token" \ + "http://127.0.0.1:$port/api/status" >"$temporary/status.json" +python3 - "$temporary/status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY + +kill -TERM "$web_pid" +stopped=false +for _ in {1..120}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + if [[ -z "$state" || "$state" == *Z* ]]; then + stopped=true + break + fi + sleep 0.25 +done +if [[ "$stopped" != true ]]; then + echo "Web process did not shut down after SIGTERM." >&2 + cat "$temporary/web.log" >&2 + exit 1 +fi +set +e +wait "$web_pid" +exit_code=$? +set -e +web_pid="" +if [[ "$exit_code" -ne 0 ]]; then + echo "Web process exited with status $exit_code during graceful shutdown." >&2 + cat "$temporary/web.log" >&2 + exit 1 +fi + +python3 - "$install_dir" "$data_home" <<'PY' +import os +import sys + +install, data_home = map(os.path.realpath, sys.argv[1:]) +expected = { + os.path.join(install, "v2rayN.WebAPI"), + os.path.join(install, "bin", "xray", "xray"), + os.path.join(install, "bin", "sing_box", "sing-box"), + os.path.join(install, "bin", "mihomo", "mihomo"), + os.path.join(data_home, "v2rayN", "bin", "xray", "xray"), + os.path.join(data_home, "v2rayN", "bin", "sing_box", "sing-box"), + os.path.join(data_home, "v2rayN", "bin", "mihomo", "mihomo"), +} +expected = {os.path.realpath(path) for path in expected if os.path.exists(path)} +residual = [] +for entry in os.scandir("/proc"): + if not entry.name.isdigit(): + continue + try: + executable = os.path.realpath(f"/proc/{entry.name}/exe") + except (OSError, PermissionError): + continue + if executable in expected: + residual.append((entry.name, executable)) +assert not residual, f"Residual Web/Core processes remain: {residual}" +PY + +echo "Full ZIP runtime smoke passed (healthy API, authenticated stopped Core, graceful shutdown, no residual Web/Core processes)." diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py new file mode 100755 index 00000000..8ec6fd05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-identity.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Real native WebAPI helper replacement/rollback smoke; uses isolated data only. + +Usage: test-native-update-identity.py +The two artifacts must carry distinct versions in v2rayN.WebAPI.build.json. +""" +import hashlib +import json +import os +from pathlib import Path +import secrets +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +NAME = "v2rayN.WebAPI" +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def fingerprint(directory): + return {str(p.relative_to(directory)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in directory.rglob("*") if p.is_file()} + + +def health(url): + try: + with opener.open(url + "/api/health", timeout=1) as response: + return dict(response.headers) + except (OSError, urllib.error.URLError): + return {} + + +def wait_health(url, version): + for _ in range(300): + headers = health(url) + if headers.get("X-v2rayn-WebAPI-version") == version: + return headers + time.sleep(0.1) + raise AssertionError("native instance failed its version health check") + + +def run_scenario(root, previous, candidate, rollback, reject_invalid=False, listen_host="127.0.0.1"): + root.mkdir() + install = root / "install" + install.mkdir() + shutil.copy2(previous / NAME, install / NAME) + shutil.copy2(previous / (NAME + ".build.json"), install / (NAME + ".build.json")) + before = json.loads((previous / (NAME + ".build.json")).read_text()) + after = json.loads((candidate / (NAME + ".build.json")).read_text()) + assert before["product"] == after["product"] == NAME + assert before["version"] != after["version"] + if rollback: + # Keep a real native executable. Force a version-health mismatch rather + # than bypassing package validation with a synthetic shell executable. + after["version"] = "0.0.999-review" + data = root / "data" + scope = data / "v2rayN" + temps = scope / "guiTemps" + temps.mkdir(parents=True) + (install / "extra").mkdir() + (install / "extra" / "marker.dat").write_text("unrelated files must survive") + (scope / "bin").mkdir() + (scope / "bin" / "marker").write_text("Core files must survive") + (install / "bin").mkdir() + (install / "bin" / ".keep").touch() # Full-install package requires a bin/ root. + family = socket.AF_INET6 if ":" in listen_host else socket.AF_INET + with socket.socket(family) as sock: + sock.bind((listen_host, 0)) + port = sock.getsockname()[1] + url = f"http://[{listen_host}]:{port}" if ":" in listen_host else f"http://{listen_host}:{port}" + env = {**os.environ, "V2RAYN_WEB_API_KEY": secrets.token_urlsafe(32), + "V2RAYN_DATA_HOME": str(data), "XDG_DATA_HOME": str(data), + "V2RAYN_LOCAL_APPLICATION_DATA_V2": "1", "ASPNETCORE_URLS": url, + "V2RAYN_WEB_AUTOSTART": "false", + "DOTNET_BUNDLE_EXTRACT_BASE_DIR": str(root / ".net-bundle")} + for key in ("INVOCATION_ID", "JOURNAL_STREAM", "container", "DOTNET_RUNNING_IN_CONTAINER"): + env.pop(key, None) + log = (root / "runtime.log").open("wb") + owner = subprocess.Popen([str(install / NAME), "--foreground", "--no-open"], env=env, stdout=log, stderr=log) + try: + wait_health(url, before["version"]) + preserved = {str(p): fingerprint(p) for p in (install / "extra", install / "bin", scope / "bin")} + staged = root / (".v2rayn-WebAPI-candidate-" + secrets.token_hex(8)) + backup = root / (".v2rayn-WebAPI-backup-" + secrets.token_hex(8)) + staged.mkdir() + (staged / (NAME + ".build.json")).write_text(json.dumps(after)) + shutil.copy2(candidate / NAME, staged / NAME) + if reject_invalid: + (staged / NAME).write_text("#!/bin/sh\nexit 1\n") + intent = temps / "WebAPI-update-runtime-state.json" + progress = temps / "WebAPI-update-progress.json" + intent.write_text(json.dumps({"wasRunning": False, "profileId": None})) + plan_path = temps / "identity-update-plan.json" + plan_path.write_text(json.dumps({ + "installDirectory": str(install), "candidateDirectory": str(staged), "backupDirectory": str(backup), + "instanceLockPath": str(scope / (NAME + ".instance.lock")), "healthUri": url + "/api/health", + "hostArguments": ["--urls", url], "expectedVersion": after["version"], "expectedCommit": after["commit"], + "rid": after["rid"], "previousVersion": before["version"], + "runtimeIntentPath": str(intent), "progressPath": str(progress), "coreWasRunning": False, + })) + plan_path.chmod(0o600) + if reject_invalid: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + helper = subprocess.Popen([str(install / NAME), "--apply-WebAPI-update", str(plan_path)], env=env, stdout=log, stderr=log) + if not reject_invalid: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + helper_code = helper.wait(timeout=100) + assert not list(install.glob(".v2rayn-WebAPI-update-helper-*")), "Isolated helper bundle was not cleaned after worker exit" + failed = rollback or reject_invalid + expected_helper_code = 1 if failed else 0 + if helper_code != expected_helper_code: + log.flush() + runtime_log = (root / "runtime.log").read_text(errors="replace") + progress_state = progress.read_text(errors="replace") if progress.exists() else "" + raise AssertionError( + f"helper returned {helper_code}, expected {expected_helper_code};\n" + f"runtime.log:\n{runtime_log}\nupdate progress:\n{progress_state}" + ) + wait_health(url, before["version"] if failed else after["version"]) + if failed: + # The old build must be restored and running, not merely copied back: + # health was already polled above and must come from a replacement process. + running = health(url) + assert int(running["X-v2rayn-WebAPI-instance-pid"]) != owner.pid, \ + "the restored build must be a running replacement process" + result = json.loads(progress.read_text()) + assert result["isComplete"] and result["success"] == (not failed) + if failed: + assert result["rollbackSucceeded"] + expected = previous if failed else candidate + assert (install / NAME).read_bytes() == (expected / NAME).read_bytes() + assert not (install / "v2rayN.Web").exists() + for directory, snapshot in preserved.items(): + assert fingerprint(Path(directory)) == snapshot + assert not backup.exists() and not intent.exists() + label = "invalid executable rejection" if reject_invalid else "rollback" if rollback else "replacement" + suffix = "" if listen_host == "127.0.0.1" else f" ({listen_host})" + print("Native WebAPI " + label + suffix + " passed (version/PID/lock health, atomic executable swap, unrelated file/Core preservation).") + finally: + if owner.poll() is None: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + if health(url): + subprocess.run([str(install / NAME), "--stop", "--urls", url], env=env, stdout=log, stderr=log, check=True, timeout=40) + log.close() + + +def primary_ipv4(): + # Documentation-range UDP connect selects the primary interface without + # sending any packets; UDP "connect" only resolves the local address. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("192.0.2.1", 9)) + address = sock.getsockname()[0] + return None if address.startswith("127.") else address + except OSError: + return None + + +def ipv6_loopback_available(): + if not socket.has_ipv6: + return False + try: + with socket.socket(socket.AF_INET6) as sock: + sock.bind(("::1", 0)) + return True + except OSError: + return False + + +def run_refusal_scenario(root, previous, host, label): + """An instance whose listener cannot be verified through the shared loopback probe + rule must refuse self-updates and keep serving instead of stopping itself for an + unverifiable replacement.""" + root.mkdir() + install = root / "install" + install.mkdir() + shutil.copy2(previous / NAME, install / NAME) + shutil.copy2(previous / (NAME + ".build.json"), install / (NAME + ".build.json")) + data = root / "data" + scope = data / "v2rayN" + temps = scope / "guiTemps" + temps.mkdir(parents=True) + (install / "bin").mkdir() + (install / "bin" / ".keep").touch() + with socket.socket() as sock: + sock.bind((host, 0)) + port = sock.getsockname()[1] + url = f"http://{host}:{port}" + key = secrets.token_urlsafe(32) + env = {**os.environ, "V2RAYN_WEB_API_KEY": key, "V2RAYN_DATA_HOME": str(data), "XDG_DATA_HOME": str(data), + "V2RAYN_LOCAL_APPLICATION_DATA_V2": "1", "ASPNETCORE_URLS": url, + "V2RAYN_WEB_AUTOSTART": "false", + "DOTNET_BUNDLE_EXTRACT_BASE_DIR": str(root / ".net-bundle")} + for entry in ("INVOCATION_ID", "JOURNAL_STREAM", "container", "DOTNET_RUNNING_IN_CONTAINER"): + env.pop(entry, None) + log = (root / "runtime.log").open("wb") + owner = subprocess.Popen([str(install / NAME), "--foreground", "--no-open"], env=env, stdout=log, stderr=log) + try: + # The health diagnostics are loopback-only, so a non-loopback listener only + # guarantees a successful response body here. + for _ in range(300): + if health(url): + break + time.sleep(0.1) + else: + raise AssertionError("the non-loopback instance did not become ready") + + def login(): + request = urllib.request.Request( + url + "/api/auth/login", method="POST", + data=json.dumps({"key": key}).encode(), + headers={"Content-Type": "application/json"}) + with opener.open(request, timeout=5) as response: + body = json.load(response) + assert body["success"] is True, body + return body["data"]["token"] + + token = login() + + def api(path, method="GET", expect=200): + request = urllib.request.Request( + url + path, method=method, + headers={"Authorization": f"Bearer {token}"}) + try: + with opener.open(request, timeout=10) as response: + status = response.status + body = json.load(response) + except urllib.error.HTTPError as error: + status = error.code + with error: + body = json.load(error) + assert status == expect, (status, body) + return body + + target = api("/api/web-updates") + assert target["success"] is True, target + view = target["data"] + assert view["canInstall"] is False, view + assert view["installReasonKey"] == "maintenance.webUpdateProbeUnavailable", view + + # A refused install answers 409 Conflict with the reason view. + refusal = api("/api/web-updates/update", method="POST", expect=409) + assert refusal["success"] is False, refusal + assert refusal["code"] == "web_update_runtime_unavailable", refusal + assert refusal["messageKey"] == "maintenance.webUpdateProbeUnavailable", refusal + + # The same unprobeable configuration must also make --stop refuse to signal + # instead of guessing a loopback address for owner verification. + stop = subprocess.run( + [str(install / NAME), "--stop", "--urls", url], + env={**env, "LC_ALL": "C", "LANG": "C"}, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=40) + assert stop.returncode != 0, stop.stdout + assert b"loopback HTTP health endpoint" in stop.stdout, stop.stdout + assert owner.poll() is None, "an unprobeable --stop must not stop the current instance" + + assert owner.poll() is None, "the refused update must not stop the current instance" + assert health(url), "the current instance must keep serving health after the refusal" + lock_path = scope / (NAME + ".instance.lock") + assert lock_path.exists() and int(lock_path.read_text().strip()) == owner.pid, \ + "the refused update must keep the same lock owner" + assert not (temps / "WebAPI-update-progress.json").exists(), \ + "a refused update must not start publishing update progress" + assert not list(root.glob(".v2rayn-WebAPI-candidate-*")), "no update candidate may be staged" + assert not list(install.parent.glob(".v2rayn-WebAPI-update-helper-*")), "no update helper may be started" + print(f"Native WebAPI unprobeable-listener refusal passed ({label}; canInstall=false with a clear reason, current instance keeps running).") + finally: + if owner.poll() is None: + owner.send_signal(signal.SIGTERM) + owner.wait(timeout=40) + log.close() + + +if __name__ == "__main__": + previous, candidate = [Path(value).resolve() for value in sys.argv[1:]] + root = Path(tempfile.mkdtemp(prefix="webapi-native-identity-", dir=os.environ.get("TMPDIR", "/tmp/opencode"))) + try: + run_scenario(root / "replacement", previous, candidate, False) + run_scenario(root / "rollback", previous, candidate, True) + run_scenario(root / "invalid-executable", previous, candidate, False, True) + run_scenario(root / "localhost-replacement", previous, candidate, False, listen_host="localhost") + if ipv6_loopback_available(): + run_scenario(root / "ipv6-only-replacement", previous, candidate, False, listen_host="::1") + else: + print("Skipped the IPv6-only update scenario: IPv6 loopback is not available.") + primary = primary_ipv4() + if primary is not None: + run_refusal_scenario(root / "probe-refused", previous, primary, "explicit non-loopback address") + else: + print("Skipped the non-loopback refusal scenario: no non-loopback IPv4 address is available.") + run_refusal_scenario( + root / "loopback-alias-refused", previous, "127.0.0.2", + "unverifiable loopback alias 127.0.0.2") + except Exception: + print(f"Failed fixture retained for diagnosis: {root}", file=sys.stderr) + raise + else: + shutil.rmtree(root) diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh new file mode 100755 index 00000000..8cce2278 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-native-update-regression.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Exercise real single-file updates with deliberately different bundle layouts. +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +previous="$(realpath "${1:?Usage: test-native-update-regression.sh }")" +test -x "$previous/v2rayN.WebAPI" +test -s "$previous/v2rayN.WebAPI.build.json" + +dotnet="${DOTNET:-$web_root/.NET/dotnet}" +if [[ "$dotnet" != */* ]]; then + dotnet="$(command -v "$dotnet")" +elif [[ ! -x "$dotnet" ]]; then + dotnet="$(command -v dotnet)" +fi +export NUGET_PACKAGES="${NUGET_PACKAGES:-$web_root/.packages/nuget}" + +# Keep native bundles and test installs beside the publish directory, not on /tmp tmpfs. +scratch="$(mktemp -d "$(dirname -- "$previous")/.webapi-update-regression.XXXXXX")" +cleanup() { + local status=$? + if [[ "$status" -eq 0 ]]; then + rm -rf -- "$scratch" + else + printf 'Native update regression fixture retained: %s\n' "$scratch" >&2 + fi +} +trap cleanup EXIT + +mapfile -t identity < <(python3 - "$previous/v2rayN.WebAPI.build.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + identity = json.load(source) +assert identity["product"] == "v2rayN.WebAPI" +print("0.0.1-regression" if identity["version"] != "0.0.1-regression" else "0.0.2-regression") +print(identity["commit"]) +print(identity["buildDate"]) +print(identity["rid"]) +PY +) +[[ "${#identity[@]}" -eq 4 ]] + +# A test-only assembly attribute grows the managed payload and shifts bundle offsets. +# Version-only fixtures can retain identical offsets and hide lazy-loading failures. +python3 - "$scratch/layout.targets" <<'PY' +from pathlib import Path +import sys + +Path(sys.argv[1]).write_text( + '' + '', encoding="utf-8") +PY + +"$dotnet" publish "$web_root/v2rayN.WebAPI.csproj" \ + --configuration Release --runtime "${identity[3]}" --self-contained true \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:WebVersion="${identity[0]}" -p:WebCommit="${identity[1]}" -p:WebBuildDate="${identity[2]}" \ + -p:WebRepository="${V2RAYN_WEB_REPOSITORY:-${GITHUB_REPOSITORY:-2dust/v2rayN}}" \ + -p:CustomBeforeMicrosoftCommonTargets="$scratch/layout.targets" \ + --output "$scratch/candidate" + +python3 - "$previous" "$scratch/candidate" "${identity[0]}" <<'PY' +from pathlib import Path +import json +import sys + +previous, candidate = map(Path, sys.argv[1:3]) +identity = json.loads((previous / "v2rayN.WebAPI.build.json").read_text()) +identity["version"] = sys.argv[3] +(candidate / "v2rayN.WebAPI.build.json").write_text(json.dumps(identity), encoding="utf-8") +assert (previous / "v2rayN.WebAPI").stat().st_size != (candidate / "v2rayN.WebAPI").stat().st_size, \ + "Regression fixtures must have different bundle layouts, not just different versions" +PY + +identity_test=(python3 "$web_root/Tests/Integration/test-native-update-identity.py" "$previous" "$scratch/candidate") +# Run in a transient scope so a hosted runner's enclosing systemd service is not mistaken for +# the WebAPI service under test. Production still rejects actual systemd-managed WebAPI updates. +if command -v systemd-run >/dev/null && systemd-run --user --scope --quiet /usr/bin/true >/dev/null 2>&1; then + systemd-run --user --scope --quiet /usr/bin/env TMPDIR="$scratch" "${identity_test[@]}" +elif command -v sudo >/dev/null && sudo -n systemd-run --scope --quiet /usr/bin/true >/dev/null 2>&1; then + runuser_bin="$(command -v runuser)" + sudo -n systemd-run --scope --quiet "$runuser_bin" --preserve-environment -u "$(id -un)" -- \ + /usr/bin/env TMPDIR="$scratch" "${identity_test[@]}" +elif grep -Eq 'system\.slice/.*\.service' /proc/self/cgroup; then + echo 'Native update regression is in a systemd service cgroup, but no transient scope is available.' >&2 + exit 1 +else + TMPDIR="$scratch" "${identity_test[@]}" +fi +printf 'Native single-file different-layout update regression passed.\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh new file mode 100755 index 00000000..46c47af2 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-release-packaging.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# Release packaging dry-run: validates Linux/Windows full-install and app-only ZIP boundaries +# plus the WebAPI-update.json manifest using small fixtures, without publishing anything. +set -euo pipefail + +web_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +asset_tool="$web_root/Scripts/web-release-assets.py" +temporary="$(mktemp -d)" +trap 'rm -rf "$temporary"' EXIT HUP INT TERM + +make_publish_fixture() { + local rid="$1" + local publish="$temporary/publish-$rid" + local app_executable="v2rayN.WebAPI" xray="bin/xray/xray" sing_box="bin/sing_box/sing-box" mihomo="bin/mihomo/mihomo" + local pe_machine="" + case "$rid" in + win-x64) pe_machine="0x8664" ;; + win-arm64) pe_machine="0xAA64" ;; + esac + if [[ -n "$pe_machine" ]]; then + app_executable="v2rayN.WebAPI.exe" + xray="bin/xray/xray.exe" + sing_box="bin/sing_box/sing-box.exe" + mihomo="bin/mihomo/mihomo.exe" + fi + mkdir -p "$publish/bin/xray" "$publish/bin/sing_box" "$publish/bin/mihomo" "$publish/bin/srss" + if [[ -n "$pe_machine" ]]; then + python3 - "$publish/$app_executable" "$publish/$xray" "$publish/$sing_box" "$publish/$mihomo" "$pe_machine" <<'PY' +from pathlib import Path +import struct +import sys + +names = sys.argv[1:-1] +machine = int(sys.argv[-1], 16) +image = bytearray(512) +image[:2] = b"MZ" +struct.pack_into(" "$publish/$app_executable" + chmod 755 "$publish/$app_executable" + for executable in "$xray" "$sing_box" "$mihomo"; do + printf '#!/bin/sh\nexit 0\n' > "$publish/$executable" + chmod 755 "$publish/$executable" + done + fi + # Simulate accidental local environment files in the publish tree. Only the reviewed + # repository template may be copied into a full install ZIP. + printf 'V2RAYN_WEB_API_KEY=fixture-secret\n' > "$publish/.env" + printf 'unreviewed template\n' > "$publish/.env.example" + local assets=(bin/geosite.dat bin/geoip.dat bin/geoip.metadb bin/Country.mmdb bin/srss/geosite-category-ads-all.srs) + if [[ "$rid" == linux-x64 || "$rid" == linux-arm64 ]]; then assets+=(bin/sing_box/libcronet.so); fi + for asset in "${assets[@]}"; do + printf 'asset\n' > "$publish/$asset" + done + # User data that must never leak into the app-only self-update package. + mkdir -p "$publish/guiConfigs" "$publish/guiLogs" "$publish/WebAPIData" + printf 'config\n' > "$publish/guiConfigs/guiNConfig.json" + printf 'log\n' > "$publish/guiLogs/app.log" + printf 'auth\n' > "$publish/WebAPIData/WebAPI-auth.json" + python3 - "$publish/v2rayN.WebAPI.build.json" "$rid" <<'PY' +import json +import sys + +path, rid = sys.argv[1:] +with open(path, "w", encoding="utf-8") as handle: + json.dump({ + "product": "v2rayN.WebAPI", + "version": "7.25.3", + "commit": "0123456789abcdef", + "buildDate": "2026-09-28T00:00:00Z", + "rid": rid, + }, handle, separators=(",", ":")) + handle.write("\n") +PY +} + +for rid in linux-x64 linux-arm64 linux-riscv64 linux-loongarch64 win-x64 win-arm64; do + make_publish_fixture "$rid" + python3 "$asset_tool" package --rid "$rid" --publish "$temporary/publish-$rid" --dist "$temporary/dist" +done + +full_x64="$(python3 "$asset_tool" get --rid linux-x64 --field full)" +full_arm64="$(python3 "$asset_tool" get --rid linux-arm64 --field full)" +full_riscv64="$(python3 "$asset_tool" get --rid linux-riscv64 --field full)" +full_loong64="$(python3 "$asset_tool" get --rid linux-loongarch64 --field full)" +update_x64="$(python3 "$asset_tool" get --rid linux-x64 --field update)" +update_arm64="$(python3 "$asset_tool" get --rid linux-arm64 --field update)" +update_riscv64="$(python3 "$asset_tool" get --rid linux-riscv64 --field update)" +update_loong64="$(python3 "$asset_tool" get --rid linux-loongarch64 --field update)" +full_windows="$(python3 "$asset_tool" get --rid win-x64 --field full)" +update_windows="$(python3 "$asset_tool" get --rid win-x64 --field update)" +full_windows_arm64="$(python3 "$asset_tool" get --rid win-arm64 --field full)" +update_windows_arm64="$(python3 "$asset_tool" get --rid win-arm64 --field update)" + +for asset in "$full_x64" "$full_arm64" "$full_riscv64" "$full_loong64" \ + "$update_x64" "$update_arm64" "$update_riscv64" "$update_loong64" \ + "$full_windows" "$update_windows" "$full_windows_arm64" "$update_windows_arm64"; do + test -s "$temporary/dist/$asset" +done +test "$full_x64" = "v2rayN-linux-64-WebAPI.zip" +test "$full_arm64" = "v2rayN-linux-arm64-WebAPI.zip" +test "$full_riscv64" = "v2rayN-linux-riscv64-WebAPI.zip" +test "$full_loong64" = "v2rayN-linux-loong64-WebAPI.zip" +test "$update_x64" = "v2rayN-linux-64-WebAPI-update.zip" +test "$update_arm64" = "v2rayN-linux-arm64-WebAPI-update.zip" +test "$update_riscv64" = "v2rayN-linux-riscv64-WebAPI-update.zip" +test "$update_loong64" = "v2rayN-linux-loong64-WebAPI-update.zip" +test "$full_windows" = "v2rayN-windows-64-WebAPI.zip" +test "$update_windows" = "v2rayN-windows-64-WebAPI-update.zip" +test "$full_windows_arm64" = "v2rayN-windows-arm64-WebAPI.zip" +test "$update_windows_arm64" = "v2rayN-windows-arm64-WebAPI-update.zip" + +python3 - "$temporary/dist/$full_x64" "$temporary/dist/$update_x64" "$temporary/dist/$full_windows" "$temporary/dist/$update_windows" "$web_root/.env.example" <<'PY' +from pathlib import Path +import sys +import zipfile + +full, update, full_windows, update_windows, env_example_source = sys.argv[1:] +with zipfile.ZipFile(full) as archive: + names = archive.namelist() + env_example_data = archive.read(".env.example") +for required in ("v2rayN.WebAPI", "v2rayN.WebAPI.build.json", ".env.example", "bin/xray/xray"): + assert required in names, required +assert names.count(".env.example") == 1, names +assert env_example_data == Path(env_example_source).read_bytes() +assert not any(".env" in name.split("/") for name in names), names +assert not any(".env.example" in name.split("/") and name != ".env.example" for name in names), names +with zipfile.ZipFile(update) as archive: + update_names = set(archive.namelist()) +assert update_names == {"v2rayN.WebAPI", "v2rayN.WebAPI.build.json"}, update_names +assert not any(segment in (".env", ".env.example") for name in update_names for segment in name.split("/")), update_names +assert not any(name.startswith(("bin/", "guiConfigs/", "guiLogs/", "WebAPIData/")) for name in update_names), update_names +with zipfile.ZipFile(full_windows) as archive: + windows_names = archive.namelist() + assert "v2rayN.WebAPI.exe" in windows_names, windows_names + assert "v2rayN.WebAPI.build.json" in windows_names, windows_names + assert "bin/xray/xray.exe" in windows_names, windows_names + assert archive.read(".env.example") == Path(env_example_source).read_bytes() + assert not any(name.split("/")[-1] == ".env" for name in windows_names), windows_names +with zipfile.ZipFile(update_windows) as archive: + windows_update_names = set(archive.namelist()) +assert windows_update_names == {"v2rayN.WebAPI.exe", "v2rayN.WebAPI.build.json"}, windows_update_names +process = __import__("subprocess").run(["unzip", "-l", update], capture_output=True, text=True) +assert process.returncode == 0, process.stderr + +web_root = Path(env_example_source).parent +web_ignore = (web_root / ".gitignore").read_text(encoding="utf-8").splitlines() +container_ignore = (web_root / "Containerfile.dockerignore").read_text(encoding="utf-8").splitlines() +assert ".env" in web_ignore, web_ignore +assert ".env" in container_ignore and "**/.env" in container_ignore, container_ignore +print("ZIP boundary assertions passed") +PY + +python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3 \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/dist" \ + --output "$temporary/dist/WebAPI-update.json" +python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/dist/WebAPI-update.json" \ + --dist "$temporary/dist" \ + --repository 2dust/v2rayN \ + --version 7.25.3 + +if python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/dist/WebAPI-update.json" \ + --dist "$temporary/dist" \ + --repository 2dust/v2rayN \ + --version 7.25.4 2>/dev/null; then + echo "Expected the wrong requested release version to be rejected" >&2 + exit 1 +fi + +python3 - "$temporary/dist/WebAPI-update.json" "$update_x64" "$update_arm64" "$update_riscv64" "$update_loong64" "$update_windows" "$update_windows_arm64" <<'PY' +import hashlib +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +asset_x64, asset_arm64, asset_riscv64, asset_loong64, asset_windows, asset_windows_arm64 = sys.argv[2:] +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +assert manifest["product"] == "v2rayN.WebAPI", manifest +assert manifest["version"] == "7.25.3", manifest +assert manifest["commit"] == "0123456789abcdef", manifest +assert manifest["buildDate"] == "2026-09-28T00:00:00Z", manifest +expected = { + "linux-x64": asset_x64, + "linux-arm64": asset_arm64, + "linux-riscv64": asset_riscv64, + "linux-loongarch64": asset_loong64, + "win-x64": asset_windows, + "win-arm64": asset_windows_arm64, +} +packages = {package["rid"]: package for package in manifest["packages"]} +assert set(packages) == set(expected), packages +for rid, asset in expected.items(): + package = packages[rid] + url = f"https://github.com/2dust/v2rayN/releases/download/7.25.3/{asset}" + assert asset.endswith("-WebAPI-update.zip"), asset + assert package["asset"] == asset, package + assert package["url"] == url, package + data = (manifest_path.parent / asset).read_bytes() + assert package["sha256"] == hashlib.sha256(data).hexdigest(), package + assert package["size"] == len(data), package +print("WebAPI-update.json assertions passed") +PY + +# A dev identity must never become a release manifest. +if python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3-dev \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/dist" \ + --output "$temporary/dev-update.json" 2>/dev/null; then + echo "Expected the dev identity manifest to be rejected" >&2 + exit 1 +fi +test ! -e "$temporary/dev-update.json" + +# A tampered asset must fail SHA-256 verification. +cp -a "$temporary/dist" "$temporary/tampered" +printf 'tampered' >> "$temporary/tampered/$update_x64" +cp -a "$temporary/dist/WebAPI-update.json" "$temporary/tampered/WebAPI-update.json" +if python3 "$web_root/Scripts/web-update-manifest.py" verify \ + --manifest "$temporary/tampered/WebAPI-update.json" \ + --dist "$temporary/tampered" \ + --repository 2dust/v2rayN 2>/dev/null; then + echo "Expected the tampered manifest to be rejected" >&2 + exit 1 +fi + +# The embedded v2rayN.WebAPI.build.json must match the manifest, and the app-only ZIP boundary +# must reject forbidden files, links, duplicates, and path traversal. Each variant regenerates +# the manifest from the tampered archive so SHA-256 and size stay self-consistent and only the +# identity/layout cross-check can fail. +python3 - "$temporary/dist" "$temporary/identity-tamper" "$update_x64" <<'PY' +import json +import shutil +import sys +import warnings +import zipfile +from pathlib import Path + +warnings.filterwarnings("ignore", message="Duplicate name:.*") + +dist = Path(sys.argv[1]) +out_root = Path(sys.argv[2]) +source = dist / sys.argv[3] +identity_name = "v2rayN.WebAPI.build.json" +fixed_time = (1980, 1, 1, 0, 0, 0) + +with zipfile.ZipFile(source) as archive: + base = [(info, archive.read(info)) for info in archive.infolist()] + +identity = json.loads(next(data for info, data in base if info.filename == identity_name)) +identity_data = next(data for info, data in base if info.filename == identity_name) +untouched = [path for path in dist.iterdir() if path.is_file() and path.name.endswith(".zip") and path != source] + + +def write_variant(name, transform): + target = out_root / name + target.mkdir(parents=True, exist_ok=True) + for path in untouched: + shutil.copy2(path, target / path.name) + with zipfile.ZipFile(target / source.name, "w", zipfile.ZIP_DEFLATED) as archive: + for info, data in transform(list(base)): + archive.writestr(info, data) + + +def identity_transform(field=None, value=None, drop=False, invalid=False): + def transform(entries): + result = [] + for info, data in entries: + if info.filename != identity_name: + result.append((info, data)) + continue + if drop: + continue + if invalid: + result.append((info, b"{ not valid json")) + continue + changed = dict(identity) + changed[field] = value + result.append((info, json.dumps(changed, separators=(",", ":")).encode("utf-8"))) + return result + return transform + + +def appended(name, data, symlink=False): + def transform(entries): + info = zipfile.ZipInfo(name, date_time=fixed_time) + info.create_system = 3 + file_type = 0o120000 if symlink else 0o100000 + info.external_attr = (file_type | 0o755) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + return entries + [(info, data)] + return transform + + +write_variant("version", identity_transform(field="version", value="7.25.4")) +write_variant("commit", identity_transform(field="commit", value="ffffffffffffffff")) +write_variant("build-date", identity_transform(field="buildDate", value="2026-09-29T00:00:00Z")) +write_variant("rid", identity_transform(field="rid", value="linux-arm64")) +write_variant("missing-identity", identity_transform(drop=True)) +write_variant("invalid-json", identity_transform(invalid=True)) +write_variant("embedded-bin", appended("bin/xray/xray", b"#!/bin/sh\nexit 0\n")) +write_variant("symlink", appended("extra/vendor.dat", b"/etc/passwd", symlink=True)) +write_variant("duplicate", appended(identity_name, identity_data)) +write_variant("traversal", appended("../outside.txt", b"nope")) +print(f"prepared identity tamper variants in {out_root}") +PY + +for variant in \ + version commit build-date rid missing-identity invalid-json \ + embedded-bin symlink duplicate traversal; do + if python3 "$web_root/Scripts/web-update-manifest.py" write \ + --version 7.25.3 \ + --repository 2dust/v2rayN \ + --commit 0123456789abcdef \ + --build-date 2026-09-28T00:00:00Z \ + --dist "$temporary/identity-tamper/$variant" \ + --output "$temporary/identity-tamper/$variant/WebAPI-update.json" \ + 2>"$temporary/identity-tamper/$variant.log"; then + echo "Expected the $variant identity tamper to be rejected" >&2 + exit 1 + fi + if ! grep -q "app-only archive $update_x64" "$temporary/identity-tamper/$variant.log"; then + echo "The $variant identity tamper was rejected without naming the app-only archive" >&2 + cat "$temporary/identity-tamper/$variant.log" >&2 + exit 1 + fi +done + +printf 'Release packaging dry-run passed\n' diff --git a/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh new file mode 100644 index 00000000..231c4b99 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/Integration/test-startup-security.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_executable="${1:?Usage: test-startup-security.sh }" +source_executable="$(realpath "$source_executable")" +test -x "$source_executable" +command -v curl >/dev/null +command -v python3 >/dev/null + +temporary="$(mktemp -d)" +web_pid="" +cleanup() { + if [[ -n "$web_pid" ]] && kill -0 "$web_pid" 2>/dev/null; then + kill -TERM "$web_pid" 2>/dev/null || true + for _ in {1..20}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + [[ -z "$state" || "$state" == *Z* ]] && break + sleep 0.1 + done + kill -KILL "$web_pid" 2>/dev/null || true + wait "$web_pid" 2>/dev/null || true + fi + rm -rf "$temporary" +} +trap cleanup EXIT HUP INT TERM + +# Run from an isolated copy so a developer's executable-directory .env is not read or copied +# into the test; individual cases create controlled configuration files beside this apphost. +runtime_directory="$temporary/runtime" +source_directory="$(dirname "$source_executable")" +mkdir -p "$runtime_directory" "$temporary/other-working-directory" +for item in "$source_directory"/* "$source_directory"/.[!.]* "$source_directory"/..?*; do + [[ -e "$item" || -L "$item" ]] || continue + [[ "${item##*/}" == ".env" ]] && continue + cp -a -- "$item" "$runtime_directory/" +done +executable="$runtime_directory/v2rayN.WebAPI" +test -x "$executable" + +new_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +assert_rejected_without_key() { + local deployment="$1" data_home="$temporary/$1-data" output="$temporary/$1-rejected.log" status + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + *) echo "Unknown deployment mode: $deployment" >&2; exit 2 ;; + esac + + set +e + if ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_DATA_HOME="$data_home" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + set -e + + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$deployment did not fail closed (exit $status)." >&2; exit 1; } + grep -Fqx 'Management Key is required in supervised/container deployments. Set V2RAYN_WEB_API_KEY before starting v2rayN.WebAPI.' "$output" + test ! -e "$data_home" +} + +wait_for_health() { + local base_url="$1" output="$2" healthy=false + for _ in {1..60}; do + if ! kill -0 "$web_pid" 2>/dev/null; then + cat "$output" >&2 + echo "Web process exited before becoming healthy." >&2 + exit 1 + fi + if curl --silent --show-error --fail "$base_url/api/health" -o "$temporary/health.json" 2>/dev/null \ + && python3 - "$temporary/health.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + assert json.load(handle) == {"status": "ok"} +PY + then + healthy=true + break + fi + sleep 0.5 + done + if [[ "$healthy" != true ]]; then + cat "$output" >&2 + echo "Web process did not become healthy." >&2 + exit 1 + fi +} + +stop_instance() { + local output="$1" stopped=false state exit_code + kill -TERM "$web_pid" + for _ in {1..80}; do + state="$(ps -o stat= -p "$web_pid" 2>/dev/null | tr -d ' ' || true)" + if [[ -z "$state" || "$state" == *Z* ]]; then + stopped=true + break + fi + sleep 0.25 + done + if [[ "$stopped" != true ]]; then + cat "$output" >&2 + echo "Web process did not stop gracefully." >&2 + exit 1 + fi + set +e + wait "$web_pid" + exit_code=$? + set -e + web_pid="" + if [[ "$exit_code" -ne 0 ]]; then + cat "$output" >&2 + echo "Web process exited with status $exit_code." >&2 + exit 1 + fi +} + +assert_rejected_without_key systemd +assert_rejected_without_key container + +assert_remote_listener_rejected() { + local source="$1" output="$temporary/listener-$1.log" status + local settings=() arguments=() + case "$source" in + urls) settings=(ASPNETCORE_URLS=http://0.0.0.0:5080) ;; + ipv6) settings=('ASPNETCORE_URLS=http://[::]:5080') ;; + lan) settings=(ASPNETCORE_URLS=http://192.168.1.20:5080) ;; + multiple) settings=('ASPNETCORE_URLS=http://localhost:5080;http://0.0.0.0:5081') ;; + cli) settings=(ASPNETCORE_URLS=http://localhost:5080); arguments=(--urls http://0.0.0.0:5081) ;; + cli-equals) arguments=(--urls=http://0.0.0.0:5081) ;; + http-ports) settings=(ASPNETCORE_HTTP_PORTS=5080) ;; + https-ports) settings=(ASPNETCORE_HTTPS_PORTS=5443) ;; + kestrel) settings=(ASPNETCORE_URLS=http://localhost:5080 Kestrel__Endpoints__Http__Url=http://0.0.0.0:5081) ;; + esac + if timeout 15 env -u V2RAYN_WEB_API_KEY \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${settings[@]}" V2RAYN_DATA_HOME="$temporary/listener-$source-data" \ + "$executable" --foreground --no-open "${arguments[@]}" >"$output" 2>&1; then + status=0 + else + status=$? + fi + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$source listener did not fail closed (exit $status)." >&2; exit 1; } + grep -Fq 'Management Key is required for non-loopback Web listeners.' "$output" +} + +for source in urls ipv6 lan multiple cli cli-equals http-ports https-ports kestrel; do + assert_remote_listener_rejected "$source" +done + +assert_malformed_configuration_fails() { + local output="$temporary/malformed-env.log" status + printf 'V2RAYN_WEB_API_KEY=must-not-be-echoed"\n' > "$runtime_directory/.env" + if ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container "$executable" --foreground --no-open + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + rm -f "$runtime_directory/.env" + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "Malformed .env did not fail startup (exit $status)." >&2; exit 1; } + grep -Fq 'Invalid v2rayN.WebAPI environment configuration:' "$output" + ! grep -Fq 'must-not-be-echoed' "$output" +} + +assert_malformed_configuration_fails + +assert_invalid_management_key_fails() { + local source="$1" length="$2" key output data_home status + output="$temporary/invalid-key-$source-$length.log" + data_home="$temporary/invalid-key-$source-$length-data" + key="$(python3 -c 'import sys; print("x" * int(sys.argv[1]))' "$length")" + rm -f "$runtime_directory/.env" + if [[ "$source" == dotenv ]]; then + printf 'V2RAYN_WEB_API_KEY=%s\n' "$key" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + fi + + set +e + if ( + cd "$temporary/other-working-directory" + if [[ "$source" == environment ]]; then + exec env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "V2RAYN_WEB_API_KEY=$key" V2RAYN_DATA_HOME="$data_home" \ + "$executable" --foreground --no-open + else + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + V2RAYN_DATA_HOME="$data_home" "$executable" --foreground --no-open + fi + ) >"$output" 2>&1; then + status=0 + else + status=$? + fi + set -e + rm -f "$runtime_directory/.env" + + [[ "$status" -eq 1 ]] || { cat "$output" >&2; echo "$source key with length $length did not fail startup (exit $status)." >&2; exit 1; } + grep -Fqx 'V2RAYN_WEB_API_KEY length must be between 12 and 4096 characters.' "$output" + ! grep -Fq "$key" "$output" + test ! -e "$data_home" +} + +start_with_valid_management_key_boundary() { + local source="$1" length="$2" key port data_home output url + local key_settings=() + key="$(python3 -c 'import sys; print("b" * int(sys.argv[1]))' "$length")" + port="$(new_port)" + data_home="$temporary/valid-key-$source-$length-data" + output="$temporary/valid-key-$source-$length.log" + url="http://127.0.0.1:$port" + rm -f "$runtime_directory/.env" + + if [[ "$source" == dotenv ]]; then + printf 'V2RAYN_WEB_API_KEY=%s\n' "$key" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + else + key_settings=("V2RAYN_WEB_API_KEY=$key") + fi + + ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS -u ASPNETCORE_HTTPS_PORTS \ + "${key_settings[@]}" \ + V2RAYN_DATA_HOME="$data_home" V2RAYN_WEB_AUTOSTART=false ASPNETCORE_URLS="$url" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +assert_invalid_management_key_fails environment 11 +assert_invalid_management_key_fails environment 4097 +assert_invalid_management_key_fails dotenv 11 +assert_invalid_management_key_fails dotenv 4097 +start_with_valid_management_key_boundary environment 12 +start_with_valid_management_key_boundary dotenv 4096 + +# Native interactive first run remains available without SSH or a preconfigured key. +native_port="$(new_port)" +native_data="$temporary/native-data" +native_log="$temporary/native-first-run.log" +native_url="http://127.0.0.1:$native_port" +native_key="first-run-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" +( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + V2RAYN_DATA_HOME="$native_data" \ + V2RAYN_WEB_AUTOSTART=true \ + ASPNETCORE_URLS="$native_url" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/native-bundle" \ + "$executable" --foreground --no-open +) >"$native_log" 2>&1 & +web_pid=$! +wait_for_health "$native_url" "$native_log" + +curl --silent --show-error --fail "$native_url/api/setup/status" >"$temporary/setup-status.json" +python3 - "$temporary/setup-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + status = json.load(handle) +assert status["setupRequired"] is True, status +assert status["setupAllowedFromThisRequest"] is True, status +PY + +setup_body="$(python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1],"confirmKey":sys.argv[1]}))' "$native_key")" +curl --silent --show-error --fail \ + -H 'Content-Type: application/json' \ + --data "$setup_body" \ + "$native_url/api/setup" >"$temporary/setup.json" +session_token="$(python3 - "$temporary/setup.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["setupRequired"] is False, response +print(response["token"]) +PY +)" +curl --silent --show-error --fail \ + -H "Authorization: Bearer $session_token" \ + "$native_url/api/status" >"$temporary/native-status.json" +python3 - "$temporary/native-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY +grep -Fq 'Core autostart was suppressed until first-run Management Key setup is complete.' "$native_log" +stop_instance "$native_log" + +start_managed_with_key() { + local deployment="$1" port="$2" data_home="$temporary/$1-data" output="$temporary/$1-key.log" url="http://127.0.0.1:$2" + local key="managed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + esac + + env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_WEB_API_KEY="$key" \ + V2RAYN_DATA_HOME="$data_home" V2RAYN_WEB_AUTOSTART=false \ + ASPNETCORE_URLS="$url" DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/$deployment-bundle" \ + "$executable" --foreground --no-open >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + stop_instance "$output" +} + +start_managed_with_dotenv_key() { + local deployment="$1" port="$2" data_home="$temporary/$1-dotenv-data" output="$temporary/$1-dotenv-key.log" url="http://127.0.0.1:$2" + local key="dotenv-managed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local markers=() + case "$deployment" in + systemd) markers=(INVOCATION_ID=startup-security-dotenv-test) ;; + container) markers=(DOTNET_RUNNING_IN_CONTAINER=true) ;; + esac + + printf 'V2RAYN_WEB_API_KEY=%s\nASPNETCORE_URLS=%s\nV2RAYN_WEB_AUTOSTART=false\n' \ + "$key" "$url" > "$runtime_directory/.env" + chmod 600 "$runtime_directory/.env" + ( + cd "$temporary/other-working-directory" + exec env -u V2RAYN_WEB_API_KEY \ + -u V2RAYN_WEB_AUTOSTART -u ASPNETCORE_URLS -u ASPNETCORE_HTTP_PORTS \ + -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + "${markers[@]}" V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/$deployment-dotenv-bundle" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + + python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1]}))' "$key" >"$temporary/$deployment-dotenv-login-request.json" + curl --silent --show-error --fail -H 'Content-Type: application/json' \ + --data-binary "@$temporary/$deployment-dotenv-login-request.json" \ + "$url/api/auth/login" >"$temporary/$deployment-dotenv-login.json" + local session_token + session_token="$(python3 - "$temporary/$deployment-dotenv-login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + curl --silent --show-error --fail -H "Authorization: Bearer $session_token" \ + "$url/api/status" >"$temporary/$deployment-dotenv-status.json" + python3 - "$temporary/$deployment-dotenv-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +assert not status["coreProcessIds"], status +PY + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +start_managed_with_process_environment_precedence() { + local port="$1" dotenv_port="$(new_port)" data_home="$temporary/systemd-precedence-data" + local output="$temporary/systemd-precedence.log" url="http://127.0.0.1:$1" + local dotenv_key="dotenv-shadowed-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + local process_key="process-priority-key-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" + + printf 'V2RAYN_WEB_API_KEY=%s\nASPNETCORE_URLS=http://127.0.0.1:%s\nV2RAYN_WEB_AUTOSTART=true\n' \ + "$dotenv_key" "$dotenv_port" > "$runtime_directory/.env" + ( + cd "$temporary/other-working-directory" + exec env -u INVOCATION_ID -u JOURNAL_STREAM -u NOTIFY_SOCKET \ + -u DOTNET_RUNNING_IN_CONTAINER -u container \ + INVOCATION_ID=startup-security-priority-test \ + V2RAYN_WEB_API_KEY="$process_key" \ + V2RAYN_WEB_AUTOSTART=false ASPNETCORE_URLS="$url" \ + V2RAYN_DATA_HOME="$data_home" \ + DOTNET_BUNDLE_EXTRACT_BASE_DIR="$temporary/systemd-priority-bundle" \ + "$executable" --foreground --no-open + ) >"$output" 2>&1 & + web_pid=$! + wait_for_health "$url" "$output" + + python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1]}))' "$process_key" >"$temporary/systemd-priority-login-request.json" + curl --silent --show-error --fail -H 'Content-Type: application/json' \ + --data-binary "@$temporary/systemd-priority-login-request.json" \ + "$url/api/auth/login" >"$temporary/systemd-priority-login.json" + local session_token + session_token="$(python3 - "$temporary/systemd-priority-login.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +assert response["success"] is True, response +print(response["data"]["token"]) +PY +)" + curl --silent --show-error --fail -H "Authorization: Bearer $session_token" \ + "$url/api/status" >"$temporary/systemd-priority-status.json" + python3 - "$temporary/systemd-priority-status.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + response = json.load(handle) +status = response["data"] +assert response["success"] is True, response +assert status["coreRunning"] is False, status +assert status["runtimeState"] == "stopped", status +PY + stop_instance "$output" + rm -f "$runtime_directory/.env" +} + +start_managed_with_dotenv_key systemd "$(new_port)" +start_managed_with_dotenv_key container "$(new_port)" +start_managed_with_process_environment_precedence "$(new_port)" +start_managed_with_key systemd "$(new_port)" +start_managed_with_key container "$(new_port)" + +echo "Startup security tests passed (loopback first-run, remote listeners fail-closed across configuration sources, malformed .env rejection, systemd/container keys from .env or process environment)." diff --git a/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs new file mode 100644 index 00000000..7627fd3f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/IpInfoColumnParityTests.cs @@ -0,0 +1,36 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class IpInfoColumnParityTests +{ + [Test] + public async Task IpInfoColumnRequiresConfiguredApiAndDesktopColumnVisibility() + { + await V2rayRuntime.ShouldShowIpInfoColumn("https://ip.example.test", hideColumnIpInfo: false).Should().BeTrue(); + await V2rayRuntime.ShouldShowIpInfoColumn(" ", hideColumnIpInfo: false).Should().BeTrue(); + await V2rayRuntime.ShouldShowIpInfoColumn(string.Empty, hideColumnIpInfo: false).Should().BeFalse(); + await V2rayRuntime.ShouldShowIpInfoColumn("https://ip.example.test", hideColumnIpInfo: true).Should().BeFalse(); + await V2rayRuntime.ShouldShowIpInfoColumn(null, hideColumnIpInfo: true).Should().BeFalse(); + } + + [Test] + public async Task CanonicalSettingsExposeIpColumnVisibilityFromApiUrlAndDesktopHideFlag() + { + var config = SniffingSettingsIntegrationTests.CreateConfig(); + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + var initiallyEmpty = await runtime.GetSettingsAsync(); + await initiallyEmpty.ShowIpInfoColumn.Should().BeFalse(); + + config.SpeedTestItem.IPAPIUrl = "https://ip.example.test"; + var configured = await runtime.GetSettingsAsync(); + await configured.ShowIpInfoColumn.Should().BeTrue(); + + config.UiItem.HideColumnIpInfo = true; + var hiddenByDesktopPreference = await runtime.GetSettingsAsync(); + await hiddenByDesktopPreference.ShowIpInfoColumn.Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs b/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs new file mode 100644 index 00000000..ee81f1db --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/LogBufferTests.cs @@ -0,0 +1,59 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class LogBufferTests +{ + [Test] + public async Task HighVolumeRetentionRemainsBoundedAndClearDropsAllEntries() + { + var logs = new LogBuffer(); + for (var index = 0; index < 50_000; index++) + { + logs.Add("core", $"line {index}"); + } + + var retained = logs.Recent(2000); + await retained.Count.Should().BeEqualTo(2000); + await retained[^1].Message.Should().BeEqualTo("line 49999"); + + logs.Clear(); + await logs.Recent(2000).Count.Should().BeEqualTo(0); + } + + [Test] + public async Task OversizedLogMessagesAreTruncatedAtTheBufferBoundary() + { + var logs = new LogBuffer(); + var entry = logs.Add("core", new string('x', 100_000)); + + await entry.Message.Length.Should().BeLessThanOrEqualTo(16 * 1024); + await entry.Message.Should().Contain("log entry truncated"); + } + + [Test] + public async Task CharacterRetentionIsBoundedIndependentlyOfEntryCount() + { + var logs = new LogBuffer(); + var message = new string('x', 10_000); + for (var index = 0; index < 300; index++) logs.Add("core", message); + + var retained = logs.Recent(2000); + await retained.Count.Should().BeLessThan(2000); + await retained.Sum(item => item.Message.Length + item.Source.Length) + .Should().BeLessThanOrEqualTo(1_000_000); + } + + [Test] + public async Task ClearingLogsAdvancesGenerationForRejectingStaleRealtimeEntries() + { + var logs = new LogBuffer(); + var previous = logs.Add("core", "old entry"); + var generation = logs.Clear(); + var current = logs.Add("core", "new entry"); + + await previous.Generation.Should().BeEqualTo(0); + await generation.Should().BeEqualTo(1); + await current.Generation.Should().BeEqualTo(1); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs new file mode 100644 index 00000000..dd0c1fec --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeEnvironmentFileTests.cs @@ -0,0 +1,159 @@ +using v2rayN.WebAPI.Configuration; + +namespace v2rayN.WebAPI.Tests; + +public class NativeEnvironmentFileTests +{ + [Test] + public async Task MissingFileIsOptionalAndBlankOrCommentOnlyFilesHaveNoEntries() + { + using var directory = new TemporaryDirectory(); + NativeEnvironmentFile.LoadFromExecutableDirectory(directory.Path); + + await NativeEnvironmentFile.Parse(string.Empty).Count.Should().BeEqualTo(0); + await NativeEnvironmentFile.Parse("\n # comment\n\t\n# another comment\n").Count.Should().BeEqualTo(0); + } + + [Test] + public async Task ParserAcceptsPlainAndQuotedValuesWithSpacesEqualsAndHashes() + { + var parsed = NativeEnvironmentFile.Parse(""" + PLAIN=value with spaces = signs # kept as data + DOUBLE=" value with spaces = signs # kept " + SINGLE='single quoted value = # data' + EMPTY= + INLINE_HASH=value#not-a-comment + """); + + await parsed["PLAIN"].Should().BeEqualTo("value with spaces = signs # kept as data"); + await parsed["DOUBLE"].Should().BeEqualTo(" value with spaces = signs # kept "); + await parsed["SINGLE"].Should().BeEqualTo("single quoted value = # data"); + await parsed["EMPTY"].Should().BeEqualTo(string.Empty); + await parsed["INLINE_HASH"].Should().BeEqualTo("value#not-a-comment"); + } + + [Test] + public async Task DuplicateKeysUseFirstOccurrence() + { + var parsed = NativeEnvironmentFile.Parse("VALUE=first\nVALUE=second\n"); + + await parsed["VALUE"].Should().BeEqualTo("first"); + } + + [Test] + public async Task ProcessEnvironmentOverridesDotEnvIncludingAnEmptyValue() + { + var dotenv = new Dictionary(StringComparer.Ordinal) + { + ["TEST_VALUE"] = "from-dotenv", + ["EMPTY_VALUE"] = "from-dotenv", + ["DOTENV_ONLY"] = "from-dotenv", + }; + var process = new Dictionary(StringComparer.Ordinal) + { + ["TEST_VALUE"] = "from-process", + ["EMPTY_VALUE"] = string.Empty, + }; + + var merged = NativeEnvironmentFile.Merge(dotenv, process); + + await merged["TEST_VALUE"].Should().BeEqualTo("from-process"); + await merged["EMPTY_VALUE"].Should().BeEqualTo(string.Empty); + await merged["DOTENV_ONLY"].Should().BeEqualTo("from-dotenv"); + } + + [Test] + public async Task LoaderUsesTheProvidedExecutableDirectoryRatherThanAnotherDirectory() + { + using var directory = new TemporaryDirectory(); + var executableDirectory = Path.Combine(directory.Path, "install"); + var workingDirectory = Path.Combine(directory.Path, "other-working-directory"); + Directory.CreateDirectory(executableDirectory); + Directory.CreateDirectory(workingDirectory); + var variable = $"V2RAYN_DOTENV_PATH_TEST_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(variable, null); + await File.WriteAllTextAsync(Path.Combine(executableDirectory, ".env"), $"{variable}=from-executable-directory\n"); + await File.WriteAllTextAsync(Path.Combine(workingDirectory, ".env"), $"{variable}=from-working-directory\n"); + + try + { + NativeEnvironmentFile.LoadFromExecutableDirectory(executableDirectory); + await Environment.GetEnvironmentVariable(variable).Should().BeEqualTo("from-executable-directory"); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task InvalidKeysQuotesNulShellSyntaxAndOversizedInputsAreRejected() + { + foreach (var invalid in new[] + { + "1BAD=value", + "export KEY=value", + "KEY=\"unterminated", + "KEY='mismatched\"", + "KEY=bare\"quote", + "KEY=first line\nsecond line", + "KEY=\"first line\nsecond line\"", + "KEY=value\0hidden", + "KEY=$(command)", + "KEY=${OTHER_VAR}", + "KEY=$OTHER_VAR", + "KEY=`command`", + "KEY=back\\slash", + }) + { + await IsRejected(invalid).Should().BeTrue(); + } + + await IsRejected(new string('x', NativeEnvironmentFile.MaximumFileBytes + 1)).Should().BeTrue(); + await IsRejected($"VALUE={new string('x', NativeEnvironmentFile.MaximumValueBytes + 1)}").Should().BeTrue(); + } + + [Test] + public async Task MalformedFileErrorIdentifiesLineWithoutEchoingItsContents() + { + var secretLikeValue = "KEY=private-malformed-value\""; + var message = string.Empty; + try + { + _ = NativeEnvironmentFile.Parse($"# safe comment\n{secretLikeValue}\n"); + } + catch (NativeEnvironmentConfigurationException exception) + { + message = exception.Message; + } + + await message.Should().Contain("Line 2"); + await message.Should().NotContain("private-malformed-value"); + } + + private static bool IsRejected(string value) + { + try + { + _ = NativeEnvironmentFile.Parse(value); + return false; + } + catch (NativeEnvironmentConfigurationException) + { + return true; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-dotenv-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs new file mode 100644 index 00000000..bbad34fd --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateFileSwapTests.cs @@ -0,0 +1,140 @@ +using v2rayN.WebAPI.Launcher; +using System.Security.Cryptography; +using System.Text; +using System.IO.MemoryMappedFiles; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateFileSwapTests +{ + private static string ExecutableName => OperatingSystem.IsWindows() ? "v2rayN.WebAPI.exe" : "v2rayN.WebAPI"; + private static string Rid => OperatingSystem.IsWindows() ? "win-x64" : "linux-x64"; + + [Test] + public async Task RollbackDoesNotModifyAnExecutableThatIsStillMemoryMapped() + { + if (!OperatingSystem.IsLinux()) return; + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: true); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); + using var executable = new FileStream(Path.Combine(plan.InstallDirectory, ExecutableName), + FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var mapping = MemoryMappedFile.CreateFromFile(executable, null, 0, + MemoryMappedFileAccess.Read, HandleInheritability.None, leaveOpen: true); + using var view = mapping.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + await view.ReadByte(0).Should().BeEqualTo((byte)'n'); + + NativeWebUpdateHelper.RestorePreviousApp(plan); + + await view.ReadByte(0).Should().BeEqualTo((byte)'n'); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("old-web"); + await Directory.GetFiles(plan.InstallDirectory, ".v2rayn-WebAPI-restore-*").Length.Should().BeEqualTo(0); + } + + [Test] + public async Task ApiSelfUpdatePreservesUnrelatedFilesByteForByte() + { + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: true); + var extraHash = await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra")); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); + + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("new-web"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "extra", "marker.dat"))).Should().BeEqualTo("UNRELATED_FILE_TEST"); + await (await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra"))).Should().BeEqualTo(extraHash); + await Directory.Exists(Path.Combine(plan.BackupDirectory, "extra")).Should().BeFalse(); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "bin", "xray", "xray"))).Should().BeEqualTo("updated-core"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "guiConfigs", "guiNConfig.json"))).Should().BeEqualTo("user-config"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ".env"))).Should().BeEqualTo("V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.Exists(Path.Combine(plan.BackupDirectory, ".env")).Should().BeFalse(); + } + + [Test] + public async Task PartialFileReplacementFailureRestoresOldAppWithoutTouchingCoreFiles() + { + using var directory = new TemporaryDirectory(); + var plan = await CreatePlanAsync(directory.Path, includeNewIdentity: false); + var extraHash = await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra")); + NativeWebUpdateHelper.CopyCurrentAppToBackup(plan); + + var rollback = await NativeWebUpdateWorkflow.ApplyAsync( + () => { NativeWebUpdateHelper.SwapCandidateAppIntoPlace(plan); return Task.CompletedTask; }, + () => Task.FromResult(false), + () => { NativeWebUpdateHelper.RestorePreviousApp(plan); return Task.CompletedTask; }, + () => Task.FromResult(true), + _ => { }, + () => Task.CompletedTask); + + await rollback.Success.Should().BeFalse(); + await rollback.RollbackSucceeded.Should().BeTrue(); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ExecutableName))).Should().BeEqualTo("old-web"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "extra", "marker.dat"))).Should().BeEqualTo("UNRELATED_FILE_TEST"); + await (await HashDirectoryAsync(Path.Combine(plan.InstallDirectory, "extra"))).Should().BeEqualTo(extraHash); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, "bin", "xray", "xray"))).Should().BeEqualTo("updated-core"); + await (await File.ReadAllTextAsync(Path.Combine(plan.InstallDirectory, ".env"))).Should().BeEqualTo("V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.Exists(Path.Combine(plan.BackupDirectory, ".env")).Should().BeFalse(); + } + + private static async Task CreatePlanAsync(string root, bool includeNewIdentity) + { + var install = Path.Combine(root, "native"); + var parent = Path.GetDirectoryName(install)!; + var candidate = Path.Combine(parent, ".v2rayn-WebAPI-candidate-test"); + var backup = Path.Combine(parent, ".v2rayn-WebAPI-backup-test"); + Directory.CreateDirectory(Path.Combine(install, "extra", "nested")); + Directory.CreateDirectory(Path.Combine(install, "bin", "xray")); + Directory.CreateDirectory(Path.Combine(install, "guiConfigs")); + Directory.CreateDirectory(candidate); + await File.WriteAllTextAsync(Path.Combine(install, ExecutableName), "old-web"); + await File.WriteAllTextAsync(Path.Combine(install, "v2rayN.WebAPI.build.json"), "old-identity"); + await File.WriteAllTextAsync(Path.Combine(install, "extra", "marker.dat"), "UNRELATED_FILE_TEST"); + await File.WriteAllTextAsync(Path.Combine(install, "extra", "nested", "test.dat"), "unrelated nested data"); + await File.WriteAllTextAsync(Path.Combine(install, "bin", "xray", "xray"), "updated-core"); + await File.WriteAllTextAsync(Path.Combine(install, "guiConfigs", "guiNConfig.json"), "user-config"); + await File.WriteAllTextAsync(Path.Combine(install, ".env"), "V2RAYN_WEB_API_KEY=private-test-key\n"); + await File.WriteAllTextAsync(Path.Combine(candidate, ExecutableName), "new-web"); + if (includeNewIdentity) + await File.WriteAllTextAsync(Path.Combine(candidate, "v2rayN.WebAPI.build.json"), "new-identity"); + return new NativeWebUpdatePlan( + install, + candidate, + backup, + Path.Combine(root, "instance.lock"), + "http://127.0.0.1:5080/api/health", + [], + "7.25.2-web.2", + "new-commit", + Rid, + "7.25.2-web.1", + Path.Combine(root, "runtime-intent.json"), + Path.Combine(root, "update-progress.json")); + } + + private static async Task HashDirectoryAsync(string path) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal)) + { + hash.AppendData(Encoding.UTF8.GetBytes(Path.GetRelativePath(path, file).Replace(Path.DirectorySeparatorChar, '/'))); + hash.AppendData([0]); + hash.AppendData(await File.ReadAllBytesAsync(file)); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-swap-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs new file mode 100644 index 00000000..907de51d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateHelperTests.cs @@ -0,0 +1,124 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateHelperTests +{ + [Test] + public async Task TransientFileOperationFailuresAreRetriedBeforeTheUpdateGivesUp() + { + var attempts = 0; + NativeWebUpdateHelper.WithTransientFileRetry( + () => + { + attempts++; + if (attempts < 3) + { + throw new IOException("The process cannot access the file because it is being used by another process."); + } + }, + "test file replacement", + retryWindow: TimeSpan.FromSeconds(2), + retryDelay: TimeSpan.FromMilliseconds(10)); + + await (attempts == 3).Should().BeTrue(); + } + + [Test] + public async Task PermanentFileOperationFailuresSurfaceTheOperationAndCauseAfterRetries() + { + var attempts = 0; + IOException? failure = null; + try + { + NativeWebUpdateHelper.WithTransientFileRetry( + () => + { + attempts++; + throw new UnauthorizedAccessException("Access is denied."); + }, + "replacing the installed Web executable 'C:/install/v2rayN.WebAPI.exe'", + retryWindow: TimeSpan.FromMilliseconds(120), + retryDelay: TimeSpan.FromMilliseconds(10)); + } + catch (IOException exception) + { + failure = exception; + } + + await (failure is not null).Should().BeTrue(); + await failure!.Message.Contains("replacing the installed Web executable").Should().BeTrue(); + await failure.Message.Contains("Access is denied.").Should().BeTrue(); + await (attempts > 1).Should().BeTrue(); + } + + [Test] + public async Task OnlyTheInstalledExecutableAndGuidNamedWorkersAreAccepted() + { + await NativeWebUpdateHelper.IsUpdateHelperExecutableName("v2rayN.WebAPI").Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName("v2rayN.WebAPI.exe").Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName( + ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N")).Should().BeTrue(); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName( + ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N") + ".exe").Should().BeTrue(); + foreach (var name in new[] { "", "v2rayN.Web", "other", ".v2rayn-WebAPI-update-helper-", + ".v2rayn-WebAPI-update-helper-not-a-guid", ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("D"), + "../v2rayN.WebAPI", ".v2rayn-WebAPI-update-helper-" + Guid.NewGuid().ToString("N") + ".bak" }) + { + await NativeWebUpdateHelper.IsUpdateHelperExecutableName(name).Should().BeFalse(); + } + } + + [Test] + public async Task WorkerBundleStaysBesideTheInstallAndSurvivesExecutableReplacement() + { + var directory = Path.Combine(Path.GetTempPath(), $"webapi-helper-copy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var executable = Path.Combine(directory, OperatingSystem.IsWindows() ? "v2rayN.WebAPI.exe" : "v2rayN.WebAPI"); + await File.WriteAllTextAsync(executable, "original-bundle"); + var helper = NativeWebUpdateHelper.CreateIsolatedHelperExecutable(executable); + await Path.GetDirectoryName(helper).Should().BeEqualTo(directory); + await NativeWebUpdateHelper.IsUpdateHelperExecutableName(Path.GetFileName(helper)).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + await File.GetUnixFileMode(helper).Should().BeEqualTo( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var candidate = Path.Combine(directory, "candidate"); + await File.WriteAllTextAsync(candidate, "different-bundle-layout"); + File.Move(candidate, executable, overwrite: true); + await (await File.ReadAllTextAsync(helper)).Should().BeEqualTo("original-bundle"); + await (await File.ReadAllTextAsync(executable)).Should().BeEqualTo("different-bundle-layout"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task SymlinkManagedExecutablesCannotCreateWorkers() + { + if (!OperatingSystem.IsLinux()) return; + var directory = Path.Combine(Path.GetTempPath(), $"webapi-helper-link-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var target = Path.Combine(directory, "target"); + await File.WriteAllTextAsync(target, "unchanged"); + var link = Path.Combine(directory, "v2rayN.WebAPI"); + File.CreateSymbolicLink(link, target); + var rejected = false; + try { NativeWebUpdateHelper.CreateIsolatedHelperExecutable(link); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + await Directory.GetFiles(directory, ".v2rayn-WebAPI-update-helper-*").Length.Should().BeEqualTo(0); + await (await File.ReadAllTextAsync(target)).Should().BeEqualTo("unchanged"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs new file mode 100644 index 00000000..21c5dc76 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/NativeWebUpdateWorkflowTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class NativeWebUpdateWorkflowTests +{ + [Test, NotInParallel] + public async Task InFlightHelperProgressIsReReadUntilTheTerminalResult() + { + var previousLocalData = Environment.GetEnvironmentVariable(ServiceLib.Global.LocalAppData); + Environment.SetEnvironmentVariable(ServiceLib.Global.LocalAppData, "0"); + var path = ServiceLib.Common.Utils.GetTempPath("WebAPI-update-progress.json"); + var previous = File.Exists(path) ? await File.ReadAllBytesAsync(path) : null; + try + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var runtime = new v2rayN.WebAPI.Services.V2rayRuntime(null!, null!, null!, null!, null!); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize( + new WebUpdateProgressState("verifying-health", false, false, "7.25.91", null, CoreWasRunning: true), options)); + await runtime.GetWebUpdateProgress()!.IsComplete.Should().BeFalse(); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize( + new WebUpdateProgressState("failed", true, false, "7.25.91", null, true, true), options)); + var terminal = runtime.GetWebUpdateProgress()!; + await terminal.Phase.Should().BeEqualTo("failed"); + await terminal.IsComplete.Should().BeTrue(); + await terminal.RollbackSucceeded.Should().BeEqualTo(true); + await terminal.CoreWasRunning.Should().BeTrue(); + } + finally + { + if (previous is null) File.Delete(path); + else await File.WriteAllBytesAsync(path, previous); + Environment.SetEnvironmentVariable(ServiceLib.Global.LocalAppData, previousLocalData); + } + } + + [Test] + public async Task PersistedProgressKeepsThePreUpdateCoreStateAndAcceptsOlderProgress() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var state = new WebUpdateProgressState("completed", true, true, "7.25.91", null, + CoreWasRunning: true); + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(state, options), options); + await restored!.CoreWasRunning.Should().BeTrue(); + + var legacy = JsonSerializer.Deserialize( + """{"phase":"completed","isComplete":true,"success":true,"version":"7.25.90"}""", options); + await legacy!.CoreWasRunning.Should().BeFalse(); + } + + [Test] + public async Task UpdateHealthRequiresTheOriginalRunningOrStoppedRuntimeIntent() + { + var runningIntent = new v2rayN.WebAPI.Services.RuntimeRestartIntent(true, "removed-profile"); + var recoveredWithFallback = new WebHealthProbeResult( + true, 42, CoreProcessIds: [99], CoreState: "running", WebVersion: "7.25.2-web.9", CoreProfileId: "fallback-profile"); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(runningIntent, recoveredWithFallback).Should().BeTrue(); + + var stoppedIntent = new v2rayN.WebAPI.Services.RuntimeRestartIntent(false, "old-profile"); + var remainsStopped = new WebHealthProbeResult(true, 43, CoreProcessIds: [], CoreState: "stopped"); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(stoppedIntent, remainsStopped).Should().BeTrue(); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(stoppedIntent, + recoveredWithFallback with { CoreState = "running" }).Should().BeFalse(); + await NativeWebUpdateWorkflow.RuntimeIntentIsSatisfied(runningIntent, + remainsStopped with { CoreState = "stopped" }).Should().BeFalse(); + } + + [Test] + public async Task HealthyNewBuildCommitsAndCleansThePreviousFiles() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => { sequence.Add("install-new"); return Task.CompletedTask; }, + () => { sequence.Add("start-new-health-ok"); return Task.FromResult(true); }, + () => { sequence.Add("restore-old"); return Task.CompletedTask; }, + () => { sequence.Add("start-old"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.Success.Should().BeTrue(); + await result.RollbackSucceeded.Should().BeFalse(); + await sequence.Contains("restore-old").Should().BeFalse(); + await sequence.Last().Should().BeEqualTo("cleanup-backup"); + } + + [Test] + public async Task NewProcessExitOrHealthTimeoutRestoresTheOldHealthyBuild() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => { sequence.Add("install-new"); return Task.CompletedTask; }, + () => { sequence.Add("new-health-timeout"); return Task.FromResult(false); }, + () => { sequence.Add("restore-old-files"); return Task.CompletedTask; }, + () => { sequence.Add("old-health-ok"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeTrue(); + await sequence.IndexOf("restore-old-files").Should().BeGreaterThan(sequence.IndexOf("new-health-timeout")); + await sequence.IndexOf("old-health-ok").Should().BeGreaterThan(sequence.IndexOf("restore-old-files")); + } + + [Test] + public async Task FileReplacementFailureStillAttemptsToRestartTheOldBuild() + { + var sequence = new List(); + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => throw new IOException("simulated replace failure"), + () => { sequence.Add("new-health"); return Task.FromResult(false); }, + () => { sequence.Add("restore-old-files"); return Task.CompletedTask; }, + () => { sequence.Add("old-health-ok"); return Task.FromResult(true); }, + sequence.Add, + () => { sequence.Add("cleanup-backup"); return Task.CompletedTask; }); + + await result.RollbackSucceeded.Should().BeTrue(); + await result.Detail.Should().Contain("simulated replace failure"); + await sequence.Contains("new-health").Should().BeFalse(); + await sequence.Contains("old-health-ok").Should().BeTrue(); + } + + [Test] + public async Task FailedOldBuildHealthIsReportedWithoutDeletingTheBackup() + { + var backupCleaned = false; + var result = await NativeWebUpdateWorkflow.ApplyAsync( + () => Task.CompletedTask, + () => Task.FromResult(false), + () => Task.CompletedTask, + () => Task.FromResult(false), + _ => { }, + () => { backupCleaned = true; return Task.CompletedTask; }); + + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeFalse(); + await backupCleaned.Should().BeFalse(); + await result.Detail.Should().Contain("did not become healthy"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs new file mode 100644 index 00000000..63e5e701 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ParityValidationTests.cs @@ -0,0 +1,239 @@ +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class ParityValidationTests +{ + [Test] + public async Task RestoredSubscriptionSelectionIsPreservedOnlyWhenItsSubItemExists() + { + var available = new[] { "subscription-a", "subscription-b" }; + await V2rayRuntime.NormalizeSelectedSubscriptionId("subscription-a", available).Should().BeEqualTo("subscription-a"); + await V2rayRuntime.NormalizeSelectedSubscriptionId("deleted-subscription", available).Should().BeEqualTo(string.Empty); + await V2rayRuntime.NormalizeSelectedSubscriptionId(string.Empty, available).Should().BeEqualTo(string.Empty); + } + + [Test] + public async Task XrayShadowsocksPlainIsAcceptedAndSavedByServiceLib() + { + var profile = CreateShadowsocks(ECoreType.Xray, "plain"); + + var valid = V2rayRuntime.TryValidateProfile(profile, out var code, out _); + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + await profile.IsValid().Should().BeFalse(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(0); + await profile.ConfigVersion.Should().BeEqualTo(4); + } + + [Test] + public async Task SingboxShadowsocksMethodIsAcceptedAndSavedByServiceLib() + { + var method = "aes-128-gcm"; + var profile = CreateShadowsocks(ECoreType.sing_box, method); + + var valid = V2rayRuntime.TryValidateProfile(profile, out var code, out _); + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + await profile.IsValid().Should().BeTrue(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(0); + } + + [Test] + public async Task InvalidShadowsocksMethodIsLeftForServiceLibToReject() + { + var profile = CreateShadowsocks(ECoreType.Xray, "not-a-method"); + + var valid = V2rayRuntime.TryValidateProfile(profile, out _, out _); + await valid.Should().BeTrue(); + + var result = await ConfigHandler.AddShadowsocksServer(new Config(), profile, toFile: false); + + await result.Should().BeEqualTo(-1); + } + + [Test] + public async Task ExistingProfileConfigTypeCannotChange() + { + var existing = CreateShadowsocks(ECoreType.Xray, "plain"); + var unchanged = CreateShadowsocks(ECoreType.Xray, "plain"); + var converted = new ProfileItem { ConfigType = EConfigType.VLESS }; + + await V2rayRuntime.IsConfigTypeUnchanged(unchanged, existing).Should().BeTrue(); + await V2rayRuntime.IsConfigTypeUnchanged(converted, existing).Should().BeFalse(); + } + + [Test] + public async Task HttpHeadersJsonUsesServiceLibParserOnlyForHttpProfiles() + { + var validHttp = CreateProfile(EConfigType.HTTP); + validHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{\"X-Test\":\"value\"}" }); + var emptyHttp = CreateProfile(EConfigType.HTTP); + emptyHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = string.Empty }); + var invalidHttp = CreateProfile(EConfigType.HTTP); + invalidHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{invalid" }); + var invalidNonHttp = CreateProfile(EConfigType.SOCKS); + invalidNonHttp.SetProtocolExtra(new ProtocolExtraItem { HttpHeaders = "{invalid" }); + var originalInvalidHeaders = invalidHttp.ProtoExtra; + + await V2rayRuntime.TryValidateProfile(validHttp, out _, out _).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(emptyHttp, out _, out _).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(invalidHttp, out var code, out _).Should().BeFalse(); + await code.Should().BeEqualTo("profile_http_headers_invalid"); + await (invalidHttp.ProtoExtra == originalInvalidHeaders).Should().BeTrue(); + await V2rayRuntime.TryValidateProfile(invalidNonHttp, out _, out _).Should().BeTrue(); + } + + [Test] + public async Task WebCoreMappingsAreLimitedToUpstreamEditableTypes() + { + var editableTypes = Enum.GetValues() + .Where(V2rayRuntime.IsWebEditableCoreType) + .ToArray(); + var expected = new[] + { + EConfigType.VMess, + EConfigType.Custom, + EConfigType.Shadowsocks, + EConfigType.SOCKS, + EConfigType.VLESS, + EConfigType.Trojan, + EConfigType.Hysteria2, + EConfigType.WireGuard, + }; + await (editableTypes.SequenceEqual(expected)).Should().BeTrue(); + + var validMappings = expected.Select(configType => new CoreTypeMapping(configType, ECoreType.Xray)).ToArray(); + await V2rayRuntime.AreWebCoreTypeMappingsValid(validMappings).Should().BeTrue(); + foreach (var configType in new[] + { + EConfigType.TUIC, EConfigType.HTTP, EConfigType.Anytls, EConfigType.Naive, + EConfigType.Outbound, EConfigType.PolicyGroup, EConfigType.ProxyChain, + }) + { + await V2rayRuntime.AreWebCoreTypeMappingsValid([new(configType, ECoreType.Xray)]).Should().BeFalse(); + } + } + + [Test] + public async Task FixedCoreSettingsOptionsRejectUnknownValuesAndAllowEmptyValues() + { + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: "chrome", userAgent: "chrome", mux4SboxProtocol: "h2mux", + xudpProxyUDP443: "reject", fragmentPackets: "tlshello")).Should().BeTrue(); + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: string.Empty, userAgent: string.Empty, mux4SboxProtocol: string.Empty, + xudpProxyUDP443: null, fragmentPackets: null)).Should().BeTrue(); + await V2rayRuntime.AreCoreSettingsOptionsValid(CreateCoreSettings( + fingerprint: "random-user-value", userAgent: "random-user-value", mux4SboxProtocol: "random-user-value", + xudpProxyUDP443: "random-user-value", fragmentPackets: "random-user-value")).Should().BeFalse(); + } + + [Test] + public async Task DestOverrideOnlyAcceptsUpstreamProtocols() + { + await V2rayRuntime.AreDestOverrideProtocolsValid(["http", "tls"]).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid([]).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid(null).Should().BeTrue(); + await V2rayRuntime.AreDestOverrideProtocolsValid(["http", "arbitrary"]).Should().BeFalse(); + } + + [Test] + public async Task RootCertificateProviderAcceptsOnlyDesktopOptionsOrAnEmptyValue() + { + await V2rayRuntime.IsRootCertProviderValid(null).Should().BeTrue(); + await V2rayRuntime.IsRootCertProviderValid(string.Empty).Should().BeTrue(); + foreach (var provider in Global.RootCertProviders) + { + await V2rayRuntime.IsRootCertProviderValid(provider).Should().BeTrue(); + } + await V2rayRuntime.IsRootCertProviderValid("not-a-desktop-provider").Should().BeFalse(); + } + + [Test] + public async Task SettingsApiDoesNotInventDesktopEditorFieldsForHiddenServiceLibValues() + { + await (typeof(CoreSettingsView).GetProperty("Mux4SboxMaxConnections") is null).Should().BeTrue(); + await (typeof(CoreSettingsInput).GetProperty("Mux4SboxMaxConnections") is null).Should().BeTrue(); + await (typeof(SpeedTestSettingsView).GetProperty("SpeedTestPageSize") is null).Should().BeTrue(); + await (typeof(SpeedTestSettingsInput).GetProperty("SpeedTestDelayInterval") is null).Should().BeTrue(); + } + + [Test] + public async Task RoutingProfileStrategiesUseTheTwoUpstreamOptionSets() + { + foreach (var strategy in new[] { string.Empty, "AsIs", "IPIfNonMatch", "IPOnDemand" }) + { + await V2rayRuntime.AreRoutingProfileStrategiesValid(strategy, string.Empty).Should().BeTrue(); + } + foreach (var strategy in new[] { "", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only" }) + { + await V2rayRuntime.AreRoutingProfileStrategiesValid("AsIs", strategy).Should().BeTrue(); + } + await V2rayRuntime.AreRoutingProfileStrategiesValid("UseIP", "random-value").Should().BeFalse(); + } + + [Test] + public async Task FullConfigTemplateTunConfigCanBeEditedSavedAndReloadedWithoutLoss() + { + var current = new FullConfigTemplateItem + { + Id = "template-id", + Remarks = "custom template", + Enabled = true, + CoreType = ECoreType.Xray, + Config = "main-template", + TunConfig = "old-tun-template", + AddProxyOnly = true, + ProxyDetour = "upstream", + }; + var saved = V2rayRuntime.ApplyCoreConfigTemplateInput(current, ECoreType.Xray, + new CoreConfigTemplateInput(current.Remarks, current.Enabled, current.Config, "new-tun-template", current.AddProxyOnly, current.ProxyDetour)); + var reloaded = V2rayRuntime.ToCoreConfigTemplateView(saved); + + await reloaded.Config.Should().BeEqualTo("main-template"); + await reloaded.TunConfig.Should().BeEqualTo("new-tun-template"); + await reloaded.AddProxyOnly.Should().BeEqualTo(true); + await reloaded.ProxyDetour.Should().BeEqualTo("upstream"); + } + + private static ProfileItem CreateShadowsocks(ECoreType coreType, string method) + { + var profile = CreateProfile(EConfigType.Shadowsocks); + profile.CoreType = coreType; + profile.Password = "password"; + profile.ConfigVersion = 3; + profile.SetProtocolExtra(new ProtocolExtraItem { SsMethod = method }); + return profile; + } + + private static ProfileItem CreateProfile(EConfigType configType) => new() + { + ConfigType = configType, + Remarks = "test profile", + Address = "example.test", + Port = 443, + }; + + private static CoreSettingsInput CreateCoreSettings( + string? fingerprint, + string? userAgent, + string? mux4SboxProtocol, + string? xudpProxyUDP443, + string? fragmentPackets) => new( + false, "warning", fingerprint, userAgent, null, null, + null, null, xudpProxyUDP443, mux4SboxProtocol, null, false, + 0, 0, false, false, fragmentPackets, [], [], null); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs new file mode 100644 index 00000000..e7c76f2d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RegionalPresetStagingTests.cs @@ -0,0 +1,678 @@ +using System.Collections.Concurrent; +using System.Net; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Dto; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class RegionalPresetStagingTests +{ + [Test] + public async Task RegionalPresetStagesAllRemoteDnsAndRoutingContentWithoutChangingUserProfileState() + { + var existingDns = CreateDnsRows(); + var responses = CreateRegionalResponses(); + var requestedUrls = new ConcurrentBag(); + var stager = new RegionalPresetStager((url, _, token) => + { + token.ThrowIfCancellationRequested(); + requestedUrls.Add(url); + return Task.FromResult(responses.GetValueOrDefault(url)); + }); + + var staged = await stager.StageAsync( + EPresetType.Russia, + existingDns, + [], + proxy: null, + CancellationToken.None); + + await staged.GeoSourceUrl.Should().BeEqualTo(Global.GeoFilesSources[1]); + await staged.SrsSourceUrl.Should().BeEqualTo(Global.SingboxRulesetSources[1]); + await staged.RouteRulesTemplateSourceUrl.Should().BeEqualTo(Global.RoutingRulesSources[1]); + await staged.DnsItems.Count.Should().BeEqualTo(2); + await (staged.DnsItems[0].Id == "xray-id" && staged.DnsItems[0].Enabled && staged.DnsItems[0].Remarks == "my Xray DNS").Should().BeTrue(); + await (staged.DnsItems[0].NormalDNS == "xray-normal-content" && staged.DnsItems[0].TunDNS == "xray-tun-content").Should().BeTrue(); + await (staged.DnsItems[1].Id == "singbox-id" && !staged.DnsItems[1].Enabled && staged.DnsItems[1].Remarks == "my sing-box DNS").Should().BeTrue(); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo("https://dns.example.test/remote"); + await staged.Routing.TemplateVersion.Should().BeEqualTo("R1"); + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("R1-Region rules"); + await existingDns[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await requestedUrls.Count.Should().BeEqualTo(7); + } + + [Test] + public async Task DefaultPresetStagesOnlyLocalDefaultsAndDoesNotInvokeDownloader() + { + var downloaded = false; + var stager = new RegionalPresetStager((_, _, _) => + { + downloaded = true; + return Task.FromResult(null); + }); + + var staged = await stager.StageAsync(EPresetType.Default, [], [], null, CancellationToken.None); + + await downloaded.Should().BeFalse(); + await staged.DnsItems.Count.Should().BeEqualTo(2); + await staged.DnsItems.All(item => !item.Enabled).Should().BeTrue(); + await staged.GeoSourceUrl.Should().BeEqualTo(string.Empty); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task SlowFailingPresetStagesDesktopFallbacksOutsideCoreAndMutationGates() + { + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failDownload = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var coreGate = new SemaphoreSlim(1, 1); + var mutationGate = new SemaphoreSlim(1, 1); + var config = SniffingSettingsIntegrationTests.CreateConfig(); + config.ConstItem.GeoSourceUrl = "original-geo-source"; + SniffingSettingsIntegrationTests.BindAppManagerConfig(config); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var dnsRows = new List { "original dns" }; + var routingRows = new List { "original routing" }; + var applyCalled = false; + RegionalPresetStage? capturedStage = null; + var stager = new RegionalPresetStager(async (_, _, token) => + { + downloadStarted.TrySetResult(); + await failDownload.Task.WaitAsync(token); + throw new IOException("simulated download failure"); + }, TimeSpan.FromSeconds(2)); + var workflow = RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, token), + (stage, _) => + { + applyCalled = true; + capturedStage = stage; + return Task.FromResult(true); + }, + CancellationToken.None); + + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + var settingsRead = await runtime.GetSettingsAsync(); + await settingsRead.App.GeoSourceUrl.Should().BeEqualTo("original-geo-source"); + await coreGate.WaitAsync(TimeSpan.Zero); + coreGate.Release(); + await mutationGate.WaitAsync(TimeSpan.Zero); + mutationGate.Release(); + + failDownload.TrySetResult(); + await workflow; + await applyCalled.Should().BeTrue(); + await capturedStage.Should().NotBeNull(); + await capturedStage!.DnsItems[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await capturedStage.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(ConfigHandler.InitBuiltinSimpleDNS().RemoteDNS); + await capturedStage.Routing.IsBuiltinFallback.Should().BeTrue(); + await capturedStage.Routing.Items.Count.Should().BeEqualTo(3); + await config.ConstItem.GeoSourceUrl.Should().BeEqualTo("original-geo-source"); + await dnsRows.SequenceEqual(new[] { "original dns" }).Should().BeTrue(); + await routingRows.SequenceEqual(new[] { "original routing" }).Should().BeTrue(); + } + + [Test] + public async Task RequestCancellationCancelsStagingBeforeApply() + { + using var cancellation = new CancellationTokenSource(); + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var applyCalled = false; + var stager = new RegionalPresetStager(async (_, _, token) => + { + downloadStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return null; + }); + var workflow = RegionalPresetWorkflow.RunAsync( + token => stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, token), + (_, _) => + { + applyCalled = true; + return Task.FromResult(true); + }, + cancellation.Token); + + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + cancellation.Cancel(); + var cancelled = false; + try + { + await workflow; + } + catch (OperationCanceledException) + { + cancelled = true; + } + + await cancelled.Should().BeTrue(); + await applyCalled.Should().BeFalse(); + } + + [Test] + public async Task FailedApplyRunsRollbackAndCannotTriggerCoreRestart() + { + var configValue = "before"; + var dnsRows = new List { "dns-before" }; + var routingRows = new List { "routing-before" }; + var restartCalled = false; + + var result = await RegionalPresetTransaction.RunAsync( + async () => + { + configValue = "after"; + dnsRows[0] = "dns-after"; + routingRows[0] = "routing-after"; + await Task.Yield(); + throw new IOException("simulated persistence failure"); + }, + () => + { + configValue = "before"; + dnsRows[0] = "dns-before"; + routingRows[0] = "routing-before"; + return Task.FromResult(true); + }); + + if (result.Success) restartCalled = true; + await result.Success.Should().BeFalse(); + await result.RollbackSucceeded.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + await configValue.Should().BeEqualTo("before"); + await dnsRows.SequenceEqual(new[] { "dns-before" }).Should().BeTrue(); + await routingRows.SequenceEqual(new[] { "routing-before" }).Should().BeTrue(); + } + + [Test] + public async Task DefaultRussiaAndIranApplySourcesBeforeGeoUpdateAndCoreCompletion() + { + foreach (var (preset, sourceIndex) in new[] + { + (EPresetType.Default, -1), + (EPresetType.Russia, 1), + (EPresetType.Iran, 2), + }) + { + var responses = sourceIndex < 0 ? new Dictionary() : CreateRegionalResponses(sourceIndex); + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + var stage = await stager.StageAsync( + preset, + sourceIndex < 0 ? [] : CreateDnsRows(), + [], + null, + CancellationToken.None); + var currentGeoSource = "old-geo-source"; + var currentSrsSource = "old-srs-source"; + var updaterGeoSource = string.Empty; + var updaterSrsSource = string.Empty; + var order = new List(); + + await RegionalPresetApplyWorkflow.RunAsync( + applyConfiguration: _ => + { + currentGeoSource = stage.GeoSourceUrl; + currentSrsSource = stage.SrsSourceUrl; + order.Add("config/db apply"); + return Task.CompletedTask; + }, + updateGeoFilesTransaction: async (beforeCommit, token) => + { + order.Add("GeoFiles update started"); + updaterGeoSource = currentGeoSource; + updaterSrsSource = currentSrsSource; + await Task.Yield(); + order.Add("GeoFiles update completed"); + await beforeCommit(() => Task.CompletedTask, token); + }, + completeCoreApply: (_, _) => + { + order.Add("Core restart"); + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => Task.CompletedTask, + CancellationToken.None); + + await updaterGeoSource.Should().BeEqualTo(sourceIndex < 0 ? string.Empty : Global.GeoFilesSources[sourceIndex]); + await updaterSrsSource.Should().BeEqualTo(sourceIndex < 0 ? string.Empty : Global.SingboxRulesetSources[sourceIndex]); + await order.SequenceEqual(new[] + { + "config/db apply", "GeoFiles update started", "GeoFiles update completed", "Core restart", + }).Should().BeTrue(); + } + } + + [Test] + public async Task RunningCoreRestartsOnceAfterGeoUpdateAndStoppedCoreStaysStopped() + { + foreach (var initialState in new[] { CoreRuntimeState.Running, CoreRuntimeState.Stopped }) + { + var geoUpdates = 0; + var restarts = 0; + var action = CoreSettingsApplyAction.SaveOnly; + + await RegionalPresetApplyWorkflow.RunAsync( + _ => Task.CompletedTask, + async (beforeCommit, token) => + { + geoUpdates++; + await beforeCommit(() => Task.CompletedTask, token); + }, + (_, _) => + { + action = CoreSettingsApplyPolicy.Decide( + initialState, + hasActiveChild: initialState == CoreRuntimeState.Running, + changed: true); + if (action == CoreSettingsApplyAction.Restart) + { + restarts++; + } + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => Task.CompletedTask, + CancellationToken.None); + + await geoUpdates.Should().BeEqualTo(1); + await restarts.Should().BeEqualTo(initialState == CoreRuntimeState.Running ? 1 : 0); + await action.Should().BeEqualTo(initialState == CoreRuntimeState.Running + ? CoreSettingsApplyAction.Restart + : CoreSettingsApplyAction.SaveOnly); + } + } + + [Test] + public async Task GeoFilesFailureRollsBackPresetStateAndSkipsCoreCompletion() + { + using var directory = new TemporaryDirectory(); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var geosite = Path.Combine(directory.Path, "geosite.dat"); + var newSrs = Path.Combine(directory.Path, "geosite-region.srs"); + await File.WriteAllTextAsync(geoip, "old-geoip"); + await File.WriteAllTextAsync(geosite, "old-geosite"); + var configSource = "old-source"; + var dns = "old-dns"; + var routing = "old-routing"; + var restartCalled = false; + var rollbackCalled = false; + var failed = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + dns = "new-dns"; + routing = "new-routing"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoip, geosite, newSrs], + [geoip, geosite], + async _ => + { + await File.WriteAllTextAsync(geoip, "partial-geoip"); + await File.WriteAllTextAsync(geosite, "partial-geosite"); + await File.WriteAllTextAsync(newSrs, "partial-srs"); + throw new IOException("simulated GeoFiles failure"); + }, + token, + beforeCommit), + (_, _) => + { + restartCalled = true; + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => + { + configSource = "old-source"; + dns = "old-dns"; + routing = "old-routing"; + rollbackCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + } + catch (IOException exception) when (exception.Message.Contains("simulated GeoFiles failure", StringComparison.Ordinal)) + { + failed = true; + } + + await failed.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await dns.Should().BeEqualTo("old-dns"); + await routing.Should().BeEqualTo("old-routing"); + await (await File.ReadAllTextAsync(geoip)).Should().BeEqualTo("old-geoip"); + await (await File.ReadAllTextAsync(geosite)).Should().BeEqualTo("old-geosite"); + await File.Exists(newSrs).Should().BeFalse(); + await rollbackCalled.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + } + + [Test] + public async Task GeoDownloadCancellationRollsBackPresetStateAndFilesWithoutCoreRestart() + { + using var directory = new TemporaryDirectory(); + using var cancellation = new CancellationTokenSource(); + var geoip = Path.Combine(directory.Path, "geoip.dat"); + var geosite = Path.Combine(directory.Path, "geosite.dat"); + await File.WriteAllTextAsync(geoip, "old-geoip"); + await File.WriteAllTextAsync(geosite, "old-geosite"); + var configSource = "old-source"; + var rollbackCalled = false; + var restartCalled = false; + var canceled = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoip, geosite], + [geoip, geosite], + async updateToken => + { + await File.WriteAllTextAsync(geoip, "partial-geoip", updateToken); + await File.WriteAllTextAsync(geosite, "partial-geosite", updateToken); + cancellation.Cancel(); + updateToken.ThrowIfCancellationRequested(); + }, + token, + beforeCommit), + (_, _) => + { + restartCalled = true; + return Task.CompletedTask; + }, + rollbackConfigurationAndRuntime: () => + { + configSource = "old-source"; + rollbackCalled = true; + return Task.CompletedTask; + }, + cancellation.Token); + } + catch (OperationCanceledException) + { + canceled = true; + } + + await canceled.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await (await File.ReadAllTextAsync(geoip)).Should().BeEqualTo("old-geoip"); + await (await File.ReadAllTextAsync(geosite)).Should().BeEqualTo("old-geosite"); + await rollbackCalled.Should().BeTrue(); + await restartCalled.Should().BeFalse(); + } + + [Test] + public async Task CoreRestartFailureRestoresPresetStateAndGeoFilesBeforeReturning() + { + using var directory = new TemporaryDirectory(); + var geoFile = Path.Combine(directory.Path, "geoip.dat"); + await File.WriteAllTextAsync(geoFile, "old-geoip"); + var configSource = "old-source"; + var runtimeState = CoreRuntimeState.Running; + var restartAttempts = 0; + var settingsRollbackCalled = false; + var failed = false; + + try + { + await RegionalPresetApplyWorkflow.RunAsync( + _ => + { + configSource = "new-source"; + return Task.CompletedTask; + }, + (beforeCommit, token) => GeoFilesUpdateTransaction.ApplyAsync( + [geoFile], + [geoFile], + updateToken => File.WriteAllTextAsync(geoFile, "new-geoip", updateToken), + token, + beforeCommit), + async (rollbackGeoFiles, _) => + { + restartAttempts++; + await rollbackGeoFiles(); + configSource = "old-source"; + runtimeState = CoreRuntimeState.Running; + throw new RegionalPresetCoreApplyFailedException(); + }, + rollbackConfigurationAndRuntime: () => + { + settingsRollbackCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + } + catch (RegionalPresetCoreApplyFailedException) + { + failed = true; + } + + await failed.Should().BeTrue(); + await configSource.Should().BeEqualTo("old-source"); + await runtimeState.Should().BeEqualTo(CoreRuntimeState.Running); + await restartAttempts.Should().BeEqualTo(1); + await settingsRollbackCalled.Should().BeFalse(); + await (await File.ReadAllTextAsync(geoFile)).Should().BeEqualTo("old-geoip"); + } + + private static List CreateDnsRows() => + [ + new() { Id = "xray-id", CoreType = ECoreType.Xray, Remarks = "my Xray DNS", Enabled = true, NormalDNS = "existing-xray-data" }, + new() { Id = "singbox-id", CoreType = ECoreType.sing_box, Remarks = "my sing-box DNS", Enabled = false, NormalDNS = "existing-singbox-data" }, + ]; + + private static Dictionary CreateRegionalResponses(int sourceIndex = 1) + { + var dnsBase = Global.DNSTemplateSources[sourceIndex]; + var xrayNormalUrl = "https://assets.example.test/xray-normal"; + var xrayTunUrl = "https://assets.example.test/xray-tun"; + var singboxNormalUrl = "https://assets.example.test/singbox-normal"; + var template = new RoutingTemplate + { + Version = "R1", + RoutingItems = + [ + new RoutingItem + { + Remarks = "Region rules", + RuleSet = JsonUtils.Serialize(new List { new() { Remarks = "sample" } }, false), + }, + ], + }; + + return new(StringComparer.Ordinal) + { + [dnsBase + "v2ray.json"] = JsonUtils.Serialize(new DNSItem { NormalDNS = xrayNormalUrl, TunDNS = xrayTunUrl }, false), + [dnsBase + "sing_box.json"] = JsonUtils.Serialize(new DNSItem { NormalDNS = singboxNormalUrl }, false), + [dnsBase + "simple_dns.json"] = JsonUtils.Serialize(new SimpleDNSItem { RemoteDNS = "https://dns.example.test/remote" }, false), + [Global.RoutingRulesSources[sourceIndex]] = JsonUtils.Serialize(template, false), + [xrayNormalUrl] = "xray-normal-content", + [xrayTunUrl] = "xray-tun-content", + [singboxNormalUrl] = "singbox-normal-content", + }; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-regional-preset-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + [Test] + public async Task FailedOrInvalidCoreDnsTemplatePreservesExistingDnsProfile() + { + var existingDns = CreateDnsRows(); + var responses = CreateRegionalResponses(); + var dnsBase = Global.DNSTemplateSources[1]; + responses[dnsBase + "v2ray.json"] = null; + responses[dnsBase + "sing_box.json"] = "not-json"; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, existingDns, [], null, CancellationToken.None); + + await (staged.DnsItems[0].Id == existingDns[0].Id + && staged.DnsItems[0].Remarks == existingDns[0].Remarks + && staged.DnsItems[0].Enabled == existingDns[0].Enabled + && staged.DnsItems[0].NormalDNS == existingDns[0].NormalDNS).Should().BeTrue(); + await (staged.DnsItems[1].Id == existingDns[1].Id + && staged.DnsItems[1].Enabled == existingDns[1].Enabled + && staged.DnsItems[1].NormalDNS == existingDns[1].NormalDNS).Should().BeTrue(); + await existingDns[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + } + + [Test] + public async Task DownloadedDnsTemplateWithFailedExternalResourceKeepsTemplateAndLeavesFailedFieldEmpty() + { + var responses = CreateRegionalResponses(); + responses["https://assets.example.test/xray-normal"] = null; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.DnsItems[0].NormalDNS.Should().BeEqualTo(string.Empty); + await staged.DnsItems[0].TunDNS.Should().BeEqualTo("xray-tun-content"); + await staged.DnsItems[0].Enabled.Should().BeTrue(); + } + + [Test] + public async Task FailedSimpleDnsUsesDesktopBuiltinAndEnablesBothCoreDnsProfiles() + { + var responses = CreateRegionalResponses(); + responses[Global.DNSTemplateSources[1] + "simple_dns.json"] = null; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + var builtin = ConfigHandler.InitBuiltinSimpleDNS(); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(builtin.RemoteDNS); + await staged.SimpleDnsItem.DirectDNS.Should().BeEqualTo(builtin.DirectDNS); + await staged.DnsItems.All(item => item.Enabled).Should().BeTrue(); + } + + [Test] + public async Task InvalidRoutingTemplateStagesBuiltinFallbackAndMakesFirstBuiltinDefaultWhenNoRoutesExist() + { + var responses = CreateRegionalResponses(); + responses[Global.RoutingRulesSources[1]] = "not-json"; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(3); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("V4-绕过大陆(Whitelist)"); + await staged.Routing.DefaultRemarks.Should().BeEqualTo(staged.Routing.Items[0].Remarks); + await staged.Routing.Items.All(item => item.RuleNum > 0).Should().BeTrue(); + await staged.Routing.ExistingDefaultItemId.Should().BeNull(); + } + + [Test] + public async Task RoutingTemplateFallbackPreservesExistingActiveRouteLikeDesktopBuiltinInitialization() + { + var responses = CreateRegionalResponses(); + responses[Global.RoutingRulesSources[1]] = null; + var active = new RoutingItem { Id = "active", Remarks = "user route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + await staged.Routing.DefaultRemarks.Should().BeNull(); + await staged.Routing.ExistingDefaultItemId.Should().BeNull(); + } + + [Test] + public async Task NewRegionalTemplatePlanActivatesFirstNewRouteEvenWhenAnotherRouteIsActive() + { + var responses = CreateRegionalResponses(); + var active = new RoutingItem { Id = "existing", Remarks = "user route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.DefaultRemarks.Should().BeEqualTo("R1-Region rules"); + await active.IsActive.Should().BeTrue(); + } + + [Test] + public async Task ImportedRoutingTemplateVersionStagesNoDuplicateAndNoDefaultSwitch() + { + var responses = CreateRegionalResponses(); + var active = new RoutingItem { Id = "imported", Remarks = "R1-previous route", IsActive = true }; + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [active], null, CancellationToken.None); + + await staged.Routing.TemplateVersion.Should().BeEqualTo("R1"); + await staged.Routing.Items.Count.Should().BeEqualTo(0); + await staged.Routing.DefaultRemarks.Should().BeNull(); + await active.IsActive.Should().BeTrue(); + } + + [Test] + public async Task SingleRoutingRuleDownloadFailureSkipsOnlyThatEntryAndActivatesFirstStagedRule() + { + var responses = CreateRegionalResponses(); + var template = new RoutingTemplate + { + Version = "R2", + RoutingItems = + [ + new RoutingItem { Remarks = "unavailable", Url = "https://rules.example.test/missing" }, + new RoutingItem { Remarks = "available", Url = "https://rules.example.test/available" }, + ], + }; + responses[Global.RoutingRulesSources[1]] = JsonUtils.Serialize(template, false); + responses["https://rules.example.test/missing"] = null; + responses["https://rules.example.test/available"] = JsonUtils.Serialize(new List { new() { Remarks = "ok" } }, false); + var stager = new RegionalPresetStager((url, _, _) => Task.FromResult(responses.GetValueOrDefault(url))); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.Routing.Items.Count.Should().BeEqualTo(1); + await staged.Routing.Items[0].Remarks.Should().BeEqualTo("R2-available"); + await staged.Routing.DefaultRemarks.Should().BeEqualTo("R2-available"); + } + + [Test] + public async Task PerDownloadTimeoutUsesDesktopFallbackInsteadOfFailingPreset() + { + var stager = new RegionalPresetStager(async (_, _, token) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return null; + }, TimeSpan.FromMilliseconds(20)); + + var staged = await stager.StageAsync(EPresetType.Russia, CreateDnsRows(), [], null, CancellationToken.None); + + await staged.DnsItems[0].NormalDNS.Should().BeEqualTo("existing-xray-data"); + await staged.SimpleDnsItem.RemoteDNS.Should().BeEqualTo(ConfigHandler.InitBuiltinSimpleDNS().RemoteDNS); + await staged.Routing.IsBuiltinFallback.Should().BeTrue(); + await staged.Routing.Items.Count.Should().BeEqualTo(3); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs new file mode 100644 index 00000000..2bf665e0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RestoreRuntimeRecoveryTests.cs @@ -0,0 +1,449 @@ +using System.Text.Json; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeRestartRecoveryTests +{ + [Test] + public async Task RestoreMarkerWithStoppedCoreSuppressesAutostart() + { + var started = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + new RuntimeRestartIntent(false, "old"), + "restored", + "default", + (_, _) => Task.FromResult(true), + (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + _ => { }, + CancellationToken.None); + + await (result.Source == RuntimeRestartRecoverySource.NotRequested).Should().BeTrue(); + await (started.Count == 0).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(new RuntimeRestartIntent(false, null), configured: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true, setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: false).Should().BeTrue(); + await RuntimeRestartRecovery.ShouldAutoStart(null, configured: true, setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: true).Should().BeFalse(); + await RuntimeRestartRecovery.ShouldRecoverCore(new RuntimeRestartIntent(true, "selected"), setupRequired: false).Should().BeTrue(); + } + + [Test] + public async Task RunningCoreRestoresThePreviousProfileWhenItStillExists() + { + var (result, started, _) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "selected", + "default", + ["old", "selected", "default"]); + + await (result.Source == RuntimeRestartRecoverySource.PreferredProfile).Should().BeTrue(); + await (result.ProfileId == "old").Should().BeTrue(); + await started.Contains("old").Should().BeTrue(); + await started.Count.Should().BeEqualTo(1); + } + + [Test] + public async Task MissingPreviousProfileFallsBackToRestoredSelectedProfile() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "selected", + "default", + ["selected", "default"]); + + await (result.Source == RuntimeRestartRecoverySource.SelectedProfile).Should().BeTrue(); + await (result.ProfileId == "selected").Should().BeTrue(); + await started.Contains("selected").Should().BeTrue(); + await logs.Any(log => log.Contains("Previous profile old no longer exists", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("Falling back to selected profile selected", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task MissingPreviousAndSelectedProfilesFallBackToServiceLibDefault() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "missing-selected", + "default", + ["default"]); + + await (result.Source == RuntimeRestartRecoverySource.DefaultProfile).Should().BeTrue(); + await (result.ProfileId == "default").Should().BeTrue(); + await started.Contains("default").Should().BeTrue(); + await logs.Any(log => log.Contains("Falling back to the ServiceLib default profile default", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task RestoreWithNoAvailableProfileKeepsCoreStoppedAndLogsContext() + { + var (result, started, logs) = await RecoverAsync( + new RuntimeRestartIntent(true, "old"), + "missing-selected", + null, + []); + + await (result.Source == RuntimeRestartRecoverySource.NoProfile).Should().BeTrue(); + await (started.Count == 0).Should().BeTrue(); + await logs.Any(log => log.Contains("no valid profile exists in the current configuration", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("Core remains stopped", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task FailedFallbackPreflightIsNotReportedAsSuccessOrRetried() + { + var starts = new List(); + var logs = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + new RuntimeRestartIntent(true, "removed"), + "also-removed", + "fallback", + (id, _) => Task.FromResult(id == "fallback"), + (id, _) => + { + starts.Add(id); + return Task.FromResult(OperationView.Fail("profile_validation_failed", "errors.invalidInput")); + }, + logs.Add, + CancellationToken.None); + + await (result.Source == RuntimeRestartRecoverySource.StartFailed).Should().BeTrue(); + await (result.FailureCode == "profile_validation_failed").Should().BeTrue(); + await starts.Contains("fallback").Should().BeTrue(); + await starts.Count.Should().BeEqualTo(1); + await logs.Any(log => log.Contains("No further restart will be attempted", StringComparison.Ordinal)).Should().BeTrue(); + await (!logs.Any(log => log.Contains("Core restored successfully", StringComparison.Ordinal))).Should().BeTrue(); + } + + [Test] + public async Task RestoreRuntimeMarkerSurvivesFailedInitializationAndIsConsumedAfterRecovery() + { + var markerJson = JsonSerializer.Serialize(new RuntimeRestartIntent(true, "old")); + foreach (var reverseCreationOrder in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var newest = DateTime.UtcNow.AddMinutes(-1); + var claims = new[] + { + (Name: "consuming-abandoned", Json: JsonSerializer.Serialize(new RuntimeRestartIntent(false, null)), Time: newest.AddMinutes(-1)), + (Name: "consuming-prior", Json: markerJson, Time: newest), + }; + foreach (var claim in reverseCreationOrder ? claims.Reverse() : claims) + { + var claimPath = path + "." + claim.Name; + await File.WriteAllTextAsync(claimPath, claim.Json); + File.SetLastWriteTimeUtc(claimPath, claim.Time); + } + var invalidClaimPath = path + ".consuming-invalid-stale"; + await File.WriteAllTextAsync(invalidClaimPath, "not-json"); + File.SetLastWriteTimeUtc(invalidClaimPath, newest.AddMinutes(1)); + + // Simulate initialization failing after the newest valid claim has been requeued. + var firstStartupFailed = false; + try + { + _ = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + throw new InvalidOperationException("simulated initialization failure"); + } + catch (InvalidOperationException) + { + firstStartupFailed = true; + } + + var secondStartup = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + await firstStartupFailed.Should().BeTrue(); + await (secondStartup is { WasRunning: true, PreferredProfileId: "old" }).Should().BeTrue(); + await File.Exists(path).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + } + + using (var directory = new TemporaryDirectory()) + { + var stoppedIntentPath = Path.Combine(directory.Path, "restore-stopped.json"); + var stoppedIntent = new RuntimeRestartIntent(false, null); + await File.WriteAllTextAsync(stoppedIntentPath, JsonSerializer.Serialize(stoppedIntent)); + try + { + _ = await V2rayRuntime.LoadRuntimeRestartIntentAsync(stoppedIntentPath, CancellationToken.None); + throw new InvalidOperationException("simulated stopped-intent initialization failure"); + } + catch (InvalidOperationException) { } + var stoppedOnSecondStartup = await V2rayRuntime.LoadRuntimeRestartIntentAsync(stoppedIntentPath, CancellationToken.None); + await (stoppedOnSecondStartup is { WasRunning: false }).Should().BeTrue(); + await File.Exists(stoppedIntentPath).Should().BeTrue(); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(stoppedIntentPath); + await File.Exists(stoppedIntentPath).Should().BeFalse(); + } + await markerJson.Should().Contain("PreferredProfileId"); + await (!markerJson.Contains("ProcessIds", StringComparison.Ordinal) + && !markerJson.Contains("ApiPort", StringComparison.Ordinal) + && !markerJson.Contains("Listeners", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task RestoreClaimTieUsesTheMoreInformativeStateRegardlessOfCreationOrder() + { + foreach (var reverseCreationOrder in new[] { false, true }) + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var sameTimestamp = DateTime.UtcNow.AddMinutes(-1); + var claims = new[] + { + (Name: "consuming-a-stopped", Intent: new RuntimeRestartIntent(false, null)), + (Name: "consuming-z-running", Intent: new RuntimeRestartIntent(true, "old")), + }; + foreach (var claim in reverseCreationOrder ? claims.Reverse() : claims) + { + var claimPath = path + "." + claim.Name; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(claim.Intent)); + File.SetLastWriteTimeUtc(claimPath, sameTimestamp); + } + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + await (recovered is { WasRunning: true, PreferredProfileId: "old" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + } + + [Test] + public async Task ValidPrimaryMarkerWinsOverAbandonedClaimsAndCleansThemUp() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + var primary = new RuntimeRestartIntent(false, "stopped-profile"); + var claim = new RuntimeRestartIntent(true, "newer-claim"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(primary)); + var claimPath = path + ".consuming-abandoned"; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(claim)); + File.SetLastWriteTimeUtc(claimPath, DateTime.UtcNow.AddMinutes(1)); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is { WasRunning: false, PreferredProfileId: "stopped-profile" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task InvalidPrimaryMarkerIsReplacedByTheNewestValidClaim() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + await File.WriteAllTextAsync(path, "not-json"); + var claimPath = path + ".consuming-recoverable"; + var expected = new RuntimeRestartIntent(true, "recovered-profile"); + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(expected)); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is { WasRunning: true, PreferredProfileId: "recovered-profile" }).Should().BeTrue(); + await Directory.GetFiles(directory.Path, "*.consuming-*").Length.Should().BeEqualTo(0); + await V2rayRuntime.CommitRestoreRuntimeStateAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task FailedClaimMoveDoesNotDeleteTheOnlyValidClaim() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "restore-state.json"); + Directory.CreateDirectory(path); + var claimPath = path + ".consuming-recoverable"; + await File.WriteAllTextAsync(claimPath, JsonSerializer.Serialize(new RuntimeRestartIntent(true, "recoverable"))); + + var recovered = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + + await (recovered is null).Should().BeTrue(); + await File.Exists(claimPath).Should().BeTrue(); + } + + [Test] + public async Task WebUpdateRuntimeIntentRemainsUntilTheExternalHealthVerifierCommitsIt() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "WebAPI-update-runtime-state.json"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(new RuntimeRestartIntent(true, "removed"))); + var intent = await V2rayRuntime.LoadRuntimeRestartIntentAsync(path, CancellationToken.None); + var started = new List(); + var recovery = await RuntimeRestartRecovery.RecoverAsync( + intent!, + selectedProfileId: "restored", + defaultProfileId: "default", + profileExists: (id, _) => Task.FromResult(id is "restored" or "default"), + startCore: (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + _ => { }, + CancellationToken.None); + + await (recovery.Source == RuntimeRestartRecoverySource.SelectedProfile).Should().BeTrue(); + await started.SequenceEqual(["restored"]).Should().BeTrue(); + await File.Exists(path).Should().BeTrue(); + await V2rayRuntime.DeleteRuntimeIntentAsync(path); + await File.Exists(path).Should().BeFalse(); + } + + private static async Task<(RuntimeRestartRecoveryResult Result, List Started, List Logs)> RecoverAsync( + RuntimeRestartIntent intent, + string? selected, + string? defaultProfile, + HashSet existing) + { + var started = new List(); + var logs = new List(); + var result = await RuntimeRestartRecovery.RecoverAsync( + intent, + selected, + defaultProfile, + (id, _) => Task.FromResult(existing.Contains(id)), + (id, _) => + { + started.Add(id); + return Task.FromResult(OperationView.Ok("core.started")); + }, + logs.Add, + CancellationToken.None); + return (result, started, logs); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-restore-intent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} + +public class FailedRestoreRuntimeRecoveryTests +{ + [Test] + public async Task TrackedCoreProcessKeepsTheRuntimeFaultedWithoutStartingAnotherCore() + { + var started = new List(); + var faulted = new List<(int[] ProcessIds, string Failure)>(); + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [4242], + (processIds, failure) => faulted.Add((processIds, failure)), + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + logs.Add, + CancellationToken.None); + + await (result is null).Should().BeTrue(); + await started.Count.Should().BeEqualTo(0); + await faulted.Count.Should().BeEqualTo(1); + await faulted[0].ProcessIds.SequenceEqual([4242]).Should().BeTrue(); + await faulted[0].Failure + .Contains("could not confirm that the previous Core had stopped", StringComparison.Ordinal) + .Should().BeTrue(); + await logs.Any(log => log.Contains("could not confirm", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task StoppedCoreWithAnUnrelatedPortOwnerStillAttemptsTheNormalStart() + { + // Web's tracked Core is gone. An unrelated program has taken over the old proxy + // port; that listener must not be mistaken for the previous Core surviving. + var started = new List(); + var faulted = false; + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [], + (_, _) => faulted = true, + (profileId, _) => + { + started.Add(profileId); + // The normal launch preflight owns the port-conflict decision. + return Task.FromResult(OperationView.Fail( + "proxy_port_in_use", + ApiMessageKeys.CorePortInUse, + new { port = 10808 })); + }, + logs.Add, + CancellationToken.None); + + await faulted.Should().BeFalse(); + await started.SequenceEqual(["old"]).Should().BeTrue(); + await (result?.Code == "proxy_port_in_use").Should().BeTrue(); + await logs.Any(log => log.Contains("proxy_port_in_use", StringComparison.Ordinal)).Should().BeTrue(); + await logs.Any(log => log.Contains("could not confirm", StringComparison.Ordinal)).Should().BeFalse(); + } + + [Test] + public async Task FailedRestoreWithStoppedRuntimeIntentNeitherStartsNorFaultsTheRuntime() + { + var started = new List(); + var faulted = false; + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(false, "old"), + [], + (_, _) => faulted = true, + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + _ => { }, + CancellationToken.None); + + await (result is null).Should().BeTrue(); + await faulted.Should().BeFalse(); + await started.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task SuccessfulTrackedStartIsReportedWithoutAFailureLog() + { + var started = new List(); + var logs = new List(); + + var result = await V2rayRuntime.RecoverRuntimeAfterFailedRestoreAsync( + new RuntimeRestartIntent(true, "old"), + [], + (_, _) => throw new InvalidOperationException("the runtime must not be faulted when no tracked process remains"), + (profileId, _) => + { + started.Add(profileId); + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CoreStarted)); + }, + logs.Add, + CancellationToken.None); + + await (result?.Success).Should().BeTrue(); + await started.SequenceEqual(["old"]).Should().BeTrue(); + await logs.Count.Should().BeEqualTo(0); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs new file mode 100644 index 00000000..c4ca0011 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RoutingParityTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using ServiceLib.Enums; +using ServiceLib.Models.Entities; + +namespace v2rayN.WebAPI.Tests; + +public class RoutingParityTests +{ + [Test] + public async Task RoutingProfileApiEntityCarriesAllDesktopFieldsAcrossJsonRoundTrip() + { + var original = new RoutingItem + { + Id = "route-profile", + Remarks = "locked rules", + Url = "https://rules.example/list.json", + RuleSet = "[]", + RuleNum = 0, + Enabled = false, + Locked = true, + CustomIcon = "custom-icon", + CustomRulesetPath4Singbox = "/srv/rules/custom.srs", + DomainStrategy = "UseIP", + DomainStrategy4Singbox = "prefer_ipv4", + Sort = 19, + IsActive = true, + }; + var json = JsonSerializer.Serialize(original, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var reloaded = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await reloaded.Id.Should().BeEqualTo(original.Id); + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.Url.Should().BeEqualTo(original.Url); + await reloaded.RuleSet.Should().BeEqualTo(original.RuleSet); + await reloaded.RuleNum.Should().BeEqualTo(original.RuleNum); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.Locked.Should().BeEqualTo(original.Locked); + await reloaded.CustomIcon.Should().BeEqualTo(original.CustomIcon); + await reloaded.CustomRulesetPath4Singbox.Should().BeEqualTo(original.CustomRulesetPath4Singbox); + await reloaded.DomainStrategy.Should().BeEqualTo(original.DomainStrategy); + await reloaded.DomainStrategy4Singbox.Should().BeEqualTo(original.DomainStrategy4Singbox); + await reloaded.Sort.Should().BeEqualTo(original.Sort); + await reloaded.IsActive.Should().BeEqualTo(original.IsActive); + } + + [Test] + public async Task RoutingRuleEntityCarriesEveryDesktopMatcherAndControlField() + { + var original = new RulesItem + { + Id = "rule-id", + Type = "field", + Port = "80,443", + Network = "tcp,udp", + InboundTag = ["socks", "tun"], + OutboundTag = "proxy", + Ip = ["geoip:cn"], + Domain = ["domain:example.com"], + Protocol = ["http", "tls"], + Process = ["browser"], + Enabled = false, + Remarks = "web rule", + RuleType = ERuleType.Routing, + }; + var json = JsonSerializer.Serialize(original, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var reloaded = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await reloaded.Id.Should().BeEqualTo(original.Id); + await reloaded.Type.Should().BeEqualTo(original.Type); + await reloaded.Port.Should().BeEqualTo(original.Port); + await reloaded.Network.Should().BeEqualTo(original.Network); + await reloaded.InboundTag!.SequenceEqual(original.InboundTag!).Should().BeTrue(); + await reloaded.OutboundTag.Should().BeEqualTo(original.OutboundTag); + await reloaded.Ip!.SequenceEqual(original.Ip!).Should().BeTrue(); + await reloaded.Domain!.SequenceEqual(original.Domain!).Should().BeTrue(); + await reloaded.Protocol!.SequenceEqual(original.Protocol!).Should().BeTrue(); + await reloaded.Process!.SequenceEqual(original.Process!).Should().BeTrue(); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.RuleType.Should().BeEqualTo(original.RuleType); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs new file mode 100644 index 00000000..dc4df8d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeMutationGateTests.cs @@ -0,0 +1,80 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeMutationGateTests +{ + [Test] + public async Task ConcurrentConfigWritesSerializeTheFixedTemporaryFile() + { + var gate = new RuntimeMutationGate(); + var configPath = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-config-{Guid.NewGuid():N}.json"); + var tempPath = $"{configPath}_temp"; + var sharedConfig = new List(); + var activeWriters = 0; + var maximumConcurrentWriters = 0; + + try + { + var writers = Enumerable.Range(0, 24).Select(value => gate.RunAsync(async () => + { + var active = Interlocked.Increment(ref activeWriters); + UpdateMaximum(ref maximumConcurrentWriters, active); + try + { + sharedConfig.Add(value); + await File.WriteAllTextAsync(tempPath, System.Text.Json.JsonSerializer.Serialize(sharedConfig)); + await Task.Delay(2); + File.Move(tempPath, configPath, overwrite: true); + } + finally + { + Interlocked.Decrement(ref activeWriters); + } + })); + + await Task.WhenAll(writers); + + var persisted = System.Text.Json.JsonSerializer.Deserialize(await File.ReadAllTextAsync(configPath)); + await maximumConcurrentWriters.Should().BeEqualTo(1); + await (persisted is not null).Should().BeTrue(); + await (persisted?.Order().SequenceEqual(Enumerable.Range(0, 24)) == true).Should().BeTrue(); + await File.Exists(tempPath).Should().BeFalse(); + } + finally + { + File.Delete(configPath); + File.Delete(tempPath); + } + } + + private static void UpdateMaximum(ref int maximum, int value) + { + while (true) + { + var current = Volatile.Read(ref maximum); + if (current >= value || Interlocked.CompareExchange(ref maximum, value, current) == current) + { + return; + } + } + } + + [Test] + [Arguments(-1)] + [Arguments(1)] + public async Task SaveFailureCannotBeTreatedAsSuccess(int saveResult) + { + var failed = false; + try + { + await V2rayRuntime.EnsureConfigSaveSucceededAsync(() => Task.FromResult(saveResult)); + } + catch (IOException) + { + failed = true; + } + + await failed.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs new file mode 100644 index 00000000..75cbb4c9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeOperationCoordinatorTests.cs @@ -0,0 +1,129 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeOperationCoordinatorTests +{ + [Test] + public async Task ExclusiveLeaseBlocksNewSharedOperations() + { + var coordinator = new RuntimeOperationCoordinator(); + var exclusive = await coordinator.EnterExclusiveAsync(); + var pending = coordinator.EnterOperationAsync().AsTask(); + + await Task.Delay(25); + await pending.IsCompleted.Should().BeFalse(); + + await exclusive.DisposeAsync(); + await using var shared = await pending; + await shared.Token.IsCancellationRequested.Should().BeFalse(); + } + + [Test] + public async Task RequestUsesItsExistingSharedLeaseWhenAnExclusiveWaiterArrives() + { + var coordinator = new RuntimeOperationCoordinator(); + var requestLease = await coordinator.EnterOperationAsync(); + var exclusiveTask = coordinator.EnterExclusiveAsync().AsTask(); + await Task.Delay(25); + await exclusiveTask.IsCompleted.Should().BeFalse(); + + // Runtime work executes under the middleware-owned lease. It must not acquire another + // shared lease here: an exclusive waiter would otherwise block this work from finishing + // and the outer request could never release its lease. + var requestWork = Task.Run(async () => + { + requestLease.Token.ThrowIfCancellationRequested(); + await Task.Yield(); + return true; + }); + await (await requestWork.WaitAsync(TimeSpan.FromSeconds(2))).Should().BeTrue(); + + // A later HTTP request queues behind the exclusive waiter. + var nextRequestTask = coordinator.EnterOperationAsync().AsTask(); + await Task.Delay(25); + await nextRequestTask.IsCompleted.Should().BeFalse(); + + await requestLease.DisposeAsync(); + await using (var maintenance = await exclusiveTask.WaitAsync(TimeSpan.FromSeconds(2))) + { + await maintenance.Token.IsCancellationRequested.Should().BeFalse(); + } + await using var nextRequest = await nextRequestTask.WaitAsync(TimeSpan.FromSeconds(2)); + await nextRequest.Token.IsCancellationRequested.Should().BeFalse(); + } + + [Test] + public async Task ReadOnlyObserversRemainAvailableDuringUpdateMaintenance() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var maintenance = await coordinator.EnterExclusiveAsync(allowReadOnlyObservations: true); + + await using var status = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var operations = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var logs = await coordinator.EnterObservationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + var mutation = coordinator.EnterOperationAsync().AsTask(); + + await (status.Token.CanBeCanceled && operations.Token.CanBeCanceled && logs.Token.CanBeCanceled).Should().BeTrue(); + await mutation.IsCompleted.Should().BeFalse(); + await maintenance.DisposeAsync(); + await using var mutationLease = await mutation.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task ReadOnlyObserversStillWaitDuringMaintenanceThatDoesNotAllowThem() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var maintenance = await coordinator.EnterExclusiveAsync(); + var observer = coordinator.EnterObservationAsync().AsTask(); + + await observer.IsCompleted.Should().BeFalse(); + await maintenance.DisposeAsync(); + await using var lease = await observer.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task SharedUpdateStagingDoesNotBlockObservationRequests() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var staging = await coordinator.EnterOperationAsync(); + await using var status = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var operations = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + await using var logs = await coordinator.EnterOperationAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + await (!status.Token.IsCancellationRequested + && !operations.Token.IsCancellationRequested + && !logs.Token.IsCancellationRequested).Should().BeTrue(); + } + + [Test] + public async Task ShutdownCancelsAndDrainsActiveLeases() + { + var coordinator = new RuntimeOperationCoordinator(); + var operation = await coordinator.EnterOperationAsync(); + var shutdown = coordinator.StopAndDrainAsync(CancellationToken.None); + + for (var attempt = 0; attempt < 100 && !operation.Token.IsCancellationRequested; attempt++) + { + await Task.Delay(10); + } + await operation.Token.IsCancellationRequested.Should().BeTrue(); + await operation.DisposeAsync(); + await shutdown; + } + + [Test] + public async Task ShutdownDrainHasAFiniteBestEffortDeadline() + { + var coordinator = new RuntimeOperationCoordinator(); + var operation = await coordinator.EnterOperationAsync(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var drained = await coordinator.StopAndDrainAsync(CancellationToken.None, TimeSpan.FromMilliseconds(40)); + + await drained.Should().BeFalse(); + await operation.Token.IsCancellationRequested.Should().BeTrue(); + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + await operation.DisposeAsync(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs new file mode 100644 index 00000000..98d8071f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/RuntimeRequestOperationPolicyTests.cs @@ -0,0 +1,62 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class RuntimeRequestOperationPolicyTests +{ + [Test] + [Arguments("GET", "/api/status")] + [Arguments("GET", "/api/operations")] + [Arguments("GET", "/api/logs")] + [Arguments("GET", "/api/logs/page")] + public async Task ReadOnlyRuntimeEndpointsUseObservationLeases(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Observation); + } + + [Test] + [Arguments("POST", "/api/core/xray/update")] + [Arguments("POST", "/api/core-updates/Xray/update")] + [Arguments("POST", "/api/core-updates/sing_box/update")] + [Arguments("POST", "/api/core/geo/update")] + [Arguments("POST", "/api/speedtests")] + [Arguments("POST", "/api/subscriptions/example/update")] + [Arguments("POST", "/api/backup/restore")] + [Arguments("POST", "/api/backup/webdav/restore")] + [Arguments("POST", "/api/settings/regional-presets/Default")] + [Arguments("POST", "/api/settings/regional-presets/Russia")] + [Arguments("POST", "/api/settings/regional-presets/Iran")] + public async Task LongRunningRequestStartsItsOwnBackgroundLease(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Background); + } + + [Test] + [Arguments("GET", "/api/backup/download")] + [Arguments("POST", "/api/backup/webdav")] + public async Task BackupSnapshotRequestsUseExclusiveLeases(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.Exclusive); + } + + [Test] + [Arguments("PUT", "/api/settings/apply")] + [Arguments("PUT", "/api/settings/inbound")] + [Arguments("PUT", "/api/settings/dns/profiles/xray")] + [Arguments("POST", "/api/settings/routing-profiles/import")] + public async Task CoreAffectingSettingsUseExclusiveMutationLeasesWithReadOnlyStatusAccess(string method, string path) + { + await RuntimeRequestOperationPolicy.Classify(method, path) + .Should().BeEqualTo(RuntimeRequestOperationKind.ExclusiveReadOnly); + } + + [Test] + public async Task OtherApiRequestsUseSharedLeases() + { + await RuntimeRequestOperationPolicy.Classify("POST", "/api/core/start") + .Should().BeEqualTo(RuntimeRequestOperationKind.Shared); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs new file mode 100644 index 00000000..3b145564 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/ShutdownCleanupSequenceTests.cs @@ -0,0 +1,219 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class ShutdownCleanupSequenceTests +{ + [Test] + public async Task CleanupStopsAnActiveCoreBeforeSavingAndClosingSharedState() + { + var coreRunning = true; + var databaseClosed = false; + var visited = new List(); + var steps = new ShutdownCleanupStep[] + { + new("scheduled operations", _ => Task.FromResult(true)), + new("operation drain", _ => Task.FromResult(true)), + new("Core stop", _ => + { + visited.Add("Core stop"); + coreRunning = false; + return Task.FromResult(true); + }), + new("profile save", _ => { visited.Add("profile save"); return Task.FromResult(true); }), + new("statistics save/close", _ => { visited.Add("statistics save/close"); return Task.FromResult(true); }), + new("configuration save", _ => { visited.Add("configuration save"); return Task.FromResult(true); }), + new("database close", _ => { visited.Add("database close"); databaseClosed = true; return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromSeconds(1), + CancellationToken.None, + _ => { }); + + await completed.Should().BeTrue(); + await coreRunning.Should().BeFalse(); + await databaseClosed.Should().BeTrue(); + await visited.SequenceEqual(["Core stop", "profile save", "statistics save/close", "configuration save", "database close"]).Should().BeTrue(); + } + + [Test] + public async Task CoreStopTimeoutIsReportedByNameAndPreventsDatabaseClose() + { + var neverCompletingCoreStop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var databaseClosed = false; + var messages = new List(); + var stages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("Core stop", _ => neverCompletingCoreStop.Task), + new("database close", _ => { databaseClosed = true; return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(70), + CancellationToken.None, + messages.Add, + stage => + { + stages.Add(stage); + ShutdownDiagnostics.SetStage(stage); + }); + + await completed.Should().BeFalse(); + await databaseClosed.Should().BeFalse(); + await messages.Any(message => message.Contains("Core stop", StringComparison.Ordinal)).Should().BeTrue(); + await (stages.FirstOrDefault() == "Core stop").Should().BeTrue(); + await ShutdownDiagnostics.CurrentStage.Should().BeEqualTo("Core stop"); + neverCompletingCoreStop.TrySetResult(true); + ShutdownDiagnostics.SetStage(null); + } + + [Test] + public async Task RuntimeHostAndLauncherShutdownBudgetsLeaveOrderedMargins() + { + await (RuntimeShutdownBudgets.RuntimeCleanup < RuntimeShutdownBudgets.HostShutdown).Should().BeTrue(); + await (RuntimeShutdownBudgets.HostShutdown < RuntimeShutdownBudgets.LauncherWait).Should().BeTrue(); + await (RuntimeShutdownBudgets.LateCleanup < RuntimeShutdownBudgets.RuntimeCleanup).Should().BeTrue(); + await (RuntimeShutdownBudgets.HostShutdown - RuntimeShutdownBudgets.RuntimeCleanup) + .Should().BeEqualTo(TimeSpan.FromSeconds(5)); + await (RuntimeShutdownBudgets.LauncherWait - RuntimeShutdownBudgets.HostShutdown) + .Should().BeEqualTo(TimeSpan.FromSeconds(10)); + } + + [Test] + public async Task LateShutdownRunsAReducedFreshCleanupAfterTheHostTokenWasCanceled() + { + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + var visited = new List(); + var messages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("Core stop", _ => { visited.Add("Core stop"); return Task.FromResult(true); }), + new("database close", _ => { visited.Add("database close"); return Task.FromResult(true); }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + RuntimeShutdownBudgets.RuntimeCleanup, + canceled.Token, + messages.Add); + + await completed.Should().BeTrue(); + await visited.SequenceEqual(["Core stop", "database close"]).Should().BeTrue(); + await messages.Any(message => message.Contains("reduced cleanup", StringComparison.Ordinal)).Should().BeTrue(); + } + + [Test] + public async Task LateShutdownStillHonorsTheReducedBudgetInsteadOfTheFullOne() + { + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var completed = await ShutdownCleanupSequence.RunAsync( + new ShutdownCleanupStep[] { new("Core stop", _ => neverCompletingStep.Task) }, + RuntimeShutdownBudgets.RuntimeCleanup, + canceled.Token, + _ => { }); + + await completed.Should().BeFalse(); + await stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(RuntimeShutdownBudgets.LateCleanup - TimeSpan.FromMilliseconds(100)); + await stopwatch.Elapsed.Should().BeLessThan(RuntimeShutdownBudgets.LateCleanup + TimeSpan.FromSeconds(2)); + neverCompletingStep.TrySetResult(true); + } + + [Test] + public async Task ActiveOperationDrainTimeoutSkipsDatabaseClose() + { + var coordinator = new RuntimeOperationCoordinator(); + await using var activeOperation = await coordinator.EnterOperationAsync(); + var databaseCloseStarted = false; + var messages = new List(); + var steps = new ShutdownCleanupStep[] + { + new("operation drain", async deadline => + await coordinator.StopAndDrainAsync(deadline.Token, deadline.Remaining)), + new("database close", _ => + { + databaseCloseStarted = true; + return Task.FromResult(true); + }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(80), + CancellationToken.None, + messages.Add); + + await completed.Should().BeFalse(); + await databaseCloseStarted.Should().BeFalse(); + await messages.Any(message => message.Contains("operation drain", StringComparison.OrdinalIgnoreCase)) + .Should().BeTrue(); + await activeOperation.DisposeAsync(); + } + + [Test] + public async Task TimedOutCleanupStepPreventsLaterSharedResourceCleanup() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var databaseCloseStarted = false; + var steps = new ShutdownCleanupStep[] + { + new("profile save", _ => + { + started.TrySetResult(true); + return neverCompletingStep.Task; + }), + new("database close", _ => + { + databaseCloseStarted = true; + return Task.FromResult(true); + }), + }; + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(80), + CancellationToken.None, + _ => { }); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + await completed.Should().BeFalse(); + await databaseCloseStarted.Should().BeFalse(); + neverCompletingStep.TrySetResult(true); + } + + [Test] + public async Task ShutdownUsesOneFiniteOverallBudget() + { + var neverCompletingStep = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var steps = new ShutdownCleanupStep[] + { + new("first cleanup", async _ => + { + await Task.Delay(50); + return true; + }), + new("second cleanup", _ => neverCompletingStep.Task), + new("third cleanup", _ => Task.FromResult(true)), + }; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var completed = await ShutdownCleanupSequence.RunAsync( + steps, + TimeSpan.FromMilliseconds(100), + CancellationToken.None, + _ => { }); + + await completed.Should().BeFalse(); + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(1)); + neverCompletingStep.TrySetResult(true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs new file mode 100644 index 00000000..83005c05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SniffingSettingsIntegrationTests.cs @@ -0,0 +1,237 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using ServiceLib.Manager; +using ServiceLib.Services.CoreConfig; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SniffingSettingsIntegrationTests +{ + [Test] + public async Task WebInboundInputEnablesXraySniffingAndUsesServiceLibDefaultsWhenOverrideIsEmpty() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: []); + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], input); + + var generated = Generate(ECoreType.Xray, config); + var sniffing = generated["inbounds"]![0]!["sniffing"]!; + + await sniffing["enabled"]!.GetValue().Should().BeTrue(); + await sniffing["destOverride"]![0]!.GetValue().Should().BeEqualTo("http"); + await sniffing["destOverride"]![1]!.GetValue().Should().BeEqualTo("tls"); + await config.Inbound[0].SniffingEnabled.Should().BeTrue(); + + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var reloadedSettings = await runtime.GetSettingsAsync(); + await reloadedSettings.Inbound.SniffingEnabled.Should().BeTrue(); + await reloadedSettings.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + } + + [Test] + public async Task WebInboundInputEnablesSingboxSniffAction() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: []); + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], input); + + var generated = Generate(ECoreType.sing_box, config); + var routeRules = generated["route"]!["rules"]!.AsArray(); + + await routeRules.Any(rule => rule?["action"]?.GetValue() == "sniff").Should().BeTrue(); + await config.Inbound[0].SniffingEnabled.Should().BeTrue(); + } + + [Test] + public async Task EnablingSniffingPreservesExplicitProtocolsAndRunningCoreChangeRequestsOneRestart() + { + var config = CreateConfig(); + var input = CreateInboundInput(sniffingEnabled: true, destOverride: ["tls"]); + var normalized = V2rayRuntime.NormalizeDestOverride(input.SniffingEnabled, input.DestOverride); + await normalized.SequenceEqual(new[] { "tls" }).Should().BeTrue(); + + var coreChanged = config.Inbound[0].SniffingEnabled != input.SniffingEnabled + || !config.Inbound[0].DestOverride.SequenceEqual(normalized, StringComparer.OrdinalIgnoreCase); + var restartCount = 0; + var execution = await CoreSettingsApplyExecutor.ExecuteAsync( + CoreRuntimeState.Running, + hasActiveChild: true, + changed: coreChanged, + () => + { + restartCount++; + return Task.FromResult(OperationView.Ok(ApiMessageKeys.CommonCompleted)); + }); + + await coreChanged.Should().BeTrue(); + await execution.Action.Should().BeEqualTo(CoreSettingsApplyAction.Restart); + await restartCount.Should().BeEqualTo(1); + } + + [Test] + public async Task EmptyDestOverrideNormalizationIsIdempotentAcrossRepeatedStaleSettingsSaves() + { + var config = CreateConfig(); + BindAppManagerConfig(config); + var stalePayload = CreateInboundInput(sniffingEnabled: true, destOverride: []); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + // First save flips sniffing on, and the Web-owned inbound apply path supplies + // ServiceLib's defaults even though the submitted protocols remain empty. + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], stalePayload); + var afterFirstSave = await runtime.GetSettingsAsync(); + await afterFirstSave.Inbound.SniffingEnabled.Should().BeTrue(); + await afterFirstSave.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + + // The browser still submits its stale empty array. A subsequent save must not + // erase the canonical backend defaults just because sniffing was already enabled. + V2rayRuntime.ApplyInboundSettings(config.Inbound[0], stalePayload); + var afterSecondSave = await runtime.GetSettingsAsync(); + await afterSecondSave.Inbound.SniffingEnabled.Should().BeTrue(); + await afterSecondSave.Inbound.DestOverride.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + + var generated = Generate(ECoreType.Xray, config); + var sniffing = generated["inbounds"]![0]!["sniffing"]!; + await sniffing["enabled"]!.GetValue().Should().BeTrue(); + await sniffing["destOverride"]![0]!.GetValue().Should().BeEqualTo("http"); + await sniffing["destOverride"]![1]!.GetValue().Should().BeEqualTo("tls"); + } + + [Test] + public async Task DestOverrideNormalizationPreservesExplicitAndDisabledValues() + { + var defaults = new InItem().DestOverride?.ToArray() ?? []; + var falseToTrue = V2rayRuntime.NormalizeDestOverride(true, []); + var trueToTrue = V2rayRuntime.NormalizeDestOverride(true, []); + var explicitProtocols = V2rayRuntime.NormalizeDestOverride(true, ["tls"]); + var disabledEmpty = V2rayRuntime.NormalizeDestOverride(false, []); + var disabledExisting = V2rayRuntime.NormalizeDestOverride(false, ["http", "tls"]); + + await falseToTrue.SequenceEqual(defaults).Should().BeTrue(); + await trueToTrue.SequenceEqual(defaults).Should().BeTrue(); + await explicitProtocols.SequenceEqual(new[] { "tls" }).Should().BeTrue(); + await disabledEmpty.Length.Should().BeEqualTo(0); + await disabledExisting.SequenceEqual(new[] { "http", "tls" }).Should().BeTrue(); + } + + private static InboundSettingsInput CreateInboundInput(bool sniffingEnabled, string[] destOverride) => new( + 10808, false, true, sniffingEnabled, destOverride, false, false, false, string.Empty, string.Empty); + + private static JsonNode Generate(ECoreType coreType, Config config) + { + var node = CreateVmessNode(coreType); + var context = new CoreConfigContext + { + Node = node, + RunCoreType = coreType, + RoutingItem = new RoutingItem + { + Id = "route-id", + Remarks = "default", + RuleSet = "[]", + DomainStrategy = Global.AsIs, + DomainStrategy4Singbox = string.Empty, + }, + RawDnsItem = null, + SimpleDnsItem = config.SimpleDNSItem, + AllProxiesMap = new Dictionary { [node.IndexId] = node }, + AppConfig = config, + FullConfigTemplate = null, + IsTunEnabled = false, + ProtectDomainList = [], + HasGlobalIPv6Address = true, + }; + var result = coreType == ECoreType.sing_box + ? new CoreConfigSingboxService(context).GenerateClientConfigContent() + : new CoreConfigV2rayService(context).GenerateClientConfigContent(); + if (result.Success != true || result.Data is null) + { + throw new InvalidOperationException($"Core configuration generation failed: {result.Msg}"); + } + return JsonNode.Parse(result.Data.ToString()!)!; + } + + internal static void BindAppManagerConfig(Config config) + { + var field = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic); + field?.SetValue(AppManager.Instance, config); + } + + private static ProfileItem CreateVmessNode(ECoreType coreType) + { + var node = new ProfileItem + { + IndexId = "sniffing-test-node", + ConfigType = EConfigType.VMess, + CoreType = coreType, + Remarks = "sniffing test", + Address = "example.com", + Port = 443, + Password = Guid.NewGuid().ToString(), + Network = nameof(ETransport.raw), + StreamSecurity = string.Empty, + Subid = string.Empty, + }; + node.SetProtocolExtra(node.GetProtocolExtra() with { AlterId = "0", VmessSecurity = Global.DefaultSecurity }); + return node; + } + + internal static Config CreateConfig() => new() + { + CoreBasicItem = new CoreBasicItem { Loglevel = "warning" }, + TunModeItem = new TunModeItem { EnableTun = false, IcmpRouting = "default" }, + KcpItem = new KcpItem(), + GrpcItem = new GrpcItem(), + RoutingBasicItem = new RoutingBasicItem { DomainStrategy = Global.AsIs, DomainStrategy4Singbox = string.Empty, RoutingIndexId = string.Empty }, + GuiItem = new GUIItem { EnableStatistics = false, DisplayRealTimeSpeed = false, EnableLog = false }, + MsgUIItem = new MsgUIItem(), + UiItem = new UIItem { CurrentLanguage = "en", CurrentFontFamily = "sans", MainColumnItem = [], WindowSizeItem = [] }, + ConstItem = new ConstItem(), + SpeedTestItem = new SpeedTestItem + { + SpeedPingTestUrl = Global.SpeedPingTestUrls.First(), + SpeedTestUrl = Global.SpeedTestUrls.First(), + SpeedTestTimeout = 10, + MixedConcurrencyCount = 1, + IPAPIUrl = string.Empty, + }, + Mux4RayItem = new Mux4RayItem { Concurrency = 8, XudpConcurrency = 16, XudpProxyUDP443 = "reject" }, + Mux4SboxItem = new Mux4SboxItem { Protocol = Global.SingboxMuxs.First(), MaxConnections = 8 }, + HysteriaItem = new HysteriaItem { UpMbps = 100, DownMbps = 100 }, + ClashUIItem = new ClashUIItem { ConnectionsColumnItem = [] }, + SystemProxyItem = new SystemProxyItem { SystemProxyExceptions = string.Empty, SystemProxyAdvancedProtocol = string.Empty }, + WebDavItem = new WebDavItem(), + CheckUpdateItem = new CheckUpdateItem(), + Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Lengths = ["100-200"], Delays = ["10-20"] }, + Inbound = + [ + new InItem + { + Protocol = nameof(EInboundProtocol.socks), + LocalPort = 10808, + UdpEnabled = true, + SniffingEnabled = false, + RouteOnly = false, + DestOverride = [], + }, + ], + GlobalHotkeys = [], + CoreTypeItem = [new CoreTypeItem { ConfigType = EConfigType.VMess, CoreType = ECoreType.Xray }], + SimpleDNSItem = ConfigHandler.InitBuiltinSimpleDNS(), + HappyEyeballs4RayItem = new HappyEyeballs4RayItem(), + IndexId = string.Empty, + SubIndexId = string.Empty, + }; +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs new file mode 100644 index 00000000..6b8bead8 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SseSerializationTests.cs @@ -0,0 +1,12 @@ +using v2rayN.WebAPI.Api; + +namespace v2rayN.WebAPI.Tests; + +public class SseSerializationTests +{ + [Test] + public async Task NullEventDataSerializesAsJsonNull() + { + await WebApiEndpoints.SerializeEventData(null).Should().BeEqualTo("null"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs new file mode 100644 index 00000000..e4dd93f3 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SseShutdownTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class SseShutdownTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task HostShutdownClosesActiveSseStreamsInsteadOfWaitingForTheShutdownTimeout() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await Harness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions, + shutdownTimeout: TimeSpan.FromSeconds(4)); + + var session = sessions.CreateSession(); + var ticket = await GetSseTicketAsync(api, session.Token); + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(5)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + // Kestrel drains open requests during a graceful stop. Without the stream + // reacting to ApplicationStopping the host would wait for the whole + // ShutdownTimeout (4s here) and the runtime cleanup would start too late. + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + api.App.Lifetime.StopApplication(); + await api.App.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(20)); + stopwatch.Stop(); + + await stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(2.5)); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + private static async Task GetSseTicketAsync(Harness api, string sessionToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + request.Headers.Authorization = new("Bearer", sessionToken); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + } + + private static async Task ReadSseStreamUntilClosedAsync(Stream stream) + { + try + { + await stream.CopyToAsync(Stream.Null).WaitAsync(TimeSpan.FromSeconds(3)); + return true; + } + catch (IOException) + { + return true; + } + catch (TimeoutException) + { + return false; + } + } + + private sealed class Harness(WebApplication app, HttpClient client, EventHub events) : IAsyncDisposable + { + public WebApplication App => app; + public HttpClient Client => client; + public EventHub Events => events; + + public static async Task StartAsync( + WebAuthService auth, + WebSessionService sessions, + TimeSpan shutdownTimeout) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.Configure(options => options.ShutdownTimeout = shutdownTimeout); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var application = builder.Build(); + application.UseRouting(); + application.UseRateLimiter(); + application.UseMiddleware(); + application.UseMiddleware(); + application.MapWebApi(); + application.MapWebAuthEndpoints(); + application.MapWebSetupEndpoints(); + await application.StartAsync(); + + var addresses = application.Services.GetRequiredService() + .Features.Get()!.Addresses; + var httpClient = new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(addresses.Single()), + Timeout = TimeSpan.FromSeconds(5), + }; + return new Harness( + application, + httpClient, + application.Services.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + if (!App.Lifetime.ApplicationStopping.IsCancellationRequested) + { + await App.StopAsync(); + } + await App.DisposeAsync(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-sse-shutdown-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs new file mode 100644 index 00000000..c0e59b64 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionCreationTests.cs @@ -0,0 +1,83 @@ +using System.Reflection; +using ServiceLib; +using ServiceLib.Helper; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SubscriptionCreationTests +{ + [Test] + public async Task EmptyUrlSubscriptionIsCreatedAndReturnedWithAnEmptyUrl() + { + var previousLocalData = Environment.GetEnvironmentVariable(Global.LocalAppData); + var manager = AppManager.Instance; + var configField = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(typeof(AppManager).FullName, "_config"); + var previousConfig = configField.GetValue(manager); + var config = new Config(); + SubItem? saved = null; + try + { + Environment.SetEnvironmentVariable(Global.LocalAppData, "0"); + configField.SetValue(manager, config); + SQLiteHelper.Instance.CreateTable(); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + var result = await runtime.AddSubscriptionAsync(new SubscriptionInput( + "自建", string.Empty, MoreUrl: string.Empty, Enabled: false, AutoUpdateInterval: 0)); + + if (result.Data is not null) + { + saved = await manager.GetSubItem(result.Data.Id); + } + + await result.Success.Should().BeTrue(); + await (result.Data is not null).Should().BeTrue(); + await result.Data!.Url.Should().BeEqualTo(string.Empty); + + await (saved is not null).Should().BeTrue(); + await saved!.Remarks.Should().BeEqualTo("自建"); + await saved.Url.Should().BeEqualTo(string.Empty); + await saved.MoreUrl.Should().BeEqualTo(string.Empty); + await saved.Enabled.Should().BeFalse(); + await saved.AutoUpdateInterval.Should().BeEqualTo(0); + + var updateResult = await runtime.UpdateSubscriptionAsync(result.Data!.Id, new SubscriptionInput( + "自建(已编辑)", string.Empty, MoreUrl: string.Empty, Enabled: true, AutoUpdateInterval: 0)); + await updateResult.Success.Should().BeTrue(); + await updateResult.Data!.Url.Should().BeEqualTo(string.Empty); + + saved = await manager.GetSubItem(result.Data.Id); + await (saved is not null).Should().BeTrue(); + await saved!.Remarks.Should().BeEqualTo("自建(已编辑)"); + await saved.Url.Should().BeEqualTo(string.Empty); + await saved.Enabled.Should().BeTrue(); + await saved.AutoUpdateInterval.Should().BeEqualTo(0); + + var reloadedView = V2rayRuntime.ToSubscriptionView(saved); + await reloadedView.Url.Should().BeEqualTo(string.Empty); + await reloadedView.Enabled.Should().BeTrue(); + await reloadedView.AutoUpdateInterval.Should().BeEqualTo(0); + } + finally + { + try + { + if (saved is not null) + { + await SQLiteHelper.Instance.DeleteAsync(saved); + } + } + finally + { + configField.SetValue(manager, previousConfig); + Environment.SetEnvironmentVariable(Global.LocalAppData, previousLocalData); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs new file mode 100644 index 00000000..b7f5a6d0 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionParityTests.cs @@ -0,0 +1,218 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Handler; +using ServiceLib.Models.Entities; +using ServiceLib.Services; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class SubscriptionParityTests +{ + [Test] + public async Task EveryEditableSubItemFieldRoundTripsAndIntervalZeroStaysZero() + { + var original = new SubItem + { + Id = "subscription-parity", + Remarks = "Manual group", + Url = "https://main.example/sub", + MoreUrl = "https://a.example/sub,https://b.example/sub", + Enabled = false, + UserAgent = "parity-agent", + RequestHeaders = "{\"X-Test\":\"value\"}", + Sort = 17, + Filter = "^edge", + AutoUpdateInterval = 0, + ConvertTarget = "mixed", + PrevProfile = "typed previous alias", + NextProfile = "typed landing alias", + PreSocksPort = 10809, + Memo = "memo", + CustomCoreType = ECoreType.sing_box, + }; + + var loaded = V2rayRuntime.ToSubscriptionView(original); + var input = new SubscriptionInput( + loaded.Remarks, + loaded.Url, + loaded.MoreUrl, + loaded.Enabled, + loaded.UserAgent, + loaded.RequestHeaders, + loaded.Filter, + loaded.AutoUpdateInterval, + loaded.ConvertTarget, + loaded.Memo, + loaded.Sort, + loaded.PrevProfile, + loaded.NextProfile, + loaded.PreSocksPort, + ECoreType.sing_box); + var saved = V2rayRuntime.ToSubItem(input, original); + var reloaded = V2rayRuntime.ToSubscriptionView(saved); + + await reloaded.Remarks.Should().BeEqualTo(original.Remarks); + await reloaded.Url.Should().BeEqualTo(original.Url); + await reloaded.MoreUrl.Should().BeEqualTo(original.MoreUrl); + await reloaded.Enabled.Should().BeEqualTo(original.Enabled); + await reloaded.UserAgent.Should().BeEqualTo(original.UserAgent); + await reloaded.RequestHeaders.Should().BeEqualTo(original.RequestHeaders); + await reloaded.Filter.Should().BeEqualTo(original.Filter); + await reloaded.AutoUpdateInterval.Should().BeEqualTo(0); + await reloaded.ConvertTarget.Should().BeEqualTo(original.ConvertTarget); + await reloaded.Memo.Should().BeEqualTo(original.Memo); + await reloaded.Sort.Should().BeEqualTo(original.Sort); + await reloaded.PrevProfile.Should().BeEqualTo("typed previous alias"); + await reloaded.NextProfile.Should().BeEqualTo("typed landing alias"); + await reloaded.PreSocksPort.Should().BeEqualTo(original.PreSocksPort); + await reloaded.CustomCoreType.Should().BeEqualTo("sing_box"); + } + + [Test] + public async Task NullAndEmptySubscriptionFieldsAreNotCollapsedDuringMapping() + { + var original = new SubItem + { + Id = "nullable-subscription", + Remarks = "Nullable values", + Url = "", + MoreUrl = "", + RequestHeaders = null, + Filter = null, + ConvertTarget = null, + Memo = null, + AutoUpdateInterval = 0, + }; + var loaded = V2rayRuntime.ToSubscriptionView(original); + var untouched = V2rayRuntime.ToSubItem(new SubscriptionInput(loaded.Remarks, loaded.Url, + MoreUrl: loaded.MoreUrl, RequestHeaders: loaded.RequestHeaders, Filter: loaded.Filter, + ConvertTarget: loaded.ConvertTarget, Memo: loaded.Memo, AutoUpdateInterval: loaded.AutoUpdateInterval), original); + var cleared = V2rayRuntime.ToSubItem(new SubscriptionInput(loaded.Remarks, loaded.Url, + MoreUrl: string.Empty, RequestHeaders: string.Empty, Filter: string.Empty, + ConvertTarget: string.Empty, Memo: string.Empty, AutoUpdateInterval: 0), original); + + await untouched.RequestHeaders.Should().BeNull(); + await untouched.Filter.Should().BeNull(); + await untouched.ConvertTarget.Should().BeNull(); + await untouched.Memo.Should().BeNull(); + await cleared.RequestHeaders.Should().BeEqualTo(string.Empty); + await cleared.Filter.Should().BeEqualTo(string.Empty); + await cleared.ConvertTarget.Should().BeEqualTo(string.Empty); + await cleared.Memo.Should().BeEqualTo(string.Empty); + await cleared.AutoUpdateInterval.Should().BeEqualTo(0); + } + + [Test] + public async Task MoreUrlDownloadsMainAndEveryAdditionalUrlAndMergesTheirContent() + { + await ServiceLib.Manager.CertPemManager.Instance.Init( + new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); + var visited = new ConcurrentQueue(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + await using var app = builder.Build(); + app.MapGet("/main", () => Respond("/main", "main-node.example")); + app.MapGet("/a", () => Respond("/a", "additional-a.example")); + app.MapGet("/b", () => Respond("/b", "additional-b.example")); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + var subscription = new SubItem + { + Id = "multiple-urls", + Remarks = "Three URLs", + Url = $"{address.TrimEnd('/')}/main", + MoreUrl = $"{address.TrimEnd('/')}/a,{address.TrimEnd('/')}/b", + UserAgent = string.Empty, + ConvertTarget = string.Empty, + }; + + var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); + var task = (Task?)download.Invoke(null, [new ServiceLib.Models.Configs.Config(), subscription, false, new DownloadService()]) + ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); + var merged = await task; + + await visited.ToArray().SequenceEqual(new[] { "/main", "/a", "/b" }).Should().BeTrue(); + await merged.Contains("main-node.example").Should().BeTrue(); + await merged.Contains("additional-a.example").Should().BeTrue(); + await merged.Contains("additional-b.example").Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + + IResult Respond(string path, string body) + { + visited.Enqueue(path); + return Results.Text(body); + } + } + + [Test] + public async Task ServiceLibKeepsConversionAndMoreUrlMutuallyExclusive() + { + await ServiceLib.Manager.CertPemManager.Instance.Init( + new ServiceLib.Models.Configs.Config { GuiItem = new ServiceLib.Models.Configs.GUIItem() }); + var visited = new ConcurrentQueue(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + await using var app = builder.Build(); + app.MapGet("/convert", () => + { + visited.Enqueue("/convert"); + return Results.Text("converted-node"); + }); + app.MapGet("/a", () => + { + visited.Enqueue("/a"); + return Results.Text("must-not-be-downloaded"); + }); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + var subscription = new SubItem + { + Id = "converted-subscription", + Remarks = "Converted subscription", + Url = "https://main.example/sub", + MoreUrl = $"{address.TrimEnd('/')}/a", + ConvertTarget = "mixed", + }; + var config = new ServiceLib.Models.Configs.Config + { + ConstItem = new ServiceLib.Models.Configs.ConstItem(), + }; + config.ConstItem.SubConvertUrl = $"{address.TrimEnd('/')}/convert?url={{0}}"; + var download = typeof(SubscriptionHandler).GetMethod("DownloadAllSubscriptions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(typeof(SubscriptionHandler).FullName, "DownloadAllSubscriptions"); + var task = (Task?)download.Invoke(null, [config, subscription, false, new DownloadService()]) + ?? throw new InvalidOperationException("The ServiceLib subscription download task was not created."); + + var merged = await task; + await visited.ToArray().SequenceEqual(new[] { "/convert" }).Should().BeTrue(); + await merged.Should().BeEqualTo("converted-node"); + } + finally + { + await app.StopAsync(); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs new file mode 100644 index 00000000..282ee368 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/SubscriptionValidationTests.cs @@ -0,0 +1,85 @@ +using ServiceLib.Enums; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class SubscriptionValidationTests +{ + [Test] + public async Task AliasOnlySubscriptionIsValid() + { + var valid = V2rayRuntime.TryValidateSubscription(new SubscriptionInput("Manual group", string.Empty), out var code, out _); + + await valid.Should().BeTrue(); + await (code == "ok").Should().BeTrue(); + } + + [Test] + public async Task NonEmptySubscriptionUrlStillRequiresAbsoluteHttpOrHttps() + { + var valid = V2rayRuntime.TryValidateSubscription(new SubscriptionInput("Remote group", "not-a-url"), out var code, out _); + + await valid.Should().BeFalse(); + await (code == "subscription_url_invalid").Should().BeTrue(); + } + + [Test] + public async Task ValidAndEmptySubscriptionRequestHeadersAreAccepted() + { + foreach (var requestHeaders in new string?[] { "{\"X-Test\":\"value\"}", null, string.Empty, " " }) + { + var valid = V2rayRuntime.TryValidateSubscription( + new SubscriptionInput("Remote group", "", RequestHeaders: requestHeaders), out var code, out _); + + await valid.Should().BeTrue(); + await code.Should().BeEqualTo("ok"); + } + } + + [Test] + public async Task InvalidSubscriptionRequestHeadersAreRejectedAsInvalidInput() + { + var valid = V2rayRuntime.TryValidateSubscription( + new SubscriptionInput("Remote group", "", RequestHeaders: "[\"not-an-object\"]"), out var code, out var messageKey); + + await valid.Should().BeFalse(); + await code.Should().BeEqualTo("subscription_headers_invalid"); + await messageKey.Should().BeEqualTo(ApiMessageKeys.CommonInvalidInput); + } + + [Test] + public async Task InvalidRequestHeadersAreRejectedBeforeDatabaseAccess() + { + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + var result = await runtime.UpdateSubscriptionAsync("existing-subscription", + new SubscriptionInput("Changed subscription", "", RequestHeaders: "[]")); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("subscription_headers_invalid"); + } + + [Test] + public async Task SubscriptionUpdateCanClearCustomCoreTypeAndReloadAsNone() + { + var existing = new SubItem + { + Id = "existing-subscription", + Remarks = "Existing subscription", + Url = "https://example.test/subscription", + CustomCoreType = ECoreType.mihomo, + }; + var input = new SubscriptionInput("Existing subscription", "https://example.test/subscription", + PreSocksPort: 10809, CustomCoreType: null); + + var updated = V2rayRuntime.ToSubItem(input, existing); + var reloaded = V2rayRuntime.ToSubscriptionView(updated); + + await (existing.CustomCoreType == ECoreType.mihomo).Should().BeTrue(); + await (updated.CustomCoreType is null).Should().BeTrue(); + await (reloaded.CustomCoreType is null).Should().BeTrue(); + await updated.PreSocksPort.Should().BeEqualTo(10809); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs b/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs new file mode 100644 index 00000000..0144dc86 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/UpdatePreReleaseSettingsTests.cs @@ -0,0 +1,212 @@ +using System.Reflection; +using System.Text.Json; +using ServiceLib; +using ServiceLib.Common; +using ServiceLib.Manager; +using ServiceLib.Models.Configs; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +[NotInParallel] +public class UpdatePreReleaseSettingsTests +{ + private const string WebTarget = "v2rayN.WebAPI"; + + [Test] + public async Task LegacyApiPreferencesMigrateWithoutChangingDesktopOrCorePreferences() + { + await (V2rayRuntime.NormalizeWebUpdateTargets(null) is null).Should().BeTrue(); + var migrated = V2rayRuntime.NormalizeWebUpdateTargets(["v2rayN", "Xray", "v2rayN.Web", WebTarget, "OtherPlatform"]); + await migrated!.SequenceEqual(["v2rayN", "Xray", WebTarget, "OtherPlatform"]).Should().BeTrue(); + using var fixture = new RuntimeFixture(); + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(new CoreUpdateSettingsInput( + ["v2rayN.Web"], false, false, ["v2rayN.Web"])); + await result.Success.Should().BeTrue(); + await fixture.Config.CheckUpdateItem.SelectedCoreTypes.SequenceEqual([WebTarget]).Should().BeTrue(); + await fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes.SequenceEqual([WebTarget]).Should().BeTrue(); + } + + [Test] + [Arguments(true, null, true)] + [Arguments(false, null, false)] + [Arguments(true, true, true)] + [Arguments(true, false, false)] + [Arguments(false, true, true)] + [Arguments(false, false, false)] + public async Task WebPreReleaseUsesItsOwnSettingUnlessExplicitlyOverridden( + bool configured, bool? preRelease, bool expected) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = configured ? [WebTarget] : []; + + await fixture.Runtime.GetUpdatePreRelease(WebTarget, preRelease).Should().BeEqualTo(expected); + // An override is request-scoped, not a change to persisted preferences. + var settings = fixture.Runtime.GetCoreUpdateSettings(); + await settings.PreRelease.Should().BeEqualTo(configured); + await settings.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(configured); + } + + [Test] + public async Task MissingPreReleaseListDefaultsToStable() + { + using var fixture = new RuntimeFixture(); + + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeFalse(); + await fixture.Runtime.GetCoreUpdateSettings().CheckPreReleaseCoreTypes.Count.Should().BeEqualTo(0); + } + + [Test] + public async Task WebPreReleaseIsIndependentOfDesktopAndCoreTargets() + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "Xray", "v2rayn.web"]; + + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeTrue(); + var settings = fixture.Runtime.GetCoreUpdateSettings(); + await settings.PreRelease.Should().BeFalse(); + await settings.CheckPreReleaseCoreTypes.SequenceEqual(["Xray"]).Should().BeTrue(); + + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = [WebTarget]; + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeTrue(); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeFalse(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task PerTargetSettingsSaveAddsOrRemovesWebAndPreservesHiddenPreferences(bool enableWeb) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.SelectedCoreTypes = ["v2rayN", "Xray", WebTarget]; + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "OtherPlatform", "Xray", WebTarget]; + var input = new CoreUpdateSettingsInput( + ["Xray"], PreRelease: !enableWeb, UseProxy: false, + CheckPreReleaseCoreTypes: enableWeb ? [WebTarget, WebTarget] : []); + + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(input); + + await result.Success.Should().BeTrue(); + var saved = fixture.Config.CheckUpdateItem; + await saved.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(enableWeb); + await saved.CheckPreReleaseCoreTypes.Contains("Xray").Should().BeFalse(); + await saved.CheckPreReleaseCoreTypes.Contains("v2rayN").Should().BeTrue(); + await saved.CheckPreReleaseCoreTypes.Contains("OtherPlatform").Should().BeTrue(); + await saved.CheckPreReleaseCoreTypes.Count(name => name == WebTarget).Should().BeEqualTo(enableWeb ? 1 : 0); + // Prerelease preferences are saved even for targets not selected for a batch. + await saved.SelectedCoreTypes.Contains(WebTarget).Should().BeFalse(); + await saved.SelectedCoreTypes.Contains("v2rayN").Should().BeTrue(); + await saved.UpdateViaProxy.Should().BeFalse(); + + var view = fixture.Runtime.GetCoreUpdateSettings(); + await view.PreRelease.Should().BeEqualTo(enableWeb); + await view.CheckPreReleaseCoreTypes.SequenceEqual(enableWeb ? [WebTarget] : []).Should().BeTrue(); + var reloaded = await fixture.ReadSavedConfigAsync(); + await reloaded.CheckUpdateItem.CheckPreReleaseCoreTypes.SequenceEqual(saved.CheckPreReleaseCoreTypes).Should().BeTrue(); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task LegacySettingsPayloadStillSavesSupportedTargetsIncludingWeb(bool preRelease) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = ["v2rayN", "Xray", WebTarget]; + var json = JsonSerializer.Serialize(new + { + selectedCoreTypes = Array.Empty(), + preRelease, + useProxy = true, + }); + var input = JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + + await (input.CheckPreReleaseCoreTypes is null).Should().BeTrue(); + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync(input); + + await result.Success.Should().BeTrue(); + await fixture.Runtime.GetUpdatePreRelease(WebTarget).Should().BeEqualTo(preRelease); + await fixture.Runtime.GetUpdatePreRelease("Xray").Should().BeEqualTo(preRelease); + await fixture.Runtime.GetUpdatePreRelease("mihomo").Should().BeFalse(); + await fixture.Runtime.GetUpdatePreRelease("sing_box").Should().BeFalse(); + await fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes.Contains("v2rayN").Should().BeTrue(); + var reloaded = await fixture.ReadSavedConfigAsync(); + await reloaded.CheckUpdateItem.CheckPreReleaseCoreTypes.Contains(WebTarget).Should().BeEqualTo(preRelease); + } + + [Test] + [Arguments("GeoFiles")] + [Arguments("v2rayN")] + [Arguments("mihomo")] + public async Task InvalidPerTargetPreReleaseSettingsDoNotMutateConfig(string target) + { + using var fixture = new RuntimeFixture(); + fixture.Config.CheckUpdateItem.CheckPreReleaseCoreTypes = [WebTarget]; + var previous = JsonUtils.Serialize(fixture.Config); + + var result = await fixture.Runtime.SaveCoreUpdateSettingsAsync( + new CoreUpdateSettingsInput([], false, false, [target])); + + await result.Success.Should().BeFalse(); + await result.Code.Should().BeEqualTo("core_update_settings_invalid"); + await JsonUtils.Serialize(fixture.Config).Should().BeEqualTo(previous); + } + + private sealed class RuntimeFixture : IDisposable + { + private readonly FieldInfo _configField = typeof(AppManager) + .GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(typeof(AppManager).FullName, "_config"); + private readonly object? _previousConfig; + private readonly string? _previousLocalData; + private readonly string _configPath; + private readonly byte[]? _previousConfigFile; + private readonly UnixFileMode? _previousConfigMode; + + public Config Config { get; } = new() { CheckUpdateItem = new CheckUpdateItem() }; + public V2rayRuntime Runtime { get; } = new(null!, null!, null!, null!, null!); + + public RuntimeFixture() + { + _previousConfig = _configField.GetValue(AppManager.Instance); + _previousLocalData = Environment.GetEnvironmentVariable(Global.LocalAppData); + // ConfigHandler writes only into the test build directory, never user data. + _configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "guiConfigs", Global.ConfigFileName); + if (File.Exists(_configPath)) + { + _previousConfigFile = File.ReadAllBytes(_configPath); + if (!OperatingSystem.IsWindows()) _previousConfigMode = File.GetUnixFileMode(_configPath); + } + Environment.SetEnvironmentVariable(Global.LocalAppData, "0"); + _configField.SetValue(AppManager.Instance, Config); + } + + public async Task ReadSavedConfigAsync() => + JsonUtils.Deserialize(await File.ReadAllTextAsync(_configPath)) + ?? throw new InvalidDataException("The saved update settings could not be reloaded."); + + public void Dispose() + { + try + { + if (_previousConfigFile is null) + { + if (File.Exists(_configPath)) File.Delete(_configPath); + } + else + { + File.WriteAllBytes(_configPath, _previousConfigFile); + if (!OperatingSystem.IsWindows() && _previousConfigMode is { } mode) + File.SetUnixFileMode(_configPath, mode); + } + } + finally + { + _configField.SetValue(AppManager.Instance, _previousConfig); + Environment.SetEnvironmentVariable(Global.LocalAppData, _previousLocalData); + } + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs new file mode 100644 index 00000000..e373d001 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebApiCapabilitiesTests.cs @@ -0,0 +1,16 @@ +using v2rayN.WebAPI.Contracts; + +namespace v2rayN.WebAPI.Tests; + +public class WebApiCapabilitiesTests +{ + [Test] + public async Task StatusCapabilitiesAreStableUniqueApiIdentifiers() + { + var capabilities = WebApiCapabilities.Current; + + await capabilities.Distinct(StringComparer.Ordinal).Count().Should().BeEqualTo(capabilities.Length); + await capabilities.Should().Contain("events.sse"); + await capabilities.Should().Contain("editor.options"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs new file mode 100644 index 00000000..dfc22c2f --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthEndpointTests.cs @@ -0,0 +1,532 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthEndpointTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task EnvironmentManagementKeyLogsInAndOnlyTheReturnedSessionAuthenticates() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + + using var invalidLogin = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = "wrong-management-key" }); + await (invalidLogin.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + using var invalidLoginDocument = JsonDocument.Parse(await invalidLogin.Content.ReadAsStringAsync()); + await invalidLoginDocument.RootElement.GetProperty("code").GetString() + .Should().BeEqualTo("management_key_invalid"); + using var validLogin = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + await (validLogin.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var loginDocument = JsonDocument.Parse(await validLogin.Content.ReadAsStringAsync()); + var sessionToken = loginDocument.RootElement.GetProperty("data").GetProperty("token").GetString()!; + await (sessionToken.Length == 43).Should().BeTrue(); + await (loginDocument.RootElement.GetProperty("data").GetProperty("expiresAt").GetDateTimeOffset() > DateTimeOffset.UtcNow).Should().BeTrue(); + + using var sessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + sessionRequest.Headers.Authorization = new("Bearer", sessionToken); + using var sessionResponse = await api.Client.SendAsync(sessionRequest); + await (sessionResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + + using var managementKeyRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + managementKeyRequest.Headers.Authorization = new("Bearer", ManagementKey); + using var managementKeyResponse = await api.Client.SendAsync(managementKeyRequest); + await (managementKeyResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var managementTicketRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + managementTicketRequest.Headers.Authorization = new("Bearer", ManagementKey); + using var managementTicketResponse = await api.Client.SendAsync(managementTicketRequest); + await (managementTicketResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var managementKeySse = await api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ManagementKey)}", + HttpCompletionOption.ResponseHeadersRead); + await (managementKeySse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var legacySessionUrl = await api.Client.GetAsync( + $"/api/events?access_token={Uri.EscapeDataString(sessionToken)}", + HttpCompletionOption.ResponseHeadersRead); + await (legacySessionUrl.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + } + + [Test] + public async Task PersistedManagementKeyExchangesForSessionAtLogin() + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + var seedAuth = new WebAuthService(configPath, null); + await ((await seedAuth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync(new WebAuthService(configPath, null), sessions); + + using var response = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var token = document.RootElement.GetProperty("data").GetProperty("token").GetString()!; + await sessions.TryValidateWithoutRenewal(token, out _).Should().BeTrue(); + } + + [Test] + public async Task RuntimeDataFailureAfterLoginDoesNotInvalidateTheEstablishedSession() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + + using var login = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + using var loginDocument = JsonDocument.Parse(await login.Content.ReadAsStringAsync()); + var token = loginDocument.RootElement.GetProperty("data").GetProperty("token").GetString()!; + using var failedDataRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/runtime-failure"); + failedDataRequest.Headers.Authorization = new("Bearer", token); + + using var failure = await api.Client.SendAsync(failedDataRequest); + + await (failure.StatusCode == HttpStatusCode.InternalServerError).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(token, out _).Should().BeTrue(); + } + + [Test] + public async Task CorrectManagementKeyWithSessionCreationFailureIsNotReportedAsWrongPassword() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + sessions.Dispose(); + + using var response = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = ManagementKey }); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + await (response.StatusCode == HttpStatusCode.InternalServerError).Should().BeTrue(); + await document.RootElement.GetProperty("code").GetString().Should().BeEqualTo("internal_error"); + await sessions.TryValidateWithoutRenewal(ManagementKey, out _).Should().BeFalse(); + } + + [Test] + public async Task SetupRequiresLocalAddressAndAllowedHostAndReturnsAUsableSession() + { + using var directory = new TemporaryDirectory(); + var configPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync(new WebAuthService(configPath, null), sessions); + + using var status = await api.Client.GetAsync("/api/setup/status"); + using var statusDocument = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await statusDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeTrue(); + await statusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeTrue(); + + using var maliciousHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/setup/status"); + maliciousHostRequest.Headers.Host = "attacker.example"; + using var maliciousHostStatus = await api.Client.SendAsync(maliciousHostRequest); + using var maliciousStatusDocument = JsonDocument.Parse(await maliciousHostStatus.Content.ReadAsStringAsync()); + await maliciousStatusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + + using var deniedRequest = new HttpRequestMessage(HttpMethod.Post, "/api/setup") + { + Content = JsonContent.Create(new { key = ManagementKey, confirmKey = ManagementKey }), + }; + deniedRequest.Headers.Host = "attacker.example"; + using var deniedResponse = await api.Client.SendAsync(deniedRequest); + await (deniedResponse.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + + using var setupResponse = await api.Client.PostAsJsonAsync("/api/setup", new { key = ManagementKey, confirmKey = ManagementKey }); + await (setupResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var setupDocument = JsonDocument.Parse(await setupResponse.Content.ReadAsStringAsync()); + await setupDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeFalse(); + var sessionToken = setupDocument.RootElement.GetProperty("token").GetString()!; + await sessions.TryValidateWithoutRenewal(sessionToken, out _).Should().BeTrue(); + + using var protectedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + protectedRequest.Headers.Authorization = new("Bearer", sessionToken); + using var protectedResponse = await api.Client.SendAsync(protectedRequest); + await (protectedResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + var persistedConfig = await File.ReadAllTextAsync(configPath); + await persistedConfig.Contains(ManagementKey, StringComparison.Ordinal).Should().BeFalse(); + } + + [Test] + public async Task LogoutRevokesOnlyTheCurrentSession() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + + using var logout = new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout"); + logout.Headers.Authorization = new("Bearer", first.Token); + using var logoutResponse = await api.Client.SendAsync(logout); + await (logoutResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + firstRequest.Headers.Authorization = new("Bearer", first.Token); + using var firstResponse = await api.Client.SendAsync(firstRequest); + await (firstResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + secondRequest.Headers.Authorization = new("Bearer", second.Token); + using var secondResponse = await api.Client.SendAsync(secondRequest); + await (secondResponse.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + + [Test] + public async Task SseTicketIsOneTimeAndCannotAuthenticateRest() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var ticket = await GetSseTicketAsync(api, session.Token); + + using var restWithTicket = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + restWithTicket.Headers.Authorization = new("Bearer", ticket); + using var restResponse = await api.Client.SendAsync(restWithTicket); + await (restResponse.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var replay = await api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(ticket)}", + HttpCompletionOption.ResponseHeadersRead); + await (replay.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + sessions.Revoke(session.Token); + } + + [Test] + public async Task IssuingSseTicketDoesNotRenewTheOwningSession() + { + using var directory = new TemporaryDirectory(); + var startedAt = DateTimeOffset.Parse("2026-03-10T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + _ = await GetSseTicketAsync(api, session.Token); + + await sessions.TryValidateWithoutRenewal(session.Token, out var snapshot).Should().BeTrue(); + await (snapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await (snapshot.ExpiresAt == session.ExpiresAt).Should().BeTrue(); + } + + [Test] + public async Task RevokingAnActiveSseSessionClosesItsStream() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var sseTicket = await GetSseTicketAsync(api, session.Token); + + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(sseTicket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + sessions.Revoke(session.Token); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + [Test] + public async Task ExpiringAnActiveSseSessionClosesItsStream() + { + using var directory = new TemporaryDirectory(); + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-04-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + var sseTicket = await GetSseTicketAsync(api, session.Token); + var responseTask = api.Client.GetAsync( + $"/api/events?sse_ticket={Uri.EscapeDataString(sseTicket)}", + HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(3)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await using var stream = await response.Content.ReadAsStreamAsync(); + var buffer = new byte[512]; + var firstRead = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(3)); + await (firstRead > 0).Should().BeTrue(); + + time.Advance(WebSessionService.SlidingLifetime + TimeSpan.FromSeconds(1)); + await sessions.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + var closed = await ReadSseStreamUntilClosedAsync(stream); + await closed.Should().BeTrue(); + } + + [Test] + public async Task AuthenticatedRestRequestRenewsSessionAfterSixDays() + { + using var directory = new TemporaryDirectory(); + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/test/session"); + request.Headers.Authorization = new("Bearer", session.Token); + using var response = await api.Client.SendAsync(request); + + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(session.Token, out var renewed).Should().BeTrue(); + await (renewed!.LastSeenAt == time.GetUtcNow()).Should().BeTrue(); + await (renewed.ExpiresAt == time.GetUtcNow() + WebSessionService.SlidingLifetime).Should().BeTrue(); + } + + [Test] + public async Task LoginRejectsOverlongKeyUniformlyAndAcceptsMaximumLengthKey() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + var maximumLengthKey = new string('k', WebAuthService.MaximumKeyLength); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), maximumLengthKey), + sessions); + + using var valid = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = maximumLengthKey }); + using var overlong = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = maximumLengthKey + "k" }); + using var empty = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = string.Empty }); + using var missing = await api.Client.PostAsJsonAsync("/api/auth/login", new { key = (string?)null }); + + await (valid.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await (overlong.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (empty.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (missing.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + } + + [Test] + public async Task OversizedLoginAndSetupBodiesAreRejectedBeforeJsonBinding() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + await using var api = await ApiHarness.StartAsync( + new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), ManagementKey), + sessions); + var oversizedJson = new string(' ', checked((int)WebAuthRequestBodyLimitMiddleware.MaximumRequestBodyBytes + 1)); + + using var loginRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") + { + Content = new StringContent(oversizedJson, Encoding.UTF8, "application/json"), + }; + loginRequest.Headers.ConnectionClose = true; + using var login = await api.Client.SendAsync(loginRequest); + using var setupRequest = new HttpRequestMessage(HttpMethod.Post, "/api/setup") + { + Content = new StringContent(oversizedJson, Encoding.UTF8, "application/json"), + }; + setupRequest.Headers.ConnectionClose = true; + using var setup = await api.Client.SendAsync(setupRequest); + + await (login.StatusCode == HttpStatusCode.RequestEntityTooLarge).Should().BeTrue(); + await (setup.StatusCode == HttpStatusCode.RequestEntityTooLarge).Should().BeTrue(); + } + + [Test] + public async Task SseHeartbeatDoesNotRenewSessionAndExpiryCancelsItsRevocationToken() + { + var startedAt = DateTimeOffset.Parse("2026-06-01T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryValidateAndRenew(session.Token, out var snapshot).Should().BeTrue(); + var context = new DefaultHttpContext(); + context.Response.Body = Stream.Null; + + for (var minute = 1; minute < 7 * 24 * 60; minute++) + { + time.Advance(TimeSpan.FromMinutes(1)); + if (!await WebApiEndpoints.TryWriteSseHeartbeatAsync(context, sessions, snapshot!, CancellationToken.None)) + { + throw new InvalidOperationException("SSE heartbeat expired the session before its sliding deadline."); + } + } + + time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + var heartbeatSent = await WebApiEndpoints.TryWriteSseHeartbeatAsync( + context, + sessions, + snapshot!, + CancellationToken.None); + + await heartbeatSent.Should().BeFalse(); + await (snapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await snapshot.RevocationToken.IsCancellationRequested.Should().BeTrue(); + } + + private static async Task ReadSseStreamUntilClosedAsync(Stream stream) + { + try + { + // A network read may return only part of already-buffered SSE frames. + await stream.CopyToAsync(Stream.Null).WaitAsync(TimeSpan.FromSeconds(3)); + return true; + } + catch (IOException) + { + return true; + } + } + + private static async Task GetSseTicketAsync(ApiHarness api, string sessionToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sse-ticket"); + request.Headers.Authorization = new("Bearer", sessionToken); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + } + + private sealed class ApiHarness : IAsyncDisposable + { + private readonly WebApplication _app; + + private ApiHarness(WebApplication app, HttpClient client, EventHub events) + { + _app = app; + Client = client; + Events = events; + } + + public HttpClient Client { get; } + public EventHub Events { get; } + + public static async Task StartAsync(WebAuthService auth, WebSessionService sessions) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var app = builder.Build(); + app.Use(async (context, next) => + { + try + { + await next(); + } + catch (Exception) when (context.Request.Path.StartsWithSegments("/api") && !context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(ApiEnvelope.Fail("internal_error", ApiMessageKeys.CommonInternal)); + } + }); + app.UseRouting(); + app.UseRateLimiter(); + app.UseMiddleware(); + app.UseMiddleware(); + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapGet("/api/test/session", () => Results.Ok(new { authorized = true })); + app.MapGet("/api/test/runtime-failure", () => Results.StatusCode(StatusCodes.Status500InternalServerError)); + await app.StartAsync(); + + var addresses = app.Services.GetRequiredService() + .Features.Get()!.Addresses; + var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(addresses.Single()), + Timeout = TimeSpan.FromSeconds(5), + }; + return new ApiHarness(app, client, app.Services.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-auth-api-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } + + private sealed class ManualTimeProvider(DateTimeOffset initialTime) : TimeProvider + { + private DateTimeOffset _now = initialTime; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan amount) => _now += amount; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs new file mode 100644 index 00000000..754a6e32 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthRateLimitTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthRateLimitTests +{ + [Test] + public async Task LoginRateLimiterRejectsExcessAttemptsWithTooManyRequests() + { + using var directory = new TemporaryDirectory(); + using var sessions = new WebSessionService(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + builder.Services.AddSingleton(new WebAuthService(Path.Combine(directory.Path, "web-auth.json"), null)); + builder.Services.AddSingleton(sessions); + + await using var app = builder.Build(); + app.UseRouting(); + app.UseRateLimiter(); + app.UseMiddleware(); + app.MapWebAuthEndpoints(); + await app.StartAsync(); + try + { + var server = app.Services.GetRequiredService(); + var address = server.Features.Get()!.Addresses.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + var statuses = new List(); + int? retryAfterSeconds = null; + + for (var attempt = 0; attempt < 6; attempt++) + { + using var response = await client.PostAsJsonAsync("/api/auth/login", new { key = "incorrect-management-key" }); + statuses.Add(response.StatusCode); + if (attempt == 5 && response.Headers.TryGetValues("Retry-After", out var values) + && int.TryParse(values.SingleOrDefault(), out var seconds)) + { + retryAfterSeconds = seconds; + } + } + + await statuses.Take(5).All(status => status == HttpStatusCode.Unauthorized).Should().BeTrue(); + await (statuses[5] == HttpStatusCode.TooManyRequests).Should().BeTrue(); + await (retryAfterSeconds > 0).Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + [Arguments("/login-limited")] + [Arguments("/setup-limited")] + public async Task AllLoopbackAddressesShareOneRateLimitPartition(string endpoint) + { + await using var app = CreatePartitionTestApp(); + await app.StartAsync(); + try + { + using var client = CreateClient(app); + var loopbackAddresses = new[] { "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1" }; + var permitLimit = endpoint == "/login-limited" ? 5 : 10; + + for (var index = 0; index < permitLimit; index++) + { + await (await GetLimitedStatusAsync(client, endpoint, loopbackAddresses[index % loopbackAddresses.Length], index)) + .Should().BeEqualTo(HttpStatusCode.OK); + } + + await (await GetLimitedStatusAsync(client, endpoint, loopbackAddresses[permitLimit % loopbackAddresses.Length], permitLimit)) + .Should().BeEqualTo(HttpStatusCode.TooManyRequests); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + [Arguments("/login-limited")] + [Arguments("/setup-limited")] + public async Task DifferentNonLoopbackAddressesKeepIndependentRateLimitPartitions(string endpoint) + { + await using var app = CreatePartitionTestApp(); + await app.StartAsync(); + try + { + using var client = CreateClient(app); + var permitLimit = endpoint == "/login-limited" ? 5 : 10; + + for (var index = 0; index < permitLimit; index++) + { + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.1", index)) + .Should().BeEqualTo(HttpStatusCode.OK); + } + + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.2", permitLimit)) + .Should().BeEqualTo(HttpStatusCode.OK); + await (await GetLimitedStatusAsync(client, endpoint, "192.0.2.1", permitLimit + 1)) + .Should().BeEqualTo(HttpStatusCode.TooManyRequests); + } + finally + { + await app.StopAsync(); + } + } + + private static WebApplication CreatePartitionTestApp() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + + var app = builder.Build(); + app.UseRouting(); + // Test-only injection lets the integration tests exercise multiple peer addresses + // without trusting forwarded headers in the WebAPI pipeline. + app.Use(async (context, next) => + { + if (IPAddress.TryParse(context.Request.Headers["X-Test-Remote-Ip"].FirstOrDefault(), out var address)) + { + context.Connection.RemoteIpAddress = address; + } + + await next(); + }); + app.UseRateLimiter(); + app.MapGet("/login-limited", () => Results.Ok()) + .RequireRateLimiting(WebAuthRateLimiting.LoginPolicy); + app.MapGet("/setup-limited", () => Results.Ok()) + .RequireRateLimiting(WebAuthRateLimiting.SetupPolicy); + return app; + } + + private static HttpClient CreateClient(WebApplication app) + { + var server = app.Services.GetRequiredService(); + var address = server.Features.Get()!.Addresses.Single(); + return new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) }; + } + + private static async Task GetLimitedStatusAsync(HttpClient client, string endpoint, string remoteIp, int requestIndex) + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Add("X-Test-Remote-Ip", remoteIp); + request.Headers.Add("X-Forwarded-For", $"198.51.100.{requestIndex + 1}"); + request.Headers.Add("Forwarded", $"for=198.51.100.{requestIndex + 1}"); + request.Headers.Add("X-Real-IP", $"198.51.100.{requestIndex + 1}"); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-rate-limit-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs new file mode 100644 index 00000000..f6f20ea6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebAuthServiceTests.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebAuthServiceTests +{ + private const string ManagementKey = "test-management-key-2026"; + + [Test] + public async Task MissingEnvironmentAndPersistedKeyRequiresSetup() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"), null); + + await auth.SetupRequired.Should().BeTrue(); + await auth.EnvironmentKeyConfigured.Should().BeFalse(); + await auth.ValidateManagementKey(ManagementKey).Should().BeFalse(); + } + + [Test] + public async Task EnvironmentKeyTakesPrecedenceWithoutPersistingIt() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var auth = new WebAuthService(path, ManagementKey); + + await auth.SetupRequired.Should().BeFalse(); + await auth.EnvironmentKeyConfigured.Should().BeTrue(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("a-different-management-key").Should().BeFalse(); + await File.Exists(path).Should().BeFalse(); + } + + [Test] + public async Task SetupPersistsOnlySaltAndVerifierAndVerifiesTheKeyImmediately() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var auth = new WebAuthService(path, null); + + var result = await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true); + + await (result == WebSetupResult.Created).Should().BeTrue(); + await auth.SetupRequired.Should().BeFalse(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("not-the-management-key").Should().BeFalse(); + + var persisted = await File.ReadAllTextAsync(path); + await persisted.Contains(ManagementKey, StringComparison.Ordinal).Should().BeFalse(); + using var document = JsonDocument.Parse(persisted); + await (document.RootElement.GetProperty("version").GetInt32() == 1).Should().BeTrue(); + await (!string.IsNullOrWhiteSpace(document.RootElement.GetProperty("salt").GetString())).Should().BeTrue(); + await (!string.IsNullOrWhiteSpace(document.RootElement.GetProperty("keyVerifier").GetString())).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + { + await (File.GetUnixFileMode(path) == (UnixFileMode.UserRead | UnixFileMode.UserWrite)).Should().BeTrue(); + } + } + + [Test] + public async Task SetupRejectsShortOrMismatchedKeys() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync("short", "short", setupAccessAllowed: true)) == WebSetupResult.KeyTooShort).Should().BeTrue(); + await ((await auth.SetupAsync(new string('x', WebAuthService.MaximumKeyLength + 1), new string('x', WebAuthService.MaximumKeyLength + 1), setupAccessAllowed: true)) == WebSetupResult.KeyTooLong).Should().BeTrue(); + await ((await auth.SetupAsync(ManagementKey, "a-different-key", setupAccessAllowed: true)) == WebSetupResult.KeysDoNotMatch).Should().BeTrue(); + await auth.SetupRequired.Should().BeTrue(); + } + + [Test] + public async Task SetupCanOnlyBeCompletedOnce() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + await ((await auth.SetupAsync("a-second-management-key", "a-second-management-key", setupAccessAllowed: true)) == WebSetupResult.AlreadyConfigured).Should().BeTrue(); + await auth.ValidateManagementKey(ManagementKey).Should().BeTrue(); + await auth.ValidateManagementKey("a-second-management-key").Should().BeFalse(); + } + + [Test] + public async Task SetupRejectsWhenRequestAccessPolicyDeniesIt() + { + using var directory = new TemporaryDirectory(); + var auth = new WebAuthService(Path.Combine(directory.Path, "WebAPI-auth.json"), null); + + await ((await auth.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: false)) == WebSetupResult.Forbidden).Should().BeTrue(); + await auth.SetupRequired.Should().BeTrue(); + } + + [Test] + public async Task PersistedVerifierSurvivesServiceRecreation() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "guiConfigs", "WebAPI-auth.json"); + var firstInstance = new WebAuthService(path, null); + await ((await firstInstance.SetupAsync(ManagementKey, ManagementKey, setupAccessAllowed: true)) == WebSetupResult.Created).Should().BeTrue(); + + var restartedInstance = new WebAuthService(path, null); + await restartedInstance.SetupRequired.Should().BeFalse(); + await restartedInstance.ValidateManagementKey(ManagementKey).Should().BeTrue(); + } + + [Test] + public async Task WebAuthStorageMovesLegacyConfigCredentialOutsideTheBackupDirectory() + { + using var directory = new TemporaryDirectory(); + var legacyPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + await File.WriteAllTextAsync(legacyPath, "local-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath(directory.Path, legacyPath); + + await authPath.Should().BeEqualTo(Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json")); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("local-verifier"); + await File.Exists(legacyPath).Should().BeFalse(); + if (OperatingSystem.IsLinux()) + { + await (File.GetUnixFileMode(Path.GetDirectoryName(authPath)!) + == (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute)).Should().BeTrue(); + } + } + + [Test] + public async Task ExistingPrivateWebAuthCredentialWinsOverAStaleLegacyCopy() + { + using var directory = new TemporaryDirectory(); + var privatePath = Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json"); + var legacyPath = Path.Combine(directory.Path, "guiConfigs", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(privatePath)!); + Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!); + await File.WriteAllTextAsync(privatePath, "private-verifier"); + await File.WriteAllTextAsync(legacyPath, "stale-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath(directory.Path, legacyPath); + + await authPath.Should().BeEqualTo(privatePath); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("private-verifier"); + await File.Exists(legacyPath).Should().BeFalse(); + } + + [Test] + public async Task PreviousPrivateWebAuthCredentialMigratesToTheRenamedLocation() + { + using var directory = new TemporaryDirectory(); + var previousPrivatePath = Path.Combine(directory.Path, "webData", "web-auth.json"); + Directory.CreateDirectory(Path.GetDirectoryName(previousPrivatePath)!); + await File.WriteAllTextAsync(previousPrivatePath, "old-private-verifier"); + + var authPath = WebAuthStorage.MigrateAndGetPath( + directory.Path, + Path.Combine(directory.Path, "guiConfigs", "web-auth.json")); + + await authPath.Should().BeEqualTo(Path.Combine(directory.Path, "WebAPIData", "WebAPI-auth.json")); + await (await File.ReadAllTextAsync(authPath)).Should().BeEqualTo("old-private-verifier"); + await File.Exists(previousPrivatePath).Should().BeFalse(); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-auth-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs new file mode 100644 index 00000000..773fea8b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebCorsTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebCorsTests +{ + private const string Origin = "https://good.example.com"; + private const string Key = "isolated-cors-management-key"; + + [Test] + public async Task ConfigurationRejectsUnsafeOriginsAndMatchesExactSchemeHostPort() + { + foreach (var invalid in new[] { "*", "null", "file://host", "https://*.example.com", "https://good.example.com/path", + "https://good.example.com?", "https://good.example.com#", "https://user:password@good.example.com", "https://good.example.com,", "https://good.example.com\\evil", "https://good.example.com/other/..", "https://good.example.com/%2e" }) + { + var rejected = false; + try { _ = new WebCorsPolicy(invalid); } catch (ArgumentException) { rejected = true; } + await rejected.Should().BeTrue(); + } + var policy = new WebCorsPolicy(" HTTPS://GOOD.EXAMPLE.COM:443, http://localhost:5173 "); + await policy.Allows(Origin).Should().BeTrue(); + await policy.Allows("http://good.example.com").Should().BeFalse(); + await policy.Allows("https://good.example.com:444").Should().BeFalse(); + await policy.Allows("https://good.example.com.evil.com").Should().BeFalse(); + } + + [Test] + public async Task DefaultDeniesCrossOriginIncludingSimpleLoginWithoutExecutingIt() + { + await using var api = await Harness.StartAsync(null); + using var request = Request(HttpMethod.Post, "/api/auth/login", Origin); + request.Content = JsonContent.Create(new { key = Key }); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await document.RootElement.GetProperty("code").GetString().Should().BeEqualTo("origin_not_allowed"); + } + + [Test] + public async Task SimilarOriginAndNullOriginAreDeniedEvenWithAValidBearer() + { + await using var api = await Harness.StartAsync(Origin); + foreach (var origin in new[] { Origin + ".evil.com", "http://good.example.com", Origin + ":444", "null" }) + { + using var request = Request(HttpMethod.Get, "/api/test/session", origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Origin").Should().BeFalse(); + } + } + + [Test] + public async Task PreflightCompletesWithoutBearerAndDoesNotEnableCookiesOrPrivateNetworkBypass() + { + await using var api = await Harness.StartAsync(Origin); + using var request = Request(HttpMethod.Options, "/api/test/session", Origin); + request.Headers.Add("Access-Control-Request-Method", "PUT"); + request.Headers.Add("Access-Control-Request-Headers", "authorization,content-type"); + request.Headers.Add("Access-Control-Request-Private-Network", "true"); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.NoContent).Should().BeTrue(); + await AssertCorsAsync(response); + await response.Headers.GetValues("Access-Control-Allow-Methods").Single().Contains("PUT").Should().BeTrue(); + await response.Headers.GetValues("Access-Control-Allow-Headers").Single().Contains("authorization", StringComparison.OrdinalIgnoreCase).Should().BeTrue(); + await response.Headers.Contains("Access-Control-Allow-Private-Network").Should().BeFalse(); + } + + [Test] + public async Task LoginAndBearerRestWorkAndManagementKeyIsStillNotAnApiToken() + { + await using var api = await Harness.StartAsync(Origin); + using var login = Request(HttpMethod.Post, "/api/auth/login", Origin); + login.Content = JsonContent.Create(new { key = Key }); + using var loggedIn = await api.Client.SendAsync(login); + await (loggedIn.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(loggedIn); + using var document = JsonDocument.Parse(await loggedIn.Content.ReadAsStringAsync()); + var token = document.RootElement.GetProperty("data").GetProperty("token").GetString()!; + using var request = Request(HttpMethod.Get, "/api/test/session", Origin, token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + using var wrong = Request(HttpMethod.Get, "/api/test/session", Origin, Key); + using var unauthorized = await api.Client.SendAsync(wrong); + await (unauthorized.StatusCode == HttpStatusCode.Unauthorized).Should().BeTrue(); + await AssertCorsAsync(unauthorized); + } + + [Test] + public async Task TicketAndRealSseEndpointWorkAcrossOriginAndTicketCannotBeConsumedByDeniedOrigin() + { + await using var api = await Harness.StartAsync(Origin); + var token = api.Sessions.CreateSession().Token; + using var ticketRequest = Request(HttpMethod.Post, "/api/auth/sse-ticket", Origin, token); + using var ticketResponse = await api.Client.SendAsync(ticketRequest); + await AssertCorsAsync(ticketResponse); + using var document = JsonDocument.Parse(await ticketResponse.Content.ReadAsStringAsync()); + var ticket = document.RootElement.GetProperty("data").GetProperty("ticket").GetString()!; + var path = "/api/events?sse_ticket=" + Uri.EscapeDataString(ticket); + using var deniedRequest = Request(HttpMethod.Get, path, Origin + ".evil.com"); + using var denied = await api.Client.SendAsync(deniedRequest); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + using var request = Request(HttpMethod.Get, path, Origin); + var responseTask = api.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + for (var attempt = 0; attempt < 100 && !responseTask.IsCompleted; attempt++) + { + api.Events.Publish("status", new { ready = true }); + await Task.Delay(10); + } + using var response = await responseTask.WaitAsync(TimeSpan.FromSeconds(5)); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + await (response.Content.Headers.ContentType?.MediaType == "text/event-stream").Should().BeTrue(); + api.Sessions.Revoke(token); + } + + [Test] + public async Task DownloadExposesOnlyRequiredPublicHeaders() + { + await using var api = await Harness.StartAsync(Origin); + using var request = Request(HttpMethod.Get, "/api/test/download", Origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + await AssertCorsAsync(response); + await (response.Content.Headers.ContentDisposition?.FileNameStar == "backup.zip").Should().BeTrue(); + await response.Headers.GetValues("Access-Control-Expose-Headers").Single().Should().BeEqualTo("Content-Disposition"); + } + + [Test] + public async Task AllowedOriginCannotSetupAndStatusExplainsWhyWhileSameOriginStillCan() + { + await using var api = await Harness.StartAsync(Origin, configured: false); + using var statusRequest = Request(HttpMethod.Get, "/api/setup/status", Origin); + using var status = await api.Client.SendAsync(statusRequest); + await AssertCorsAsync(status); + using var statusDocument = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await statusDocument.RootElement.GetProperty("setupRequired").GetBoolean().Should().BeTrue(); + await statusDocument.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + using var setupRequest = Request(HttpMethod.Post, "/api/setup", Origin); + setupRequest.Content = JsonContent.Create(new { key = Key, confirmKey = Key }); + using var denied = await api.Client.SendAsync(setupRequest); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + await api.Auth.SetupRequired.Should().BeTrue(); + using var sameOrigin = Request(HttpMethod.Post, "/api/setup", api.Client.BaseAddress!.GetLeftPart(UriPartial.Authority)); + sameOrigin.Content = JsonContent.Create(new { key = Key, confirmKey = Key }); + using var created = await api.Client.SendAsync(sameOrigin); + await (created.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + + [Test] + public async Task NativeAndSameOriginRequestsAreUnchangedWithoutAllowedOrigins() + { + await using var api = await Harness.StartAsync(null); + foreach (var origin in new[] { null, api.Client.BaseAddress!.GetLeftPart(UriPartial.Authority) }) + { + using var request = Request(HttpMethod.Get, "/api/test/session", origin, api.Sessions.CreateSession().Token); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + } + } + + [Test] + public async Task SameOriginBrowserBehindTlsProxyKeepsRestButCannotRelaySetup() + { + await using var api = await Harness.StartAsync(null, configured: false); + var token = api.Sessions.CreateSession().Token; + using var request = Request(HttpMethod.Get, "/api/test/session", "https://proxy.example.com", token); + request.Headers.Add("Sec-Fetch-Site", "same-origin"); + using var response = await api.Client.SendAsync(request); + await (response.StatusCode == HttpStatusCode.OK).Should().BeTrue(); + // Genuine cross-origin browser requests cannot forge same-origin metadata. + using var crossSite = Request(HttpMethod.Get, "/api/test/session", "https://proxy.example.com", token); + crossSite.Headers.Add("Sec-Fetch-Site", "same-site"); + using var denied = await api.Client.SendAsync(crossSite); + await (denied.StatusCode == HttpStatusCode.Forbidden).Should().BeTrue(); + using var statusRequest = Request(HttpMethod.Get, "/api/setup/status", "https://proxy.example.com"); + statusRequest.Headers.Add("Sec-Fetch-Site", "same-origin"); + using var status = await api.Client.SendAsync(statusRequest); + using var document = JsonDocument.Parse(await status.Content.ReadAsStringAsync()); + await document.RootElement.GetProperty("setupAllowedFromThisRequest").GetBoolean().Should().BeFalse(); + } + + private static HttpRequestMessage Request(HttpMethod method, string path, string? origin, string? token = null) + { + var request = new HttpRequestMessage(method, path); + if (origin is not null) request.Headers.Add("Origin", origin); + if (token is not null) request.Headers.Authorization = new("Bearer", token); + return request; + } + + private static async Task AssertCorsAsync(HttpResponseMessage response) + { + await response.Headers.GetValues("Access-Control-Allow-Origin").Single().Should().BeEqualTo(Origin); + await response.Headers.Contains("Access-Control-Allow-Credentials").Should().BeFalse(); + } + + private sealed class Harness(WebApplication app, HttpClient client, WebAuthService auth, string directory) : IAsyncDisposable + { + public HttpClient Client { get; } = client; + public WebAuthService Auth { get; } = auth; + public WebSessionService Sessions => app.Services.GetRequiredService(); + public EventHub Events => app.Services.GetRequiredService(); + + public static async Task StartAsync(string? origins, bool configured = true) + { + var directory = Path.Combine(Path.GetTempPath(), "webapi-cors-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var auth = new WebAuthService(Path.Combine(directory, "web-auth.json"), configured ? Key : null); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Logging.ClearProviders(); + var cors = new WebCorsPolicy(origins); + builder.Services.AddSingleton(cors); + builder.Services.AddCors(options => options.AddPolicy(WebCorsPolicy.PolicyName, cors.Configure)); + builder.Services.AddSingleton(auth); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddRateLimiter(WebAuthRateLimiting.Configure); + var app = builder.Build(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseRouting(); + app.UseMiddleware(); + app.UseCors(WebCorsPolicy.PolicyName); + app.UseRateLimiter(); + app.UseMiddleware(); + app.MapWebApi(); + app.MapWebAuthEndpoints(); + app.MapWebSetupEndpoints(); + app.MapGet("/api/test/session", () => Results.Ok(new { authorized = true })); + app.MapGet("/api/test/download", () => Results.File(new byte[] { 1, 2, 3 }, "application/zip", "backup.zip")); + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + return new Harness(app, new HttpClient(new SocketsHttpHandler { UseProxy = false }) { BaseAddress = new Uri(address) }, auth, directory); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs new file mode 100644 index 00000000..6d8a61a6 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebDeploymentSecurityPolicyTests.cs @@ -0,0 +1,33 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebDeploymentSecurityPolicyTests +{ + [Test] + public async Task NativeInteractiveInstallCanStartWithoutEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(false, false, null).Should().BeNull(); + await WebDeploymentSecurityPolicy.GetStartupError(false, false, " ").Should().BeNull(); + } + + [Test] + public async Task SupervisedDeploymentRequiresAnEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(true, false, null) + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(true, false, "\t") + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(true, false, "a-configured-key") + .Should().BeNull(); + } + + [Test] + public async Task ContainerDeploymentRequiresAnEnvironmentKey() + { + await WebDeploymentSecurityPolicy.GetStartupError(false, true, null) + .Should().BeEqualTo(WebDeploymentSecurityPolicy.MissingManagementKeyMessage); + await WebDeploymentSecurityPolicy.GetStartupError(false, true, "a-configured-key") + .Should().BeNull(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs new file mode 100644 index 00000000..dcd4df3e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebHealthEndpointTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebHealthEndpointTests +{ + [Test] + public async Task LanHealthResponseContainsOnlyStatusAndDoesNotExposeRuntimeDetails() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.20"); + + var response = WebHealthEndpoint.CreateResponse(context, runtime: null!); + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await json.Should().BeEqualTo("{\"status\":\"ok\"}"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-version").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-state").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-shutdown-stage").Should().BeFalse(); + } + + [Test] + public async Task LoopbackHealthProbeRetainsTheFieldsRequiredByLauncherAndUpdater() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + context.Request.Host = new HostString("127.0.0.1", 5080); + var runtime = new V2rayRuntime(null!, null!, null!, null!, null!); + + _ = WebHealthEndpoint.CreateResponse(context, runtime); + + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeTrue(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-version").Should().BeTrue(); + await context.Response.Headers["X-v2rayn-WebAPI-core-state"].ToString().Should().BeEqualTo("stopped"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeTrue(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeTrue(); + } + + [Test] + public async Task LocalReverseProxyDoesNotGetExtendedHealthFields() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + context.Request.Host = new HostString("127.0.0.1", 5080); + context.Request.Headers["X-Forwarded-For"] = "198.51.100.25"; + + var response = WebHealthEndpoint.CreateResponse(context, runtime: null!); + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await json.Should().BeEqualTo("{\"status\":\"ok\"}"); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-instance-pid").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-process-ids").Should().BeFalse(); + await context.Response.Headers.ContainsKey("X-v2rayn-WebAPI-core-profile-id").Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs new file mode 100644 index 00000000..8771e9bf --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebLauncherTests.cs @@ -0,0 +1,545 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class WebLauncherTests +{ + [Test] + public async Task InstanceLockOwnerCanBeReadWhileAnotherProcessHoldsTheLock() + { + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + await WebInstanceLock.ReadOwnerProcessId(lockPath).Should().BeEqualTo(Environment.ProcessId); + await WebInstanceLock.IsHeld(lockPath).Should().BeTrue(); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out _).Should().BeFalse(); + } + await WebInstanceLock.IsHeld(lockPath).Should().BeFalse(); + } + + [Test] + public async Task StopFlagSelectsStopModeAndIsNotForwardedToHost() + { + var options = WebLaunchOptions.Parse( + ["--stop", "--foreground", "--background", "--urls", "http://127.0.0.1:5090"], + isLinux: true, + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false); + + await (options.Mode == WebLaunchMode.Stop).Should().BeTrue(); + await options.HostArguments.SequenceEqual(["--urls", "http://127.0.0.1:5090"]).Should().BeTrue(); + } + + [Test] + public async Task ForegroundFlagAlwaysSelectsForegroundMode() + { + var options = WebLaunchOptions.Parse(["--foreground"], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task BackgroundFlagSelectsLauncherMode() + { + var options = WebLaunchOptions.Parse(["--background"], isLinux: true, daemonEnvironment: true, containerEnvironment: true, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + } + + [Test] + public async Task BackgroundChildRunsTheForegroundHostWithoutRecursiveLauncherArguments() + { + var options = WebLaunchOptions.Parse(["--background-child", "--no-open", "--urls", "http://127.0.0.1:5090"], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + await (options.Mode == WebLaunchMode.BackgroundChild).Should().BeTrue(); + await options.NoOpen.Should().BeTrue(); + await options.HostArguments.SequenceEqual(["--urls", "http://127.0.0.1:5090"]).Should().BeTrue(); + } + + [Test] + public async Task SystemdAndContainerDefaultsRemainForeground() + { + var systemd = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: true, containerEnvironment: false, interactiveLaunch: false); + var container = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: false, containerEnvironment: true, interactiveLaunch: false); + + await (systemd.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + await (container.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + await (WebLaunchOptions.Parse(["--foreground"], true, true, true, false).Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task NativeLinuxDefaultUsesTheBackgroundLauncher() + { + var desktop = WebLaunchOptions.Parse([], isLinux: true, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + var otherPlatform = WebLaunchOptions.Parse([], isLinux: false, daemonEnvironment: false, containerEnvironment: false, interactiveLaunch: false); + + await (desktop.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + await (otherPlatform.Mode == WebLaunchMode.Foreground).Should().BeTrue(); + } + + [Test] + public async Task InteractiveLaunchOverridesLeakedSystemdMarkersForTheDefaultLauncher() + { + // Terminals started by systemd export INVOCATION_ID/JOURNAL_STREAM to every + // command they run; commands run in such a terminal are still interactive. + var nestedTerminal = WebLaunchOptions.Parse( + [], + isLinux: true, + daemonEnvironment: true, + containerEnvironment: false, + interactiveLaunch: true); + + await (nestedTerminal.Mode == WebLaunchMode.BackgroundLauncher).Should().BeTrue(); + } + + [Test] + public async Task InteractiveLaunchMarkerIsDetectedAndHelpFlagsAreNotForwarded() + { + await LauncherEnvironment.IsInteractiveLaunchMarker( + new Dictionary { [LauncherEnvironment.InteractiveLaunchEnvironmentVariable] = "1" }) + .Should().BeTrue(); + await LauncherEnvironment.IsInteractiveLaunchMarker(new Dictionary()) + .Should().BeFalse(); + + var options = WebLaunchOptions.Parse( + ["--help"], + isLinux: true, + daemonEnvironment: false, + containerEnvironment: false, + interactiveLaunch: false); + await (options.HostArguments.Length == 0).Should().BeTrue(); + } + + [Test] + public async Task HelpTextListsTheDocumentedFlagsInEveryLocale() + { + foreach (var locale in new[] + { + LauncherLocale.SimplifiedChinese, + LauncherLocale.TraditionalChinese, + LauncherLocale.English, + }) + { + var help = LauncherMessages.Help(locale); + await help.Contains("--foreground", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--background", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--no-open", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--stop", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("--help", StringComparison.Ordinal).Should().BeTrue(); + await help.Contains("V2RAYN_WEB_API_KEY", StringComparison.Ordinal).Should().BeTrue(); + } + } + + [Test] + public async Task SystemdEnvironmentDetectionRecognizesInvocationMarkers() + { + var environment = new Dictionary { ["INVOCATION_ID"] = "unit-run-id" }; + await LauncherEnvironment.IsDaemonEnvironment(environment).Should().BeTrue(); + await LauncherEnvironment.IsContainerEnvironment(new Dictionary { ["container"] = "podman" }).Should().BeTrue(); + } + + [Test] + public async Task ExistingHealthyInstanceIsReusedAndBrowserAdapterIsInvoked() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + var browser = new FakeBrowserOpener(); + var result = await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(true, WebInstanceLock.ReadOwnerProcessId(lockPath)), + browser, + noOpen: false, + TimeSpan.FromSeconds(1)); + + await result.Existing.Should().BeTrue(); + await result.BrowserOpened.Should().BeTrue(); + await (browser.LastUri == new Uri("http://127.0.0.1:5080/")).Should().BeTrue(); + } + } + + [Test] + public async Task HealthyEndpointFromAnotherInstanceIsNotReusedForThisDataHome() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: true, out var heldLock).Should().BeTrue(); + using (heldLock) + { + var expectedOwner = WebInstanceLock.ReadOwnerProcessId(lockPath); + var browser = new FakeBrowserOpener(); + var timedOut = false; + try + { + await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(true, expectedOwner + 1), + browser, + noOpen: false, + TimeSpan.FromMilliseconds(50)); + } + catch (TimeoutException) + { + timedOut = true; + } + + await timedOut.Should().BeTrue(); + await (browser.LastUri is null).Should().BeTrue(); + } + } + + [Test] + public async Task UnlockedStaleLockFileCanBeRecovered() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await File.WriteAllTextAsync(lockPath, "99999999\n"); + + var result = await ExistingInstanceHandler.TryReuseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + new Uri("http://127.0.0.1:5080/"), + new FakeHealthProbe(false), + new FakeBrowserOpener(), + noOpen: true, + TimeSpan.FromMilliseconds(100)); + + await result.Existing.Should().BeFalse(); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var recovered).Should().BeTrue(); + recovered!.Dispose(); + } + + [Test] + public async Task StopTreatsAnUnlockedStaleLockAsAlreadyStopped() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + await File.WriteAllTextAsync(lockPath, "123\n"); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.NotRunning).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopSendsOneSigTermToVerifiedOwnerAndWaitsForLockRelease() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using (heldLock) + { + var signals = new FakeSignalSender(); + var health = new FakeHealthProbe(true, 123); + signals.OnSignal = processId => + { + _ = ReleaseLockAfterDelayAsync(heldLock, health); + }; + var stopper = new WebStopper( + health, + signals, + stopTimeout: TimeSpan.FromMilliseconds(500), + pollInterval: TimeSpan.FromMilliseconds(10)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.Stopped).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await (health.ProbeCount >= 2).Should().BeTrue(); + } + } + + [Test] + public async Task StopRefusesToCompeteWithSystemdManagedLifecycle() + { + await WebStopper.IsSystemdServiceCgroup("0::/system.slice/v2rayn-web.service").Should().BeTrue(); + await WebStopper.IsSystemdServiceCgroup("0::/user.slice/user-1000.slice/session-2.scope").Should().BeFalse(); + } + + [Test] + public async Task StopDoesNotReportSuccessWhileAKnownCoreChildRemainsAlive() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + using (heldLock) + { + var health = new FakeHealthProbe(true, 123, [Environment.ProcessId]); + var signals = new FakeSignalSender(); + signals.OnSignal = processId => _ = ReleaseLockAfterDelayAsync(heldLock, health); + var stopper = new WebStopper(health, signals, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(10)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.CoreProcessStillRunning).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + } + } + + [Test] + public async Task StopNeverSignalsWhenHealthPidDoesNotMatchLockOwner() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 456), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopNeverSignalsWhenHealthCannotConfirmTheInstance() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(false, 123), signals); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.IdentityUnverified).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + } + + [Test] + public async Task StopWithoutAProbeEndpointNeverSignals() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals); + + var result = await stopper.StopAsync(lockPath, healthUri: null); + + await (result == WebStopResult.ProbeUnavailable).Should().BeTrue(); + await (signals.ProcessIds.Count == 0).Should().BeTrue(); + + var notRunning = await stopper.StopAsync( + Path.Combine(directory.Path, "missing-instance.lock"), healthUri: null); + await (notRunning == WebStopResult.NotRunning).Should().BeTrue(); + } + + [Test] + public async Task StopTimesOutWithoutEscalatingBeyondOneSigTerm() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var signals = new FakeSignalSender(); + var stopper = new WebStopper( + new FakeHealthProbe(true, 123), + signals, + stopTimeout: TimeSpan.FromMilliseconds(60), + pollInterval: TimeSpan.FromMilliseconds(5)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.TimedOut).Should().BeTrue(); + await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue(); + await WebInstanceLock.IsHeld(lockPath).Should().BeTrue(); + } + + [Test] + public async Task StopTimeoutReportsLastObservedCleanupStage() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var lockPath = Path.Combine(directory.Path, "instance.lock"); + using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123); + var health = new FakeHealthProbe(true, 123); + health.SetShutdownStage("Core stop"); + var stopper = new WebStopper( + health, + new FakeSignalSender(), + stopTimeout: TimeSpan.FromMilliseconds(60), + pollInterval: TimeSpan.FromMilliseconds(5)); + + var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health")); + + await (result == WebStopResult.TimedOut).Should().BeTrue(); + await (stopper.LastObservedShutdownStage == "Core stop").Should().BeTrue(); + await LauncherMessages.StopMessage(result, LauncherLocale.English, stopper.LastObservedShutdownStage) + .Contains("Core stop", StringComparison.Ordinal).Should().BeTrue(); + } + + [Test] + public async Task LockIsReleasedWhenOwnerExitsAndDataHomesUseDistinctLocks() + { + if (!OperatingSystem.IsLinux()) return; + + using var directory = new TemporaryDirectory(); + var firstPath = Path.Combine(directory.Path, "one", "instance.lock"); + var secondPath = Path.Combine(directory.Path, "two", "instance.lock"); + await WebInstanceLock.TryAcquire(firstPath, writeOwner: true, out var first).Should().BeTrue(); + await WebInstanceLock.TryAcquire(secondPath, writeOwner: true, out var second).Should().BeTrue(); + using (first) + using (second) + { + await WebInstanceLock.IsHeld(firstPath).Should().BeTrue(); + await WebInstanceLock.IsHeld(secondPath).Should().BeTrue(); + } + await WebInstanceLock.IsHeld(firstPath).Should().BeFalse(); + await WebInstanceLock.TryAcquire(firstPath, writeOwner: false, out var recovered).Should().BeTrue(); + recovered!.Dispose(); + } + + [Test] + public async Task LauncherMessagesUseTheSharedLocaleResources() + { + var command = LauncherMessages.ExecutableCommand("/opt/v2rayn/v2rayN.WebAPI"); + var chinese = LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.SimplifiedChinese); + var english = LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.English); + + await chinese.Contains("API 健康端点", StringComparison.Ordinal).Should().BeTrue(); + await chinese.Contains("Ctrl+C 不会停止", StringComparison.Ordinal).Should().BeTrue(); + await chinese.Contains("./v2rayN.WebAPI --stop", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.AlreadyRunning("http://127.0.0.1:5080", command, false, LauncherLocale.TraditionalChinese) + .Contains("--stop", StringComparison.Ordinal).Should().BeTrue(); + await (LauncherMessages.Started("http://127.0.0.1:5080", command, LauncherLocale.TraditionalChinese) + .Contains("--stop", StringComparison.Ordinal)).Should().BeTrue(); + await english.Contains("API health endpoint", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.ForegroundStarted("http://127.0.0.1:5080", LauncherLocale.English) + .Contains("Ctrl+C", StringComparison.Ordinal).Should().BeTrue(); + await (LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.English) + == "v2rayN.WebAPI is not running for this data directory.").Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.SimplifiedChinese) + .Contains("数据目录", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.NotRunning, LauncherLocale.TraditionalChinese) + .Contains("資料目錄", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.SupervisorManaged, LauncherLocale.English) + .Contains("systemctl stop", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.CoreProcessStillRunning, LauncherLocale.SimplifiedChinese) + .Contains("Core", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.English) + .Contains("loopback HTTP health endpoint", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.SimplifiedChinese) + .Contains("回环 HTTP 健康端点", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StopMessage(WebStopResult.ProbeUnavailable, LauncherLocale.TraditionalChinese) + .Contains("回送 HTTP 健康端點", StringComparison.Ordinal).Should().BeTrue(); + await LauncherMessages.StartUnprobeable(LauncherLocale.English) + .Contains("--foreground", StringComparison.Ordinal).Should().BeTrue(); + if (OperatingSystem.IsLinux()) + { + await (LauncherMessages.ExecutableCommand("/usr/share/dotnet/dotnet", "/opt/v2rayn/v2rayN.WebAPI.dll") + == "\"/usr/share/dotnet/dotnet\" \"/opt/v2rayn/v2rayN.WebAPI.dll\"").Should().BeTrue(); + } + await (LauncherMessages.ResolveLocale("zh_Hant_TW") == LauncherLocale.TraditionalChinese).Should().BeTrue(); + await (LauncherMessages.ResolveLocale("zh_CN.UTF-8") == LauncherLocale.SimplifiedChinese).Should().BeTrue(); + await (LauncherMessages.ResolveLocale("fr_FR.UTF-8") == LauncherLocale.English).Should().BeTrue(); + } + + private static async Task AcquireLockWithOwnerAsync(string lockPath, int ownerProcessId) + { + await File.WriteAllTextAsync(lockPath, $"{ownerProcessId}\n"); + await WebInstanceLock.TryAcquire(lockPath, writeOwner: false, out var heldLock).Should().BeTrue(); + return heldLock!; + } + + private static async Task ReleaseLockAfterDelayAsync(WebInstanceLock heldLock, FakeHealthProbe health) + { + await Task.Delay(40); + heldLock.Dispose(); + health.SetResult(false, null); + } + + private sealed class FakeHealthProbe(bool healthy, int? processId = null, int[]? coreProcessIds = null) : IWebHealthProbe + { + private bool _healthy = healthy; + private int? _processId = processId; + private string? _shutdownStage; + private int[] _coreProcessIds = coreProcessIds ?? []; + + public int ProbeCount { get; private set; } + + public Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) => + RecordProbe(); + + public void SetResult(bool isHealthy, int? instanceProcessId, int[]? coreProcessIds = null) + { + _healthy = isHealthy; + _processId = instanceProcessId; + if (coreProcessIds is not null) _coreProcessIds = coreProcessIds; + } + + public void SetShutdownStage(string? stage) => _shutdownStage = stage; + + private Task RecordProbe() + { + ProbeCount++; + return Task.FromResult(new WebHealthProbeResult(_healthy, _processId, _shutdownStage, _coreProcessIds)); + } + } + + private sealed class FakeSignalSender : IProcessSignalSender + { + public List ProcessIds { get; } = []; + public Action? OnSignal { get; set; } + + public bool SendSigTerm(int processId) + { + ProcessIds.Add(processId); + OnSignal?.Invoke(processId); + return true; + } + } + + private sealed class FakeBrowserOpener : IBrowserOpener + { + public Uri? LastUri { get; private set; } + + public bool TryOpen(Uri apiUri) + { + LastUri = apiUri; + return true; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-launcher-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs new file mode 100644 index 00000000..268dbc22 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebListenerSecurityPolicyTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebListenerSecurityPolicyTests +{ + [Test] + [Arguments("http://127.0.0.1:5080", false)] + [Arguments("http://localhost:5080", false)] + [Arguments("http://[::1]:5080", false)] + [Arguments("http://0.0.0.0:5080", true)] + [Arguments("http://[::]:5080", true)] + [Arguments("http://192.168.1.20:5080", true)] + [Arguments("http://*:5080", true)] + [Arguments("http://+:5080", true)] + [Arguments("http://nas:5080", true)] + [Arguments("http://localhost.example:5080", true)] + [Arguments("http://127.0.0.1:5080;https://[::1]:5443", false)] + [Arguments("http://localhost:5080;http://0.0.0.0:5081", true)] + [Arguments("http://192.168.1.20:5080;http://localhost:5081", true)] + [Arguments("not-a-url", true)] + public async Task AllEffectiveListenersMustBeLoopbackWithoutKey(string urls, bool requiresKey) + { + var configuration = Config(("urls", urls)); + await (WebListenerSecurityPolicy.GetStartupError(configuration, null) is not null).Should().BeEqualTo(requiresKey); + await (WebListenerSecurityPolicy.GetStartupError(configuration, " \t") is not null).Should().BeEqualTo(requiresKey); + await WebListenerSecurityPolicy.GetStartupError(configuration, "configured-key").Should().BeNull(); + } + + [Test] + [Arguments("http_ports")] + [Arguments("https_ports")] + public async Task PortOnlyConfigurationIsWildcardButUrlsTakePrecedence(string portsKey) + { + await WebListenerSecurityPolicy.GetStartupError(Config((portsKey, "5080;5081")), null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + await WebListenerSecurityPolicy.GetStartupError(Config((portsKey, "5080"), ("urls", "http://localhost:5081")), null) + .Should().BeNull(); + } + + [Test] + public async Task KestrelEndpointsAndPreferHostingUrlsRespectBindingPrecedence() + { + var configuration = Config(("urls", "http://localhost:5080"), ("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5081")); + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + configuration["preferHostingUrls"] = "true"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null).Should().BeNull(); + configuration["urls"] = "http://0.0.0.0:5080"; + configuration["Kestrel:Endpoints:Http:Url"] = "http://[::1]:5081"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + configuration["preferHostingUrls"] = "false"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null).Should().BeNull(); + configuration["Kestrel:Endpoints:Other:Url"] = "http://192.168.1.20:5082"; + await WebListenerSecurityPolicy.GetStartupError(configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + } + + [Test] + public async Task DefaultIsLoopbackAndDoesNotReplaceHttpsPorts() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + builder.Configuration.Sources.Clear(); + builder.Configuration.AddInMemoryCollection(); + WebListenerSecurityPolicy.ApplyDefault(builder); + await builder.Configuration["urls"].Should().BeEqualTo(WebListenerSecurityPolicy.DefaultUrl); + await WebListenerSecurityPolicy.GetStartupError(builder.Configuration, null).Should().BeNull(); + builder.Configuration["urls"] = ""; + builder.Configuration["https_ports"] = "5443"; + WebListenerSecurityPolicy.ApplyDefault(builder); + await WebListenerSecurityPolicy.GetStartupError(builder.Configuration, null) + .Should().BeEqualTo(WebListenerSecurityPolicy.MissingManagementKeyMessage); + } + + [Test] + [Arguments("--urls", "http://localhost:5080", false)] + [Arguments("--urls=http://0.0.0.0:5080", null, true)] + [Arguments("--http_ports", "5080", true)] + public async Task CommandLineOverridesPrefixedEnvironmentConfiguration(string argument, string? value, bool requiresKey) + { + // Same provider order as CreateBuilder: prefixed host configuration, then CLI. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["urls"] = "http://192.168.1.20:5080" }) + .AddCommandLine(value is null ? [argument] : [argument, value]).Build(); + // URLs win over port-only configuration, so remove the URL for the ports-only case. + if (argument == "--http_ports") configuration["urls"] = ""; + await (WebListenerSecurityPolicy.GetStartupError(configuration, null) is not null).Should().BeEqualTo(requiresKey); + } + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder().AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)).Build(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs new file mode 100644 index 00000000..fa5db688 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebProbeUriResolverTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using Microsoft.Extensions.Configuration; +using v2rayN.WebAPI.Launcher; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebProbeUriResolverTests +{ + [Test] + [Arguments("http://127.0.0.1:5080", "http://127.0.0.1:5080/api/health")] + [Arguments("http://localhost:5080", "http://localhost:5080/api/health")] + [Arguments("http://[::1]:5080", "http://[::1]:5080/api/health")] + [Arguments("http://0.0.0.0:5080", "http://127.0.0.1:5080/api/health")] + [Arguments("http://[::]:5080", "http://[::1]:5080/api/health")] + [Arguments("http://127.0.0.2:5080", null)] + [Arguments("http://127.255.255.254:5080", null)] + [Arguments("http://*:5080", "http://localhost:5080/api/health")] + [Arguments("http://+:5080", "http://localhost:5080/api/health")] + [Arguments("http://nas:5080", "http://localhost:5080/api/health")] + [Arguments("https://127.0.0.1:5443", null)] + [Arguments("http://192.168.1.20:5080", null)] + [Arguments("http://[2001:db8::1]:5080", null)] + [Arguments("not-a-url", null)] + [Arguments("", null)] + public async Task EveryListenUrlMapsToItsMatchingLoopbackProbe(string listenUrl, string? expected) + { + var resolved = WebProbeUriResolver.TryCreateHealthUri(listenUrl, out var healthUri); + + if (expected is null) + { + await resolved.Should().BeFalse(); + return; + } + + await resolved.Should().BeTrue(); + await healthUri.ToString().Should().BeEqualTo(expected); + } + + [Test] + public async Task DefaultConfigurationResolvesToTheDefaultLoopbackProbe() + { + await WebProbeUriResolver.TryResolve(Config(), out var endpoints).Should().BeTrue(); + await endpoints.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + await endpoints.ApiUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/"); + } + + [Test] + public async Task CustomPortsAndHttpPortsResolve() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "http://127.0.0.1:5090")), out var custom) + .Should().BeTrue(); + await custom.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5090/api/health"); + + await WebProbeUriResolver.TryResolve(Config(("http_ports", "6001")), out var ports).Should().BeTrue(); + await ports.HealthUri.ToString().Should().BeEqualTo("http://localhost:6001/api/health"); + } + + [Test] + public async Task Ipv6OnlyAndMixedListenersPickAProbeableEndpoint() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "http://[::1]:5090")), out var ipv6).Should().BeTrue(); + await ipv6.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5090/api/health"); + + await WebProbeUriResolver.TryResolve( + Config(("urls", "http://192.168.1.20:5080;http://[::1]:5091")), out var mixed).Should().BeTrue(); + await mixed.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5091/api/health"); + + // A loopback alias cannot be verified, so the resolver falls through to the + // next verifiable listener instead of producing an unverifiable probe. + await WebProbeUriResolver.TryResolve( + Config(("urls", "http://127.0.0.2:5080;http://127.0.0.1:5092")), out var aliasMixed).Should().BeTrue(); + await aliasMixed.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5092/api/health"); + } + + [Test] + public async Task KestrelEndpointsOverrideHostingUrlsUnlessPreferHostingUrlsIsSet() + { + var configuration = Config( + ("urls", "http://127.0.0.1:5080"), + ("Kestrel:Endpoints:Http:Url", "http://[::1]:5081")); + await WebProbeUriResolver.TryResolve(configuration, out var kestrel).Should().BeTrue(); + await kestrel.HealthUri.ToString().Should().BeEqualTo("http://[::1]:5081/api/health"); + + configuration["preferHostingUrls"] = "true"; + await WebProbeUriResolver.TryResolve(configuration, out var hosting).Should().BeTrue(); + await hosting.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + } + + [Test] + public async Task HttpsOnlyListenersAreRefusedAndMixedListenersFallBackToHttp() + { + await WebProbeUriResolver.TryResolve(Config(("urls", "https://127.0.0.1:5443")), out _).Should().BeFalse(); + await WebProbeUriResolver.TryResolve(Config(("https_ports", "5443")), out _).Should().BeFalse(); + + await WebProbeUriResolver.TryResolve( + Config(("urls", "https://[::1]:5443;http://0.0.0.0:5080")), out var mixed).Should().BeTrue(); + await mixed.HealthUri.ToString().Should().BeEqualTo("http://127.0.0.1:5080/api/health"); + } + + [Test] + public async Task LauncherUrisFollowTheSameResolutionAndNeverGuess() + { + var (health, api) = Program.GetLauncherUris(Config(("urls", "http://[::1]:5090"))); + await health!.ToString().Should().BeEqualTo("http://[::1]:5090/api/health"); + await api!.ToString().Should().BeEqualTo("http://[::1]:5090/"); + + // A configured localhost stays localhost instead of being rewritten to 127.0.0.1. + var (localhostHealth, localhostApi) = Program.GetLauncherUris(Config(("urls", "http://localhost:5090"))); + await localhostHealth!.ToString().Should().BeEqualTo("http://localhost:5090/api/health"); + await localhostApi!.ToString().Should().BeEqualTo("http://localhost:5090/"); + + var (httpsHealth, httpsApi) = Program.GetLauncherUris(Config(("urls", "https://127.0.0.1:5443"))); + await (httpsHealth is null).Should().BeTrue(); + await (httpsApi is null).Should().BeTrue(); + } + + [Test] + [Arguments("http://127.0.0.1:5080/api/health", true)] + [Arguments("http://localhost:5080/api/health", true)] + [Arguments("http://[::1]:5080/api/health", true)] + [Arguments("https://[::1]:5443/api/health", false)] + [Arguments("http://0.0.0.0:5080/api/health", false)] + [Arguments("http://127.0.0.2:5080/api/health", false)] + [Arguments("http://192.168.1.20:5080/api/health", false)] + [Arguments("not-a-uri", false)] + public async Task UpdatePlansOnlyAcceptLoopbackHttpHealthUris(string healthUri, bool accepted) + { + await WebProbeUriResolver.IsProbeableLoopbackUri(healthUri).Should().BeEqualTo(accepted); + } + + [Test] + public async Task LoopbackAliasListenersCannotProduceAProbe() + { + foreach (var listenUrl in new[] { "http://127.0.0.2:5080", "http://127.1.2.3:5080", "http://127.255.255.254:5080" }) + { + await WebProbeUriResolver.TryCreateHealthUri(listenUrl, out _).Should().BeFalse(); + await WebProbeUriResolver.TryResolve(Config(("urls", listenUrl)), out _).Should().BeFalse(); + } + } + + [Test] + public async Task EveryProducedProbeUriSatisfiesTheSharedLoopbackRule() + { + foreach (var listenUrl in new[] + { + "http://127.0.0.1:5080", "http://localhost:5080", "http://[::1]:5080", + "http://0.0.0.0:5080", "http://[::]:5080", "http://*:5080", "http://+:5080", + "http://nas:5080", + }) + { + await WebProbeUriResolver.TryCreateHealthUri(listenUrl, out var healthUri).Should().BeTrue(); + await WebProbeUriResolver.IsProbeableLoopbackUri(healthUri.ToString()).Should().BeTrue(); + } + } + + [Test] + public async Task HealthDiagnosticsAndProbesShareTheSameAddressRule() + { + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "127.0.0.1").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "localhost").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.IPv6Loopback, "::1").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.IPv6Loopback, "[::1]").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Parse("192.168.1.20"), "127.0.0.1").Should().BeFalse(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, "192.168.1.20").Should().BeFalse(); + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Any, "localhost").Should().BeFalse(); + + // Every probe host the resolver can produce is accepted by the diagnostics rule. + foreach (var probeHost in new[] { "localhost", "127.0.0.1", "::1" }) + { + await WebSetupAccessPolicy.IsLocalDiagnosticSource(IPAddress.Loopback, probeHost).Should().BeTrue(); + } + } + + [Test] + public async Task UnprobeableListenerConfigurationRefusesInstallWithAClearReason() + { + var unprobeable = new V2rayRuntime(null!, null!, Config(("urls", "https://127.0.0.1:5443")), null!, null!); + await unprobeable.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + var nonLoopback = new V2rayRuntime(null!, null!, Config(("urls", "http://192.168.1.20:5080")), null!, null!); + await nonLoopback.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + // 127.0.0.2 is loopback for the OS but is not verifiable by the health + // diagnostics or the update helper, so installing must be refused up front. + var loopbackAlias = new V2rayRuntime(null!, null!, Config(("urls", "http://127.0.0.2:5080")), null!, null!); + await loopbackAlias.GetWebUpdateRuntimeInstallReason().Should().BeEqualTo("maintenance.webUpdateProbeUnavailable"); + + var probeable = new V2rayRuntime(null!, null!, Config(("urls", "http://localhost:5080")), null!, null!); + await (probeable.GetWebUpdateRuntimeInstallReason() is null).Should().BeTrue(); + } + + private static IConfigurationRoot Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(item => item.Key, item => (string?)item.Value)) + .Build(); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs new file mode 100644 index 00000000..51135107 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebReleaseChannelTests.cs @@ -0,0 +1,98 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebReleaseChannelTests +{ + [Test] + public async Task OfficialReleaseTagsAreAcceptedAndLegacyWebTagsAreIgnored() + { + var accepted = WebReleaseChannel.TryParseReleaseTag("7.25.3", out var version); + await accepted.Should().BeTrue(); + await version.Should().BeEqualTo("7.25.3"); + + foreach (var legacy in new[] { "web-v7.25.3-web.1", "7.25.3-web.1", "v7.25.3", "7.25", "7.25.3.1", "", null }) + { + var parsed = WebReleaseChannel.TryParseReleaseTag(legacy, out var parsedVersion); + await parsed.Should().BeFalse(); + await parsedVersion.Should().BeEqualTo(string.Empty); + } + } + + [Test] + public async Task ReleaseIndexUrlIsBuiltFromTheBuildRepository() + { + await WebReleaseChannel.BuildReleaseIndexUrl("2dust/v2rayN") + .Should().BeEqualTo("https://api.github.com/repos/2dust/v2rayN/releases?per_page=100"); + await WebReleaseChannel.DefaultRepository.Should().BeEqualTo("2dust/v2rayN"); + + var rejected = false; + try { _ = WebReleaseChannel.BuildReleaseIndexUrl("2dust/v2rayN/../../evil"); } + catch (ArgumentException) { rejected = true; } + await rejected.Should().BeTrue(); + await WebReleaseChannel.IsValidRepository("community-fork/v2rayN").Should().BeTrue(); + await WebReleaseChannel.IsValidRepository("not-a-repository").Should().BeFalse(); + } + + [Test] + public async Task TrustedAssetUrlsMustUseTheBuildRepositoryTagAndAsset() + { + var repository = "2dust/v2rayN"; + var tag = "7.25.3"; + var asset = "v2rayN-linux-64-WebAPI-update.zip"; + var trusted = $"https://github.com/{repository}/releases/download/{tag}/{asset}"; + + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, tag, asset).Should().BeTrue(); + // A build must not accept the same asset from another repository. + await WebReleaseChannel.IsTrustedAssetUrl(trusted.Replace(repository, "another-owner/v2rayN", StringComparison.Ordinal), + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("http://github.com/2dust/v2rayN/releases/download/7.25.3/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("https://example.com/2dust/v2rayN/releases/download/7.25.3/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, "7.25.4", asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(trusted, repository, tag, "WebAPI-update.json").Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl("https://github.com/2dust/v2rayN/releases/download/7.25.3/sub/" + asset, + repository, tag, asset).Should().BeFalse(); + await WebReleaseChannel.IsTrustedAssetUrl(null, repository, tag, asset).Should().BeFalse(); + } + + [Test] + public async Task RuntimeIdentifiersMapToSupportedArchiveNames() + { + await WebUpdatePackageStager.ArtifactArch("linux-x64").Should().BeEqualTo("64"); + await WebUpdatePackageStager.ArtifactArch("linux-arm64").Should().BeEqualTo("arm64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-x64").Should().BeEqualTo("v2rayN-linux-64-WebAPI.zip"); + await WebUpdatePackageStager.FullInstallAssetName("linux-arm64").Should().BeEqualTo("v2rayN-linux-arm64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-x64").Should().BeEqualTo("v2rayN-linux-64-WebAPI-update.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-arm64").Should().BeEqualTo("v2rayN-linux-arm64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("win-x64").Should().BeEqualTo("64"); + await WebUpdatePackageStager.FullInstallAssetName("win-x64").Should().BeEqualTo("v2rayN-windows-64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("win-x64").Should().BeEqualTo("v2rayN-windows-64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("linux-riscv64").Should().BeEqualTo("riscv64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-riscv64").Should().BeEqualTo("v2rayN-linux-riscv64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-riscv64").Should().BeEqualTo("v2rayN-linux-riscv64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("linux-loongarch64").Should().BeEqualTo("loong64"); + await WebUpdatePackageStager.FullInstallAssetName("linux-loongarch64").Should().BeEqualTo("v2rayN-linux-loong64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("linux-loongarch64").Should().BeEqualTo("v2rayN-linux-loong64-WebAPI-update.zip"); + await WebUpdatePackageStager.ArtifactArch("win-arm64").Should().BeEqualTo("arm64"); + await WebUpdatePackageStager.FullInstallAssetName("win-arm64").Should().BeEqualTo("v2rayN-windows-arm64-WebAPI.zip"); + await WebUpdatePackageStager.AppOnlyAssetName("win-arm64").Should().BeEqualTo("v2rayN-windows-arm64-WebAPI-update.zip"); + await WebUpdatePackageStager.IsSupportedRid("linux-riscv64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("linux-loongarch64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("win-arm64").Should().BeTrue(); + await WebUpdatePackageStager.IsSupportedRid("linux-unknown64").Should().BeFalse(); + await WebUpdatePackageStager.FullInstallAssetName("linux-unknown64").Should().BeNull(); + await WebUpdatePackageStager.AppOnlyAssetName(null).Should().BeNull(); + } + + [Test] + public async Task OfficialVersionsCompareGreaterThanDevAndLegacyIdentities() + { + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2", "7.25.3", allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.2", allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("0.0.0-dev", "7.25.3", allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2-web.9", "7.25.3", allowPrerelease: false).Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs new file mode 100644 index 00000000..bb5605e7 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebRestartCoordinatorTests.cs @@ -0,0 +1,286 @@ +using v2rayN.WebAPI.Launcher; + +namespace v2rayN.WebAPI.Tests; + +public class WebRestartCoordinatorTests +{ + [Test] + public async Task NativeSelfContainedRestartUsesDetachedBackgroundChildCommand() + { + if (!OperatingSystem.IsLinux()) return; + + var command = WebReplacementCommand.Create( + "/usr/bin/setsid", + "/opt/v2rayN.WebAPI", + null, + ["--urls", "http://127.0.0.1:5090"]); + + await (command is not null).Should().BeTrue(); + await (command!.LauncherPath == "/usr/bin/setsid").Should().BeTrue(); + await command.Arguments.SequenceEqual([ + "/opt/v2rayN.WebAPI", + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5090", + ]).Should().BeTrue(); + await command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task FrameworkDependentRestartPreservesDotnetDllAndHostArguments() + { + if (!OperatingSystem.IsLinux()) return; + + var processPath = "/usr/bin/dotnet"; + var entryPoint = WebReplacementCommand.GetManagedEntryPoint(processPath, ["/opt/v2rayN/v2rayN.WebAPI.dll", "--urls"]); + var command = WebReplacementCommand.Create( + "/usr/bin/setsid", + processPath, + entryPoint, + ["--urls", "http://127.0.0.1:5090", "--contentRoot", "/opt/v2rayN"]); + + await (entryPoint == "/opt/v2rayN/v2rayN.WebAPI.dll").Should().BeTrue(); + await command!.Arguments.SequenceEqual([ + "/usr/bin/dotnet", + "/opt/v2rayN/v2rayN.WebAPI.dll", + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5090", + "--contentRoot", + "/opt/v2rayN", + ]).Should().BeTrue(); + await (WebReplacementCommand.Create("/usr/bin/setsid", processPath, null, []) is null).Should().BeTrue(); + } + + [Test] + public async Task NativeWindowsRestoreStartsTheExecutableWithoutLinuxSetsid() + { + var executable = "/opt/v2rayN.WebAPI.exe"; + var command = WebReplacementCommand.CreateNative( + isLinux: false, + isWindows: true, + setsidPath: null, + processPath: executable, + managedEntryPoint: null, + hostArguments: ["--urls", "http://127.0.0.1:5080"]); + + await (command is not null).Should().BeTrue(); + await command!.LauncherPath.Should().BeEqualTo(Path.GetFullPath(executable)); + await command.CreateNoWindow.Should().BeTrue(); + await command.Arguments.SequenceEqual([ + WebLaunchOptions.BackgroundChildFlag, + "--urls", + "http://127.0.0.1:5080", + ]).Should().BeTrue(); + await command.ToProcessStartInfo().UseShellExecute.Should().BeFalse(); + } + + [Test] + public async Task SystemdAndContainerOwnRestartWhileNativeLinuxOwnsItsReplacement() + { + var systemd = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: true, containerEnvironment: false); + var cgroupManaged = WebStopper.IsSystemdServiceCgroup("0::/system.slice/v2rayn-web.service"); + var cgroupSystemd = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: cgroupManaged, containerEnvironment: false); + var container = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: false, containerEnvironment: true); + var native = WebLifecyclePlanner.Create(WebLifecycleAction.RestoreAndRestart, true, daemonEnvironment: false, containerEnvironment: false); + var nativeRestart = WebLifecyclePlanner.Create(WebLifecycleAction.Restart, true, daemonEnvironment: false, containerEnvironment: false); + var stop = WebLifecyclePlanner.Create(WebLifecycleAction.Stop, true, daemonEnvironment: false, containerEnvironment: false); + var shutdown = WebLifecyclePlanner.Create(WebLifecycleAction.Shutdown, true, daemonEnvironment: false, containerEnvironment: false); + + await systemd.ExitsForSupervisor.Should().BeTrue(); + await cgroupSystemd.ExitsForSupervisor.Should().BeTrue(); + await container.ExitsForSupervisor.Should().BeTrue(); + await systemd.ShouldStartReplacement.Should().BeFalse(); + await cgroupSystemd.ShouldStartReplacement.Should().BeFalse(); + await container.ShouldStartReplacement.Should().BeFalse(); + await native.ShouldStartReplacement.Should().BeTrue(); + await nativeRestart.ShouldStartReplacement.Should().BeTrue(); + await stop.ShouldStartReplacement.Should().BeFalse(); + await shutdown.ShouldStartReplacement.Should().BeFalse(); + } + + [Test] + public async Task HandoffWaitsForCurrentScopeLockAndDoesNotTouchAnotherDataHome() + { + var currentScope = "/data/one/v2rayN.WebAPI.instance.lock"; + var otherScope = "/data/two/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(currentScope, held: true, owner: 111); + ownership.Set(otherScope, held: true, owner: 222); + var starter = new FakeProcessStarter(() => + { + var lockWasReleasedBeforeSpawn = !ownership.IsLockHeld(currentScope); + ownership.Set(currentScope, held: true, owner: 4242); + return (new FakeReplacementProcess(4242), lockWasReleasedBeforeSpawn); + }); + var health = new FakeHealthProbe(4242); + var releasedOldOwner = false; + async Task Delay(TimeSpan delay, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromMilliseconds(1), cancellationToken); + if (!releasedOldOwner) + { + ownership.Set(currentScope, held: false, owner: null); + releasedOldOwner = true; + } + } + + var coordinator = new NativeWebRestartCoordinator( + health, + starter, + ownership, + timeout: TimeSpan.FromSeconds(1), + pollInterval: TimeSpan.FromMilliseconds(1), + delay: Delay); + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + currentScope, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeTrue(); + await starter.StartCount.Should().BeEqualTo(1); + await starter.StartObservedReleasedLock.Should().BeTrue(); + await ownership.ObservedPaths.All(path => path == currentScope).Should().BeTrue(); + await ownership.IsLockHeld(otherScope).Should().BeTrue(); + await (health.LastUri == new Uri("http://127.0.0.1:5080/api/health")).Should().BeTrue(); + } + + [Test] + public async Task HandoffNeverSpawnsWhileOldOwnerStillHoldsLock() + { + var lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(lockPath, held: true, owner: 123); + var starter = new FakeProcessStarter(() => (new FakeReplacementProcess(456), false)); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(456), + starter, + ownership, + timeout: TimeSpan.FromMilliseconds(30), + pollInterval: TimeSpan.FromMilliseconds(2)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("did not release"); + await starter.StartCount.Should().BeEqualTo(0); + } + + [Test] + public async Task FailedReplacementStartupIsReportedWithoutRetryingOrKillingOtherInstances() + { + var lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var otherScope = "/data/other/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(otherScope, held: true, owner: 222); + var starter = new FakeProcessStarter(() => (new FakeReplacementProcess(456, hasExited: true), false)); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(null), + starter, + ownership, + timeout: TimeSpan.FromSeconds(1), + pollInterval: TimeSpan.FromMilliseconds(1)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("exited before its Web health endpoint"); + await starter.StartCount.Should().BeEqualTo(1); + await ownership.IsLockHeld(otherScope).Should().BeTrue(); + } + + [Test] + public async Task HandoffDoesNotAcceptAHealthyDifferentLockOwnerAsTheReplacement() + { + const string lockPath = "/data/current/v2rayN.WebAPI.instance.lock"; + var ownership = new FakeOwnershipProbe(); + ownership.Set(lockPath, held: false, owner: null); + var starter = new FakeProcessStarter(() => + { + ownership.Set(lockPath, held: true, owner: 789); + return (new FakeReplacementProcess(456), true); + }); + var coordinator = new NativeWebRestartCoordinator( + new FakeHealthProbe(789), + starter, + ownership, + timeout: TimeSpan.FromMilliseconds(30), + pollInterval: TimeSpan.FromMilliseconds(2)); + + var result = await coordinator.StartReplacementAfterOwnerReleaseAsync( + lockPath, + new Uri("http://127.0.0.1:5080/api/health"), + NewCommand()); + + await result.Success.Should().BeFalse(); + await result.Message.Should().Contain("did not become healthy"); + await result.ReplacementProcessId.Should().BeEqualTo(456); + await starter.StartCount.Should().BeEqualTo(1); + } + + private static WebReplacementCommand NewCommand() => new( + "/usr/bin/setsid", + ["/opt/v2rayN.WebAPI", WebLaunchOptions.BackgroundChildFlag], + "/opt"); + + private sealed class FakeOwnershipProbe : IWebInstanceOwnershipProbe + { + private readonly Dictionary _scopes = new(StringComparer.Ordinal); + + public List ObservedPaths { get; } = []; + + public void Set(string path, bool held, int? owner) => _scopes[path] = (held, owner); + + public bool IsLockHeld(string lockPath) + { + ObservedPaths.Add(lockPath); + return _scopes.GetValueOrDefault(lockPath).Held; + } + + public int? ReadOwnerProcessId(string lockPath) + { + ObservedPaths.Add(lockPath); + return _scopes.GetValueOrDefault(lockPath).Owner; + } + } + + private sealed class FakeProcessStarter(Func<(FakeReplacementProcess Process, bool LockReleased)> start) : IWebReplacementProcessStarter + { + public int StartCount { get; private set; } + public bool StartObservedReleasedLock { get; private set; } + + public IWebReplacementProcess? Start(WebReplacementCommand command) + { + StartCount++; + var result = start(); + StartObservedReleasedLock = result.LockReleased; + return result.Process; + } + } + + private sealed class FakeReplacementProcess(int id, bool hasExited = false) : IWebReplacementProcess + { + public int Id { get; } = id; + public bool HasExited { get; } = hasExited; + public bool Killed { get; private set; } + public void KillTree() => Killed = true; + public void Dispose() { } + } + + private sealed class FakeHealthProbe(int? processId) : IWebHealthProbe + { + public Uri? LastUri { get; private set; } + + public Task ProbeAsync(Uri healthUri, CancellationToken cancellationToken) + { + LastUri = healthUri; + return Task.FromResult(new WebHealthProbeResult(processId.HasValue, processId)); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs new file mode 100644 index 00000000..eb3ae56d --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSecurityHeadersTests.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSecurityHeadersTests +{ + [Test] + public async Task BrowserResponsesReceiveSecurityHeadersAndSelfHostedCsp() + { + var context = new DefaultHttpContext(); + var downstreamCalled = false; + var middleware = new WebSecurityHeadersMiddleware(_ => + { + downstreamCalled = true; + return Task.CompletedTask; + }); + + await middleware.InvokeAsync(context); + + await downstreamCalled.Should().BeTrue(); + await context.Response.Headers["X-Content-Type-Options"].ToString().Should().BeEqualTo("nosniff"); + await context.Response.Headers["Referrer-Policy"].ToString().Should().BeEqualTo("no-referrer"); + await context.Response.Headers["X-Frame-Options"].ToString().Should().BeEqualTo("DENY"); + var csp = context.Response.Headers["Content-Security-Policy"].ToString(); + await csp.Should().Contain("frame-ancestors 'none'"); + await csp.Should().Contain("script-src 'self'"); + await csp.Should().Contain("connect-src 'self'"); + await csp.Should().Contain("style-src 'self' 'unsafe-inline'"); + await csp.Should().NotContain("'unsafe-eval'"); + await csp.Should().NotContain("https:"); + await csp.Should().NotContain("ws:"); + await csp.Should().NotContain("data:"); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs new file mode 100644 index 00000000..040be4ef --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSessionServiceTests.cs @@ -0,0 +1,167 @@ +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSessionServiceTests +{ + [Test] + public async Task SessionTokensAreRandomUrlSafeAndManagementKeysAreNotSessions() + { + using var sessions = new WebSessionService(); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + const string managementKey = "this-is-a-management-key"; + + await (first.Token.Length == 43).Should().BeTrue(); + await first.Token.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').Should().BeTrue(); + await (first.Token != second.Token).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(first.Token, out _).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(managementKey, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionsSlideForSevenDaysAndExpireAfterIdleTimeout() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-01-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var created = sessions.CreateSession(); + + time.Advance(TimeSpan.FromDays(6)); + await sessions.TryValidateAndRenew(created.Token, out var renewed).Should().BeTrue(); + await (renewed!.LastSeenAt == time.GetUtcNow()).Should().BeTrue(); + await (renewed.ExpiresAt == time.GetUtcNow() + WebSessionService.SlidingLifetime).Should().BeTrue(); + + time.Advance(TimeSpan.FromDays(8)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionHasThirtyDayAbsoluteLifetimeEvenWhenActive() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-02-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var startedAt = time.GetUtcNow(); + var created = sessions.CreateSession(); + + for (var day = 0; day < 5; day++) + { + time.Advance(TimeSpan.FromDays(5)); + await sessions.TryValidateAndRenew(created.Token, out _).Should().BeTrue(); + } + + await (time.GetUtcNow() == startedAt + TimeSpan.FromDays(25)).Should().BeTrue(); + await sessions.TryValidateAndRenew(created.Token, out var active).Should().BeTrue(); + await (active!.AbsoluteExpiresAt == startedAt + WebSessionService.AbsoluteLifetime).Should().BeTrue(); + time.Advance(TimeSpan.FromDays(5)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task RevocationCancelsActiveConnectionsAndOnlyRevokesThatSession() + { + using var sessions = new WebSessionService(); + var first = sessions.CreateSession(); + var second = sessions.CreateSession(); + await sessions.TryValidateAndRenew(first.Token, out var activeSession).Should().BeTrue(); + + await sessions.Revoke(first.Token).Should().BeTrue(); + await activeSession!.RevocationToken.IsCancellationRequested.Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(first.Token, out _).Should().BeFalse(); + await sessions.TryValidateWithoutRenewal(second.Token, out _).Should().BeTrue(); + } + + [Test] + public async Task SseTicketsAreRandomShortLivedOneTimeAndCannotAuthenticateRest() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-20T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + time.Advance(TimeSpan.FromDays(6)); + + await sessions.TryCreateSseTicket(session.Token, out var ticket).Should().BeTrue(); + await (ticket!.Token.Length == 43).Should().BeTrue(); + await ticket.Token.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_').Should().BeTrue(); + await (ticket.Token != session.Token).Should().BeTrue(); + await (ticket.ExpiresAt == time.GetUtcNow() + WebSessionService.SseTicketLifetime).Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(ticket.Token, out _).Should().BeFalse(); + + await sessions.TryConsumeSseTicket(ticket.Token, out var consumed).Should().BeTrue(); + await (consumed!.LastSeenAt == session.ExpiresAt - WebSessionService.SlidingLifetime).Should().BeTrue(); + await sessions.TryConsumeSseTicket(ticket.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task ExpiredTicketsAndRevokedSessionsCannotOpenOrCreateSseTickets() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-25T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryCreateSseTicket(session.Token, out var ticket).Should().BeTrue(); + + await sessions.Revoke(session.Token).Should().BeTrue(); + await sessions.TryCreateSseTicket(session.Token, out _).Should().BeFalse(); + await sessions.TryConsumeSseTicket(ticket!.Token, out _).Should().BeFalse(); + + var secondSession = sessions.CreateSession(); + await sessions.TryCreateSseTicket(secondSession.Token, out var expiringTicket).Should().BeTrue(); + time.Advance(WebSessionService.SseTicketLifetime + TimeSpan.FromSeconds(1)); + await sessions.TryConsumeSseTicket(expiringTicket!.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task CleanupRemovesExpiredSessions() + { + var time = new ManualTimeProvider(DateTimeOffset.Parse("2026-03-01T00:00:00Z")); + using var sessions = new WebSessionService(time); + var session = sessions.CreateSession(); + await sessions.TryValidateAndRenew(session.Token, out var active).Should().BeTrue(); + + time.Advance(WebSessionService.SlidingLifetime + TimeSpan.FromSeconds(1)); + await (sessions.CleanupExpiredSessions() == 1).Should().BeTrue(); + await active!.RevocationToken.IsCancellationRequested.Should().BeTrue(); + await sessions.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task SessionsDoNotSurviveServiceRestart() + { + var firstProcess = new WebSessionService(); + var session = firstProcess.CreateSession(); + firstProcess.Dispose(); + + using var restartedProcess = new WebSessionService(); + await restartedProcess.TryValidateWithoutRenewal(session.Token, out _).Should().BeFalse(); + } + + [Test] + public async Task PassiveHeartbeatValidationDoesNotRenewAndExpiresTheSession() + { + var startedAt = DateTimeOffset.Parse("2026-03-15T00:00:00Z"); + var time = new ManualTimeProvider(startedAt); + using var sessions = new WebSessionService(time); + var created = sessions.CreateSession(); + WebSessionSnapshot? lastValidSnapshot = null; + + for (var minute = 1; minute < 7 * 24 * 60; minute++) + { + time.Advance(TimeSpan.FromMinutes(1)); + await sessions.TryValidateWithoutRenewal(created.Token, out lastValidSnapshot).Should().BeTrue(); + } + + time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + await sessions.TryValidateWithoutRenewal(created.Token, out _).Should().BeFalse(); + await (sessions.CleanupExpiredSessions() == 0).Should().BeTrue(); + await (lastValidSnapshot!.LastSeenAt == startedAt).Should().BeTrue(); + await (lastValidSnapshot.ExpiresAt == created.ExpiresAt).Should().BeTrue(); + await lastValidSnapshot.RevocationToken.IsCancellationRequested.Should().BeTrue(); + } + + private sealed class ManualTimeProvider(DateTimeOffset initialTime) : TimeProvider + { + private DateTimeOffset _now = initialTime; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan amount) => _now += amount; + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs new file mode 100644 index 00000000..a1651c8b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebSetupAccessPolicyTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using v2rayN.WebAPI.Security; + +namespace v2rayN.WebAPI.Tests; + +public class WebSetupAccessPolicyTests +{ + [Test] + public async Task Ipv4LoopbackAndAllowedHostsCanInitialize() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "localhost", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "127.0.0.1", false).Should().BeTrue(); + } + + [Test] + public async Task Ipv6LoopbackAndBracketedHostCanInitialize() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.IPv6Loopback, "::1", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback.MapToIPv6(), "localhost", false).Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalHost("[::1]").Should().BeTrue(); + await WebSetupAccessPolicy.IsLocalHost(new HostString("[::1]:5080").Host).Should().BeTrue(); + } + + [Test] + public async Task RemoteAddressAndRebindingHostAreDenied() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Parse("192.0.2.25"), "localhost", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(null, "localhost", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "attacker.example", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "127.0.0.2", false).Should().BeFalse(); + } + + [Test] + public async Task PrivateNetworkClientCannotPerformFirstRunSetup() + { + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "192.168.1.20", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("10.0.0.25"), "192.168.1.20", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "192.168.1.21", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("203.0.113.8"), "192.168.1.20", false).Should().BeFalse(); + } + + [Test] + public async Task ForwardingHeadersNeverGrantSetupAccess() + { + await WebSetupAccessPolicy.IsAllowed(IPAddress.Loopback, "localhost", true).Should().BeFalse(); + var headers = new HeaderDictionary { ["X-Forwarded-Host"] = "localhost" }; + await WebSetupAccessPolicy.HasForwardedHeaders(headers).Should().BeTrue(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Loopback, + "localhost", + WebSetupAccessPolicy.HasForwardedHeaders(headers)).Should().BeFalse(); + + var privateClient = IPAddress.Parse("192.168.1.10"); + await WebSetupAccessPolicy.IsAllowed(privateClient, "192.168.1.20", true).Should().BeFalse(); + } + + [Test] + public async Task PublicAndProxyAddressSetupIsRejected() + { + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("203.0.113.12"), "203.0.113.10", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "2001:db8::10", false).Should().BeFalse(); + await WebSetupAccessPolicy.IsAllowed( + IPAddress.Parse("192.168.1.10"), "proxy.example", false).Should().BeFalse(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs new file mode 100644 index 00000000..bcd68c0e --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebTunScopeTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ServiceLib.Enums; +using ServiceLib.Handler.Builder; +using ServiceLib.Models.Configs; +using ServiceLib.Models.CoreConfigs; +using ServiceLib.Models.Entities; +using v2rayN.WebAPI.Api; +using v2rayN.WebAPI.Contracts; +using v2rayN.WebAPI.Security; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebTunScopeTests +{ + [Test] + public async Task WebContractsDoNotExposeTunManagementButCoreDnsIncludesTunDns() + { + await HasProperty(typeof(WebSettingsView), "Tun").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunEnabled").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunInterfaceName").Should().BeFalse(); + await HasProperty(typeof(StatusView), "TunInterfaceActive").Should().BeFalse(); + await HasProperty(typeof(DnsProfileView), "TunDNS").Should().BeTrue(); + await HasProperty(typeof(DnsProfileInput), "TunDNS").Should().BeTrue(); + await HasProperty(typeof(CoreConfigTemplateView), "TunConfig").Should().BeTrue(); + await HasProperty(typeof(CoreConfigTemplateInput), "TunConfig").Should().BeTrue(); + } + + [Test] + public async Task TunSettingsRouteIsNotRegistered() + { + using var sessions = new WebSessionService(); + var session = sessions.CreateSession(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); + builder.Services.AddSingleton(sessions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + await using var app = builder.Build(); + app.UseRouting(); + app.UseMiddleware(); + app.MapWebApi(); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + using var client = new HttpClient { BaseAddress = new Uri(address) }; + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/settings/tun"); + request.Headers.Authorization = new("Bearer", session.Token); + using var response = await client.SendAsync(request); + + await (response.StatusCode == HttpStatusCode.NotFound).Should().BeTrue(); + } + finally + { + await app.StopAsync(); + } + } + + [Test] + public async Task GeneratedMainOrPreContextWithTunIsRejectedWithoutChangingSavedConfig() + { + var config = new Config(); + config.TunModeItem = new ServiceLib.Models.Configs.TunModeItem(); + config.TunModeItem.EnableTun = true; + var main = CreateContextResult(isTunEnabled: false); + var noTun = V2rayRuntime.GetTunLaunchRejection(new CoreConfigContextBuilderAllResult(main, null)); + await (noTun is null).Should().BeTrue(); + + var mainTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(CreateContextResult(isTunEnabled: true), null)); + await (mainTun?.Code == "tun_not_supported").Should().BeTrue(); + await (mainTun?.MessageKey == ApiMessageKeys.CoreTunNotSupported).Should().BeTrue(); + + var preTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(main, CreateContextResult(isTunEnabled: true))); + await (preTun?.Code == "tun_not_supported").Should().BeTrue(); + + var invalidTun = V2rayRuntime.GetTunLaunchRejection( + new CoreConfigContextBuilderAllResult(CreateContextResult(isTunEnabled: true, valid: false), null)); + await (invalidTun?.Code == "tun_not_supported").Should().BeTrue(); + await config.TunModeItem.EnableTun.Should().BeTrue(); + } + + private static bool HasProperty(Type type, string name) => + type.GetProperties().Any(property => property.Name == name); + + private static CoreConfigContextBuilderResult CreateContextResult(bool isTunEnabled, bool valid = true) => + new( + new CoreConfigContext + { + Node = new ProfileItem(), + RunCoreType = ECoreType.Xray, + IsTunEnabled = isTunEnabled, + }, + valid ? NodeValidatorResult.Empty() : new NodeValidatorResult(["invalid"], [])); +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs new file mode 100644 index 00000000..8ae321bc --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateDeploymentPolicyTests.cs @@ -0,0 +1,39 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdateDeploymentPolicyTests +{ + [Test] + public async Task OnlyWritableNativeSingleFileInstallCanApplyUpdates() + { + var native = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, true); + await native.Kind.Should().BeEqualTo(WebDeploymentKind.NativeWritable); + await native.CanCheck.Should().BeTrue(); + await native.CanInstall.Should().BeTrue(); + + var readOnly = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, false); + await readOnly.CanCheck.Should().BeTrue(); + await readOnly.CanInstall.Should().BeFalse(); + await readOnly.InstallReasonKey.Should().BeEqualTo("maintenance.webUpdateReadOnly"); + + var systemd = WebUpdateDeploymentPolicy.Evaluate(true, false, true, true, false); + await systemd.Kind.Should().BeEqualTo(WebDeploymentKind.SystemdManaged); + await systemd.CanInstall.Should().BeFalse(); + await systemd.InstallReasonKey.Should().BeEqualTo("maintenance.webUpdateSystemdAdmin"); + + var container = WebUpdateDeploymentPolicy.Evaluate(true, true, false, true, true); + await container.Kind.Should().BeEqualTo(WebDeploymentKind.Container); + await container.CanCheck.Should().BeTrue(); + await container.CanInstall.Should().BeFalse(); + + var unsupported = WebUpdateDeploymentPolicy.Evaluate(false, false, false, false, true); + await unsupported.CanCheck.Should().BeTrue(); + await unsupported.CanInstall.Should().BeFalse(); + + var windowsNative = WebUpdateDeploymentPolicy.Evaluate(true, false, false, true, true); + await windowsNative.Kind.Should().BeEqualTo(WebDeploymentKind.NativeWritable); + await windowsNative.CanCheck.Should().BeTrue(); + await windowsNative.CanInstall.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs new file mode 100644 index 00000000..e83705e5 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdatePackageStagerTests.cs @@ -0,0 +1,459 @@ +using System.IO.Compression; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdatePackageStagerTests +{ + [Test] + public async Task LegacyProductIdentityIsRejectedWithManualReinstallInstruction() + { + var rejected = false; + try + { + _ = WebUpdatePackageStager.ParseManifest("{\"product\":\"v2rayN.Web\"}"); + } + catch (InvalidDataException exception) + { + rejected = exception.Message.Contains("manual installation of v2rayN.WebAPI", StringComparison.Ordinal); + } + await rejected.Should().BeTrue(); + } + + [Test] + public async Task OfficialVersionChannelHandlesOlderNewerEqualPrereleaseAndLegacyBuilds() + { + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true, + currentCommit: "old-build", candidateCommit: "release-build").Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3", allowPrerelease: true, + currentCommit: "same", candidateCommit: "same").Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.2", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("0.0.0-dev", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + // Development identities are older than the official releases they track. + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3-dev.2", "7.25.3", allowPrerelease: true) + .Should().BeTrue(); + // Migration from the retired web-v/-web.N channel: a legacy build upgrades to the official tag, + // and an official build is never "updated" back to a legacy prerelease. + await WebUpdatePackageStager.IsUpdateAvailable("7.25.2-web.5", "7.25.3", allowPrerelease: false) + .Should().BeTrue(); + await WebUpdatePackageStager.IsUpdateAvailable("7.25.3", "7.25.3-web.1", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.IsUpdateAvailable("not-a-version", "7.25.3", allowPrerelease: true) + .Should().BeFalse(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: false, allowPrerelease: false).Should().BeTrue(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: true, allowPrerelease: false).Should().BeFalse(); + await WebUpdatePackageStager.ShouldConsiderRelease(isPrerelease: true, allowPrerelease: true).Should().BeTrue(); + + var build = WebBuildIdentity.Current; + await WebUpdatePackageStager.IsValidVersion(build.Version).Should().BeTrue(); + await WebReleaseChannel.IsValidRepository(build.Repository).Should().BeTrue(); + await string.IsNullOrWhiteSpace(build.Rid).Should().BeFalse(); + } + + [Test] + public async Task ManifestRequiresWebProductHttpsAssetsAndAnExactRid() + { + var manifest = new WebUpdateManifest("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T00:00:00Z", + [ + new WebUpdatePackage("linux-x64", "v2rayN-linux-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", + new string('a', 64), 1234), + new WebUpdatePackage("linux-arm64", "v2rayN-linux-arm64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-arm64-WebAPI-update.zip", + new string('b', 64), 2345), + new WebUpdatePackage("win-x64", "v2rayN-windows-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-windows-64-WebAPI-update.zip", + new string('c', 64), 3456), + new WebUpdatePackage("linux-riscv64", "v2rayN-linux-riscv64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-riscv64-WebAPI-update.zip", + new string('d', 64), 4567), + new WebUpdatePackage("linux-loongarch64", "v2rayN-linux-loong64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-loong64-WebAPI-update.zip", + new string('e', 64), 5678), + new WebUpdatePackage("win-arm64", "v2rayN-windows-arm64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-windows-arm64-WebAPI-update.zip", + new string('f', 64), 6789), + ]); + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var parsed = WebUpdatePackageStager.ParseManifest(json); + await parsed.Version.Should().BeEqualTo("7.25.3"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-x64").Rid.Should().BeEqualTo("linux-x64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-arm64").Rid.Should().BeEqualTo("linux-arm64"); + await WebUpdatePackageStager.RequirePackage(parsed, "win-x64").Rid.Should().BeEqualTo("win-x64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-riscv64").Rid.Should().BeEqualTo("linux-riscv64"); + await WebUpdatePackageStager.RequirePackage(parsed, "linux-loongarch64").Rid.Should().BeEqualTo("linux-loongarch64"); + await WebUpdatePackageStager.RequirePackage(parsed, "win-arm64").Rid.Should().BeEqualTo("win-arm64"); + await WebReleaseChannel.IsTrustedAssetUrl(parsed.Packages[0].Url, "2dust/v2rayN", parsed.Version, parsed.Packages[0].Asset) + .Should().BeTrue(); + + var wrongRidRejected = false; + try { _ = WebUpdatePackageStager.RequirePackage(parsed, "linux-unknown64"); } + catch (InvalidDataException) { wrongRidRejected = true; } + await wrongRidRejected.Should().BeTrue(); + + var oversized = manifest with + { + Packages = [manifest.Packages[0] with { Size = WebUpdatePackageStager.MaximumArchiveBytes + 1 }], + }; + var oversizedRejected = false; + try + { + _ = WebUpdatePackageStager.ParseManifest(JsonSerializer.Serialize(oversized, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } + catch (InvalidDataException) { oversizedRejected = true; } + await oversizedRejected.Should().BeTrue(); + } + + [Test] + public async Task UnknownRidsInANewerManifestAreIgnoredForForwardCompatibility() + { + var manifest = new WebUpdateManifest("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T00:00:00Z", + [ + new WebUpdatePackage("linux-x64", "v2rayN-linux-64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", + new string('a', 64), 1234), + new WebUpdatePackage("linux-future64", "v2rayN-linux-future64-WebAPI-update.zip", + "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-future64-WebAPI-update.zip", + new string('b', 64), 2345), + ]); + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var parsed = WebUpdatePackageStager.ParseManifest(json); + + await WebUpdatePackageStager.RequirePackage(parsed, "linux-x64").Rid.Should().BeEqualTo("linux-x64"); + var unknownRejected = false; + try { _ = WebUpdatePackageStager.RequirePackage(parsed, "linux-future64"); } + catch (InvalidDataException) { unknownRejected = true; } + await unknownRejected.Should().BeTrue(); + } + + [Test] + public async Task NativeExecutableChecksEnforceTheDeclaredArchitecture() + { + using var directory = new TemporaryDirectory(); + foreach (var (rid, machine) in new[] + { + ("linux-x64", 62), ("linux-arm64", 183), ("linux-riscv64", 243), ("linux-loongarch64", 258), + }) + { + var matching = Path.Combine(directory.Path, $"elf-{rid}"); + await File.WriteAllBytesAsync(matching, BuildElfImage(machine)); + WebUpdatePackageStager.RequireNativeExecutable(matching, rid); + + var mismatched = Path.Combine(directory.Path, $"elf-wrong-{rid}"); + await File.WriteAllBytesAsync(mismatched, BuildElfImage(machine == 62 ? 183 : 62)); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(mismatched, rid); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + + foreach (var (rid, machine) in new[] { ("win-x64", 0x8664), ("win-arm64", 0xAA64) }) + { + var matching = Path.Combine(directory.Path, $"pe-{rid}.exe"); + await File.WriteAllBytesAsync(matching, BuildPeImage((ushort)machine)); + WebUpdatePackageStager.RequireNativeExecutable(matching, rid); + + var mismatched = Path.Combine(directory.Path, $"pe-wrong-{rid}.exe"); + await File.WriteAllBytesAsync(mismatched, BuildPeImage((ushort)(machine == 0x8664 ? 0xAA64 : 0x8664))); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(mismatched, rid); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + } + + private static byte[] BuildElfImage(int machine) + { + var image = new byte[128]; + new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }.CopyTo(image, 0); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(16), 2); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(18), (ushort)machine); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(20), 1); + BinaryPrimitives.WriteUInt64LittleEndian(image.AsSpan(32), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(52), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(54), 56); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(56), 1); + return image; + } + + private static byte[] BuildPeImage(ushort machine) + { + var image = new byte[512]; + image[0] = (byte)'M'; + image[1] = (byte)'Z'; + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(0x3C), 128); + new byte[] { (byte)'P', (byte)'E', 0, 0 }.CopyTo(image, 128); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(132), machine); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(134), 1); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(148), 240); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(150), 0x0002); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(152), 0x020B); + return image; + } + + [Test] + public async Task AppOnlyZipPackageIsVerifiedAndExtractedForItsExactIdentity() + { + using var directory = new TemporaryDirectory(); + var archive = Path.Combine(directory.Path, "web-app.zip"); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.3", "0123456789abcdef", "2026-09-28T01:00:00Z", "linux-x64"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + var package = manifest.Packages[0]; + + var extracted = Path.Combine(directory.Path, "stage"); + var actualIdentity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archive, extracted, manifest, package, CancellationToken.None); + await actualIdentity.Should().BeEqualTo(identity); + await File.Exists(Path.Combine(extracted, "bin", "xray", "xray")).Should().BeFalse(); + await File.Exists(Path.Combine(extracted, "guiConfigs", "guiNConfig.json")).Should().BeFalse(); + await File.Exists(Path.Combine(extracted, "WebAPIData", "WebAPI-auth.json")).Should().BeFalse(); + await Directory.GetFiles(extracted, "*", SearchOption.AllDirectories).Length.Should().BeEqualTo(2); + } + + [Test] + public async Task WindowsAppOnlyZipRequiresAndExtractsAnX64PeExecutable() + { + using var directory = new TemporaryDirectory(); + var archive = Path.Combine(directory.Path, "web-app-win.zip"); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.4", "windows-commit", + "2026-10-09T00:00:00Z", "win-x64"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI.exe", WindowsExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, identity.Rid, identity.BuildDate); + var extracted = Path.Combine(directory.Path, "windows-stage"); + + var actualIdentity = await WebUpdatePackageStager.VerifyAndExtractAsync( + archive, extracted, manifest, manifest.Packages[0], CancellationToken.None); + + await actualIdentity.Should().BeEqualTo(identity); + await File.Exists(Path.Combine(extracted, "v2rayN.WebAPI.exe")).Should().BeTrue(); + await File.Exists(Path.Combine(extracted, "v2rayN.WebAPI")).Should().BeFalse(); + await Directory.GetFiles(extracted, "*", SearchOption.AllDirectories).Length.Should().BeEqualTo(2); + } + + [Test] + public async Task ChecksumMismatchCorruptArchiveWrongRidAndMissingExecutableAreRejected() + { + using var directory = new TemporaryDirectory(); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.4", "abcdef0123456789", "2026-09-28T02:00:00Z", "linux-x64"); + var archive = Path.Combine(directory.Path, "good.zip"); + await WriteArchiveAsync(archive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + var damaged = Path.Combine(directory.Path, "damaged.zip"); + var bytes = await File.ReadAllBytesAsync(archive); + bytes[^1] ^= 0x01; + await File.WriteAllBytesAsync(damaged, bytes); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + damaged, Path.Combine(directory.Path, "checksum-failure"), manifest, manifest.Packages[0], CancellationToken.None)); + + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + archive, Path.Combine(directory.Path, "wrong-rid"), manifest, + manifest.Packages[0] with { Rid = "linux-arm64" }, CancellationToken.None)); + + var missingExecutableArchive = Path.Combine(directory.Path, "missing.zip"); + await WriteArchiveAsync(missingExecutableArchive, + [ + ("v2rayN.WebAPI.build.json", JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web))), + ]); + var missingManifest = CreateManifest(missingExecutableArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + missingExecutableArchive, Path.Combine(directory.Path, "missing-executable"), missingManifest, + missingManifest.Packages[0], CancellationToken.None)); + } + + [Test] + public async Task ZipTraversalLinksDuplicatesAndUnexpectedEntriesAreRejected() + { + using var directory = new TemporaryDirectory(); + var identity = new WebUpdatePackageIdentity("v2rayN.WebAPI", "7.25.5", "0123abcdef", "2026-09-28T03:00:00Z", "linux-x64"); + var identityJson = JsonSerializer.SerializeToUtf8Bytes(identity, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + foreach (var entryName in new[] + { + "../escape", "/absolute", "extra/../escape", + ".env", ".env.example", "extra/.env", "extra/.env.example", + }) + { + var archive = Path.Combine(directory.Path, Guid.NewGuid().ToString("N") + ".zip"); + await WriteArchiveAsync(archive, [(entryName, Encoding.UTF8.GetBytes("nope"))]); + var manifest = CreateManifest(archive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + archive, Path.Combine(directory.Path, Guid.NewGuid().ToString("N")), manifest, + manifest.Packages[0], CancellationToken.None)); + } + + var symlinkArchive = Path.Combine(directory.Path, "symlink.zip"); + await WriteSymlinkAsync(symlinkArchive, "extra/marker.dat", "../../outside"); + var symlinkManifest = CreateManifest(symlinkArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + symlinkArchive, Path.Combine(directory.Path, "symlink-stage"), symlinkManifest, + symlinkManifest.Packages[0], CancellationToken.None)); + + var duplicateArchive = Path.Combine(directory.Path, "duplicate.zip"); + await WriteArchiveAsync(duplicateArchive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", identityJson), + ("v2rayN.WebAPI.build.json", identityJson), + ]); + var duplicateManifest = CreateManifest(duplicateArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + duplicateArchive, Path.Combine(directory.Path, "duplicate-stage"), duplicateManifest, + duplicateManifest.Packages[0], CancellationToken.None)); + + var unexpectedArchive = Path.Combine(directory.Path, "unexpected.zip"); + await WriteArchiveAsync(unexpectedArchive, + [ + ("v2rayN.WebAPI", NativeExecutableFixture()), + ("v2rayN.WebAPI.build.json", identityJson), + ("extra/marker.dat", Encoding.UTF8.GetBytes("UNEXPECTED_FILE_TEST")), + ]); + var unexpectedManifest = CreateManifest(unexpectedArchive, identity.Version, identity.Commit, "linux-x64", identity.BuildDate); + await ExpectInvalidDataAsync(() => WebUpdatePackageStager.VerifyAndExtractAsync( + unexpectedArchive, Path.Combine(directory.Path, "unexpected-stage"), unexpectedManifest, + unexpectedManifest.Packages[0], CancellationToken.None)); + } + + [Test] + public async Task ScriptsTruncatedImagesAndWrongArchitectureCannotReachProcessReplacement() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "candidate"); + foreach (var image in new[] { Encoding.UTF8.GetBytes("#!/bin/sh\nexit 1\n"), NativeExecutableFixture()[..64], NativeExecutableFixture(183) }) + { + await File.WriteAllBytesAsync(path, image); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(path, "linux-x64"); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + await File.WriteAllBytesAsync(path, NativeExecutableFixture()); + WebUpdatePackageStager.RequireNativeExecutable(path, "linux-x64"); + } + + [Test] + public async Task WindowsExecutableMustBePe32PlusAmd64() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "candidate.exe"); + foreach (var image in new[] + { + Encoding.UTF8.GetBytes("MZ but not a PE executable"), + WindowsExecutableFixture(machine: 0x014C), + WindowsExecutableFixture(optionalHeaderMagic: 0x010B), + WindowsExecutableFixture()[..80], + }) + { + await File.WriteAllBytesAsync(path, image); + var rejected = false; + try { WebUpdatePackageStager.RequireNativeExecutable(path, "win-x64"); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + await File.WriteAllBytesAsync(path, WindowsExecutableFixture()); + WebUpdatePackageStager.RequireNativeExecutable(path, "win-x64"); + } + + private static byte[] NativeExecutableFixture(ushort machine = 62) + { + var image = new byte[120]; // Minimal ELF/program-table fixture, never executed. + new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2, 1, 1 }.CopyTo(image, 0); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(16), 3); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(18), machine); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(20), 1); + BinaryPrimitives.WriteUInt64LittleEndian(image.AsSpan(32), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(52), 64); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(54), 56); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(56), 1); + return image; + } + + private static byte[] WindowsExecutableFixture(ushort machine = 0x8664, ushort optionalHeaderMagic = 0x020B) + { + var image = new byte[512]; // Minimal PE32+ AMD64 image fixture, never executed. + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(0), 0x5A4D); + BinaryPrimitives.WriteUInt32LittleEndian(image.AsSpan(0x3C), 128); + "PE\0\0"u8.CopyTo(image.AsSpan(128)); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(132), machine); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(134), 1); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(148), 240); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(150), 0x0002); + BinaryPrimitives.WriteUInt16LittleEndian(image.AsSpan(152), optionalHeaderMagic); + return image; + } + + private static WebUpdateManifest CreateManifest(string archive, string version, string commit, string rid, string buildDate) + { + using var stream = File.OpenRead(archive); + var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + var asset = WebUpdatePackageStager.AppOnlyAssetName(rid) + ?? throw new InvalidOperationException($"No app-only asset for {rid}."); + return new WebUpdateManifest("v2rayN.WebAPI", version, commit, buildDate, + [new WebUpdatePackage(rid, asset, + $"https://github.com/2dust/v2rayN/releases/download/{version}/{asset}", + hash, new FileInfo(archive).Length)]); + } + + private static async Task WriteArchiveAsync(string path, IReadOnlyList<(string Name, byte[] Content)> files) + { + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var item in files) + { + var entry = archive.CreateEntry(item.Name, CompressionLevel.SmallestSize); + await using var stream = entry.Open(); + await stream.WriteAsync(item.Content); + } + } + + private static async Task WriteSymlinkAsync(string path, string name, string target) + { + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + var entry = archive.CreateEntry(name); + entry.ExternalAttributes = (0xA000 | 0x1FF) << 16; + await using var stream = entry.Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(target)); + } + + private static async Task ExpectInvalidDataAsync(Func> action) + { + var rejected = false; + try { _ = await action(); } + catch (InvalidDataException) { rejected = true; } + await rejected.Should().BeTrue(); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"v2rayn-WebAPI-package-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs new file mode 100644 index 00000000..80d3657b --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/WebUpdateReleaseSelectionTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class WebUpdateReleaseSelectionTests +{ + private const string ReleaseIndex = """ + [ + { + "tag_name": "web-v7.25.9-web.3", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/web-v7.25.9-web.3/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.6", + "draft": true, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.6/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.5", + "draft": false, + "prerelease": true, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.5/WebAPI-update.json", "size": 512 } + ] + }, + { + "tag_name": "7.25.4", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "v2rayN-linux-64.zip", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.4/v2rayN-linux-64.zip", "size": 1024 } + ] + }, + { + "tag_name": "7.25.3", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.3/WebAPI-update.json", "size": 512 }, + { "name": "v2rayN-linux-64-WebAPI-update.zip", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/7.25.3/v2rayN-linux-64-WebAPI-update.zip", "size": 4096 } + ] + }, + { + "tag_name": "v7.25.7", + "draft": false, + "prerelease": false, + "assets": [ + { "name": "WebAPI-update.json", "browser_download_url": "https://github.com/2dust/v2rayN/releases/download/v7.25.7/WebAPI-update.json", "size": 512 } + ] + } + ] + """; + + [Test] + public async Task OfficialReleaseIndexSelectsOnlyTaggedReleasesWithAWebManifest() + { + var stable = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: false); + await stable.Count.Should().BeEqualTo(1); + var selected = stable[0]; + await selected.Tag.Should().BeEqualTo("7.25.3"); + await selected.Version.Should().BeEqualTo("7.25.3"); + await selected.IsPrerelease.Should().BeFalse(); + await selected.ManifestUrl.Should().BeEqualTo("https://github.com/2dust/v2rayN/releases/download/7.25.3/WebAPI-update.json"); + await selected.Assets.ContainsKey("v2rayN-linux-64-WebAPI-update.zip").Should().BeTrue(); + + var withPrerelease = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: true); + await withPrerelease.Count.Should().BeEqualTo(2); + await withPrerelease[0].Version.Should().BeEqualTo("7.25.5"); + await withPrerelease[1].Version.Should().BeEqualTo("7.25.3"); + } + + [Test] + public async Task NewestOfficialCandidateWinsAfterVersionOrdering() + { + var candidates = V2rayRuntime.ParseWebReleaseCandidates(ReleaseIndex, allowPrerelease: false); + candidates.Sort((left, right) => new ServiceLib.Models.Dto.SemanticVersion(right.Version) + .CompareTo(new ServiceLib.Models.Dto.SemanticVersion(left.Version))); + await candidates[0].Version.Should().BeEqualTo("7.25.3"); + } + + [Test] + public async Task InvalidReleaseIndexesAreRejected() + { + var malformedRejected = false; + try { _ = V2rayRuntime.ParseWebReleaseCandidates("{ not json", allowPrerelease: false); } + catch (JsonException) { malformedRejected = true; } + await malformedRejected.Should().BeTrue(); + + var nonArrayRejected = false; + try { _ = V2rayRuntime.ParseWebReleaseCandidates("{}", allowPrerelease: false); } + catch (InvalidDataException) { nonArrayRejected = true; } + await nonArrayRejected.Should().BeTrue(); + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs b/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs new file mode 100644 index 00000000..29a8b63c --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/XrayGeoStagingTests.cs @@ -0,0 +1,36 @@ +using v2rayN.WebAPI.Services; + +namespace v2rayN.WebAPI.Tests; + +public class XrayGeoStagingTests +{ + [Test] + public async Task ApplyCopiesTheLatestGeoFilesAfterStaging() + { + var root = Path.Combine(Path.GetTempPath(), $"v2rayn-WebAPI-xray-stage-{Guid.NewGuid():N}"); + var installPath = Path.Combine(root, "installed"); + var stagingPath = Path.Combine(root, "staging"); + Directory.CreateDirectory(installPath); + Directory.CreateDirectory(stagingPath); + + try + { + var installedGeo = Path.Combine(installPath, "geoip.dat"); + var stagedGeo = Path.Combine(stagingPath, "geoip.dat"); + await File.WriteAllTextAsync(installedGeo, "old geo data"); + await File.WriteAllTextAsync(Path.Combine(stagingPath, "xray"), "staged executable"); + + await File.Exists(stagedGeo).Should().BeFalse(); + + // A Geo update completes after Xray staging but before exclusive apply. + await File.WriteAllTextAsync(installedGeo, "latest geo data"); + V2rayRuntime.CopyLatestGeoFilesForApply(installPath, stagingPath); + + await (await File.ReadAllTextAsync(stagedGeo)).Should().BeEqualTo("latest geo data"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj new file mode 100644 index 00000000..d79249ce --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj @@ -0,0 +1,27 @@ + + + Exe + net10.0 + false + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/v2rayN/v2rayN.WebAPI/compose.yaml b/v2rayN/v2rayN.WebAPI/compose.yaml new file mode 100644 index 00000000..380b72a9 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/compose.yaml @@ -0,0 +1,26 @@ +services: + v2rayn-web: + build: + context: ../.. + dockerfile: v2rayN/v2rayN.WebAPI/Containerfile + args: + RID: ${V2RAYN_BUILD_RID:-linux-x64} + image: localhost/v2rayn-web:latest + restart: unless-stopped + environment: + ASPNETCORE_URLS: http://0.0.0.0:5080 + V2RAYN_WEB_API_KEY: ${V2RAYN_WEB_API_KEY:?Set V2RAYN_WEB_API_KEY in your environment} + V2RAYN_WEB_ALLOWED_ORIGINS: ${V2RAYN_WEB_ALLOWED_ORIGINS:-} + V2RAYN_WEB_AUTOSTART: "true" + V2RAYN_WEB_PROXY_PORT: ${V2RAYN_PROXY_PORT:-10808} + V2RAYN_WEB_PROXY_LISTEN_ALL: "true" + V2RAYN_DATA_HOME: /data + ports: + - "127.0.0.1:${V2RAYN_WEB_PORT:-5080}:5080/tcp" + - "127.0.0.1:${V2RAYN_PROXY_PORT:-10808}:${V2RAYN_PROXY_PORT:-10808}/tcp" + - "127.0.0.1:${V2RAYN_PROXY_PORT:-10808}:${V2RAYN_PROXY_PORT:-10808}/udp" + volumes: + - v2rayn-data:/data + +volumes: + v2rayn-data: diff --git a/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj b/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj new file mode 100644 index 00000000..3bb46b05 --- /dev/null +++ b/v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj @@ -0,0 +1,46 @@ + + + net10.0 + v2rayN.WebAPI + v2rayN.WebAPI + enable + enable + false + $(DefaultItemExcludes);.NET/**;.packages/**;Tests/**;TestResults/**;publish/** + 0.0.0-dev + unknown + unknown + 2dust/v2rayN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/v2rayN/v2rayN.slnx b/v2rayN/v2rayN.slnx index c1532839..a01d2390 100644 --- a/v2rayN/v2rayN.slnx +++ b/v2rayN/v2rayN.slnx @@ -2,6 +2,7 @@ + @@ -21,4 +22,6 @@ + +