Compare commits

..

18 Commits

Author SHA1 Message Date
dependabot[bot] a4a223d412 Bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  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:07 +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
2dust 1883178b5f up 7.25.6 2026-10-11 13:55:36 +08:00
2dust 1cde521d75 Prevent zip path traversal in app upgrade
Hardened upgrade extraction by validating each zip entry resolves under the app startup directory before writing files, and aborting with a clear failure message on suspicious paths. Added shared path utilities for directory containment checks and OS-aware path string comparison, then reused them for executable matching and `bin` prefix checks during extraction.

https://github.com/2dust/v2rayN/issues/10313
2026-10-11 13:52:28 +08:00
Ali Ahmadi 2407d44f4f Fix empty latency tests and Linux startup errors (#10335)
* Fix overflow when speed tests have no eligible profiles

* Preserve core executable permissions in Debian packages

* Avoid requesting an icon refresh before view initialization
2026-10-11 11:06:46 +08:00
dependabot[bot] fbf822d74c Bump TUnit from 1.72.16 to 1.73.19 (#10334)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.73.19
  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-11 10:56:36 +08:00
liu-xindi 4869a6cbba Route IPv6 into the Xray TUN when EnableIPv6Address is on (#10329)
* Route IPv6 into the Xray TUN on NAT66 hosts and when IPv6 is enabled

#10080 dropped ::/0 from autoSystemRoutingTable whenever the host held no
address inside 2000::/3. A host behind NAT66 holds only unique local
addresses, yet it reaches the IPv6 internet through its IPv6 default
gateway. With ::/0 gone, its IPv6 follows the system default route,
bypasses the tunnel and exposes the real IPv6 address (#10327).

The check also overrode EnableIPv6Address. Before #9930, enabling it
always added ::/0; since #10080 the option only assigned the interface
address, so users who had enabled it were affected as well.

Route ::/0 when EnableIPv6Address is on, and otherwise when the host has
IPv6 connectivity: a global IPv6 address or an IPv6 default gateway.
Routes whose next hop is ::, such as the one the tunnel installs, do not
count. A host with neither, as in #10051, still skips ::/0. The
RouteExcludeAddress branch uses the same decision.

Reproduced in network namespaces with Xray 26.7.28: on a NAT66 client the
7.25.4 TUN inbound let curl -6 reach the echo server from the NAT66
address, while the inbound generated by this change sends it through the
proxy, with EnableIPv6Address on or off.

Fixes #10327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Simplify: route ::/0 when EnableIPv6Address is on

Drop the rename and the gateway detection; keep HasGlobalIPv6Address
and let EnableIPv6Address override it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142Bna7M2GhesX7P7d4CSrt

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:55:39 +08:00
DHR60 b501efbc45 Fix (#10326) 2026-10-10 09:14:30 +08:00
Miheichev Aleksandr Sergeevich 5ea8ae6420 fix(ui): wrap the pre-release label in the update dialog (#10303)
The per-core pre-release switch and its label sit in a horizontal
StackPanel inside a fixed-width column of the update dialog.
Translations longer than the column do not fit: in Avalonia the label
runs over the core name and the update status (Russian, French,
Hungarian, Persian), and in WPF it is cut off. Use a DockPanel so that
the label wraps inside its column, as the interval label of the
subscription window does since #10292. The dialog keeps its width.

In Avalonia the panel also had a vertical margin on top of the
margin of the switch, so the two rows with the switch were 8 px taller
than the other rows. Use a horizontal-only margin, as the WPF view
already does.
2026-10-07 11:11:27 +08:00
Miheichev Aleksandr Sergeevich fa9d4a4769 fix(ui): line up the backup dialog columns again (#10302)
#10292 made the label and button columns of the backup view Auto with
a minimum width, so that longer labels and buttons fit. Each of the two
cards sizes its own grid, so when the remote (WebDAV) buttons are wider
than the local ones, the local buttons no longer sit under the remote
ones and the separators of the two cards differ in length. This shows
in English and in every other language except Chinese.

Share the column widths of the two cards with SharedSizeGroup: both
cards line up again, and the long labels and buttons still fit. In
Avalonia the buttons stay centred in the shared column; in WPF they
fill it as before, so the local buttons get as wide as the remote ones.
2026-10-07 11:09:32 +08:00
DHR60 771ef3192a Update cert (#10308) 2026-10-07 11:04:58 +08:00
174 changed files with 29242 additions and 164 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@v7
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@v7
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@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: false
with:
context: .
file: ./v2rayN/v2rayN.WebAPI/Containerfile
platforms: ${{ matrix.platform }}
tags: v2rayn-webapi:ci
build-args: |
RID=${{ matrix.rid }}
WEB_VERSION=${{ needs.identity.outputs.version }}
WEB_REPOSITORY=${{ github.repository }}
WEB_COMMIT=${{ github.sha }}
WEB_BUILD_DATE=${{ needs.identity.outputs.build_date }}
push: false
release-assets:
name: Assemble API release assets
if: needs.identity.outputs.release == 'true'
needs: [identity, verify, windows, windows-arm64, native, native-riscv64, native-loong64, container]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v7
with:
pattern: v2rayN.WebAPI-*
path: release-assets
merge-multiple: true
- name: Build and verify WebAPI-update.json
env:
RELEASE_TAG: ${{ needs.identity.outputs.tag }}
run: |
set -euo pipefail
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \
--version "$RELEASE_TAG" \
--repository "${{ github.repository }}" \
--commit "${{ github.sha }}" \
--build-date "${{ needs.identity.outputs.build_date }}" \
--dist release-assets \
--output release-assets/WebAPI-update.json
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \
--manifest release-assets/WebAPI-update.json \
--dist release-assets \
--repository "${{ github.repository }}"
- name: Smoke-test full API ZIP
run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip
- uses: actions/upload-artifact@v7
with:
name: WebAPI-release-packages
path: release-assets/*.zip
if-no-files-found: error
- uses: actions/upload-artifact@v7
with:
name: WebAPI-release-manifest
path: release-assets/WebAPI-update.json
if-no-files-found: error
+7
View File
@@ -20,6 +20,13 @@ jobs:
secrets: secrets:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 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: update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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" $wingetPackage = "2dust.v2rayN"
$gitToken = "${{ secrets.PT_WINGET }}" $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 $targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
+9
View File
@@ -676,6 +676,15 @@ EOF
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} + find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
[[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true [[ -f "$stage/opt/v2rayN/v2rayN" ]] && chmod 0755 "$stage/opt/v2rayN/v2rayN" || true
# Core binaries must be executable before installation: ordinary users cannot
# chmod the root-owned files installed by dpkg.
local core
for core in xray/xray sing_box/sing-box mihomo/mihomo; do
if [[ -f "$stage/opt/v2rayN/bin/$core" ]]; then
chmod 0755 "$stage/opt/v2rayN/bin/$core"
fi
done
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb" deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
dpkg-deb --root-owner-group --build "$stage" "$deb_out" dpkg-deb --root-owner-group --build "$stage" "$deb_out"
+10 -3
View File
@@ -45,6 +45,8 @@ internal class UpgradeApp
var thisAppOldFile = $"{Utils.GetExePath()}.tmp"; var thisAppOldFile = $"{Utils.GetExePath()}.tmp";
File.Delete(thisAppOldFile); File.Delete(thisAppOldFile);
var splitKey = "/"; var splitKey = "/";
var allowedBaseDir = Utils.StartupPath();
var pathComparison = Utils.GetPathComparison();
using var archive = ZipFile.OpenRead(fileName); using var archive = ZipFile.OpenRead(fileName);
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
@@ -65,16 +67,21 @@ internal class UpgradeApp
} }
var fullName = string.Join(splitKey, lst[1..lst.Length]); var fullName = string.Join(splitKey, lst[1..lst.Length]);
var entryOutputPath = Utils.GetPath(fullName);
if (!Utils.IsPathUnderDirectory(allowedBaseDir, entryOutputPath))
{
Console.WriteLine($"{Resx.Resource.FailedUpgrade} blocked potential path traversal: {entry.FullName}");
return;
}
if (string.Equals(Utils.GetExePath(), Utils.GetPath(fullName), StringComparison.OrdinalIgnoreCase)) if (string.Equals(Utils.GetExePath(), entryOutputPath, pathComparison))
{ {
File.Move(Utils.GetExePath(), thisAppOldFile); File.Move(Utils.GetExePath(), thisAppOldFile);
} }
var entryOutputPath = Utils.GetPath(fullName);
Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!); Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!);
//In the bin folder, if the file already exists, it will be skipped //In the bin folder, if the file already exists, it will be skipped
if (fullName.StartsWith("bin") && File.Exists(entryOutputPath)) if (fullName.StartsWith("bin", pathComparison) && File.Exists(entryOutputPath))
{ {
continue; continue;
} }
+22
View File
@@ -24,6 +24,28 @@ internal class Utils
return Path.Combine(startupPath, fileName); return Path.Combine(startupPath, fileName);
} }
public static bool IsPathUnderDirectory(string baseDir, string targetPath)
{
if (string.IsNullOrWhiteSpace(baseDir) || string.IsNullOrWhiteSpace(targetPath))
{
return false;
}
var baseFull = Path.GetFullPath(baseDir);
var targetFull = Path.GetFullPath(targetPath);
baseFull = baseFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return targetFull.StartsWith(baseFull, GetPathComparison());
}
public static StringComparison GetPathComparison()
{
return OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
}
public static string V2rayN => "v2rayN"; public static string V2rayN => "v2rayN";
public static void StartV2RayN() public static void StartV2RayN()
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>7.25.5</Version> <Version>7.25.6</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
+2 -2
View File
@@ -13,7 +13,7 @@
<PackageVersion Include="IPNetwork2" Version="4.3.0" /> <PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" /> <PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
<PackageVersion Include="CliWrap" Version="3.10.5" /> <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="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" /> <PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" /> <PackageVersion Include="QRCoder" Version="1.8.0" />
@@ -27,7 +27,7 @@
<PackageVersion Include="sqlite-net-e" Version="1.11.285" /> <PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" /> <PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" /> <PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.72.16" /> <PackageVersion Include="TUnit" Version="1.75.0" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" /> <PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" /> <PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
@@ -0,0 +1,42 @@
namespace ServiceLib.Tests.Common;
public class UnixFileModeTests
{
[Test]
public async Task AlreadyExecutableSystemBinary_ShouldNotRequireOwnership()
{
if (OperatingSystem.IsWindows())
{
return;
}
// /bin/sh is executable but owned by root on the Unix CI runners.
// A regular user must not need chmod permission just to launch it.
var mode = File.GetUnixFileMode("/bin/sh");
await Utils.SetUnixFileMode("/bin/sh").Should().BeTrue();
await File.GetUnixFileMode("/bin/sh").Should().BeEqualTo(mode);
}
[Test]
public async Task NonExecutableOwnedFile_ShouldGainExecuteBitsWithoutLosingOtherPermissions()
{
if (OperatingSystem.IsWindows())
{
return;
}
var path = Path.GetTempFileName();
try
{
var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
File.SetUnixFileMode(path, mode);
await Utils.SetUnixFileMode(path).Should().BeTrue();
await File.GetUnixFileMode(path).Should().BeEqualTo(mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
}
finally
{
File.Delete(path);
}
}
}
@@ -587,13 +587,11 @@ public class CoreConfigV2rayServiceTests
} }
[Test] [Test]
[Arguments(true)] public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6()
[Arguments(false)]
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
{ {
// A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel, // A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel,
// while ::/0 would pull IPv6 attempts into a tunnel they cannot leave. // while ::/0 would pull IPv6 attempts into a tunnel they cannot leave.
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address); var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: false);
CoreConfigTestFactory.BindAppManagerConfig(config); CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main"); var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
@@ -611,6 +609,26 @@ public class CoreConfigV2rayServiceTests
await ipv6Routes.Should().BeEmpty(); await ipv6Routes.Should().BeEmpty();
} }
[Test]
public async Task GenerateClientConfigContent_Tun_ShouldRouteIPv6WhenEnabledWithoutGlobalIPv6()
{
// Hosts behind NAT66 hold only ULA addresses, so EnableIPv6Address must still route ::/0.
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: true);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun");
await tunInbound.Should().NotBeNull();
await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("::/0");
}
[Test] [Test]
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6() public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
{ {
@@ -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;
}
}
@@ -0,0 +1,59 @@
namespace ServiceLib.Tests.Services;
public class SpeedtestServiceBatchTests
{
[Test]
[Arguments(0)]
[Arguments(1000)]
public async Task EmptySelection_ShouldReturnNoBatches(int pageSize)
{
await GetBatches([], pageSize).Count.Should().BeEqualTo(0);
}
[Test]
[Arguments(0)]
[Arguments(-1)]
public async Task NonPositivePageSize_ShouldKeepEveryEligibleProfile(int pageSize)
{
var profiles = CreateProfiles();
var batches = GetBatches(profiles, pageSize);
await batches.Count.Should().BeEqualTo(profiles.Count);
await batches.SelectMany(batch => batch).Count().Should().BeEqualTo(profiles.Count);
foreach (var profile in profiles)
{
await batches.SelectMany(batch => batch).Count(item => ReferenceEquals(item, profile)).Should().BeEqualTo(1);
}
}
[Test]
public async Task NormalBatches_ShouldRespectPageSizeAndKeepCoreTypesSeparate()
{
var profiles = CreateProfiles();
var batches = GetBatches(profiles, 2);
await batches.Count.Should().BeEqualTo(3);
await batches.SelectMany(batch => batch).Count().Should().BeEqualTo(profiles.Count);
foreach (var batch in batches)
{
await (batch.Count > 0 && batch.Count <= 2).Should().BeTrue();
await batch.Select(item => item.CoreType).Distinct().Count().Should().BeEqualTo(1);
}
}
private static List<ServerTestItem> CreateProfiles() =>
[
new() { CoreType = ECoreType.Xray },
new() { CoreType = ECoreType.Xray },
new() { CoreType = ECoreType.Xray },
new() { CoreType = ECoreType.sing_box },
new() { CoreType = ECoreType.sing_box }
];
private static List<List<ServerTestItem>> GetBatches(List<ServerTestItem> profiles, int pageSize)
{
var service = new SpeedtestService(new Config { SpeedTestItem = new() }, _ => Task.CompletedTask);
var method = typeof(SpeedtestService).GetMethod("GetTestBatchItem", BindingFlags.Instance | BindingFlags.NonPublic)!;
return (List<List<ServerTestItem>>)method.Invoke(service, [profiles, pageSize])!;
}
}
+5 -1
View File
@@ -1441,7 +1441,11 @@ public class Utils
if (File.Exists(fileName)) if (File.Exists(fileName))
{ {
var currentMode = File.GetUnixFileMode(fileName); var currentMode = File.GetUnixFileMode(fileName);
File.SetUnixFileMode(fileName, currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); var executableMode = currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
if (currentMode != executableMode)
{
File.SetUnixFileMode(fileName, executableMode);
}
return true; return true;
} }
} }
+3 -3
View File
@@ -656,10 +656,10 @@ public class Global
{ ECoreType.Xray, "XTLS/Xray-core" }, { ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" }, { ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" }, { ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" }, { ECoreType.hysteria, "HyNetworks/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" }, { ECoreType.hysteria2, "HyNetworks/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" }, { ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" }, { ECoreType.tuic, "tuic-protocol/tuic" },
{ ECoreType.juicity, "juicity/juicity" }, { ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" }, { ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" }, { 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 static readonly Lazy<ProfileExManager> _instance = new(() => new());
private ConcurrentBag<ProfileExItem> _lstProfileEx = []; 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; public static ProfileExManager Instance => _instance.Value;
private static readonly string _tag = "ProfileExHandler"; 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 )"); 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) 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); _queIndexIds.Enqueue(indexId);
} }
} }
private async Task SaveQueueIndexIds() private async Task SaveQueueIndexIds()
{ {
var cnt = _queIndexIds.Count; var lstIds = new List<string>();
if (cnt > 0) while (_queIndexIds.TryDequeue(out var id))
{
lstIds.Add(id);
}
if (lstIds.Count > 0)
{ {
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync(); var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
List<ProfileExItem> lstInserts = []; List<ProfileExItem> lstInserts = [];
List<ProfileExItem> lstUpdates = []; 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 item = lstExists.FirstOrDefault(t => t.IndexId == id);
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id); var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
if (itemNew is null) if (itemNew is null)
@@ -103,13 +114,26 @@ public class ProfileExManager
private ProfileExItem GetProfileExItem(string? indexId) 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() public async Task ClearAll()
{ {
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem "); await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
_lstProfileEx = []; lock (_profileExLock)
{
_lstProfileEx = [];
}
} }
public async Task SaveTo() public async Task SaveTo()
-105
View File
@@ -300,40 +300,6 @@ AAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3CekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ
5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su 5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
-----END CERTIFICATE----- -----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBe
MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0
ZC4xKjAoBgNVBAsMIWVQS0kgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe
Fw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMxMjdaMF4xCzAJBgNVBAYTAlRXMSMw
IQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEqMCgGA1UECwwhZVBL
SSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAH
SyZbCUNsIZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAh
ijHyl3SJCRImHJ7K2RKilTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3X
DZoTM1PRYfl61dd4s5oz9wCGzh1NlDivqOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1
TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX12ruOzjjK9SXDrkb5wdJ
fzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0OWQqraffA
sgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uU
WH1+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLS
nT0IFaUQAS2zMnaolQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pH
dmX2Os+PYhcZewoozRrSgx4hxyy/vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJip
NiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXiZo1jDiVN1Rmy5nk3pyKdVDEC
AwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/QkqiMAwGA1UdEwQF
MAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH
ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGB
uvl2ICO1J2B01GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6Yl
PwZpVnPDimZI+ymBV3QGypzqKOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkP
JXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdVxrsStZf0X4OFunHB2WyBEXYKCrC/
gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEPNXubrjlpC2JgQCA2
j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+rGNm6
5ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUB
o2M3IUxExJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS
/jQ6fbjpKdx2qcgw+BRxgMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2z
Gp1iro2C6pSe3VkQw63d4k3jMdXH7OjysP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTE
W9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmODBCEIZ43ygknQW/2xzQ+D
hNQ+IIX3Sj0rnP0qCglN6oH4EZw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF
MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD
@@ -732,20 +698,6 @@ LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
4uJEvlz36hz1 4uJEvlz36hz1
-----END CERTIFICATE----- -----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout736G
jOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL2
4CejQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7
VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azTL818+FsuVbu/3ZL3pAzcMeGiAjEA/Jdm
ZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV11RZt+cRLInUue4X
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
@@ -805,38 +757,6 @@ HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/
bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm
-----END CERTIFICATE----- -----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt
nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY
6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu
MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k
RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg
f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV
+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo
dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW
Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa
G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq
gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H
vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8
0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC
B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u
NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg
yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev
HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6
xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR
TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg
JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV
7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl
6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL
BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ
@@ -1189,31 +1109,6 @@ AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB
RA+GsCyRxj3qrg+E RA+GsCyRxj3qrg+E
-----END CERTIFICATE----- -----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQG
EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3
MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl
cnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBBcmFueSAoQ2xhc3MgR29sZCkgRsWR
dGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgxMjA2MTUwODIxWjCB
pzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxOZXRM
b2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlm
aWNhdGlvbiBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNz
IEdvbGQpIEbFkXRhbsO6c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAxCRec75LbRTDofTjl5Bu0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrT
lF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw/HpYzY6b7cNGbIRwXdrz
AZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAkH3B5r9s5
VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRG
ILdwfzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2
BJtr+UBdADTHLpl1neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAG
AQH/AgEEMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2M
U9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwWqZw8UQCgwBEIBaeZ5m8BiFRh
bvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTtaYtOUZcTh5m2C
+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC
bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2F
uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2
XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt
MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg
@@ -356,6 +356,8 @@ public partial class CoreConfigSingboxService
{ {
case EConfigType.WireGuard: 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"]; endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
var peer = new Peer4Sbox var peer = new Peer4Sbox
{ {
@@ -67,12 +67,10 @@ public partial class CoreConfigV2rayService
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First(); var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
tunInbound.settings.gateway = [address]; tunInbound.settings.gateway = [address];
// Route both families into the tunnel regardless of EnableIPv6Address. That option only // Without ::/0, IPv6 bypasses the tunnel. Skip it only when the host has no global IPv6
// controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing // address and EnableIPv6Address is off; NAT66 hosts have only ULA addresses.
// table makes IPv6 follow the system default route and bypass the tunnel entirely. var routeIPv6 = _config.TunModeItem.EnableIPv6Address || context.HasGlobalIPv6Address;
// A host without a global IPv6 address is the exception: it has nothing to leak, tunInbound.settings.autoSystemRoutingTable = routeIPv6
// and IPv6 sent into the tunnel would have no way back out.
tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address
? ["0.0.0.0/0", "::/0"] ? ["0.0.0.0/0", "::/0"]
: ["0.0.0.0/0"]; : ["0.0.0.0/0"];
if (_config.TunModeItem.EnableIPv6Address == true) if (_config.TunModeItem.EnableIPv6Address == true)
@@ -108,7 +106,7 @@ public partial class CoreConfigV2rayService
.Where(x => x != null).ToList(); .Where(x => x != null).ToList();
var includeList = new List<IPNetwork2> { wholeInternet }; var includeList = new List<IPNetwork2> { wholeInternet };
var includeListV6 = context.HasGlobalIPv6Address var includeListV6 = routeIPv6
? new List<IPNetwork2> { wholeInternetV6 } ? new List<IPNetwork2> { wholeInternetV6 }
: new List<IPNetwork2>(); : new List<IPNetwork2>();
@@ -587,6 +587,12 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize) private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize)
{ {
List<List<ServerTestItem>> lstTest = []; List<List<ServerTestItem>> lstTest = [];
if (lstSelected.Count == 0)
{
return lstTest;
}
pageSize = Math.Max(pageSize, 1);
var lst1 = lstSelected.Where(t => t.CoreType == ECoreType.Xray).ToList(); var lst1 = lstSelected.Where(t => t.CoreType == ECoreType.Xray).ToList();
var lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList(); var lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList();
@@ -2,7 +2,7 @@ namespace ServiceLib.ViewModels;
public partial class ClashProxiesViewModel : MyReactiveObject public partial class ClashProxiesViewModel : MyReactiveObject
{ {
private readonly int _delayTimeout = 99999999; private const int _delayTimeout = 99999999;
private ClashItem _clashItem = new(); private ClashItem _clashItem = new();
public ClashProxiesViewModel() public ClashProxiesViewModel()
@@ -235,6 +235,11 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
return; return;
} }
var dicCurrentDetails = new Dictionary<string, ClashProxyModel>();
foreach (var item in ProxyDetails)
{
dicCurrentDetails.TryAdd(item.Name, item);
}
var lstDetails = new List<ClashProxyModel>(); var lstDetails = new List<ClashProxyModel>();
foreach (var item in proxy.all) foreach (var item in proxy.all)
{ {
@@ -243,15 +248,29 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
continue; 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 lstDetails.Add(new ClashProxyModel
{ {
IsActive = item == proxy.now, IsActive = item == proxy.now,
Name = item, Name = item,
Type = proxy2.type, Type = proxy2.type,
Delay = delay <= 0 ? _delayTimeout : delay, Delay = isValidDelay ? delay : _delayTimeout,
DelayName = delay <= 0 ? string.Empty : $"{delay}ms", DelayName = !hasData ? string.Empty : (isValidDelay ? $"{delay}ms" : "-1"),
}); });
} }
// sort // sort
@@ -362,8 +381,9 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{ {
return; return;
} }
detail.Delay = Convert.ToInt32(result.Delay); var isValid = int.TryParse(result.Delay, out var delay) && delay > 0 && delay != _delayTimeout;
detail.DelayName = $"{detail.Delay}ms"; detail.Delay = isValid ? delay : _delayTimeout;
detail.DelayName = isValid ? $"{delay}ms" : "-1";
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -527,7 +527,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public async Task AddServerViaImageAsync() public async Task AddServerViaImageAsync()
{ {
var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default); var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
await AddScanResultAsync(imageFileName); await ScanImageResult(imageFileName);
} }
public async Task ScanImageResult(string fileName) public async Task ScanImageResult(string fileName)
@@ -219,7 +219,8 @@ public partial class StatusBarViewModel : MyReactiveObject
await ConfigHandler.InitBuiltinRouting(_config); await ConfigHandler.InitBuiltinRouting(_config);
await RefreshRoutingsMenu(); await RefreshRoutingsMenu();
await InboundDisplayStatus(); await InboundDisplayStatus();
await ChangeSystemProxyAsync(_config.SystemProxyItem.SysProxyType, true); // The view refreshes its icon on initialization; interaction handlers are not registered yet.
await ChangeSystemProxyAsync(_config.SystemProxyItem.SysProxyType, false);
BlRouting = true; BlRouting = true;
} }
@@ -24,7 +24,7 @@
HorizontalAlignment="Left" /> HorizontalAlignment="Left" />
</StackPanel> </StackPanel>
<StackPanel> <StackPanel Grid.IsSharedSizeScope="True">
<Border <Border
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -32,8 +32,14 @@
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto"> <Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" MinWidth="300" /> <ColumnDefinition
<ColumnDefinition Width="Auto" MinWidth="200" /> Width="Auto"
MinWidth="300"
SharedSizeGroup="BackupLabel" />
<ColumnDefinition
Width="Auto"
MinWidth="200"
SharedSizeGroup="BackupButton" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock <TextBlock
Grid.Row="0" Grid.Row="0"
@@ -82,8 +88,14 @@
Theme="{DynamicResource CardBorder}"> Theme="{DynamicResource CardBorder}">
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto,Auto"> <Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" MinWidth="300" /> <ColumnDefinition
<ColumnDefinition Width="Auto" MinWidth="200" /> Width="Auto"
MinWidth="300"
SharedSizeGroup="BackupLabel" />
<ColumnDefinition
Width="Auto"
MinWidth="200"
SharedSizeGroup="BackupButton" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel <StackPanel
Grid.Row="0" Grid.Row="0"
@@ -75,22 +75,23 @@
VerticalAlignment="Center" VerticalAlignment="Center"
IsChecked="{Binding IsSelected}" /> IsChecked="{Binding IsSelected}" />
<StackPanel <DockPanel
Grid.Column="1" Grid.Column="1"
Margin="{StaticResource Margin4}" Margin="{StaticResource MarginLr4}"
IsVisible="{Binding ShowCheckPreRelease}" IsVisible="{Binding ShowCheckPreRelease}">
Orientation="Horizontal">
<ToggleSwitch <ToggleSwitch
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" HorizontalAlignment="Left"
VerticalAlignment="Center"
IsChecked="{Binding IsCheckPreRelease}" IsChecked="{Binding IsCheckPreRelease}"
OffContent="" OffContent=""
OnContent="" /> OnContent="" />
<TextBlock <TextBlock
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}" /> Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}"
</StackPanel> TextWrapping="Wrap" />
</DockPanel>
<TextBlock <TextBlock
Grid.Column="2" Grid.Column="2"
+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();
}
}
}

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