Compare commits

...

33 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-20 18:43:16 +00:00
Nozilla-X 57f17d5939 fix(profile-ex): make ProfileExItem creation atomic per IndexId (#10420)
ConcurrentBag only makes individual operations safe. GetProfileExItem performed FirstOrDefault and AddProfileEx as separate operations, so concurrent speed tests could both miss and create duplicate entries for the same IndexId. Recheck under a dedicated lock before creating, and replace the bag under the same lock in InitData/ClearAll so creation cannot target a bag that is being swapped. Existing reads and item updates stay lock-free.
2026-10-20 16:58:12 +08:00
Nozilla-X 66dd7da6e6 feat(web): 添加无头 WebAPI 支持 (#10300)
* feat(web): add headless ServiceLib API and replaceable UI host

* build(web): package API without bundled WebUI

* ci(web): build and test headless API packages

* docs(web): document the headless API and optional WebUI

* test(web): verify restore and build before API tests

* docs(web): align API release examples with current upstream

* test(web): cover creating and editing an empty-URL subscription group

Verify the API accepts and persists a subscription whose Url and MoreUrl stay empty, that an edit keeps the empty URL and zero interval, and that ServiceLib is untouched.

* fix(web): support per-target prerelease update preferences

* fix(web): require management key for non-loopback listeners

* ci: reduce development Web API build matrix

* fix(web): make native self-update rollback atomic and preserve progress

* docs(web): remove self-update E2E report

* ci(web): decouple Web releases from Linux Desktop

* feat(web): support standalone WebUI clients and rename the API to v2rayN.WebAPI

- Resolve every REST, login, setup, download, SSE-ticket and EventSource
  request through one configurable Backend base per WebUI origin, with a
  strict exact-match origin allowlist and no wildcard or credential CORS.
- Run the origin guard before authentication, and keep first-run setup,
  instance diagnostics and the hosted-UI CSP same-origin as before.
- Rename the project directory, csproj, namespace, executable, lock,
  build identity and self-update target to v2rayN.WebAPI while retaining
  V2RAYN_WEB_* settings, WebVersion metadata, webVersion fields and the
  existing release asset contracts.
- Migrate API-owned update preference identifiers and reject legacy
  v2rayN.Web update packages with a manual reinstall instruction.
- Require staged native executables to be Linux ELF images for their RID.
- Add CORS, update-identity and setup-security tests, and record the
  standalone-WebUI audit under v2rayN.WebAPI/STANDALONE-WEBUI-AUDIT.md.

* Remove WebUI hosting and configuration coupling from WebAPI

* Finish WebAPI cleanup and isolate native self-update worker

* Keep WebAPI client documentation implementation-neutral

* ci(webapi): verify Windows win-x64 build and startup

* fix(webapi): validate keys and unify loopback limits

* test(webapi): isolate update smoke from systemd runner

* feat(webapi): add Windows x64 release and self-update

* fix(webapi): verify release manifest version

* ci(webapi): publish web packages through the official release flow

* ci(webapi): run the web release inside the release-all workflow

* webapi: finish the rename and fix shutdown, terminal launch and help

- finish the Web -> WebAPI product rename: workflow files build-WebAPI.yml /
  release-WebAPI.yml (path filters, reusable call sites, container tag),
  solution entry, health headers X-v2rayn-WebAPI-*, WebAPI.self-update
  capability, WebAPI-update-progress.json / WebAPI-update-runtime-state.json,
  WebAPIData/WebAPI-auth.json with a migration from the legacy locations,
  and the README compatibility notes
- end /api/events when the host starts stopping so Kestrel's graceful stop
  cannot pin the whole shutdown budget with a client attached; a runtime
  cleanup that starts after the host budget was exhausted now runs a reduced
  fresh pass instead of skipping Core stop and shared-state saves entirely
- record the interactive launch context for detached children and helpers so
  systemd markers leaked into a terminal (INVOCATION_ID / JOURNAL_STREAM) no
  longer force foreground mode or misfire the supervised-deployment key check,
  self-update gating and lifecycle decisions
- add --help / -h with the options and common environment variables in all
  three launcher locales, and keep help flags out of the host arguments

* fix(webapi): retry transient file replacements during native updates

Windows antivirus and the search indexer can hold a freshly written
executable open for a moment, so replacing the installed executable or its
build identity could fail with a bare access-denied error and abort the
update or its rollback. Retry these replacements for a bounded window and
report the operation, target path and cause when they still fail.

* ci(webapi): display the release-all Web job as release-WebAPI

The reusable-workflow job key still used the pre-rename name, which is the
label the Actions UI shows for the nested Web release jobs.

* feat(webapi): add win-arm64, linux-riscv64 and linux-loongarch64 coverage

- build, package, sign and self-update support for the three architectures:
  win-arm64 (windows-11-arm native startup smoke), linux-riscv64 (RISE RISC-V
  runners with the community xujiegb/dotnet-riscv SDK; single-file publishing
  verified on real hardware) and linux-loongarch64 (the upstream QEMU loong64
  VM with the official Loongson SDK, smoke-tested inside the VM)
- per-RID native executable validation: ELF machines x86-64/AArch64/RISC-V
  (0xF3)/LoongArch (0x102) and Windows PE AMD64/ARM64 (0xAA64), behind a
  single IsSupportedRid gate
- release asset map and packager/manifest tables extended; the riscv64 and
  loongarch64 Core bundles ship without the cronet payload
- release manifests now ignore unknown RID entries so a newer manifest cannot
  be rejected wholesale by an older build

* fix(webapi): keep the loongarch64 QEMU smoke out of the packaged publish tree

The in-VM smoke ran the app inside the virtiofs-shared publish directory and
left runtime files owned by the VM root (v2rayN.WebAPI.instance.lock) that the
host runner could not read, so packaging failed with EACCES. The smoke now runs
in a VM-local copy, and a host-side guard verifies the shared tree is complete
and readable before packaging.

* docs(webapi): document the six-platform release matrix

Releases now cover linux-x64/arm64/riscv64/loongarch64 and win-x64/arm64;
note the per-RID machine validation and the forward-compatible manifest
behavior in the API release documentation.

* ci(webapi): keep the release-all desktop dispatch exactly upstream

build-all.yml dispatches the desktop workflows with "ref": "master" exactly
as upstream does. The fork-only github.ref_name change is reverted so the PR
leaves the existing release behavior untouched; the WebAPI release stays an
additive reusable job in the same workflow.

* fix(webapi): probe the effective listener and refuse unverifiable updates

GetCurrentHealthUri and GetLauncherUris forced every probe to 127.0.0.1 and
fell back to port 5080, so IPv6-only, wildcard and port-only listeners could
be checked at the wrong endpoint. The detached update helper then either
rolled back a healthy replacement or restarted without being able to verify
the old instance, and --stop could probe the wrong process.

Both callers now resolve the probe through one WebProbeUriResolver that uses
the same effective Kestrel configuration as the host (command line, urls,
http/https ports, Kestrel endpoints, preferHostingUrls) and maps each
listener to its matching loopback address: "*", "+" and host names probe
localhost, 0.0.0.0 probes 127.0.0.1, [::] probes [::1], and loopback
literals keep their family. When no loopback HTTP probe exists (HTTPS-only
or an explicit non-loopback address), GetWebUpdateTarget reports
canInstall=false with maintenance.webUpdateProbeUnavailable and the update
is refused before anything is stopped; the helper still accepts only
loopback HTTP plan URIs, now including [::1]. --stop reports the new
stopUnprobeable result instead of signalling an unverified owner, and the
background launcher fails closed with startUnprobeable.

Regression: new resolver/listener tests (default, custom port, IPv6-only,
HTTP_PORTS, Kestrel override, HTTPS-only) and the real update fixture now
covers an IPv6-only replacement, asserts that a failed update leaves the old
build running (replacement PID), and proves that an unprobeable instance
refuses both the API and --stop while staying up.

* ci(webapi): capture the real smoke PID and bound the SIGTERM waits

The riscv64 smoke backgrounded "cd && app" and recorded the subshell PID,
so the later kill could target the wrapper while the app kept running. Both
the riscv64 and loong64 smokes now start the app directly, record the
application PID, fail fast when the process exits before health, wait a
bounded time for SIGTERM before printing the log and force-killing, and
assert a zero exit status. The loong64 VM smoke replaces its fixed
two-second sleep with the same exit assertion.

* chore(webapi): drop unreferenced legacy scripts

bootstrap-xray.sh installed the removed bundled core-bin layout, and
test-backup-fixtures.py compared desktop/Web backup ZIPs from before the
WebUI decoupling; nothing references either script.

* fix(webapi): unify the loopback probe, diagnostics and helper rules

The resolver rewrote a configured localhost to 127.0.0.1 while the health
diagnostics and the update helper each carried their own copy of the
accepted-host rule. Keep localhost as localhost (resolving it covers both
families instead of forcing one IPv4 literal) and make
WebProbeUriResolver.IsProbeableLoopbackUri the single rule: plain HTTP to a
host the diagnostics accept as local (localhost, 127.0.0.1 or ::1). The
helper validates plans with that rule, the health endpoint uses the shared
WebSetupAccessPolicy.IsLocalDiagnosticSource predicate, and every probe URI
the resolver produces satisfies the rule. Unprobeable listeners
(HTTPS-only or an explicit non-loopback address) still refuse the
self-update up front: canInstall=false with
maintenance.webUpdateProbeUnavailable while the current instance keeps
running.

Regression: localhost stays localhost across the resolver and launcher
tests, the probe-rule invariant, the shared diagnostics/probe address
rule, a direct unit test of the refusal reason, and a real
localhost-listener replacement scenario in the update fixture.

* fix(webapi): gate generated probes through the shared loopback rule

MapToLoopbackHost accepted every address the OS reports as loopback, which in
.NET is the whole 127.0.0.0/8 range, and TryCreateHealthUri returned true
without checking the generated URI against the same rule the diagnostics and
the update helper use. A listener on 127.0.0.2 therefore passed the up-front
canInstall check, but the helper rejected its plan (and refused to restart
the previous build for the same reason), so an update could stop the service
without bringing anything back.

TryCreateHealthUri now gates the constructed probe through
IsProbeableLoopbackUri, so loopback aliases are refused before the instance
is stopped and a mixed listener list falls through to the next verifiable
endpoint.

Regression: 127.0.0.2/127.255.255.254 mapping rows, a dedicated
loopback-alias rejection test, the alias canInstall=false reason case, the
mixed-list fall-through, and a real 127.0.0.2-listener fixture scenario that
proves the update is refused while the current instance keeps running.

* refactor(webapi): centralize integration tests and simplify release scripts

Move the Linux integration tests (startup security, release packaging
dry-run, full-ZIP runtime, native update regression and its fixture) from
Scripts/ into Tests/Integration/; Scripts/verify.sh stays the unified entry
and all path resolution, workflow calls, README text and cross-script
invocations were updated. No test coverage changes.

Merge Test-Windows.ps1 and Test-WindowsUpdate.ps1 into one
Tests/Integration/Test-Windows.ps1 with -Mode startup|update: both modes
share the native process control (CREATE_NEW_PROCESS_GROUP + CTRL_BREAK),
environment isolation, free-port selection, no-proxy health waiting, JSON
fixtures and retained-on-failure cleanup. CI keeps the Windows x64/ARM64
startup smokes and the x64 replacement/rollback smoke.

Delete the package-native.sh thin wrapper; the workflow and the packaging
dry-run now call web-release-assets.py package directly with the same
--rid/--publish/--dist arguments, so asset names and ZIP boundaries stay
unchanged.

* test(webapi): keep the Windows health-body assertion in the merged smoke

The merged Wait-TestHealth only accepted HTTP 200 in startup mode, while
the original startup smoke parsed the health JSON and required
status == "ok". Restore the body assertion in the shared helper so both
modes verify the health payload instead of trusting the status code alone.

* fix(webapi): preflight and verify native backup restores

* test(webapi): isolate restore preflight smoke from systemd

* test(webapi): set Windows smoke autostart default

* test(webapi): retry transient Windows smoke cleanup

* test(webapi): retry Windows smoke temp cleanup longer

* ci(webapi): check whitespace on workflow dispatch
2026-10-20 10:52:23 +08:00
DHR60 5e0ea3275d Fix (#10404) 2026-10-18 19:23:03 +08:00
Dmitry 673283da4b Fix data race in ProfileExManager (#10388)
* Fix data race in ProfileExManager

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

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

* Fix data race in ProfileExManager

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

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

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

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

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

* Fix clash delay
2026-10-13 20:36:41 +08:00
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
2dust f5747bb321 up 7.25.5 2026-10-05 16:43:51 +08:00
ariansharifi d099e44ff6 Fix subscription custom configs ignoring custom config socks port (#10296)
* Fix subscription custom configs ignoring custom config socks port

Full Xray/sing-box configs imported from a subscription no longer got the
subscription's "Custom config socks port" (SubItem.PreSocksPort) after
eff5845 and 9638b4c. 7.24.4 and earlier copied it onto every imported
config, and the Clash, Hysteria2 and raw-file paths still do.

Without it, GetPreSocksItem builds no pre-socks core for these profiles,
so they cannot be used in TUN mode. Setting the port on the profile is
not a workaround, because every subscription update re-creates the
profiles.

* Add regression test for subscription custom config socks port
2026-10-05 16:36:52 +08:00
DHR60 c63d271d16 Up TUnit.Assertions.Should (#10294) 2026-10-05 16:27:38 +08:00
Miheichev Aleksandr Sergeevich 6152cab494 fix(ui): make cut-off button labels and input hints readable (#10292)
* fix(ui): let update and backup buttons fit their labels

The update dialog gives its two buttons a fixed 100 px width, and the
backup view puts its buttons in a fixed 200 px grid column, so longer
labels are cut off, for example "Only Chec", "Check and" and "Backup
to remote (WebD" in English. Use MinWidth="100" for the update buttons
and an Auto column with MinWidth="200" for the backup buttons: short
labels keep their current size and long ones fit.

The "Automatic update interval (minutes)" label of the subscription
window is cut off the same way between the switch and the interval
field, so let it wrap.

* fix(ui): show cut-off input hints as tooltips

The hints of the previous and next proxy remarks fields in the
subscription window and of the server and log filters are wider than
their fields, so their end is cut off and there is no way to read it.
Show the same text as a tooltip, as the custom config socks port field
of the subscription window already does.

* fix(ui): let dialog buttons and backup labels fit longer translations

The Confirm and Cancel buttons of the editing and settings windows,
and the Reset button of the hotkey window, have a fixed 100 px width,
and the backup view puts its labels in a fixed 300 px column.
Translations longer than the English text are cut off there: the
Russian "Подтвердить" reads "Подтверди", and "Резервное копирование
на удалённый сервер (WebDAV)" loses its end. Use MinWidth="100" for
these buttons, as the message box already does with MinWidth="90",
and an Auto label column with MinWidth="300".
2026-10-05 16:27:19 +08:00
2dust 3187eeef79 Code clean 2026-10-04 11:41:34 +08:00
Miheichev Aleksandr Sergeevich 3364ec0e57 i18n(ru): translate the update dialog buttons in full (#10291)
Add the Russian string for menuCheckAndUpdate, which #10282 added to
ResUI.resx, as "Проверить и обновить". Restore the full Russian text
of menuCheckOnly ("Только проверить") and menuCheckUpdate ("Проверить
обновления"), which were shortened only to fit the fixed 100 px
buttons of the update dialog; they fit once the buttons are sized by
their labels.
2026-10-04 11:08:10 +08:00
dependabot[bot] 23033fa002 Bump TUnit from 1.68.17 to 1.72.16 (#10288)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.72.16
  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-04 11:07:42 +08:00
Eliot the Cougar 125cbb18a1 Keep Xray startup logs visible in horizontal and vertical layouts (#10272)
* Keep Xray log content visible in separate log layouts

* Revert wpf

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-10-04 11:06:06 +08:00
2dust f85326cf0e Add per-core prerelease update selection
Replaces the global prerelease toggle with per-core prerelease options in both WPF and Avalonia update views, and persists selections via `CheckPreReleaseCoreTypes` in config. Update checks now resolve prerelease mode per selected core (including auto-check flow), and the sing-box max-version lock is removed to allow newer versions.
2026-10-03 14:58:45 +08:00
Tosd 3666fc549d Resx: clarify update button wording (#10282) 2026-10-02 15:08:27 +08:00
Miheichev Aleksandr Sergeevich a147249f95 i18n(ru): translate the Add [MASQUE] menu item (#10276)
Add the Russian string for menuAddMasqueServer, which #10233 added to
ResUI.resx. Without it the Russian UI shows this one item of the
Configuration menu in English. The text follows the other
Add [protocol] items: "Добавить сервер [MASQUE]".
2026-10-02 15:06:49 +08:00
Eliot the Cougar a28f68c3a7 Avoid reentrant log scrolling during Avalonia window dragging (#10271) 2026-10-01 19:55:30 +08:00
Ezra-Y 1a689f869c Fix macOS TUN interface name collisions (#10270) 2026-10-01 19:51:08 +08:00
DHR60 50023a5976 Xray tun features (#10264)
* Xray tun features

* Try sync
2026-10-01 14:55:09 +08:00
DHR60 c659ca8af8 Add MASQUE support (#10233)
* Add MASQUE support

* Add SettingsCoreType
2026-10-01 14:49:45 +08:00
240 changed files with 29895 additions and 316 deletions
+923
View File
@@ -0,0 +1,923 @@
name: build v2rayN.WebAPI
on:
workflow_call:
inputs:
release_tag:
required: false
type: string
default: ''
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag (for example 7.x.y). Empty builds a development API package.'
required: false
default: ''
type: string
pull_request:
branches: [master]
paths:
- 'v2rayN/v2rayN.WebAPI/**'
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'v2rayN/v2rayN.slnx'
- 'global.json'
- '.github/workflows/build-WebAPI.yml'
- '.github/workflows/release-WebAPI.yml'
- '.github/workflows/build-all.yml'
- '.github/workflows/upload-sign.yml'
push:
branches: [master]
paths:
- 'v2rayN/v2rayN.WebAPI/**'
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'v2rayN/v2rayN.slnx'
- 'global.json'
- '.github/workflows/build-WebAPI.yml'
- '.github/workflows/release-WebAPI.yml'
- '.github/workflows/build-all.yml'
- '.github/workflows/upload-sign.yml'
permissions:
contents: read
jobs:
identity:
name: Resolve API build identity
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
release: ${{ steps.resolve.outputs.release }}
build_date: ${{ steps.resolve.outputs.build_date }}
steps:
- name: Resolve release identity
id: resolve
shell: bash
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
set -euo pipefail
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$RELEASE_TAG" ]]; then
{
echo "version=0.0.0-dev"
echo "tag="
echo "release=false"
echo "build_date=$build_date"
} >> "$GITHUB_OUTPUT"
else
[[ "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 1
}
{
echo "version=$RELEASE_TAG"
echo "tag=$RELEASE_TAG"
echo "release=true"
echo "build_date=$build_date"
} >> "$GITHUB_OUTPUT"
fi
verify:
name: Backend, ServiceLib, package smoke
runs-on: ubuntu-24.04
env:
TUNIT_MAX_PARALLEL_TESTS: 1
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- name: Verify API build, backend and ServiceLib tests, native startup and package boundaries
run: bash v2rayN/v2rayN.WebAPI/Scripts/verify.sh "$RUNNER_TEMP/v2rayN.WebAPI-verify"
- name: Check patch whitespace
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
run: |
set -euo pipefail
base_sha="$BASE_SHA"
if [[ -z "$base_sha" ]]; then
base_sha="$(git rev-parse HEAD^)"
fi
if [[ "$base_sha" =~ ^0+$ ]]; then
empty_tree="$(git hash-object -t tree -w /dev/null)"
git diff --check "$empty_tree" HEAD
else
git diff --check "$base_sha...HEAD"
fi
windows:
name: Windows WebAPI build and runtime smoke
needs: identity
runs-on: windows-latest
env:
TUNIT_MAX_PARALLEL_TESTS: 1
WEB_VERSION: ${{ needs.identity.outputs.version }}
WEB_COMMIT: ${{ github.sha }}
WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
WEB_REPOSITORY: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Restore WebAPI
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj
- name: Restore WebAPI.Tests
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj
- name: Restore ServiceLib.Tests
shell: pwsh
run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
- name: Build WebAPI
shell: pwsh
run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore
- name: Run WebAPI.Tests
shell: pwsh
run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore
- name: Run ServiceLib.Tests
shell: pwsh
run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore
- name: Publish win-x64 self-contained single-file WebAPI
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=$env:WEB_VERSION `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = $env:WEB_VERSION
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-x64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Verify single-file publish output
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$executable = Join-Path $publishDirectory 'v2rayN.WebAPI.exe'
if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) {
throw "dotnet publish did not produce $executable"
}
if ((Get-Item -LiteralPath $executable).Length -eq 0) {
throw "Published executable is empty: $executable"
}
$looseDlls = @(Get-ChildItem -LiteralPath $publishDirectory -Filter '*.dll' -File -Recurse)
if ($looseDlls.Count -gt 0) {
throw "Single-file publish left loose DLLs: $($looseDlls.FullName -join ', ')"
}
- name: Smoke-test first startup, health endpoint and graceful shutdown
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory
- name: Publish previous Windows fixture for update regression
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=0.0.1-regression `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = '0.0.1-regression'
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-x64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Add the matching Windows Core bundle
if: inputs.release_tag != ''
shell: pwsh
run: |
$bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64.zip'
$expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-core'
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$previousDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous'
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-64.zip' -OutFile $bundle
Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force
$sourceBin = Join-Path $expanded 'v2rayN-windows-64\bin'
if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) {
throw "Windows Core bundle is missing $sourceBin"
}
foreach ($directory in @($publishDirectory, $previousDirectory)) {
$destinationBin = Join-Path $directory 'bin'
New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null
Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force
}
foreach ($directory in @($publishDirectory, $previousDirectory)) {
$checkBin = Join-Path $directory 'bin'
foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) {
$required = Join-Path $checkBin $relative
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows Core asset: $required" }
}
}
$candidateBin = Join-Path $publishDirectory 'bin'
& (Join-Path $candidateBin 'xray\xray.exe') -version
if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $candidateBin 'sing_box\sing-box.exe') version
if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $candidateBin 'mihomo\mihomo.exe') -v
if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" }
- name: Smoke-test Windows backup restore handoff and Core intent recovery
if: inputs.release_tag != ''
shell: pwsh
run: |
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 `
-Mode restore `
-PublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64')
- name: Smoke-test Windows self-update replacement and rollback
if: inputs.release_tag != ''
shell: pwsh
run: |
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 `
-Mode update `
-PreviousPublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous') `
-CandidatePublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64')
- name: Package and smoke-test Windows full-install and app-only ZIPs
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-dist'
python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-x64 --publish $publishDirectory --dist $dist
$fullZip = Join-Path $dist 'v2rayN-windows-64-WebAPI.zip'
$appOnlyZip = Join-Path $dist 'v2rayN-windows-64-WebAPI-update.zip'
if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) {
throw 'Windows release ZIPs were not created.'
}
$extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-WebAPI-smoke'
Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted
"Windows full-install ZIP: $fullZip"
"Windows app-only update ZIP: $appOnlyZip"
- uses: actions/upload-artifact@v7
if: inputs.release_tag != ''
with:
name: v2rayN.WebAPI-win-x64
path: ${{ runner.temp }}/v2rayN.WebAPI-win-x64-dist/*.zip
if-no-files-found: error
# The self-update replacement smoke stays on win-x64: the updater logic is RID-generic
# and the per-RID PE/ELF checks are covered by unit tests. This job proves the win-arm64
# build, first startup, health endpoint and graceful shutdown on native ARM64 Windows.
windows-arm64:
name: Windows ARM64 WebAPI build and runtime smoke
needs: identity
runs-on: windows-11-arm
env:
TUNIT_MAX_PARALLEL_TESTS: 1
WEB_VERSION: ${{ needs.identity.outputs.version }}
WEB_COMMIT: ${{ github.sha }}
WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
WEB_REPOSITORY: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Restore WebAPI
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj
- name: Restore WebAPI.Tests
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj
- name: Restore ServiceLib.Tests
shell: pwsh
run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
- name: Build WebAPI
shell: pwsh
run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore
- name: Run WebAPI.Tests
shell: pwsh
run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore
- name: Run ServiceLib.Tests
shell: pwsh
run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore
- name: Publish win-arm64 self-contained single-file WebAPI
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-arm64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=$env:WEB_VERSION `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = $env:WEB_VERSION
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-arm64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Smoke-test first startup, health endpoint and graceful shutdown
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory
- name: Add the matching Windows ARM64 Core bundle
if: inputs.release_tag != ''
shell: pwsh
run: |
$bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64.zip'
$expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-core'
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-arm64.zip' -OutFile $bundle
Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force
$sourceBin = Join-Path $expanded 'v2rayN-windows-arm64\bin'
if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) {
throw "Windows ARM64 Core bundle is missing $sourceBin"
}
$destinationBin = Join-Path $publishDirectory 'bin'
New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null
Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force
foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) {
$required = Join-Path $destinationBin $relative
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows ARM64 Core asset: $required" }
}
& (Join-Path $destinationBin 'xray\xray.exe') -version
if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $destinationBin 'sing_box\sing-box.exe') version
if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $destinationBin 'mihomo\mihomo.exe') -v
if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" }
- name: Package Windows ARM64 full-install and app-only ZIPs
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
$dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64-dist'
python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-arm64 --publish $publishDirectory --dist $dist
$fullZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI.zip'
$appOnlyZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI-update.zip'
if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) {
throw 'Windows ARM64 release ZIPs were not created.'
}
$extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-WebAPI-smoke'
Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted
"Windows ARM64 full-install ZIP: $fullZip"
"Windows ARM64 app-only update ZIP: $appOnlyZip"
- uses: actions/upload-artifact@v7
if: inputs.release_tag != ''
with:
name: v2rayN.WebAPI-win-arm64
path: ${{ runner.temp }}/v2rayN.WebAPI-win-arm64-dist/*.zip
if-no-files-found: error
native:
name: Native API build (${{ matrix.rid }})
needs: identity
strategy:
fail-fast: false
matrix:
# Development builds keep x64 coverage; releases also validate ARM64.
# Only tested Linux RIDs are enabled; native RID/runner pairs are independent
# of Desktop workflows and the container platform matrix.
include: >-
${{ fromJSON(needs.identity.outputs.release == 'true'
&& '[{"rid":"linux-x64","runner":"ubuntu-24.04"},{"rid":"linux-arm64","runner":"ubuntu-24.04-arm"}]'
|| '[{"rid":"linux-x64","runner":"ubuntu-24.04"}]') }}
runs-on: ${{ matrix.runner }}
env:
RID: ${{ matrix.rid }}
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- name: Publish native API
run: bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID"
- name: Verify native executable
run: test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI"
- name: Add the matching Linux Core bundle
if: needs.identity.outputs.release == 'true'
run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin"
- name: Package API install and self-update ZIPs
if: needs.identity.outputs.release == 'true'
run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist
- name: Smoke-test bundled Cores
if: needs.identity.outputs.release == 'true'
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/bin/xray/xray" -version
"$publish/bin/sing_box/sing-box" version
"$publish/bin/mihomo/mihomo" -v
- uses: actions/upload-artifact@v7
if: needs.identity.outputs.release == 'true'
with:
name: v2rayN.WebAPI-${{ matrix.rid }}
path: dist/*.zip
if-no-files-found: error
# linux-riscv64 runs natively on the RISE RISC-V runners with the community
# xujiegb/dotnet-riscv SDK; single-file publish is verified to work there.
native-riscv64:
name: Native API build (linux-riscv64)
needs: identity
if: needs.identity.outputs.release == 'true'
runs-on: ubuntu-24.04-riscv
container: debian:13
env:
RID: linux-riscv64
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
steps:
- name: Prepare tools (Debian)
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || \
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu72
- name: Checkout repo
shell: bash
env: { GITHUB_TOKEN: "${{ github.token }}" }
run: |
set -euo pipefail
rm -rf ./*
git init .
git config --global --add safe.directory "$PWD"
git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install community .NET SDK (linux-riscv64)
shell: bash
run: |
set -euo pipefail
SDK=dotnet-sdk-10.0.111-linux-riscv64.tar.gz
curl -fL "https://github.com/xujiegb/dotnet-riscv/releases/download/10.0.111/$SDK" -o /tmp/$SDK
mkdir -p "$HOME/.dotnet"
tar -C "$HOME/.dotnet" -xzf /tmp/$SDK
echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV"
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$PATH"
dotnet --info
- name: Publish native API
shell: bash
run: |
set -euo pipefail
export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$PATH"
bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID"
test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI"
- name: Add the matching Linux Core bundle
shell: bash
run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin"
- name: Package API install and self-update ZIPs
shell: bash
run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist
- name: Smoke-test bundled Cores
shell: bash
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/bin/xray/xray" -version
"$publish/bin/sing_box/sing-box" version
"$publish/bin/mihomo/mihomo" -v
- name: Smoke-test the published API (native riscv64)
shell: bash
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/v2rayN.WebAPI" --help | head -n 5
export V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef
export ASPNETCORE_URLS=http://127.0.0.1:5080
cd "$publish"
./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 &
app_pid=$!
echo "$app_pid" > /tmp/smoke.pid
echo "riscv64 API smoke application PID: $app_pid"
ok=0
for i in $(seq 1 90); do
if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
if ! kill -0 "$app_pid" 2>/dev/null; then break; fi
sleep 1
done
if [ "$ok" != 1 ]; then
echo 'riscv64 API smoke did not become healthy'
tail -n 60 /tmp/smoke.log || true
kill -TERM "$app_pid" 2>/dev/null || true
sleep 1
kill -KILL "$app_pid" 2>/dev/null || true
exit 1
fi
kill -TERM "$app_pid"
stopped=0
for i in $(seq 1 30); do
if ! kill -0 "$app_pid" 2>/dev/null; then stopped=1; break; fi
sleep 1
done
if [ "$stopped" != 1 ]; then
echo 'riscv64 API smoke did not exit within 30s after SIGTERM'
tail -n 60 /tmp/smoke.log || true
kill -KILL "$app_pid" 2>/dev/null || true
exit 1
fi
set +e
wait "$app_pid"
exit_code=$?
set -e
if [ "$exit_code" -ne 0 ]; then
echo "riscv64 API smoke exited with status $exit_code after SIGTERM"
tail -n 60 /tmp/smoke.log || true
exit 1
fi
echo 'linux-riscv64 native API smoke OK'
- uses: actions/upload-artifact@v7
with:
name: v2rayN.WebAPI-linux-riscv64
path: dist/*.zip
if-no-files-found: error
# linux-loongarch64 publishes inside the upstream QEMU loong64 VM with the official
# Loongson .NET SDK; the VM runs the native smoke before the host packages the ZIPs.
native-loong64:
name: Native API build (linux-loongarch64)
needs: identity
if: needs.identity.outputs.release == 'true'
runs-on: ubuntu-26.04-arm
env:
RID: linux-loongarch64
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2
EFI_CODE_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-code.fd
EFI_VARS_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-vars.fd
QCOW2_IMAGE: debian13-loong64.qcow2
EFI_CODE: edk2-loongarch64-code.fd
EFI_VARS: edk2-loongarch64-vars.fd
QEMU_VERSION: 10.2.4
steps:
- name: Prepare host tools
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd
sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
virtiofsd --version || virtiofsd --help | head
- name: Host clone repo
shell: bash
env: { GITHUB_TOKEN: "${{ github.token }}" }
run: |
set -euo pipefail
rm -rf repo
git init repo
git -C repo config --global --add safe.directory "$PWD/repo" || true
git -C repo remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git -C repo fetch --depth=1 origin "${GITHUB_SHA}"
git -C repo checkout FETCH_HEAD
- name: Download QEMU prebuild
run: |
set -euo pipefail
wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz"
tar -xzf qemu-linux-arm64-v8.2.tar.gz
mkdir -p "$HOME/qemu-install"
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
- name: Download loong64 qcow2 and EFI firmware
shell: bash
run: |
set -euo pipefail
wget -O "$QCOW2_IMAGE" "$QCOW2_URL"
wget -O "$EFI_CODE" "$EFI_CODE_URL"
wget -O "$EFI_VARS" "$EFI_VARS_URL"
qemu-img info "$QCOW2_IMAGE"
- name: Stage the VM build script and the matching Core bundle
shell: bash
run: |
set -euo pipefail
cat > webapi-loong.env <<EOF
WEB_VERSION=${V2RAYN_WEB_VERSION}
WEB_REPOSITORY=${V2RAYN_WEB_REPOSITORY}
WEB_COMMIT=${V2RAYN_WEB_COMMIT}
WEB_BUILD_DATE=${V2RAYN_WEB_BUILD_DATE}
EOF
cat > webapi-loong-run.sh <<'SCRIPT'
#!/usr/bin/env bash
set +e
set -o pipefail
source /workspace/webapi-loong.env
export DOTNET_ROOT=/root/.dotnet
export PATH="$DOTNET_ROOT:$PATH"
mkdir -p /root/.dotnet
SDK=dotnet-sdk-10.0.111-linux-loongarch64.tar.gz
curl -fL "https://github.com/loongson/dotnet/releases/download/v10.0.111-loongarch64/$SDK" -o /tmp/$SDK || exit 9
tar -C /root/.dotnet -xzf /tmp/$SDK || exit 9
dotnet --info >/dev/null 2>&1 || exit 9
cd /root/v2rayN-src || exit 9
mkdir -p /workspace/publish/linux-loongarch64
V2RAYN_WEB_VERSION="$WEB_VERSION" V2RAYN_WEB_REPOSITORY="$WEB_REPOSITORY" V2RAYN_WEB_COMMIT="$WEB_COMMIT" V2RAYN_WEB_BUILD_DATE="$WEB_BUILD_DATE" \
bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh linux-loongarch64 /workspace/publish/linux-loongarch64 2>&1 | tee /tmp/publish.log
rc=$?
if [ $rc -eq 0 ]; then
mkdir -p /workspace/publish/linux-loongarch64/bin
cp -a /workspace/core-bundle/. /workspace/publish/linux-loongarch64/bin/
# Smoke a VM-local copy: the shared publish tree must stay exactly the packaging
# input, without runtime lock/data files owned by the VM user.
rm -rf /root/websmoke
cp -a /workspace/publish/linux-loongarch64 /root/websmoke
cd /root/websmoke || exit 9
./bin/xray/xray -version >/dev/null || rc=9
./bin/sing_box/sing-box version >/dev/null || rc=9
./bin/mihomo/mihomo -v >/dev/null || rc=9
./v2rayN.WebAPI --help >/dev/null || rc=9
V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef ASPNETCORE_URLS=http://127.0.0.1:5080 ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 &
APP=$!
echo "$APP" > /tmp/smoke.pid
ok=0
for i in $(seq 1 90); do
if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
if ! kill -0 "$APP" 2>/dev/null; then break; fi
sleep 1
done
if [ "$ok" != 1 ]; then
echo 'loongarch64 API smoke did not become healthy'
tail -n 60 /tmp/smoke.log
kill -KILL "$APP" 2>/dev/null || true
rc=9
fi
if [ "$rc" = 0 ]; then
kill -TERM "$APP"
exited=0
for i in $(seq 1 60); do
if ! kill -0 "$APP" 2>/dev/null; then exited=1; break; fi
sleep 1
done
if [ "$exited" != 1 ]; then
echo 'loongarch64 API smoke did not exit within 60s after SIGTERM'
tail -n 60 /tmp/smoke.log
kill -KILL "$APP" 2>/dev/null || true
rc=9
else
wait "$APP"
app_rc=$?
if [ "$app_rc" != 0 ]; then
echo "loongarch64 API smoke exited with status $app_rc after SIGTERM"
tail -n 60 /tmp/smoke.log
rc=9
fi
fi
fi
fi
if [ "$rc" != 0 ]; then echo '=== publish.log ==='; tail -n 120 /tmp/publish.log 2>/dev/null; fi
echo "__WEBAPI_DONE__$rc"
SCRIPT
bash repo/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "$GITHUB_WORKSPACE/core-bundle"
ls -la "$GITHUB_WORKSPACE/core-bundle" | head -n 15
- name: Build WebAPI loongarch64 in the VM (publish + native smoke)
shell: bash
timeout-minutes: 180
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/dist"
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-webapi-loongarch64.sock"
rm -f "$VIRTIOFSD_SOCKET"
sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto &
VIRTIOFSD_PID=$!
trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT
for i in {1..100}; do
[[ -S "$VIRTIOFSD_SOCKET" ]] && break
sleep 0.1
done
[[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; }
sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true
sudo chmod 660 "$VIRTIOFSD_SOCKET" || true
expect <<'EOF'
log_user 1
set timeout 10800
set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64"
set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu"
set virtiofs_socket $env(VIRTIOFSD_SOCKET)
set qcow2 $env(QCOW2_IMAGE)
set efi_code $env(EFI_CODE)
set efi_vars $env(EFI_VARS)
proc wait_prompt {} {
expect {
-re "__CI_PROMPT__# " {}
timeout { exit 1 }
eof { exit 1 }
}
}
proc run_cmd {cmd} {
send -- "$cmd\r"
wait_prompt
}
spawn $qemu_bin \
-L $qemu_rom_dir \
-machine virt \
-accel tcg,thread=multi,tb-size=2048 \
-cpu la464 \
-m 9216 \
-object memory-backend-memfd,id=mem0,size=9216M,share=on \
-numa node,memdev=mem0 \
-smp 4 \
-drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \
-drive if=pflash,format=raw,unit=1,file=$efi_vars \
-device virtio-blk-pci,drive=hd0,bootindex=1 \
-drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \
-netdev user,id=net0 \
-device virtio-net-pci-non-transitional,netdev=net0 \
-chardev socket,id=charfs0,path=$virtiofs_socket \
-device vhost-user-fs-pci,chardev=charfs0,tag=workspace \
-display none \
-serial stdio \
-monitor none
expect -re "login:|debian login:"
send -- "root\r"
expect -re "Password:|密码:|密码:"
send -- "password\r"
expect {
-re "# " {}
timeout { exit 1 }
eof { exit 1 }
}
send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r"
wait_prompt
run_cmd "mkdir -p /workspace"
run_cmd "mount -t virtiofs workspace /workspace"
run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true"
run_cmd "dhclient \$IFACE || true"
run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf"
run_cmd "apt-get update || apt-get update || apt-get update"
run_cmd "DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1"
run_cmd "rm -rf /root/v2rayN-src"
run_cmd "rsync -a /workspace/repo/ /root/v2rayN-src/"
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
send -- "bash /workspace/webapi-loong-run.sh\r"
expect {
-re "__WEBAPI_DONE__0" { send -- "poweroff\r" }
-re "__WEBAPI_DONE__" { exit 1 }
timeout { exit 1 }
eof { exit 1 }
}
EOF
- name: Verify the loongarch64 publish tree
shell: bash
run: |
set -euo pipefail
publish="$GITHUB_WORKSPACE/publish/linux-loongarch64"
test -x "$publish/v2rayN.WebAPI"
test -f "$publish/v2rayN.WebAPI.build.json"
unreadable="$(find "$publish" ! -readable -print -quit)"
if [ -n "$unreadable" ]; then
echo "The loongarch64 publish tree contains unreadable entries:"
find "$publish" ! -readable -print | head -n 20
exit 1
fi
echo "loongarch64 publish tree is complete and readable"
- name: Package API install and self-update ZIPs
shell: bash
run: |
set -euo pipefail
python3 repo/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "$GITHUB_WORKSPACE/publish/linux-loongarch64" --dist "$GITHUB_WORKSPACE/dist"
ls -la "$GITHUB_WORKSPACE/dist"
- uses: actions/upload-artifact@v7
with:
name: v2rayN.WebAPI-linux-loongarch64
path: dist/*.zip
if-no-files-found: error
container:
name: API Containerfile (${{ matrix.rid }})
needs: identity
strategy:
fail-fast: false
matrix:
# Development builds keep amd64 coverage; releases also validate ARM64.
include: >-
${{ fromJSON(needs.identity.outputs.release == 'true'
&& '[{"rid":"linux-x64","platform":"linux/amd64"},{"rid":"linux-arm64","platform":"linux/arm64"}]'
|| '[{"rid":"linux-x64","platform":"linux/amd64"}]') }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Build API container (no push)
uses: docker/build-push-action@v7
env:
DOCKER_BUILD_RECORD_UPLOAD: false
with:
context: .
file: ./v2rayN/v2rayN.WebAPI/Containerfile
platforms: ${{ matrix.platform }}
tags: v2rayn-webapi:ci
build-args: |
RID=${{ matrix.rid }}
WEB_VERSION=${{ needs.identity.outputs.version }}
WEB_REPOSITORY=${{ github.repository }}
WEB_COMMIT=${{ github.sha }}
WEB_BUILD_DATE=${{ needs.identity.outputs.build_date }}
push: false
release-assets:
name: Assemble API release assets
if: needs.identity.outputs.release == 'true'
needs: [identity, verify, windows, windows-arm64, native, native-riscv64, native-loong64, container]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v7
with:
pattern: v2rayN.WebAPI-*
path: release-assets
merge-multiple: true
- name: Build and verify WebAPI-update.json
env:
RELEASE_TAG: ${{ needs.identity.outputs.tag }}
run: |
set -euo pipefail
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py write \
--version "$RELEASE_TAG" \
--repository "${{ github.repository }}" \
--commit "${{ github.sha }}" \
--build-date "${{ needs.identity.outputs.build_date }}" \
--dist release-assets \
--output release-assets/WebAPI-update.json
python3 v2rayN/v2rayN.WebAPI/Scripts/web-update-manifest.py verify \
--manifest release-assets/WebAPI-update.json \
--dist release-assets \
--repository "${{ github.repository }}"
- name: Smoke-test full API ZIP
run: bash v2rayN/v2rayN.WebAPI/Tests/Integration/test-full-zip-runtime.sh release-assets/v2rayN-linux-64-WebAPI.zip
- uses: actions/upload-artifact@v7
with:
name: WebAPI-release-packages
path: release-assets/*.zip
if-no-files-found: error
- uses: actions/upload-artifact@v7
with:
name: WebAPI-release-manifest
path: release-assets/WebAPI-update.json
if-no-files-found: error
+7
View File
@@ -20,6 +20,13 @@ jobs:
secrets:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
release-WebAPI:
if: inputs.release_tag != ''
uses: ./.github/workflows/release-WebAPI.yml
with:
release_tag: ${{ inputs.release_tag }}
secrets: inherit
update:
runs-on: ubuntu-latest
steps:
+53
View File
@@ -0,0 +1,53 @@
name: release v2rayN.WebAPI
on:
workflow_call:
inputs:
release_tag:
required: true
type: string
secrets:
GPG_PRIVATE_KEY:
required: false
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag (for example 7.x.y).'
required: true
type: string
permissions:
contents: write
actions: read
jobs:
build:
uses: ./.github/workflows/build-WebAPI.yml
permissions:
contents: read
with:
release_tag: ${{ inputs.release_tag }}
release-web-packages:
name: sign and upload Web API packages
# Upload through the shared signing flow, exactly like the desktop packages.
if: inputs.release_tag != ''
needs: build
uses: ./.github/workflows/upload-sign.yml
with:
release_tag: ${{ inputs.release_tag }}
artifact_name: WebAPI-release-packages
file_pattern: 'v2rayN-*-WebAPI*.zip'
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
release-web-manifest:
name: sign and upload Web update manifest
if: inputs.release_tag != ''
# Advertise the update only after its packages and manifest have been uploaded.
needs: [build, release-web-packages]
uses: ./.github/workflows/upload-sign.yml
with:
release_tag: ${{ inputs.release_tag }}
artifact_name: WebAPI-release-manifest
file_pattern: 'WebAPI-update.json'
secrets: { GPG_PRIVATE_KEY: "${{ secrets.GPG_PRIVATE_KEY }}" }
+3 -1
View File
@@ -19,7 +19,9 @@ jobs:
$wingetPackage = "2dust.v2rayN"
$gitToken = "${{ secrets.PT_WINGET }}"
$github = Invoke-RestMethod -uri "https://api.github.com/repos/2dust/v2rayN/releases"
$headers = @{ Authorization = "Bearer $gitToken"; Accept = "application/vnd.github+json" }
$github = Invoke-RestMethod -Uri "https://api.github.com/repos/2dust/v2rayN/releases" -Headers $headers
$targetRelease = $github | Where-Object -Property prerelease -match 'False' | Select -First 1
+9
View File
@@ -676,6 +676,15 @@ EOF
find "$stage/opt/v2rayN" -type f -exec chmod 0644 {} +
[[ -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"
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";
File.Delete(thisAppOldFile);
var splitKey = "/";
var allowedBaseDir = Utils.StartupPath();
var pathComparison = Utils.GetPathComparison();
using var archive = ZipFile.OpenRead(fileName);
foreach (var entry in archive.Entries)
@@ -65,16 +67,21 @@ internal class UpgradeApp
}
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);
}
var entryOutputPath = Utils.GetPath(fullName);
Directory.CreateDirectory(Path.GetDirectoryName(entryOutputPath)!);
//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;
}
+22
View File
@@ -24,6 +24,28 @@ internal class Utils
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 void StartV2RayN()
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>7.25.4</Version>
<Version>7.25.6</Version>
</PropertyGroup>
<PropertyGroup>
+4 -4
View File
@@ -13,7 +13,7 @@
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.8" />
<PackageVersion Include="Downloader" Version="5.10.1" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
@@ -27,11 +27,11 @@
<PackageVersion Include="sqlite-net-e" Version="1.11.285" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.53.4.1" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="TUnit" Version="1.68.17" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
<PackageVersion Include="TUnit" Version="1.75.0" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.72.16-beta" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
</ItemGroup>
</Project>
</Project>
@@ -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);
}
}
}
@@ -1,8 +1,3 @@
using System.Reflection;
using ServiceLib.Enums;
using ServiceLib.Manager;
using ServiceLib.Models;
namespace ServiceLib.Tests.CoreConfig;
internal static class CoreConfigTestFactory
@@ -716,7 +716,6 @@ public class CoreConfigSingboxServiceTests
}
}
[Test]
public async Task GenerateClientConfigContent_CustomOutbound_ShouldReplaceWithUserCustomOutboundJson()
{
@@ -51,7 +51,7 @@ public class CoreConfigV2rayServiceTests
.Select(item => item!.GetValue<string>())
.Should().BeEquivalentTo(["a=1", "b=2"]);
}
[Test]
public async Task GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
{
@@ -587,13 +587,11 @@ public class CoreConfigV2rayServiceTests
}
[Test]
[Arguments(true)]
[Arguments(false)]
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address)
public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6()
{
// 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.
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address);
var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address: false);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
@@ -611,6 +609,26 @@ public class CoreConfigV2rayServiceTests
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]
public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6()
{
@@ -0,0 +1,42 @@
using ServiceLib.Tests.CoreConfig;
namespace ServiceLib.Tests.Handler;
public class ConfigHandlerCustomSubscriptionTests
{
private const string FullXrayConfigs = """
[
{"remarks":"custom-1","inbounds":[{"tag":"socks","listen":"127.0.0.1","port":10808,"protocol":"socks","settings":{"auth":"noauth","udp":true}}],"outbounds":[{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"127.0.0.1","port":443,"users":[{"id":"00000000-0000-0000-0000-000000000001","encryption":"none"}]}]},"streamSettings":{"network":"tcp"}}],"routing":{"domainStrategy":"AsIs","rules":[]}},
{"remarks":"custom-2","inbounds":[{"tag":"socks","listen":"127.0.0.1","port":10808,"protocol":"socks","settings":{"auth":"noauth","udp":true}}],"outbounds":[{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"127.0.0.1","port":443,"users":[{"id":"00000000-0000-0000-0000-000000000001","encryption":"none"}]}]},"streamSettings":{"network":"tcp"}}],"routing":{"domainStrategy":"AsIs","rules":[]}}
]
""";
[Test]
public async Task AddBatchServers_FullConfigsFromSubscription_InheritSubscriptionPreSocksPort()
{
var config = CoreConfigTestFactory.CreateConfig();
CoreConfigTestFactory.BindAppManagerConfig(config);
SQLiteHelper.Instance.CreateTable<SubItem>();
SQLiteHelper.Instance.CreateTable<ProfileItem>();
var subId = $"sub-{Guid.NewGuid():N}";
await SQLiteHelper.Instance.ReplaceAsync(new SubItem
{
Id = subId,
Remarks = "sub",
Url = "http://127.0.0.1/sub",
PreSocksPort = 10808,
});
var count = await ConfigHandler.AddBatchServers(config, FullXrayConfigs, subId, true);
var profiles = await AppManager.Instance.ProfileItems(subId) ?? [];
await count.Should().BeEqualTo(2);
await profiles.Should().HaveCount(2);
foreach (var profile in profiles)
{
await profile.ConfigType.Should().BeEqualTo(EConfigType.Custom);
await profile.PreSocksPort.Should().BeEqualTo(10808);
}
}
}
@@ -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])!;
}
}
@@ -43,9 +43,9 @@ public class McBeService : IUdpTest
{
// 0x1c | client alive time in ms (recorded from previous ping) |
// server GUID | Magic | string length | Edition
//
//
// Edition Example:
//
//
// MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133;
if (mcbeResponseBytes.Length < 48)
{
+30 -1
View File
@@ -501,6 +501,31 @@ public class Utils
};
}
public static List<(string, string)> ParseHeaders(string? headers)
{
var result = new List<(string, string)>();
if (headers.IsNullOrEmpty())
{
return result;
}
var lines = headers.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var parts = line.Split(new[] { ':' }, 2);
if (parts.Length != 2)
{
continue;
}
var key = parts[0].Trim();
var value = parts[1].Trim();
if (!string.IsNullOrEmpty(key))
{
result.Add((key, value));
}
}
return result;
}
#endregion Conversion Functions
#region Data Checks
@@ -1416,7 +1441,11 @@ public class Utils
if (File.Exists(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;
}
}
+1
View File
@@ -15,6 +15,7 @@ public enum EConfigType
Anytls = 11,
Naive = 12,
Outbound = 13,
MASQUE = 14,
PolicyGroup = 101,
ProxyChain = 102,
}
+7 -4
View File
@@ -261,7 +261,8 @@ public class Global
{ EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" },
{ EConfigType.Naive, "naive" }
{ EConfigType.Naive, "naive" },
{ EConfigType.MASQUE, "masque" },
};
public static readonly List<string> VmessSecurities =
@@ -371,6 +372,7 @@ public class Global
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.WireGuard,
EConfigType.MASQUE,
EConfigType.SOCKS,
EConfigType.HTTP,
];
@@ -386,6 +388,7 @@ public class Global
EConfigType.Anytls,
EConfigType.Naive,
EConfigType.WireGuard,
EConfigType.MASQUE,
EConfigType.SOCKS,
EConfigType.HTTP,
];
@@ -653,10 +656,10 @@ public class Global
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.hysteria, "HyNetworks/hysteria" },
{ ECoreType.hysteria2, "HyNetworks/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.tuic, "tuic-protocol/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
@@ -49,6 +49,7 @@ public class CoreConfigContextBuilder
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
IsWindows = Utils.IsWindows(),
IsMacOS = Utils.IsMacOS(),
IsLinux = Utils.IsLinux(),
HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(),
ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : []
};
+30 -3
View File
@@ -292,6 +292,7 @@ public static class ConfigHandler
EConfigType.WireGuard => await AddWireguardServer(config, item),
EConfigType.Anytls => await AddAnytlsServer(config, item),
EConfigType.Naive => await AddNaiveServer(config, item),
EConfigType.MASQUE => await AddMasqueServer(config, item),
_ => -1,
};
return ret;
@@ -992,6 +993,29 @@ public static class ConfigHandler
return 0;
}
public static async Task<int> AddMasqueServer(Config config, ProfileItem profileItem, bool toFile = true)
{
profileItem.ConfigType = EConfigType.MASQUE;
profileItem.Address = profileItem.Address.TrimEx();
profileItem.Username = profileItem.Username.TrimEx();
profileItem.Password = profileItem.Password.TrimEx();
profileItem.Fingerprint = string.Empty;
profileItem.Alpn = string.Empty;
profileItem.Network = string.Empty;
profileItem.AllowInsecure = string.Empty;
if (profileItem.StreamSecurity.IsNullOrEmpty())
{
profileItem.StreamSecurity = Global.StreamSecurity;
}
if (profileItem.Password.IsNullOrEmpty())
{
return -1;
}
await AddServerCommon(config, profileItem, toFile);
return 0;
}
/// <summary>
/// Sort the server list by the specified column
/// Updates the sort order in the profile extension data
@@ -1684,6 +1708,7 @@ public static class ConfigHandler
EConfigType.WireGuard => await AddWireguardServer(config, profileItem, false),
EConfigType.Anytls => await AddAnytlsServer(config, profileItem, false),
EConfigType.Naive => await AddNaiveServer(config, profileItem, false),
EConfigType.MASQUE => await AddMasqueServer(config, profileItem, false),
_ => -1,
};
@@ -1753,7 +1778,7 @@ public static class ConfigHandler
}
if (lstProfiles.Count > 0)
{
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub, subItem);
if (count > 0)
{
return count;
@@ -1799,7 +1824,7 @@ public static class ConfigHandler
if (lstProfiles?.Count > 0)
{
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub);
var count = await AddBatchCustomServers(config, lstProfiles, subid, isSub, subItem);
if (count > 0)
{
return count;
@@ -1813,7 +1838,8 @@ public static class ConfigHandler
Config config,
List<ProfileItem> lstProfiles,
string subid,
bool isSub)
bool isSub,
SubItem? subItem)
{
var count = 0;
foreach (var it in lstProfiles)
@@ -1823,6 +1849,7 @@ public static class ConfigHandler
if (it.ConfigType == EConfigType.Custom)
{
it.PreSocksPort = subItem?.PreSocksPort;
if (await AddCustomServer(config, it, true) == 0)
{
count++;
+1 -1
View File
@@ -229,7 +229,7 @@ public sealed class CoreInfoManager
DownloadUrlLinuxLoong64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-loong64.tar.gz",
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
//LockedMaxVersion = new SemanticVersion(1, 14, int.MaxValue),
Match = "sing-box",
VersionArg = "version",
},
+33 -9
View File
@@ -4,7 +4,8 @@ public class ProfileExManager
{
private static readonly Lazy<ProfileExManager> _instance = new(() => new());
private ConcurrentBag<ProfileExItem> _lstProfileEx = [];
private readonly Queue<string> _queIndexIds = new();
private readonly ConcurrentQueue<string> _queIndexIds = new();
private readonly Lock _profileExLock = new();
public static ProfileExManager Instance => _instance.Value;
private static readonly string _tag = "ProfileExHandler";
@@ -27,29 +28,39 @@ public class ProfileExManager
{
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem where indexId not in ( select indexId from ProfileItem )");
_lstProfileEx = new(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
var lstProfileEx = new ConcurrentBag<ProfileExItem>(await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync());
lock (_profileExLock)
{
_lstProfileEx = lstProfileEx;
}
}
private void IndexIdEnqueue(string indexId)
{
if (indexId.IsNotEmpty() && !_queIndexIds.Contains(indexId))
if (indexId.IsNotEmpty())
{
//Speed tests touch this from many threads; duplicates are harmless here since
//the save loop just writes the same row more than once
_queIndexIds.Enqueue(indexId);
}
}
private async Task SaveQueueIndexIds()
{
var cnt = _queIndexIds.Count;
if (cnt > 0)
var lstIds = new List<string>();
while (_queIndexIds.TryDequeue(out var id))
{
lstIds.Add(id);
}
if (lstIds.Count > 0)
{
var lstExists = await SQLiteHelper.Instance.TableAsync<ProfileExItem>().ToListAsync();
List<ProfileExItem> lstInserts = [];
List<ProfileExItem> lstUpdates = [];
for (var i = 0; i < cnt; i++)
foreach (var id in lstIds)
{
var id = _queIndexIds.Dequeue();
var item = lstExists.FirstOrDefault(t => t.IndexId == id);
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
if (itemNew is null)
@@ -103,13 +114,26 @@ public class ProfileExManager
private ProfileExItem GetProfileExItem(string? indexId)
{
return _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId) ?? AddProfileEx(indexId);
var profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
if (profileEx is not null)
{
return profileEx;
}
lock (_profileExLock)
{
profileEx = _lstProfileEx.FirstOrDefault(t => t.IndexId == indexId);
return profileEx ?? AddProfileEx(indexId);
}
}
public async Task ClearAll()
{
await SQLiteHelper.Instance.ExecuteAsync($"delete from ProfileExItem ");
_lstProfileEx = [];
lock (_profileExLock)
{
_lstProfileEx = [];
}
}
public async Task SaveTo()
+1 -1
View File
@@ -134,7 +134,7 @@ public class TaskManager
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.CheckPreReleaseUpdate, _config.CheckUpdateItem.UpdateViaProxy);
var msgs = await updateService.CheckHasUpdateOnlyAll(_config.CheckUpdateItem.UpdateViaProxy);
foreach (var msg in msgs)
{
await _updateFunc?.Invoke(false, msg);
@@ -242,9 +242,9 @@ public class WebDavItem
[Serializable]
public class CheckUpdateItem
{
public bool CheckPreReleaseUpdate { get; set; }
public bool UpdateViaProxy { get; set; } = true;
public List<string>? SelectedCoreTypes { get; set; }
public List<string>? CheckPreReleaseCoreTypes { get; set; }
}
[Serializable]
@@ -24,6 +24,7 @@ public record CoreConfigContext
public bool IsWindows { get; init; }
public bool IsMacOS { get; init; }
public bool IsLinux { get; init; }
// Defaults to true so that a context built without this flag keeps routing IPv6 into the
// tunnel; only a positive detection of the host having no global IPv6 address turns it off.
@@ -128,8 +128,6 @@ public class User4Sbox
public class Outbound4Sbox : BaseServer4Sbox
{
public string? server { get; set; }
public int? server_port { get; set; }
public List<string>? server_ports { get; set; }
public string? uuid { get; set; }
public string? security { get; set; }
@@ -171,6 +169,10 @@ public class Endpoints4Sbox : BaseServer4Sbox
public string? udp_timeout { get; set; }
public int? workers { get; set; }
public List<Peer4Sbox> peers { get; set; }
public string? username { get; set; }
public string? password { get; set; }
public string? path { get; set; }
public Dictionary<string, List<string>>? headers { get; set; }
}
public class Peer4Sbox
@@ -271,10 +273,8 @@ public class Server4Sbox : BaseServer4Sbox
public string? inet4_range { get; set; }
public string? inet6_range { get; set; }
public string? client_subnet { get; set; }
public string? server { get; set; }
public new string? domain_resolver { get; set; }
[JsonPropertyName("interface")] public string? Interface { get; set; }
public int? server_port { get; set; }
public string? path { get; set; }
public Headers4Sbox? headers { get; set; }
@@ -359,4 +359,6 @@ public abstract class BaseServer4Sbox : DialFields4Sbox
{
public string type { get; set; }
public string tag { get; set; }
public string? server { get; set; }
public int? server_port { get; set; }
}
@@ -92,6 +92,10 @@ public class Inboundsettings4Ray
public string? autoOutboundsInterface { get; set; }
public List<string>? dns { get; set; }
public bool? autoSystemDnsToGateway { get; set; }
public List<string>? autoSystemWfpBlockLeak { get; set; }
}
public class Sniffing4Ray
@@ -315,6 +319,7 @@ public class StreamSettings4Ray
public GrpcSettings4Ray? grpcSettings { get; set; }
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
public MasqueSettings4Ray? masqueSettings { get; set; }
public object? finalmask { get; set; }
@@ -443,6 +448,12 @@ public class HysteriaSettings4Ray
public string? auth { get; set; }
}
public class MasqueSettings4Ray
{
public string? path { get; set; }
public Dictionary<string, string>? headers { get; set; }
}
public class UdpHop4Ray
{
public string? ports { get; set; }
@@ -3,6 +3,8 @@ namespace ServiceLib.Models.Dto;
public partial class CheckUpdateModel : ReactiveObject
{
public bool? IsSelected { get; set; }
public bool IsCheckPreRelease { get; set; }
public bool ShowCheckPreRelease { get; set; }
public ECoreType? CoreType { get; set; }
[Reactive] public partial string? Remarks { get; set; }
public string? FileName { get; set; }
@@ -46,6 +46,10 @@ public record ProtocolExtraItem
public int? InsecureConcurrency { get; init; }
public bool? NaiveQuic { get; init; }
// MASQUE
public string? MasquePath { get; init; }
public string? MasqueHeaders { get; init; }
// group profile
public string? GroupType { get; init; }
public string? ChildItems { get; init; }
+18
View File
@@ -789,6 +789,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Add [MASQUE] 的本地化字符串。
/// </summary>
public static string menuAddMasqueServer {
get {
return ResourceManager.GetString("menuAddMasqueServer", resourceCulture);
}
}
/// <summary>
/// 查找类似 Add [NaïveProxy] 的本地化字符串。
/// </summary>
@@ -924,6 +933,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Check and Update 的本地化字符串。
/// </summary>
public static string menuCheckAndUpdate {
get {
return ResourceManager.GetString("menuCheckAndUpdate", resourceCulture);
}
}
/// <summary>
/// 查找类似 Only Check 的本地化字符串。
/// </summary>
+7 -1
View File
@@ -399,6 +399,9 @@
<data name="MsgServerTitle" xml:space="preserve">
<value>Filter, press Enter to execute</value>
</data>
<data name="menuCheckAndUpdate" xml:space="preserve">
<value>Check and Update</value>
</data>
<data name="menuCheckUpdate" xml:space="preserve">
<value>Check Update</value>
</data>
@@ -1899,4 +1902,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbSortingProcess" xml:space="preserve">
<value>Process</value>
</data>
</root>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>Add [MASQUE]</value>
</data>
</root>
+8 -2
View File
@@ -399,8 +399,11 @@
<data name="MsgServerTitle" xml:space="preserve">
<value>Фильтр, нажмите Enter для выполнения</value>
</data>
<data name="menuCheckAndUpdate" xml:space="preserve">
<value>Проверить и обновить</value>
</data>
<data name="menuCheckUpdate" xml:space="preserve">
<value>Обновить</value>
<value>Проверить обновления</value>
</data>
<data name="menuClose" xml:space="preserve">
<value>Закрыть</value>
@@ -1780,7 +1783,7 @@
<value>Доступна новая версия {0}: {1}</value>
</data>
<data name="menuCheckOnly" xml:space="preserve">
<value>Проверить</value>
<value>Только проверить</value>
</data>
<data name="MsgNotSupport" xml:space="preserve">
<value>Не поддерживается</value>
@@ -1899,4 +1902,7 @@
<data name="TbSortingProcess" xml:space="preserve">
<value>Процесс</value>
</data>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>Добавить сервер [MASQUE]</value>
</data>
</root>
+7 -1
View File
@@ -399,6 +399,9 @@
<data name="MsgServerTitle" xml:space="preserve">
<value>过滤器,按回车执行</value>
</data>
<data name="menuCheckAndUpdate" xml:space="preserve">
<value>检查并更新</value>
</data>
<data name="menuCheckUpdate" xml:space="preserve">
<value>检查更新</value>
</data>
@@ -1891,4 +1894,7 @@
<data name="TbSortingProcess" xml:space="preserve">
<value>进程</value>
</data>
</root>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>添加 [MASQUE]</value>
</data>
</root>
@@ -399,6 +399,9 @@
<data name="MsgServerTitle" xml:space="preserve">
<value>過濾器,按 Enter 執行</value>
</data>
<data name="menuCheckAndUpdate" xml:space="preserve">
<value>檢查並更新</value>
</data>
<data name="menuCheckUpdate" xml:space="preserve">
<value>檢查更新</value>
</data>
-105
View File
@@ -300,40 +300,6 @@ AAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3CekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ
5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
-----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-----
MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF
MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD
@@ -732,20 +698,6 @@ LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI
4uJEvlz36hz1
-----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-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
@@ -805,38 +757,6 @@ HQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/
bmF0774BxL4YSFlhgjICICadVGNA3jdgUM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm
-----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-----
MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL
BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ
@@ -1189,31 +1109,6 @@ AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB
RA+GsCyRxj3qrg+E
-----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-----
MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt
MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg
@@ -63,7 +63,7 @@ public partial class CoreConfigSingboxService
}
var tunInbound = JsonUtils.Deserialize<Inbound4Sbox>(EmbedUtils.GetEmbedText(Global.TunSingboxInboundFileName)) ?? new Inbound4Sbox { };
tunInbound.interface_name = context.IsMacOS ? $"utun{new Random().Next(99)}" : "singbox_tun";
tunInbound.interface_name = context.IsMacOS ? null : "singbox_tun";
tunInbound.mtu = _config.TunModeItem.Mtu;
tunInbound.auto_route = _config.TunModeItem.AutoRoute;
tunInbound.strict_route = _config.TunModeItem.StrictRoute;
@@ -77,7 +77,7 @@ public partial class CoreConfigSingboxService
}
}
if (_node.ConfigType == EConfigType.WireGuard)
if (_node.ConfigType is EConfigType.WireGuard or EConfigType.MASQUE)
{
var endpoint = JsonUtils.Deserialize<Endpoints4Sbox>(txtOutbound);
FillEndpoint(endpoint);
@@ -350,13 +350,15 @@ public partial class CoreConfigSingboxService
{
var protocolExtra = _node.GetProtocolExtra();
endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
endpoint.type = Global.ProtocolTypes[_node.ConfigType];
switch (_node.ConfigType)
{
case EConfigType.WireGuard:
{
endpoint.server = null;
endpoint.server_port = null;
endpoint.address = Utils.String2List(protocolExtra.WgInterfaceAddress)?.Select(s => s.Trim()).ToList() ?? ["172.16.0.2/32"];
var peer = new Peer4Sbox
{
public_key = protocolExtra.WgPublicKey ?? string.Empty,
@@ -371,6 +373,28 @@ public partial class CoreConfigSingboxService
endpoint.peers = [peer];
break;
}
case EConfigType.MASQUE:
{
endpoint.type = "masque-client";
endpoint.server = _node.Address;
endpoint.server_port = _node.Port;
endpoint.username = _node.Username;
endpoint.password = _node.Password;
endpoint.path = protocolExtra?.MasquePath.NullIfEmpty();
if (!string.IsNullOrEmpty(protocolExtra?.MasqueHeaders))
{
var headers = Utils.ParseHeaders(protocolExtra.MasqueHeaders)
.GroupBy(x => x.Item1, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.Item2).ToList(),
StringComparer.OrdinalIgnoreCase
);
endpoint.headers = headers.Count > 0 ? headers : null;
}
FillOutboundTls(endpoint);
break;
}
}
}
catch (Exception ex)
@@ -402,7 +426,7 @@ public partial class CoreConfigSingboxService
}
}
private void FillOutboundTls(Outbound4Sbox outbound)
private void FillOutboundTls(BaseServer4Sbox server)
{
try
{
@@ -476,7 +500,7 @@ public partial class CoreConfigSingboxService
{
tls.ech = ech;
}
outbound.tls = tls;
server.tls = tls;
}
catch (Exception ex)
{
@@ -67,12 +67,10 @@ public partial class CoreConfigV2rayService
var address = _config.TunModeItem.IPv4Address.NullIfEmpty() ?? Global.TunIPv4Address.First();
tunInbound.settings.gateway = [address];
// Route both families into the tunnel regardless of EnableIPv6Address. That option only
// controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing
// table makes IPv6 follow the system default route and bypass the tunnel entirely.
// A host without a global IPv6 address is the exception: it has nothing to leak,
// and IPv6 sent into the tunnel would have no way back out.
tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address
// Without ::/0, IPv6 bypasses the tunnel. Skip it only when the host has no global IPv6
// address and EnableIPv6Address is off; NAT66 hosts have only ULA addresses.
var routeIPv6 = _config.TunModeItem.EnableIPv6Address || context.HasGlobalIPv6Address;
tunInbound.settings.autoSystemRoutingTable = routeIPv6
? ["0.0.0.0/0", "::/0"]
: ["0.0.0.0/0"];
if (_config.TunModeItem.EnableIPv6Address == true)
@@ -90,6 +88,15 @@ public partial class CoreConfigV2rayService
// tunInbound.sniffing.routeOnly = inbound.sniffing.routeOnly;
tunInbound.sniffing.routeOnly = true;
if (context.IsWindows && _config.TunModeItem.StrictRoute == true)
{
tunInbound.settings.autoSystemWfpBlockLeak = ["dns", "misconfigtun"];
}
if (context.IsLinux)
{
tunInbound.settings.autoSystemDnsToGateway = true;
}
if (_config.TunModeItem.RouteExcludeAddress is { Count: > 0 })
{
var wholeInternet = IPNetwork2.Parse("0.0.0.0/0");
@@ -99,7 +106,7 @@ public partial class CoreConfigV2rayService
.Where(x => x != null).ToList();
var includeList = new List<IPNetwork2> { wholeInternet };
var includeListV6 = context.HasGlobalIPv6Address
var includeListV6 = routeIPv6
? new List<IPNetwork2> { wholeInternetV6 }
: new List<IPNetwork2>();
@@ -182,6 +182,12 @@ public partial class CoreConfigV2rayService
outboundSettings.version = 2;
break;
}
case EConfigType.MASQUE:
{
outboundSettings.address = _node.Address;
outboundSettings.port = _node.Port;
break;
}
case EConfigType.WireGuard:
{
var address = _node.Address;
@@ -257,8 +263,13 @@ public partial class CoreConfigV2rayService
{
network = "hysteria";
}
else if (_node.ConfigType == EConfigType.MASQUE)
{
network = "masque";
}
streamSettings.network = network;
var transport = _node.GetTransportExtra();
var protocolExtra = _node.GetProtocolExtra();
var host = string.Empty;
var path = string.Empty;
var kcpSeed = string.Empty;
@@ -502,7 +513,6 @@ public partial class CoreConfigV2rayService
break;
case "hysteria":
var protocolExtra = _node.GetProtocolExtra();
var ports = protocolExtra?.Ports;
int? upMbps = protocolExtra?.UpMbps is { } su and >= 0
? su
@@ -574,6 +584,32 @@ public partial class CoreConfigV2rayService
streamSettings.finalmask = hy2Finalmask;
break;
case "masque":
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var authValue = $"Basic {Utils.Base64Encode($"{_node.Username}:{_node.Password}")}";
headers.Add("Authorization", authValue);
if (!string.IsNullOrEmpty(protocolExtra?.MasqueHeaders))
{
var userHeaders = Utils.ParseHeaders(protocolExtra.MasqueHeaders)
.GroupBy(x => x.Item1, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => string.Join(", ", g.Select(x => x.Item2)),
StringComparer.OrdinalIgnoreCase
);
if (userHeaders.TryGetValue("Authorization", out var userAuthValue))
{
headers["Authorization"] = userAuthValue;
}
headers = userHeaders;
}
streamSettings.masqueSettings = new()
{
path = protocolExtra?.MasquePath.NullIfEmpty(),
headers = headers.Count > 0 ? headers : null,
};
break;
default:
// raw
if (headerType == Global.RawHeaderHttp)
@@ -587,6 +587,12 @@ public class SpeedtestService(Config config, Func<SpeedTestResult, Task> updateF
private List<List<ServerTestItem>> GetTestBatchItem(List<ServerTestItem> lstSelected, int pageSize)
{
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 lst2 = lstSelected.Where(t => t.CoreType == ECoreType.sing_box).ToList();
+2 -1
View File
@@ -111,7 +111,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
return await CheckUpdateAsync(downloadHandle, type, checkPreRelease, blProxy, cancellationToken);
}
public async Task<List<string>> CheckHasUpdateOnlyAll(bool preRelease, bool blProxy = true, CancellationToken cancellationToken = default)
public async Task<List<string>> CheckHasUpdateOnlyAll(bool blProxy = true, CancellationToken cancellationToken = default)
{
var msgs = new List<string>();
foreach (var type in CoreInfoManager.Instance.GetCheckUpdateCoreTypes())
@@ -121,6 +121,7 @@ public partial class UpdateService(Config config, Func<bool, string, Task> updat
continue;
}
var preRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(type.ToString()) ?? false;
var result = await CheckHasUpdateOnly(type, preRelease, blProxy, cancellationToken);
if (result.Success && result.Version != null)
{
@@ -97,6 +97,12 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
[Reactive]
public partial int GeckoMaxPacketSize { get; set; }
[Reactive]
public partial string MasquePath { get; set; }
[Reactive]
public partial string MasqueHeaders { get; set; }
[Reactive]
public partial string RawHeaderType { get; set; }
@@ -322,6 +328,8 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
Hy2RealmUrl = protocolExtra.Hy2RealmUrl ?? string.Empty;
GeckoMinPacketSize = protocolExtra.GeckoMinPacketSize.ToInt();
GeckoMaxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt();
MasquePath = protocolExtra.MasquePath ?? string.Empty;
MasqueHeaders = protocolExtra.MasqueHeaders ?? string.Empty;
RawHeaderType = transport.RawHeaderType ?? Global.None;
Host = transport.Host ?? string.Empty;
@@ -443,6 +451,8 @@ public partial class AddServerViewModel : MyReactiveObject, ICloseable
Hy2RealmUrl = realm?.ToUri().NullIfEmpty(),
GeckoMinPacketSize = GeckoMinPacketSize > 0 ? GeckoMinPacketSize.ToString() : null,
GeckoMaxPacketSize = GeckoMaxPacketSize > 0 ? GeckoMaxPacketSize.ToString() : null,
MasquePath = MasquePath.NullIfEmpty(),
MasqueHeaders = MasqueHeaders.NullIfEmpty(),
});
SelectedSource.SetTransportExtra(transport);
@@ -12,7 +12,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
public BulkObservableCollection<CheckUpdateModel> CheckUpdateModels { get; } = [];
public ReactiveCommand<RxVoid, RxVoid> CheckUpdateCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> CheckOnlyCmd { get; }
[Reactive] public partial bool EnableCheckPreReleaseUpdate { get; set; }
[Reactive] public partial bool EnableUpdateViaProxy { get; set; }
public CheckUpdateViewModel()
@@ -33,12 +33,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
_ = UpdateView(_v2rayN, ex.Message);
});
EnableCheckPreReleaseUpdate = _config.CheckUpdateItem.CheckPreReleaseUpdate;
EnableUpdateViaProxy = _config.CheckUpdateItem.UpdateViaProxy;
this.WhenAnyValue(x => x.EnableCheckPreReleaseUpdate)
.SubscribeAsync(async _ => await OnCheckPreReleaseUpdateChanged());
this.WhenAnyValue(x => x.EnableUpdateViaProxy)
.Subscribe(c => _ = OnUpdateViaProxyChanged());
@@ -73,6 +69,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
return new()
{
IsSelected = false,
IsCheckPreRelease = false,
ShowCheckPreRelease = false,
CoreType = coreType,
IsGeoFile = false,
Remarks = ResUI.menuCheckUpdate + $" ({ResUI.MsgNotSupport})",
@@ -83,6 +81,8 @@ public partial class CheckUpdateViewModel : MyReactiveObject
return new()
{
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(coreType.ToString()) ?? true,
IsCheckPreRelease = _config.CheckUpdateItem.CheckPreReleaseCoreTypes?.Contains(coreType.ToString()) ?? false,
ShowCheckPreRelease = CoreInfoManager.Instance.GetCheckPreRelease(coreType, true),
CoreType = coreType,
IsGeoFile = false,
Remarks = lastResult ?? ResUI.menuCheckUpdate,
@@ -94,22 +94,14 @@ public partial class CheckUpdateViewModel : MyReactiveObject
return new()
{
IsSelected = _config.CheckUpdateItem.SelectedCoreTypes?.Contains(_geo) ?? true,
IsCheckPreRelease = false,
ShowCheckPreRelease = false,
CoreType = null,
IsGeoFile = true,
Remarks = ResUI.menuCheckUpdate,
};
}
private async Task OnCheckPreReleaseUpdateChanged()
{
if (_config.CheckUpdateItem.CheckPreReleaseUpdate == EnableCheckPreReleaseUpdate)
{
return;
}
_config.CheckUpdateItem.CheckPreReleaseUpdate = EnableCheckPreReleaseUpdate;
await SaveSelectedCoreTypes();
}
private async Task OnUpdateViaProxyChanged()
{
if (_config.CheckUpdateItem.UpdateViaProxy == EnableUpdateViaProxy)
@@ -127,6 +119,11 @@ public partial class CheckUpdateViewModel : MyReactiveObject
.Select(t => t.CoreTypeForStorage)
.ToList();
_config.CheckUpdateItem.CheckPreReleaseCoreTypes =
CheckUpdateModels.Where(t => t.IsCheckPreRelease == true)
.Select(t => t.CoreTypeForStorage)
.ToList();
await ConfigHandler.SaveConfig(_config);
}
@@ -167,7 +164,7 @@ public partial class CheckUpdateViewModel : MyReactiveObject
}
var updateService = new UpdateService(_config, async (success, msg) => await Task.CompletedTask);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, EnableCheckPreReleaseUpdate, EnableUpdateViaProxy);
var result = await updateService.CheckHasUpdateOnly(item.CoreType.Value, item.IsCheckPreRelease, EnableUpdateViaProxy);
if (result.Success && result.Version != null)
{
await UpdateView(item.CoreType, string.Format(ResUI.MsgCheckUpdateHasNewVersion, item.CoreType, result.Version));
@@ -213,15 +210,11 @@ public partial class CheckUpdateViewModel : MyReactiveObject
await UpdateView(_v2rayN, ResUI.MsgNotSupport);
continue;
}
await CheckUpdateN(EnableCheckPreReleaseUpdate);
}
else if (item.CoreType is ECoreType.Xray or ECoreType.sing_box)
{
await CheckUpdateCore(item, EnableCheckPreReleaseUpdate);
await CheckUpdateN(item.IsCheckPreRelease);
}
else if (item.CoreType.HasValue)
{
await CheckUpdateCore(item, false);
await CheckUpdateCore(item, item.IsCheckPreRelease);
}
}
@@ -2,7 +2,7 @@ namespace ServiceLib.ViewModels;
public partial class ClashProxiesViewModel : MyReactiveObject
{
private readonly int _delayTimeout = 99999999;
private const int _delayTimeout = 99999999;
private ClashItem _clashItem = new();
public ClashProxiesViewModel()
@@ -235,6 +235,11 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
return;
}
var dicCurrentDetails = new Dictionary<string, ClashProxyModel>();
foreach (var item in ProxyDetails)
{
dicCurrentDetails.TryAdd(item.Name, item);
}
var lstDetails = new List<ClashProxyModel>();
foreach (var item in proxy.all)
{
@@ -243,15 +248,29 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
continue;
}
var delay = proxy2.history?.Count > 0 ? proxy2.history.Last().delay : -1;
var delay = -1;
var hasData = false;
if (proxy2.history is { Count: > 0 })
{
delay = proxy2.history.Last().delay;
hasData = true;
}
else if (dicCurrentDetails.TryGetValue(item, out var detail))
{
delay = detail.Delay;
hasData = true;
}
var isValidDelay = delay is > 0 and not _delayTimeout;
lstDetails.Add(new ClashProxyModel
{
IsActive = item == proxy.now,
Name = item,
Type = proxy2.type,
Delay = delay <= 0 ? _delayTimeout : delay,
DelayName = delay <= 0 ? string.Empty : $"{delay}ms",
Delay = isValidDelay ? delay : _delayTimeout,
DelayName = !hasData ? string.Empty : (isValidDelay ? $"{delay}ms" : "-1"),
});
}
// sort
@@ -362,8 +381,9 @@ public partial class ClashProxiesViewModel : MyReactiveObject
{
return;
}
detail.Delay = Convert.ToInt32(result.Delay);
detail.DelayName = $"{detail.Delay}ms";
var isValid = int.TryParse(result.Delay, out var delay) && delay > 0 && delay != _delayTimeout;
detail.Delay = isValid ? delay : _delayTimeout;
detail.DelayName = isValid ? $"{delay}ms" : "-1";
await Task.CompletedTask;
}
@@ -32,6 +32,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public ReactiveCommand<RxVoid, RxVoid> AddWireguardServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddAnytlsServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddNaiveServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddMasqueServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCustomServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddCustomOutboundServerCmd { get; }
public ReactiveCommand<RxVoid, RxVoid> AddPolicyGroupServerCmd { get; }
@@ -140,6 +141,10 @@ public partial class MainWindowViewModel : MyReactiveObject
{
await AddServerAsync(EConfigType.Naive);
});
AddMasqueServerCmd = ReactiveCommand.CreateFromTask(async () =>
{
await AddServerAsync(EConfigType.MASQUE);
});
AddCustomServerCmd = ReactiveCommand.CreateFromTask(async () =>
{
await AddServerAsync(EConfigType.Custom);
@@ -522,7 +527,7 @@ public partial class MainWindowViewModel : MyReactiveObject
public async Task AddServerViaImageAsync()
{
var imageFileName = await BrowseImageFileInteraction.HandleSafe(RxVoid.Default);
await AddScanResultAsync(imageFileName);
await ScanImageResult(imageFileName);
}
public async Task ScanImageResult(string fileName)
@@ -113,6 +113,7 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
[Reactive] public partial string CoreType6 { get; set; }
[Reactive] public partial string CoreType7 { get; set; }
[Reactive] public partial string CoreType9 { get; set; }
[Reactive] public partial string CoreType14 { get; set; }
#endregion CoreType
@@ -283,6 +284,10 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
case 9:
CoreType9 = type;
break;
case 14:
CoreType14 = type;
break;
}
});
await Task.CompletedTask;
@@ -451,6 +456,10 @@ public partial class OptionSettingViewModel : MyReactiveObject, ICloseable
type = CoreType9;
break;
case 14:
type = CoreType14;
break;
default:
continue;
}
@@ -219,7 +219,8 @@ public partial class StatusBarViewModel : MyReactiveObject
await ConfigHandler.InitBuiltinRouting(_config);
await RefreshRoutingsMenu();
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;
}
@@ -24,12 +24,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -23,12 +23,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -24,12 +24,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -783,6 +783,70 @@
HorizontalAlignment="Left" />
</Grid>
<Grid
x:Name="gridMasque"
Grid.Row="2"
ColumnDefinitions="300,Auto"
IsVisible="False"
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock
Grid.Row="1"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbUsername}" />
<TextBox
x:Name="txtId14"
Grid.Row="1"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbId3}" />
<TextBox
x:Name="txtSecurity14"
Grid.Row="2"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}" />
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbPath}" />
<TextBox
x:Name="txtPath14"
Grid.Row="3"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}" />
<TextBlock
Grid.Row="4"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbHttpOutboundHeaders}" />
<TextBox
x:Name="txtHttpHeaders14"
Grid.Row="4"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
AcceptsReturn="True"
Classes="TextArea"
PlaceholderText="Header: Value"
TextWrapping="NoWrap" />
</Grid>
<Separator
x:Name="sepa2"
Grid.Row="3"
@@ -141,6 +141,13 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
this.Bind(ViewModel, vm => vm.InsecureConcurrency, v => v.txtInsecureConcurrency12.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.Uot, v => v.togUotEnabled12.IsChecked).DisposeWith(currentTypeDisposables);
break;
case EConfigType.MASQUE:
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtId14.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtSecurity14.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.MasquePath, v => v.txtPath14.Text).DisposeWith(currentTypeDisposables);
this.Bind(ViewModel, vm => vm.MasqueHeaders, v => v.txtHttpHeaders14.Text).DisposeWith(currentTypeDisposables);
break;
}
})
.DisposeWith(disposables);
@@ -194,7 +201,6 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
this.BindCommand(ViewModel, vm => vm.FetchCertChainCmd, v => v.btnFetchCertChain).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
});
}
private void InitializeData(ProfileItem profileItem)
@@ -284,6 +290,14 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
cmbCongestionControl12.ItemsSource = Global.NaiveCongestionControls;
break;
case EConfigType.MASQUE:
gridMasque.IsVisible = true;
sepa2.IsVisible = false;
gridTransport.IsVisible = false;
cmbFingerprint.IsEnabled = false;
cmbAlpn.IsEnabled = false;
break;
}
cmbStreamSecurity.ItemsSource = lstStreamSecurity;
cmbStreamSecurity.SelectedItem = profileItem.StreamSecurity;
@@ -24,16 +24,23 @@
HorizontalAlignment="Left" />
</StackPanel>
<StackPanel>
<StackPanel Grid.IsSharedSizeScope="True">
<Border
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Theme="{DynamicResource CardBorder}">
<Grid
Margin="{StaticResource Margin4}"
ColumnDefinitions="300,200"
RowDefinitions="Auto,Auto,Auto,Auto">
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto">
<Grid.ColumnDefinitions>
<ColumnDefinition
Width="Auto"
MinWidth="300"
SharedSizeGroup="BackupLabel" />
<ColumnDefinition
Width="Auto"
MinWidth="200"
SharedSizeGroup="BackupButton" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
@@ -79,10 +86,17 @@
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Theme="{DynamicResource CardBorder}">
<Grid
Margin="{StaticResource Margin4}"
ColumnDefinitions="300,200"
RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<Grid Margin="{StaticResource Margin4}" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<Grid.ColumnDefinitions>
<ColumnDefinition
Width="Auto"
MinWidth="300"
SharedSizeGroup="BackupLabel" />
<ColumnDefinition
Width="Auto"
MinWidth="200"
SharedSizeGroup="BackupButton" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Row="0"
Grid.Column="0"
@@ -35,27 +35,17 @@
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<TextBlock
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}" />
<ToggleSwitch
x:Name="togEnableCheckPreReleaseUpdate"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left" />
<Button
x:Name="btnCheckOnly"
Width="100"
MinWidth="100"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.menuCheckOnly}" />
<Button
x:Name="btnCheckUpdate"
Width="100"
MinWidth="100"
Margin="{StaticResource Margin4}"
Content="{x:Static resx:ResUI.menuCheckUpdate}" />
Content="{x:Static resx:ResUI.menuCheckAndUpdate}" />
</StackPanel>
</Border>
@@ -75,8 +65,8 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Panel Width="500" VerticalAlignment="Center">
<Grid ColumnDefinitions="1*,1*,3*" RowDefinitions="Auto">
<Panel Width="680" VerticalAlignment="Center">
<Grid ColumnDefinitions="1*,3*,1*,3*" RowDefinitions="Auto">
<ToggleSwitch
x:Name="togAutoRefresh"
Grid.Column="0"
@@ -84,13 +74,32 @@
HorizontalAlignment="Left"
VerticalAlignment="Center"
IsChecked="{Binding IsSelected}" />
<TextBlock
<DockPanel
Grid.Column="1"
Margin="{StaticResource MarginLr4}"
IsVisible="{Binding ShowCheckPreRelease}">
<ToggleSwitch
Margin="{StaticResource Margin4}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
IsChecked="{Binding IsCheckPreRelease}"
OffContent=""
OnContent="" />
<TextBlock
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.TbSettingsEnableCheckPreReleaseUpdate}"
TextWrapping="Wrap" />
</DockPanel>
<TextBlock
Grid.Column="2"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="{Binding CoreTypeForStorage}" />
<TextBlock
Grid.Column="2"
Grid.Column="3"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="{Binding Remarks}"
@@ -10,7 +10,6 @@ public partial class CheckUpdateView : ReactiveUserControl<CheckUpdateViewModel>
{
this.OneWayBind(ViewModel, vm => vm.CheckUpdateModels, v => v.lstCheckUpdates.ItemsSource).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.EnableCheckPreReleaseUpdate, v => v.togEnableCheckPreReleaseUpdate.IsChecked).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.EnableUpdateViaProxy, v => v.togEnableUpdateViaProxy.IsChecked).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.CheckOnlyCmd, v => v.btnCheckOnly).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.CheckUpdateCmd, v => v.btnCheckUpdate).DisposeWith(disposables);
@@ -24,12 +24,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -24,12 +24,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -23,17 +23,17 @@
Orientation="Horizontal">
<Button
x:Name="btnReset"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbReset}" />
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -18,6 +18,15 @@
ShowInTaskbar="True"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.Styles>
<!-- Keep the log selected; hide only the strip when it has no other tabs. -->
<Style Selector="TabControl.logPane /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="IsVisible" Value="{Binding ShowClashUI}" />
</Style>
<Style Selector="TabControl.logPane /template/ Border#PART_BorderSeparator">
<Setter Property="IsVisible" Value="{Binding ShowClashUI}" />
</Style>
</Window.Styles>
<dialogHost:DialogHost CloseOnClickAway="True" DisableOpeningAnimation="True">
<DockPanel>
<DockPanel Margin="{StaticResource Margin8}" DockPanel.Dock="Top">
@@ -45,6 +54,7 @@
<MenuItem x:Name="menuAddTrojanServer" Header="{x:Static resx:ResUI.menuAddTrojanServer}" />
<MenuItem x:Name="menuAddHysteria2Server" Header="{x:Static resx:ResUI.menuAddHysteria2Server}" />
<MenuItem x:Name="menuAddWireguardServer" Header="{x:Static resx:ResUI.menuAddWireguardServer}" />
<MenuItem x:Name="menuAddMasqueServer" Header="{x:Static resx:ResUI.menuAddMasqueServer}" />
<MenuItem x:Name="menuAddSocksServer" Header="{x:Static resx:ResUI.menuAddSocksServer}" />
<MenuItem x:Name="menuAddHttpServer" Header="{x:Static resx:ResUI.menuAddHttpServer}" />
<Separator />
@@ -123,6 +133,7 @@
<TabControl
x:Name="tabMain"
Grid.Column="2"
Classes="logPane"
HorizontalContentAlignment="Left">
<TabItem x:Name="tabMsgView" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
<TabItem x:Name="tabClashProxies" Header="{x:Static resx:ResUI.TbProxies}" />
@@ -138,6 +149,7 @@
<TabControl
x:Name="tabMain1"
Grid.Row="2"
Classes="logPane"
TabStripPlacement="Left">
<TabItem x:Name="tabMsgView1" Header="{x:Static resx:ResUI.MsgInformationTitle}" />
<TabItem x:Name="tabClashProxies1" Header="{x:Static resx:ResUI.TbProxies}" />
@@ -46,6 +46,7 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
this.BindCommand(ViewModel, vm => vm.AddWireguardServerCmd, v => v.menuAddWireguardServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddAnytlsServerCmd, v => v.menuAddAnytlsServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddNaiveServerCmd, v => v.menuAddNaiveServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddMasqueServerCmd, v => v.menuAddMasqueServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomServerCmd, v => v.menuAddCustomServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddCustomOutboundServerCmd, v => v.menuAddCustomOutboundServer).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.AddPolicyGroupServerCmd, v => v.menuAddPolicyGroupServer).DisposeWith(disposables);
@@ -418,7 +419,6 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabMsgView.IsVisible).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies.IsVisible).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections.IsVisible).DisposeWith(currentLayoutDisposables);
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain.SelectedIndex).DisposeWith(currentLayoutDisposables);
@@ -429,7 +429,6 @@ public partial class MainWindow : WindowBase<MainWindowViewModel>
this.OneWayBind(ViewModel, vm => vm.MsgViewModel, v => v.tabMsgView1.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ClashProxiesViewModel, v => v.tabClashProxies1.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ClashConnectionsViewModel, v => v.tabClashConnections1.Content).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabMsgView1.IsVisible).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashProxies1.IsVisible).DisposeWith(currentLayoutDisposables);
this.OneWayBind(ViewModel, vm => vm.ShowClashUI, v => v.tabClashConnections1.IsVisible).DisposeWith(currentLayoutDisposables);
this.Bind(ViewModel, vm => vm.TabMainSelectedIndex, v => v.tabMain1.SelectedIndex).DisposeWith(currentLayoutDisposables);
+2 -1
View File
@@ -23,7 +23,8 @@
Width="200"
Margin="{StaticResource MarginLr8}"
VerticalContentAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.MsgFilterTitle}" />
PlaceholderText="{x:Static resx:ResUI.MsgFilterTitle}"
ToolTip.Tip="{x:Static resx:ResUI.MsgFilterTitle}" />
<Button
x:Name="btnCopy"
+8 -3
View File
@@ -47,9 +47,14 @@ public partial class MsgView : ReactiveUserControl<MsgViewModel>
if (togScrollToEnd.IsChecked ?? true)
{
txtMsg.ScrollToEnd();
Dispatcher.UIThread.Invoke(() =>
txtMsg.ScrollTo(txtMsg.LineCount, 0, AvaloniaEdit.Rendering.VisualYPosition.TextBottom, txtMsg.Bounds.Height, 0),
DispatcherPriority.Background);
// Let layout finish without a nested dispatcher loop during a native window drag.
Dispatcher.UIThread.Post(() =>
{
if (togScrollToEnd.IsChecked ?? true)
{
txtMsg.ScrollTo(txtMsg.LineCount, 0, AvaloniaEdit.Rendering.VisualYPosition.TextBottom, txtMsg.Bounds.Height, 0);
}
}, DispatcherPriority.Background);
}
}
@@ -23,12 +23,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -1192,7 +1192,7 @@
<Grid
Margin="{StaticResource Margin4}"
ColumnDefinitions="Auto,Auto"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<TextBlock
Grid.Row="1"
Grid.Column="0"
@@ -1296,6 +1296,19 @@
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}" />
<TextBlock
Grid.Row="9"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
Text="MASQUE" />
<ComboBox
x:Name="cmbCoreType14"
Grid.Row="9"
Grid.Column="1"
Width="200"
Margin="{StaticResource Margin4}" />
</Grid>
</TabItem>
</TabControl>
@@ -46,6 +46,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
cmbCoreType6.ItemsSource = Global.CoreTypes;
cmbCoreType7.ItemsSource = Global.CoreTypes;
cmbCoreType9.ItemsSource = Global.CoreTypes;
cmbCoreType14.ItemsSource = Global.CoreTypes;
cmbMixedConcurrencyCount.ItemsSource = Enumerable.Range(Global.SpeedTestConcurrencyCountMin, 20).ToList();
cmbSpeedTestTimeout.ItemsSource = Enumerable.Range(2, 5).Select(i => i * 5).ToList();
@@ -145,6 +146,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
this.Bind(ViewModel, vm => vm.CoreType6, v => v.cmbCoreType6.SelectedValue).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CoreType7, v => v.cmbCoreType7.SelectedValue).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CoreType9, v => v.cmbCoreType9.SelectedValue).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.CoreType14, v => v.cmbCoreType14.SelectedValue).DisposeWith(disposables);
this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables);
});
@@ -28,12 +28,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -72,7 +72,8 @@
Margin="{StaticResource MarginLr4}"
VerticalContentAlignment="Center"
Text="{Binding ServerFilter, Mode=TwoWay}"
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}" />
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}"
ToolTip.Tip="{x:Static resx:ResUI.MsgServerTitle}" />
</WrapPanel>
<!-- Profiles grid -->
@@ -66,7 +66,8 @@
Width="200"
Margin="{StaticResource MarginLr4}"
VerticalContentAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}" />
PlaceholderText="{x:Static resx:ResUI.MsgServerTitle}"
ToolTip.Tip="{x:Static resx:ResUI.MsgServerTitle}" />
<Button
x:Name="btnAutofitColumnWidth"
@@ -439,6 +439,7 @@ public partial class ProfilesView : ReactiveUserControl<ProfilesViewModel>
private static readonly DataFormat<ProfileItemModel> LstProfilesRowFormat =
DataFormat.CreateInProcessFormat<ProfileItemModel>("LstProfilesRow");
private (Point, PointerPressedEventArgs)? _dragStartPoint;
private void LstProfiles_PointerPressed(object? sender, PointerPressedEventArgs e)
@@ -208,12 +208,12 @@
</StackPanel>
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -30,12 +30,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -22,12 +22,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
@@ -136,7 +136,8 @@
Margin="{StaticResource Margin4}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="{x:Static resx:ResUI.LvAutoUpdateInterval}" />
Text="{x:Static resx:ResUI.LvAutoUpdateInterval}"
TextWrapping="Wrap" />
</DockPanel>
<TextBlock
@@ -230,7 +231,8 @@
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}"
ToolTip.Tip="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
x:Name="btnSelectPrevProfile"
Grid.Row="10"
@@ -250,7 +252,8 @@
Grid.Column="1"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}" />
PlaceholderText="{x:Static resx:ResUI.LvPrevProfileTip}"
ToolTip.Tip="{x:Static resx:ResUI.LvPrevProfileTip}" />
<Button
x:Name="btnSelectNextProfile"
Grid.Row="11"
@@ -20,12 +20,12 @@
Orientation="Horizontal">
<Button
x:Name="btnSave"
Width="100"
MinWidth="100"
Content="{x:Static resx:ResUI.TbConfirm}"
IsDefault="True" />
<Button
x:Name="btnCancel"
Width="100"
MinWidth="100"
Margin="{StaticResource MarginLr8}"
Content="{x:Static resx:ResUI.TbCancel}"
IsCancel="True" />
+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();
}

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