Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot] dbdd78739e Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 18:43:16 +00:00
Nozilla-X 57f17d5939 fix(profile-ex): make ProfileExItem creation atomic per IndexId (#10420)
ConcurrentBag only makes individual operations safe. GetProfileExItem performed FirstOrDefault and AddProfileEx as separate operations, so concurrent speed tests could both miss and create duplicate entries for the same IndexId. Recheck under a dedicated lock before creating, and replace the bag under the same lock in InitData/ClearAll so creation cannot target a bag that is being swapped. Existing reads and item updates stay lock-free.
2026-10-20 16:58:12 +08:00
Nozilla-X 66dd7da6e6 feat(web): 添加无头 WebAPI 支持 (#10300)
* 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
2026-10-20 10:52:23 +08:00
DHR60 5e0ea3275d Fix (#10404) 2026-10-18 19:23:03 +08:00
Dmitry 673283da4b Fix data race in ProfileExManager (#10388)
* Fix data race in ProfileExManager

Parallel speed tests over many profiles corrupt the save queue of test results: IndexIdEnqueue touches a plain Queue<string> from every test thread, so Contains plus Enqueue race - the same profile gets queued twice, and Queue.Contains can even throw ArgumentOutOfRangeException out of SetTestDelay, losing the result. SaveQueueIndexIds has the same class of bug: it snapshots Count and then dequeues in a loop, so concurrent enqueues throw or lose entries. GetProfileExItem could create duplicate rows for the same index the same way.

Guard the queue and the profile lookup with a lock and drain a snapshot instead of a stale count. No behavior change besides correctness.

* Fix data race in ProfileExManager

Parallel speed tests over many profiles corrupt the save queue of test results: IndexIdEnqueue touches a plain Queue<string> from every test thread, so Contains plus Enqueue race - the same profile gets queued twice, and Queue.Contains can even throw ArgumentOutOfRangeException out of SetTestDelay, losing the result. SaveQueueIndexIds has the same class of bug: it snapshots Count and then dequeues in a loop, so concurrent enqueues throw or lose entries.

Switch the queue to ConcurrentQueue and drain it with TryDequeue. As suggested in review, duplicates are not filtered anymore: this is a helper, and the save loop just writes the same row more than once.
2026-10-17 19:37:43 +08:00
Miheichev Aleksandr Sergeevich 1f083adbe4 chore(deps): update Downloader to 5.10.1 (#10382)
Downloader 5.9.8 -> 5.10.1

5.10.0 no longer reads the headers of an error response as the file's:
with 5.9.8, an error status on the first request followed by a good
response made the download complete with the file cut to the length of
the error body. 5.10.0 retries that first request on 408, 429, 503 and
504, but a transport error or timeout there no longer gets the second
attempt that 5.9.8 made. 5.10.1 only fixes the mirror choice for
downloads with several URLs, which DownloaderHelper does not use.

Dependabot does not propose this update: its NuGet compatibility check
rejects Downloader 5.10.x for the WPF project's
net10.0-windows10.0.19041 target.
2026-10-17 16:59:38 +08:00
dependabot[bot] 80ab14d057 Bump TUnit from 1.73.19 to 1.75.0 (#10357)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.75.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 16:55:34 +08:00
2dust 95f438abc9 Update winget-publish.yml
Update winget-publish.yml
2026-10-14 15:37:05 +08:00
DHR60 f1023e08e1 Fix (#10344)
* chore. update core urls

* Fix clash delay
2026-10-13 20:36:41 +08:00
157 changed files with 29012 additions and 21 deletions
+923
View File
@@ -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 <<EOF
WEB_VERSION=${V2RAYN_WEB_VERSION}
WEB_REPOSITORY=${V2RAYN_WEB_REPOSITORY}
WEB_COMMIT=${V2RAYN_WEB_COMMIT}
WEB_BUILD_DATE=${V2RAYN_WEB_BUILD_DATE}
EOF
cat > 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@v7
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
+7
View File
@@ -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:
+53
View File
@@ -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 }}" }
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
$wingetPackage = "2dust.v2rayN"
$gitToken = "${{ secrets.PT_WINGET }}"
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
$headers = @{ Authorization = "Bearer $gitToken"; Accept = "application/vnd.github+json" }
$github = Invoke-RestMethod -Uri "https://api.github.com/repos/2dust/v2rayN/releases" -Headers $headers
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
+2 -2
View File
@@ -13,7 +13,7 @@
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.8" />
<PackageVersion Include="Downloader" Version="5.10.1" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
@@ -27,7 +27,7 @@
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.73.19" />
<PackageVersion Include="TUnit" Version="1.75.0" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
@@ -0,0 +1,91 @@
namespace ServiceLib.Tests.Manager;
public class ProfileExManagerConcurrencyTests
{
[Test]
public async Task SetTestDelay_FromManyThreads_ShouldNotThrow()
{
var manager = CreateManager();
var exceptions = new List<Exception>();
var threads = Environment.ProcessorCount * 2;
//Hammer the same manager from several streams at once: the queue is mutated while
//other threads keep pushing into it
var barrier = new Barrier(threads);
Parallel.For(0, threads, t =>
{
barrier.SignalAndWait();
for (var i = 0; i < 5000; i++)
{
try
{
manager.SetTestDelay($"index{i % 500}", i);
}
catch (Exception ex)
{
lock (exceptions)
{
exceptions.Add(ex);
}
}
}
});
await exceptions.Count.Should().BeEqualTo(0);
}
[Test]
public async Task SetTestDelay_FromManyThreads_ShouldNotLoseQueuedIds()
{
var manager = CreateManager();
var ids = 500;
Parallel.For(0, Environment.ProcessorCount * 2, t =>
{
for (var i = 0; i < 5000; i++)
{
manager.SetTestDelay($"id{i % ids}", i);
}
});
var drained = Drain(manager);
await drained.Count.Should().BeGreaterThanOrEqualTo(ids);
await drained.Distinct().Count().Should().BeEqualTo(ids);
}
[Test]
public async Task SetTestDelay_ShouldCreateOneEntryPerIndex()
{
var manager = CreateManager();
await Parallel.ForEachAsync(Enumerable.Range(0, 500), new ParallelOptions { MaxDegreeOfParallelism = 32 },
async (i, ct) =>
{
manager.SetTestDelay($"same{i % 10}", i);
await Task.CompletedTask;
});
var items = await manager.GetProfileExs();
await items.Count.Should().BeEqualTo(10);
await items.Select(t => t.IndexId).Distinct().Count().Should().BeEqualTo(10);
}
private static ProfileExManager CreateManager()
{
return (ProfileExManager)Activator.CreateInstance(typeof(ProfileExManager), nonPublic: true)!;
}
private static List<string> Drain(ProfileExManager manager)
{
var queue = (System.Collections.Concurrent.ConcurrentQueue<string>)typeof(ProfileExManager)
.GetField("_queIndexIds", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(manager)!;
var result = new List<string>();
while (queue.TryDequeue(out var id))
{
result.Add(id);
}
return result;
}
}
+3 -3
View File
@@ -656,10 +656,10 @@ public class Global
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.hysteria, "HyNetworks/hysteria" },
{ ECoreType.hysteria2, "HyNetworks/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.tuic, "tuic-protocol/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
+33 -9
View File
@@ -4,7 +4,8 @@ public class ProfileExManager
{
private static readonly Lazy<ProfileExManager> _instance = new(() => new());
private ConcurrentBag<ProfileExItem> _lstProfileEx = [];
private readonly Queue<string> _queIndexIds = new();
private readonly ConcurrentQueue<string> _queIndexIds = new();
private readonly Lock _profileExLock = new();
public static ProfileExManager Instance => _instance.Value;
private static readonly string _tag = "ProfileExHandler";
@@ -27,29 +28,39 @@ public class ProfileExManager
{
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem where indexId not in ( select indexId from ProfileItem )");
_lstProfileEx = new(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
var lstProfileEx = new ConcurrentBag<ProfileExItem>(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
lock (_profileExLock)
{
_lstProfileEx = lstProfileEx;
}
}
private void IndexIdEnqueue(string indexId)
{
if (indexId.IsNotEmpty() && !_queIndexIds.Contains(indexId))
if (indexId.IsNotEmpty())
{
//Speed tests touch this from many threads; duplicates are harmless here since
//the save loop just writes the same row more than once
_queIndexIds.Enqueue(indexId);
}
}
private async Task SaveQueueIndexIds()
{
var cnt = _queIndexIds.Count;
if (cnt > 0)
var lstIds = new List<string>();
while (_queIndexIds.TryDequeue(out var id))
{
lstIds.Add(id);
}
if (lstIds.Count > 0)
{
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
List<ProfileExItem> lstInserts = [];
List<ProfileExItem> lstUpdates = [];
for (var i = 0; i < cnt; i++)
foreach (var id in lstIds)
{
var id = _queIndexIds.Dequeue();
var item = lstExists.FirstOrDefault(t => t.IndexId == id);
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
if (itemNew is null)
@@ -103,13 +114,26 @@ public class ProfileExManager
private ProfileExItem GetProfileExItem(string? indexId)
{
return _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId) ?? AddProfileEx(indexId);
var profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
if (profileEx is not null)
{
return profileEx;
}
lock (_profileExLock)
{
profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
return profileEx ?? AddProfileEx(indexId);
}
}
public async Task ClearAll()
{
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
_lstProfileEx = [];
lock (_profileExLock)
{
_lstProfileEx = [];
}
}
public async Task SaveTo()
@@ -356,6 +356,8 @@ public partial class CoreConfigSingboxService
{
case EConfigType.WireGuard:
{
endpoint.server = null;
endpoint.server_port = null;
endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
var peer = new Peer4Sbox
{
@@ -2,7 +2,7 @@ namespace ServiceLib.ViewModels;
public partial class ClashProxiesViewModel : MyReactiveObject
{
private readonly int _delayTimeout = 99999999;
private const int _delayTimeout = 99999999;
private ClashItem _clashItem = new();
public ClashProxiesViewModel()
@@ -235,6 +235,11 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
return;
}
var dicCurrentDetails = new Dictionary<string, ClashProxyModel>();
foreach (var item in ProxyDetails)
{
dicCurrentDetails.TryAdd(item.Name, item);
}
var lstDetails = new List<ClashProxyModel>();
foreach (var item in proxy.all)
{
@@ -243,15 +248,29 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
continue;
}
var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1;
var delay = -1;
var hasData = false;
if (proxy2.history is { Count: > 0 })
{
delay = proxy2.history.Last().delay;
hasData = true;
}
else if (dicCurrentDetails.TryGetValue(item, out var detail))
{
delay = detail.Delay;
hasData = true;
}
var isValidDelay = delay is > 0 and not _delayTimeout;
lstDetails.Add(new ClashProxyModel
{
IsActive = item == proxy.now,
Name = item,
Type = proxy2.type,
Delay = delay <= 0 ? _delayTimeout : delay,
DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
Delay = isValidDelay ? delay : _delayTimeout,
DelayName = !hasData ? string.Empty : (isValidDelay ? $"{delay}ms" : "-1"),
});
}
// sort
@@ -362,8 +381,9 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
return;
}
detail.Delay = Convert.ToInt32(result.Delay);
detail.DelayName = $"{detail.Delay}ms";
var isValid = int.TryParse(result.Delay, out var delay) && delay > 0 && delay != _delayTimeout;
detail.Delay = isValid ? delay : _delayTimeout;
detail.DelayName = isValid ? $"{delay}ms" : "-1";
await Task.CompletedTask;
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+33
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
/.packages/
/.NET/*
!/.NET/.gitignore
/TestResults/
.env
/bin/
/obj/
/publish/
/core-bin/
/Tests/bin/
/Tests/obj/
/Tests/TestResults/
@@ -0,0 +1,9 @@
using ServiceLib.Base;
namespace v2rayN.WebAPI.Adapters;
public sealed class HeadlessWindowDialog : IWindowDialog
{
public Task<bool> ShowDialogAsync<TViewModel>(TViewModel vm)
where TViewModel : class => Task.FromResult(false);
}
+21
View File
@@ -0,0 +1,21 @@
using v2rayN.WebAPI.Contracts;
namespace v2rayN.WebAPI.Api;
public static class ApiReplies
{
public static IResult Ok<T>(T data, string messageKey = ApiMessageKeys.CommonLoaded) =>
Results.Ok(ApiEnvelope<T>.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<object>.Fail(code, messageKey), statusCode: StatusCodes.Status404NotFound);
public static IResult Conflict(string code, string messageKey, object? data = null) =>
Results.Json(ApiEnvelope<object>.Fail(code, messageKey, data), statusCode: StatusCodes.Status409Conflict);
}
@@ -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);
}
}
}
}
+405
View File
@@ -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<RulesItem> 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<bool> 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<object>.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized),
context.RequestAborted);
}
}
@@ -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<object>.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<object>.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);
}
@@ -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");
}
}
@@ -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);
}
@@ -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"
}
]
}
@@ -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);
/// <summary>
/// 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.
/// </summary>
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<string, string> 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<string, string> 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<string, string>(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<string, string> Merge(
IReadOnlyDictionary<string, string> fileValues,
IReadOnlyDictionary<string, string?> processEnvironment)
{
var merged = new Dictionary<string, string>(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<string, string?> ReadProcessEnvironment()
{
var values = new Dictionary<string, string?>(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}.");
}
+49
View File
@@ -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"]
@@ -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
+572
View File
@@ -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<CoreTypeMapping> 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<string, string[]> 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<CoreUpdateTargetView> Targets,
bool GeoFilesSelected,
bool PreRelease,
bool UseProxy,
IReadOnlyList<string>? 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<CoreTypeMapping> Mappings);
public sealed record SettingsApplyInput(
InboundSettingsInput Inbound,
CoreSettingsInput Core,
AppSettingsInput Application,
SpeedTestSettingsInput SpeedTest,
IReadOnlyList<CoreTypeMapping> CoreTypes,
string? DomainStrategy,
string? DomainStrategy4Singbox);
public sealed record ApiEnvelope<T>(bool Success, string Code, string MessageKey, T? Data)
{
public static ApiEnvelope<T> Ok(T? data, string messageKey = ApiMessageKeys.CommonLoaded) => new(true, "ok", messageKey, data);
public static ApiEnvelope<T> 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<LogView> Items, int Page, int PageSize, int Total, int TotalPages);
public sealed record WebEvent(string Type, object? Data, DateTimeOffset Timestamp);
@@ -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",
];
}
@@ -0,0 +1 @@
V2RAYN_WEB_API_KEY=
@@ -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
+56
View File
@@ -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.
@@ -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"}}
@@ -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"}}
@@ -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"}}
@@ -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);
}
}
/// <summary>
/// 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.
/// </summary>
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<string> hostArguments,
Func<string, bool>? fileExists = null,
Func<string, bool>? 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;
}
}
}
@@ -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<int> RunAsync(string planPath)
{
var executable = Environment.ProcessPath;
try
{
return await RunCoreAsync(planPath);
}
finally
{
ScheduleWindowsHelperCleanup(executable);
}
}
private static async Task<int> RunCoreAsync(string planPath)
{
NativeWebUpdatePlan? plan = null;
try
{
plan = JsonSerializer.Deserialize<NativeWebUpdatePlan>(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<WebUpdatePackageIdentity>(
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<bool> 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<RuntimeRestartIntent>(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<bool> 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<bool> 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);
}
@@ -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<NativeWebUpdateTransactionResult> ApplyAsync(
Func<Task> installCandidateAsync,
Func<Task<bool>> startCandidateAndVerifyHealthAsync,
Func<Task> restorePreviousFilesAsync,
Func<Task<bool>> startPreviousAndVerifyHealthAsync,
Action<string> setPhase,
Func<Task> 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<NativeWebUpdateTransactionResult> TryRestoreAndVerifyAsync(
Func<Task> restorePreviousFilesAsync,
Func<Task<bool>> startPreviousAndVerifyHealthAsync,
Func<Task> cleanBackupsAsync,
Action<string> 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}");
}
}
}
@@ -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);
}
@@ -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<string, string?> environment)
{
return HasValue(environment, "INVOCATION_ID")
|| HasValue(environment, "JOURNAL_STREAM")
|| HasValue(environment, "NOTIFY_SOCKET");
}
public static bool IsContainerEnvironment(IReadOnlyDictionary<string, string?> 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<string, string?> environment) =>
HasValue(environment, InteractiveLaunchEnvironmentVariable);
/// <summary>
/// True when this process runs with an interactive terminal attached or the
/// launch chain was marked as interactive by the process that had one.
/// </summary>
public static bool IsInteractiveLaunch(IReadOnlyDictionary<string, string?> environment) =>
!Console.IsInputRedirected || IsInteractiveLaunchMarker(environment);
/// <summary>
/// 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.
/// </summary>
public static bool IsSystemdManagedDeployment()
{
if (WebStopper.IsManagedBySystemd(Environment.ProcessId))
{
return true;
}
var environment = ReadCurrentEnvironment();
return IsDaemonEnvironment(environment) && !IsInteractiveLaunch(environment);
}
public static Dictionary<string, string?> ReadCurrentEnvironment() =>
Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
private static bool HasValue(IReadOnlyDictionary<string, string?> 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<string, string> 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}");
}
}
@@ -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<WebHealthProbeResult> ProbeAsync(Uri healthUri, CancellationToken cancellationToken);
}
public sealed record WebHealthProbeResult(
bool IsHealthy,
int? InstanceProcessId,
string? ShutdownStage = null,
IReadOnlyList<int>? 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<WebHealthProbeResult> 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<ExistingInstanceResult> 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<int> 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();
}
@@ -0,0 +1,121 @@
using System.Net;
using v2rayN.WebAPI.Security;
namespace v2rayN.WebAPI.Launcher;
/// <summary>
/// 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
/// <see cref="IsProbeableLoopbackUri"/>.
/// </summary>
public static class WebProbeUriResolver
{
public const string HealthPath = "/api/health";
public sealed record WebProbeEndpoints(Uri HealthUri, Uri ApiUri);
/// <summary>
/// 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.
/// </summary>
public static bool IsProbeableLoopbackUri(string? value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& uri.Scheme == Uri.UriSchemeHttp
&& WebSetupAccessPolicy.IsLocalHost(uri.Host);
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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<string> 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<string> 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<string> hostArguments)
{
if ((!isLinux && !isWindows)
|| string.IsNullOrWhiteSpace(processPath)
|| (isLinux && string.IsNullOrWhiteSpace(setsidPath)))
{
return null;
}
var arguments = new List<string>();
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<TimeSpan, CancellationToken, Task> _delay;
public NativeWebRestartCoordinator(
IWebHealthProbe healthProbe,
IWebReplacementProcessStarter processStarter,
IWebInstanceOwnershipProbe ownershipProbe,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
Func<TimeSpan, CancellationToken, Task>? 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<WebRestartHandoffResult> 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;
}
+193
View File
@@ -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<WebStopResult> 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<int>(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;
}
}
}
+414
View File
@@ -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<int> 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<WebHostRunResult> 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<EventHub>();
builder.Services.AddSingleton<LogBuffer>();
builder.Services.AddSingleton<RuntimeOperationCoordinator>();
builder.Services.AddSingleton<V2rayRuntime>();
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = RuntimeShutdownBudgets.HostShutdown);
builder.Services.AddHostedService<V2rayHostedService>(services =>
new V2rayHostedService(
services.GetRequiredService<V2rayRuntime>(),
services.GetRequiredService<WebAuthService>()));
builder.Services.AddSingleton(webAuth);
builder.Services.AddSingleton<WebSessionService>();
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<V2rayRuntime>();
app.UseMiddleware<WebSecurityHeadersMiddleware>();
app.UseMiddleware<WebAuthRequestBodyLimitMiddleware>();
app.UseRouting();
app.UseMiddleware<WebOriginGuardMiddleware>();
app.UseCors(WebCorsPolicy.PolicyName); // OPTIONS must complete before session auth.
app.UseRateLimiter();
app.UseMiddleware<WebSessionAuthenticationMiddleware>();
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<object>.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<object>.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<object>.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<RuntimeOperationCoordinator>();
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<object>.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<string, string?> 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);
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("v2rayN.WebAPI.Tests")]
+238
View File
@@ -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 <session-token>` 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=<your-management-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 <previous-publish-dir> <candidate-publish-dir>`
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 <native-publish-directory>` 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.
@@ -0,0 +1,69 @@
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <linux-x64|linux-arm64|linux-riscv64|linux-loongarch64> <destination-bin-directory>" >&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"
@@ -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"
+55
View File
@@ -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"
+269
View File
@@ -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:])
+336
View File
@@ -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 <sha> \
--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<tag>[^/]+)/(?P<asset>[^/]+)$"
)
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:])
@@ -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<string> 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()));
}
@@ -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<IHttpMaxRequestBodySizeFeature>();
if (requestBodySizeFeature is { IsReadOnly: false })
{
requestBodySizeFeature.MaxRequestBodySize = MaximumRequestBodyBytes;
}
if (context.Request.ContentLength > MaximumRequestBodyBytes)
{
context.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
await context.Response.WriteAsJsonAsync(
ApiEnvelope<object>.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");
}
@@ -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<WebSetupResult> 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<StoredWebAuth>(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,
}
@@ -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;
}
}
@@ -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<string> _origins;
public WebCorsPolicy(string? configuration)
{
_origins = new HashSet<string>(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<object>.Fail("origin_not_allowed", ApiMessageKeys.CommonInvalidInput));
return;
}
await next(context);
}
}
@@ -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;
}
@@ -0,0 +1,67 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
namespace v2rayN.WebAPI.Security;
/// <summary>Checks server binding configuration, never request Host/forwarding headers.</summary>
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<string> 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);
}
@@ -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);
}
}
@@ -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<object>.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<object>.Fail("unauthorized", ApiMessageKeys.CommonUnauthorized));
return;
}
await next(context);
}
}
@@ -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<string, SessionEntry> _sessions = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SseTicketEntry> _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<KeyValuePair<string, SessionEntry>>)_sessions)
.Remove(new KeyValuePair<string, SessionEntry>(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);
@@ -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));
/// <summary>
/// The loopback peer + local Host pair required for per-instance health diagnostics.
/// The update/launcher probe rules (see WebProbeUriResolver) target exactly this pair.
/// </summary>
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));
}
+2
View File
@@ -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.
@@ -0,0 +1,81 @@
using SQLite;
namespace v2rayN.WebAPI.Services;
internal static class BackupDatabaseCompatibility
{
private static readonly IReadOnlyDictionary<string, string> RequiredPrimaryKeys = new Dictionary<string, string>(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<string>("PRAGMA quick_check;"), "ok", StringComparison.OrdinalIgnoreCase))
{
error = "SQLite quick_check did not return ok.";
return false;
}
var tableNames = database.Query<TableName>(
"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<TableColumn>($"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; }
}
}
@@ -0,0 +1,24 @@
using v2rayN.WebAPI.Contracts;
using v2rayN.WebAPI.Launcher;
namespace v2rayN.WebAPI.Services;
internal static class BackupRestorePreflightGate
{
public static Task<OperationView> ExecuteAsync(
NativeWebRestorePreflightResult preflight,
Func<Task<OperationView>> 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();
}
}
@@ -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<Task<IReadOnlyList<DNSItem>>> load,
Func<Config, DNSItem, Task<int>> 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];
}
}
}
@@ -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);
}
@@ -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<CoreLaunchPreflightResult> BuildAndValidateAsync(
Config config,
ProfileItem profile,
Func<ECoreType, OperationView?> validateCoreBinary)
{
var built = await CoreConfigContextBuilder.BuildAll(config, profile);
return Validate(profile, built, validateCoreBinary);
}
internal static CoreLaunchPreflightResult Validate(
ProfileItem profile,
CoreConfigContextBuilderAllResult built,
Func<ECoreType, OperationView?> 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<OperationView> ExecuteAsync(
Func<Task<CoreLaunchPreflightResult>> buildAndValidate,
Func<Task> stopCore,
Action markCoreStopped,
Func<CoreLaunchPreflightResult, Task<OperationView>> 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;
}
}
@@ -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);
/// <summary>
/// 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.
/// </summary>
internal static class CoreProcessTracker
{
public static HashSet<int> CaptureExistingProcessIds()
{
var ids = new HashSet<int>();
foreach (var process in Process.GetProcesses())
{
using (process)
{
try { ids.Add(process.Id); }
catch (InvalidOperationException) { }
}
}
return ids;
}
public static CoreProcessIdentity[] CaptureStartedProcesses(
IReadOnlySet<int> existingProcessIds,
IEnumerable<string> expectedExecutablePaths,
IEnumerable<string>? 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<CoreProcessIdentity>();
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<CoreProcessIdentity> identities)
{
var active = new List<CoreProcessIdentity>();
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<string> expectedPaths,
HashSet<string>? 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<string> arguments, HashSet<string> 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<string> GetShebangInterpreters(string executablePath)
{
var interpreters = new HashSet<string>(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;
}
@@ -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);
/// <summary>
/// Immutable description of the Core process that was actually launched. Config changes
/// never mutate this snapshot; only confirmed runtime transitions replace it.
/// </summary>
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);
}
@@ -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<CoreSettingsApplyExecution> ExecuteAsync(
CoreRuntimeState state,
bool hasActiveChild,
bool changed,
Func<Task<OperationView>> restart)
{
var action = CoreSettingsApplyPolicy.Decide(state, hasActiveChild, changed);
var restartResult = action == CoreSettingsApplyAction.Restart ? await restart() : null;
return new(action, restartResult);
}
}
@@ -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<string>(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<string>(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<string> 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);
}
}
}
+53
View File
@@ -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<Guid, EventSubscriber> _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<WebEvent> Subscribe(
[EnumeratorCancellation] CancellationToken cancellationToken = default,
bool includeLogs = false)
{
var id = Guid.NewGuid();
var channel = Channel.CreateBounded<WebEvent>(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<WebEvent> Channel, bool IncludeLogs);
}
@@ -0,0 +1,33 @@
using System.Collections.Concurrent;
namespace v2rayN.WebAPI.Services;
internal sealed class GeoFilesUpdateCompletion
{
private readonly ConcurrentQueue<string> _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.");
}
}
}
@@ -0,0 +1,24 @@
namespace v2rayN.WebAPI.Services;
/// <summary>
/// Serializes every Web-owned GeoFiles transaction, regardless of whether it was started
/// manually, as part of a batch update, or by a regional preset.
/// </summary>
internal sealed class GeoFilesUpdateGate
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RunAsync(Func<Task> update, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
await _gate.WaitAsync(cancellationToken);
try
{
await update();
}
finally
{
_gate.Release();
}
}
}
@@ -0,0 +1,151 @@
namespace v2rayN.WebAPI.Services;
/// <summary>
/// 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.
/// </summary>
internal static class GeoFilesUpdateTransaction
{
public static async Task ApplyAsync(
IEnumerable<string> managedFiles,
IEnumerable<string> requiredFiles,
Func<CancellationToken, Task> update,
CancellationToken cancellationToken,
Func<Func<Task>, 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<string, GeoFileBackup>(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;
}
@@ -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<LogView> _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<LogView> 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;
}
}
}
@@ -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<RoutingItem> Items,
string? DefaultRemarks = null,
IReadOnlyList<string>? 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<DNSItem> DnsItems,
RegionalRoutingPlan Routing);
internal sealed class RegionalPresetStager(
Func<string, IWebProxy?, CancellationToken, Task<string?>> download,
TimeSpan? timeout = null)
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private readonly TimeSpan _timeout = timeout ?? DefaultTimeout;
public async Task<RegionalPresetStage> StageAsync(
EPresetType preset,
IReadOnlyList<DNSItem> existingDnsItems,
IReadOnlyList<RoutingItem> 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<DNSItem> 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<DNSItem>(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<string?>(null)
: TryDownloadAsync(template.NormalDNS, proxy, cancellationToken);
var tunDnsTask = string.IsNullOrWhiteSpace(template.TunDNS)
? Task.FromResult<string?>(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<SimpleDNSItem>(content);
return template is null
? (ConfigHandler.InitBuiltinSimpleDNS(), true)
: (template, false);
}
private async Task<RegionalRoutingPlan> StageRoutingAsync(
string url,
IReadOnlyList<RoutingItem> existingItems,
string? legacyRoutingIndexId,
IWebProxy? proxy,
CancellationToken cancellationToken)
{
var content = await TryDownloadAsync(url, proxy, cancellationToken);
var template = content is null ? null : JsonUtils.Deserialize<RoutingTemplate>(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<RoutingItem>().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<RoutingItem?> 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<List<RulesItem>>(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<RoutingItem> 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<string>()
: [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<List<RulesItem>>(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<string?> 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<string> 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<TResult> RunAsync<TStage, TResult>(
Func<CancellationToken, Task<TStage>> stage,
Func<TStage, CancellationToken, Task<TResult>> 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<CancellationToken, Task> applyConfiguration,
Func<Func<Func<Task>, CancellationToken, Task>, CancellationToken, Task> updateGeoFilesTransaction,
Func<Func<Task>, CancellationToken, Task> completeCoreApply,
Func<Task> 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<RegionalPresetApplyResult> RunAsync(Func<Task> apply, Func<Task<bool>> 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);
}
}
}
@@ -0,0 +1,39 @@
namespace v2rayN.WebAPI.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class RuntimeMutationGate
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task RunAsync(Func<Task> mutation, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(mutation);
await _semaphore.WaitAsync(cancellationToken);
try
{
await mutation();
}
finally
{
_semaphore.Release();
}
}
public async Task<T> RunAsync<T>(Func<Task<T>> mutation, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(mutation);
await _semaphore.WaitAsync(cancellationToken);
try
{
return await mutation();
}
finally
{
_semaphore.Release();
}
}
}
@@ -0,0 +1,226 @@
namespace v2rayN.WebAPI.Services;
/// <summary>
/// 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.
/// </summary>
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<Lease> EnterOperationAsync(CancellationToken cancellationToken = default) =>
EnterAsync(exclusive: false, allowReadOnlyObservation: false, cancellationToken);
/// <summary>
/// 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.
/// </summary>
public ValueTask<Lease> EnterObservationAsync(CancellationToken cancellationToken = default) =>
EnterAsync(exclusive: false, allowReadOnlyObservation: true, cancellationToken);
public ValueTask<Lease> EnterExclusiveAsync(
CancellationToken cancellationToken = default,
bool allowReadOnlyObservations = false) =>
EnterAsync(exclusive: true, allowReadOnlyObservation: false, cancellationToken, allowReadOnlyObservations);
private async ValueTask<Lease> 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<bool> 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;
}
}
}
@@ -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;
}
}
@@ -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<RuntimeRestartRecoveryResult> RecoverAsync(
RuntimeRestartIntent intent,
string? selectedProfileId,
string? defaultProfileId,
Func<string, CancellationToken, Task<bool>> profileExists,
Func<string, CancellationToken, Task<OperationView>> startCore,
Action<string> 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<RuntimeRestartRecoveryResult> StartAsync(
string profileId,
string reason,
RuntimeRestartRecoverySource source,
Func<string, CancellationToken, Task<OperationView>> startCore,
Action<string> 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;
}
@@ -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<ShutdownDeadline, Task<bool>> ExecuteAsync);
internal static class ShutdownCleanupSequence
{
public static async Task<bool> RunAsync(
IEnumerable<ShutdownCleanupStep> steps,
TimeSpan budget,
CancellationToken cancellationToken,
Action<string> log,
Action<string?>? 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;
}
}
@@ -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);
}
@@ -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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView?> RecoverRuntimeAfterFailedRestoreAsync(
RuntimeRestartIntent restoreState,
int[] remainingCoreProcessIds,
Action<int[], string> markRuntimeFaulted,
Func<string?, CancellationToken, Task<OperationView>> startCore,
Action<string> 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<RuntimeRestartIntent?> LoadRestoreRuntimeStateAsync(CancellationToken cancellationToken) =>
LoadRuntimeRestartIntentAsync(RestoreRuntimeStatePath, cancellationToken);
internal static async Task<RuntimeRestartIntent?> LoadRuntimeRestartIntentAsync(string path, CancellationToken cancellationToken)
{
RequeueAbandonedRestoreRuntimeClaim(path);
try
{
if (!File.Exists(path)) return null;
var json = await File.ReadAllTextAsync(path, cancellationToken);
return JsonSerializer.Deserialize<RuntimeRestartIntent>(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<RuntimeRestartIntent>(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<RuntimeRestartIntent>(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<string>(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<Config>(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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<IReadOnlyList<ProfileGroupView>> 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<ProfileGroupView>
{
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<OperationView> 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<OperationView> 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<OperationView> 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<OperationView> DeleteProfilesAsync(IEnumerable<string> 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<OperationView> CopyProfilesAsync(IEnumerable<string> 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<OperationView> 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<OperationView> 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<OperationView> SortProfilesAsync(SortProfilesRequest request)
{
var groupId = request.SubscriptionId ?? Config.SubIndexId ?? string.Empty;
if (!Enum.TryParse<EServerColName>(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<OperationView> 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<OperationView> 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<OperationView> 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<IReadOnlyList<ProfileExportItem>> ExportProfileDataAsync(ProfileExportRequest request)
{
var items = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(request.ProfileIds);
var output = new List<ProfileExportItem>();
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<string?> GetSubscriptionShareAsync(string id)
{
return (await AppManager.Instance.GetSubItem(id))?.Url;
}
public async Task<OperationView> 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<string> GetRunningSubscriptionUpdates()
{
lock (_subscriptionGate)
{
return _subscriptionTasks.Where(pair => !pair.Value.IsCompleted).Select(pair => pair.Key).ToArray();
}
}
}
@@ -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<bool> 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<CancellationToken, Task> 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<SubItem> 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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<ApiEnvelope<WebUpdateCheckView>> 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<WebUpdateCheckView>.Fail("web_update_check_failed", ApiMessageKeys.WebUpdateCheckFailed, view);
}
return ApiEnvelope<WebUpdateCheckView>.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<WebUpdateProgressState>(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<WebUpdateReleaseCheck> 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);
}
}
/// <summary>
/// Extracts official release candidates that carry a Web update manifest from a GitHub
/// "list releases" response. Legacy <c>web-v*</c> tags are intentionally ignored.
/// </summary>
internal static List<WebReleaseCandidate> 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<WebReleaseCandidate>();
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<string, (string Url, long Size)>(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<WebUpdateStage> 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<Exception>(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<string, (string Url, long Size)> Assets);
}
File diff suppressed because it is too large Load Diff
@@ -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<AssemblyMetadataAttribute>()
.Where(item => item.Key is "WebVersion" or "WebCommit" or "WebBuildDate" or "WebRepository")
.ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal)
?? new Dictionary<string, string?>(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!);
}
}
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace v2rayN.WebAPI.Services;
/// <summary>
/// The single source of truth for v2rayN Web release asset names.
/// <c>Assets/web-assets.json</c> is embedded here and read by the packaging and manifest
/// scripts, so the updater, the manifest, and the release ZIPs never disagree about names.
/// </summary>
internal static class WebReleaseAssets
{
private const string ResourceName = "v2rayN.WebAPI.Assets.web-assets.json";
private static readonly IReadOnlyDictionary<string, Entry> 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<string, Entry> 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<string, Entry>(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;
}
}
@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
namespace v2rayN.WebAPI.Services;
/// <summary>
/// Describes the official v2rayN release channel used by the Web self-update.
/// Official releases use the upstream release tag (for example <c>7.25.3</c>) and carry
/// <c>WebAPI-update.json</c> plus the app-only ZIP archives as additional release assets.
/// </summary>
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();
}
@@ -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);
}
}
@@ -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<WebUpdatePackage> 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<WebUpdateManifest>(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}.");
}
/// <summary>
/// Maps a supported .NET runtime identifier to the release archive architecture suffix
/// (<c>linux-x64</c>/<c>win-x64</c> become <c>64</c>; <c>linux-arm64</c> stays <c>arm64</c>).
/// </summary>
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<WebUpdatePackageIdentity> 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<string>(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<WebUpdatePackageIdentity>(
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<byte> 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<byte> 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<byte> 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<byte> 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();
}
+3
View File
@@ -0,0 +1,3 @@
/bin/
/obj/
/TestResults/
@@ -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<ServiceLib.Models.Entities.SubItem>();
var restored = connection.Table<ServiceLib.Models.Entities.SubItem>().First();
var columns = connection.Query<TableColumnRow>("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;
}
}
@@ -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);
}
}
@@ -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<DNSItem> Rows, List<ECoreType> SavedTypes)> Ensure(List<DNSItem> initial)
{
var rows = new List<DNSItem>(initial);
var savedTypes = new List<ECoreType>();
await BuiltinDnsProfileBootstrap.EnsureAsync(
new Config(),
() => Task.FromResult<IReadOnlyList<DNSItem>>(rows.ToArray()),
(_, item) =>
{
rows.Add(item);
savedTypes.Add(item.CoreType);
return Task.FromResult(0);
});
return (rows, savedTypes);
}
private static async Task AssertBuiltinPair(IReadOnlyList<DNSItem> 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",
};
}
@@ -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);
}
}
@@ -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<string>();
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);
}
}

Some files were not shown because too many files have changed in this diff Show More