Compare commits

..

97 Commits

Author SHA1 Message Date
Miheichev Aleksandr Sergeevich 756916978d fix(xray): stop emitting obsolete echForceQuery (#10480) 2026-11-01 21:11:17 +08:00
Miheichev Aleksandr Sergeevich a2a8a77ff6 chore(deps): update MaterialDesignThemes to 5.4.0 (#10423) 2026-11-01 21:08:08 +08:00
dependabot[bot] 2bc79bc5a1 Bump Repobot.SQLite.Unofficial from 3.53.4.1 to 3.54.1 (#10416)
---
updated-dependencies:
- dependency-name: Repobot.SQLite.Unofficial
  dependency-version: 3.54.1
  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-11-01 21:07:41 +08:00
dependabot[bot] 4c8b865d03 Bump docker/build-push-action from 6 to 7 (#10427)
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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 21:07:20 +08:00
Miheichev Aleksandr Sergeevich 3d44ba8ec8 chore(deps): update NLog to 6.2.2 (#10409) 2026-11-01 21:06:39 +08:00
dependabot[bot] aac4520f59 Bump actions/setup-python from 6 to 7 (#10426)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 21:05:17 +08:00
dependabot[bot] b13ae2592a Bump actions/download-artifact from 7 to 8 (#10425)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 21:05:07 +08:00
dependabot[bot] bf2584c9a9 Bump docker/setup-buildx-action from 3 to 4 (#10424)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 21:04:55 +08:00
Miheichev Aleksandr Sergeevich e044b90c83 chore(deps): update Downloader to 5.10.2 (#10491) 2026-11-01 21:04:52 +08:00
DHR60 3dce503a08 Fix (#10494) 2026-11-01 21:01:34 +08:00
DHR60 70ab27e436 Fix udp test (#10484) 2026-11-01 20:58:58 +08:00
DHR60 9593b289b9 Fix (#10460) 2026-11-01 20:54:43 +08:00
DHR60 5664270be7 Update cert (#10450) 2026-11-01 20:54:27 +08:00
Lukas 926790b071 fix(speedtest): clear the last speed when a speed test starts (#10449)
A speed test only stored a speed above zero, so a node that was skipped
because its delay test failed, or whose download failed, kept the speed
of the previous run. The list showed the skip or error text until it was
sorted or refreshed; then the old speed came back and the node sorted as
if it had been measured. Clear the stored speed when a speed or mixed
test starts.
2026-11-01 20:53:45 +08:00
Ekko82 e1c9633535 fix: keep the PAC listener running after a client reset (#10447)
* fix: keep the PAC listener running after a client reset

* Update PacManager.cs

* Update PacManager.cs

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-11-01 20:50:32 +08:00
UnrealTale 36184045bb Fix (#10431) (#10432) 2026-11-01 20:44:44 +08:00
DHR60 fcd313007a Update GlobalHotKeys (#10493) 2026-11-01 20:44:11 +08:00
Wu Shuwen 7e14cf9773 Preserve subscription profiles on failed import (#10429) 2026-11-01 20:43:48 +08:00
Wu Shuwen ad3e24b6b5 Fix fragment packet selection for REALITY (#10428) 2026-11-01 20:39:08 +08:00
Miheichev Aleksandr Sergeevich 4169c12a1f test: strengthen ProfileExManager concurrency regression coverage (#10410) 2026-11-01 20:35:38 +08:00
DHR60 8b93d35766 Add ProDataGrid (#10347)
* Bump Avalonia.Desktop from 12.1.2 to 12.1.4

---
updated-dependencies:
- dependency-name: Avalonia.Desktop
  dependency-version: 12.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Remove Avalonia.Controls.DataGrid

* Add ProDataGrid Feature

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-11-01 20:34:24 +08:00
DHR60 78b0b2224b Native Aot (#10295)
* Protect models

* Json Context

* YAML AOT-friendly optimization

* Run the Test in AOT mode

* Remove TaskScheduler

* Clean build output

* Workflows
2026-11-01 20:29:06 +08:00
DHR60 e2f4292321 ReactiveUI 26 (#10249)
* ReactiveUI 25

* Fix

* Up

* ReactiveUI 26
2026-11-01 20:00:02 +08: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
2dust 7d6a967c18 up 7.25.4 2026-09-30 14:38:34 +08:00
2dust 6c2edcb382 Revert "Bump Avalonia.Desktop from 12.1.2 to 12.1.3 (#10216)"
This reverts commit e1cb99cd6e.
2026-09-30 14:34:07 +08:00
2dust 086c991464 up 7.25.3 2026-09-30 09:55:08 +08:00
Alice Apate a9da04acc2 Add support for TLS1.3-only subscription URL for macOS (#10256)
* add support for TLS1.3-only subscription url for macOS

* Update v2rayN.Desktop.csproj

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-30 09:54:26 +08:00
2dust e0c790eeb2 Tune speedtest parallelism
Set `MaxDegreeOfParallelism` explicitly to the current selection size in three speed test parallel sections so task fan-out matches the workload. Also wrapped socket connect timing in a catch block that ignores connection exceptions, preventing a failed connect from aborting the flow while still completing cleanup.
https://github.com/2dust/v2rayN/issues/10246
2026-09-28 10:28:32 +08:00
2dust d433aa3b04 Fix
https://github.com/2dust/v2rayN/issues/10236
2026-09-26 11:43:22 +08:00
dependabot[bot] 91a7ed65d5 Bump ReactiveUI from 24.2.0 to 24.3.0 (#10231)
---
updated-dependencies:
- dependency-name: ReactiveUI
  dependency-version: 24.3.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-09-26 11:12:07 +08:00
Miheichev Aleksandr Sergeevich 984e685ab3 chore(deps): update Downloader to 5.9.8 (#10222)
Downloader 5.9.6 -> 5.9.8

5.9.7 adds RemoteFileInfo.ContentType. 5.9.8 fixes stop/dispose races:
a cancelled multi-chunk download could report a NullReferenceException
as its error, and a Dispose racing the completion signal could swallow
DownloadFileCompleted. Nothing DownloaderHelper calls changed its API.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:11:20 +08:00
DHR60 29f99ceacc Fix speed test (#10219) 2026-09-26 11:10:30 +08:00
DHR60 93d8174dbe Fix async (#10218)
* Fix async

* Fix async for statistics

* Try fix pac async

* Fix
2026-09-26 11:08:36 +08:00
DHR60 fa1e201c76 Fix sing-box dns (#10234) 2026-09-26 10:39:29 +08:00
dependabot[bot] e1cb99cd6e Bump Avalonia.Desktop from 12.1.2 to 12.1.3 (#10216)
---
updated-dependencies:
- dependency-name: Avalonia.Desktop
  dependency-version: 12.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 11:03:05 +08:00
2dust 28139853a8 up 7.25.2
https://github.com/2dust/v2rayN/commit/5d9718a0bb93fe4bfa6d8a9a8f985e4f16642ba1
2026-09-20 10:31:00 +08:00
2dust 5d9718a0bb Fix (#10203)
up 7.25.2
2026-09-20 10:17:59 +08:00
2dust 740729c847 Raise speed test concurrency minimum 2026-09-20 10:11:12 +08:00
2dust 13cd2ef80d Update Directory.Packages.props 2026-09-20 09:43:16 +08:00
DHR60 be3566df2f Add finalMask parsing to Wireguard configuration handling (#10201) 2026-09-19 17:38:51 +08:00
DHR60 6e9ec6cbee Fix (#10200) 2026-09-19 14:49:01 +08:00
DHR60 e6e95e00d7 Fix udptest (#10199)
* Fix

* Reformat

* Fix tcp read

* Fix verify
2026-09-19 14:42:51 +08:00
dependabot[bot] 2fbe81041b Bump TUnit from 1.66.27 to 1.68.4 (#10187)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.68.4
  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-09-19 10:55:48 +08:00
sdhfsl 05901b5673 fix(security): guard subscription/message regex filters against ReDoS (#10185)
Filter patterns (subscription subFilter, policy-group Filter, message
MsgFilter) accept arbitrary user input while the tested text (remarks
from subscriptions, log lines) is attacker-influenced. Regex.IsMatch
without timeout hangs on evil patterns like (a+)+$ - a malicious
subscription can freeze the UI/log pipeline on every update.

Add Utils.IsRegexMatch with a 2s timeout; fail open (match) with a log
so no node or message is silently dropped. Apply to all four call
sites.

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
2026-09-19 10:54:23 +08:00
Delyan Lev. Neimand bdd0ee1620 Resx: fix English typo (#10164)
Fix English: Not Support -> Not Supported
2026-09-19 10:42:44 +08:00
Miheichev Aleksandr Sergeevich 00d1001def i18n(ru): translate new strings for Mux, request headers and updates (#10160)
Add the 10 Russian strings missing from ResUI.ru.resx: the
update-via-proxy toggle in Check Update, the WireGuard DNS label, the
Xray Mux settings row and its concurrency, XUDP concurrency and
UDP/443 (QUIC) handling fields, the subscription HTTP headers label,
tip and validation message, and the Process column in Connections.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 10:42:05 +08:00
DHR60 21bafe52e0 Optimize download and speedtest (#10147)
* Optimize RunMixedTestAsync

* Use stopCts instead of exitLoopKey

* Refactor
2026-09-19 10:41:40 +08:00
DHR60 cef40d38ee Optimize ViewModel activation lifecycle (#10146)
* Optimize ViewModel activation lifecycle

* Fix

* Fix

* Fix

* Set period to 1s
2026-09-12 17:48:27 +08:00
2dust 4f5a6be12d Update build-windows-x86.yml 2026-09-11 20:06:06 +08:00
DHR60 7674d7e472 Delete redundant fields (#10144) 2026-09-10 13:52:28 +08:00
2dust 77c462ec7b up 7.25.1 2026-09-10 09:43:13 +08:00
2dust 526cd6834d Show language names in theme settings 2026-09-10 09:40:16 +08:00
Jamal Ali 7be23d7ebc Add Azerbaijani (az) UI localization (#10141) 2026-09-10 09:18:00 +08:00
tt2563 d952c180c9 Update ResUI.zh-Hant.resx (#10139)
Update Traditional Chinese translation
2026-09-10 09:15:00 +08:00
2dust 80fa6575a3 Bug fix 2026-09-09 14:33:31 +08:00
jryx0 f05e44d7d9 优化策略组选择 (#10136)
Co-authored-by: 张皓 <hao.zhang@gmail.com>
2026-09-09 14:30:21 +08:00
稔君 b10c16a94a Add custom HTTP headers for subscription updates (#10118)
* Add custom HTTP headers for subscription updates

* Res

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-09 14:24:05 +08:00
DHR60 143ada1358 Remove vnext (#10132) 2026-09-09 13:59:52 +08:00
2dust d8c7bd9d02 Bug fix
https://github.com/2dust/v2rayN/issues/10107
2026-09-09 13:55:31 +08:00
dependabot[bot] 85666af36a Bump ReactiveUI.Avalonia from 12.1.1 to 12.1.2 (#10133)
---
updated-dependencies:
- dependency-name: ReactiveUI.Avalonia
  dependency-version: 12.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 09:22:47 +08:00
DHR60 7286c67a78 Display more information for Connections (#10116)
* Display sniff host

* Display process path
2026-09-09 09:22:30 +08:00
2dust 8b8e579bc8 Bug fix
https://github.com/2dust/v2rayN/issues/10113
2026-09-08 20:32:26 +08:00
dependabot[bot] ed4dd7f6d9 Bump TUnit from 1.65.68 to 1.66.27 (#10127)
---
updated-dependencies:
- dependency-name: TUnit
  dependency-version: 1.66.27
  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-09-08 19:59:09 +08:00
CrayzyCray bb62c9eda5 Enable auto decompression for string downloads (#10122) 2026-09-08 19:58:55 +08:00
2dust 3d50d9f82c Use profile IDs for server move operations
Refactor `ConfigHandler.MoveServer` to accept a `List<string>` of profile `IndexId`s instead of full `ProfileItem` objects. In `ProfilesViewModel`, remove the duplicated `_lstProfile` cache and build move lists directly from `ProfileItems`, so move and drag-reorder actions use the live UI order and avoid stale profile snapshots.
2026-09-06 10:31:45 +08:00
345 changed files with 34955 additions and 2012 deletions
+923
View File
@@ -0,0 +1,923 @@
name: build v2rayN.WebAPI
on:
workflow_call:
inputs:
release_tag:
required: false
type: string
default: ''
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag (for example 7.x.y). Empty builds a development API package.'
required: false
default: ''
type: string
pull_request:
branches: [master]
paths:
- 'v2rayN/v2rayN.WebAPI/**'
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'v2rayN/v2rayN.slnx'
- 'global.json'
- '.github/workflows/build-WebAPI.yml'
- '.github/workflows/release-WebAPI.yml'
- '.github/workflows/build-all.yml'
- '.github/workflows/upload-sign.yml'
push:
branches: [master]
paths:
- 'v2rayN/v2rayN.WebAPI/**'
- 'v2rayN/ServiceLib/**'
- 'v2rayN/ServiceLib.Tests/**'
- 'v2rayN/Directory.Build.*'
- 'v2rayN/Directory.Packages.props'
- 'v2rayN/v2rayN.slnx'
- 'global.json'
- '.github/workflows/build-WebAPI.yml'
- '.github/workflows/release-WebAPI.yml'
- '.github/workflows/build-all.yml'
- '.github/workflows/upload-sign.yml'
permissions:
contents: read
jobs:
identity:
name: Resolve API build identity
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
release: ${{ steps.resolve.outputs.release }}
build_date: ${{ steps.resolve.outputs.build_date }}
steps:
- name: Resolve release identity
id: resolve
shell: bash
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
set -euo pipefail
build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$RELEASE_TAG" ]]; then
{
echo "version=0.0.0-dev"
echo "tag="
echo "release=false"
echo "build_date=$build_date"
} >> "$GITHUB_OUTPUT"
else
[[ "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 1
}
{
echo "version=$RELEASE_TAG"
echo "tag=$RELEASE_TAG"
echo "release=true"
echo "build_date=$build_date"
} >> "$GITHUB_OUTPUT"
fi
verify:
name: Backend, ServiceLib, package smoke
runs-on: ubuntu-24.04
env:
TUNIT_MAX_PARALLEL_TESTS: 1
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- name: Verify API build, backend and ServiceLib tests, native startup and package boundaries
run: bash v2rayN/v2rayN.WebAPI/Scripts/verify.sh "$RUNNER_TEMP/v2rayN.WebAPI-verify"
- name: Check patch whitespace
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
run: |
set -euo pipefail
base_sha="$BASE_SHA"
if [[ -z "$base_sha" ]]; then
base_sha="$(git rev-parse HEAD^)"
fi
if [[ "$base_sha" =~ ^0+$ ]]; then
empty_tree="$(git hash-object -t tree -w /dev/null)"
git diff --check "$empty_tree" HEAD
else
git diff --check "$base_sha...HEAD"
fi
windows:
name: Windows WebAPI build and runtime smoke
needs: identity
runs-on: windows-latest
env:
TUNIT_MAX_PARALLEL_TESTS: 1
WEB_VERSION: ${{ needs.identity.outputs.version }}
WEB_COMMIT: ${{ github.sha }}
WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
WEB_REPOSITORY: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Restore WebAPI
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj
- name: Restore WebAPI.Tests
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj
- name: Restore ServiceLib.Tests
shell: pwsh
run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
- name: Build WebAPI
shell: pwsh
run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore
- name: Run WebAPI.Tests
shell: pwsh
run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore
- name: Run ServiceLib.Tests
shell: pwsh
run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore
- name: Publish win-x64 self-contained single-file WebAPI
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=$env:WEB_VERSION `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = $env:WEB_VERSION
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-x64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Verify single-file publish output
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$executable = Join-Path $publishDirectory 'v2rayN.WebAPI.exe'
if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) {
throw "dotnet publish did not produce $executable"
}
if ((Get-Item -LiteralPath $executable).Length -eq 0) {
throw "Published executable is empty: $executable"
}
$looseDlls = @(Get-ChildItem -LiteralPath $publishDirectory -Filter '*.dll' -File -Recurse)
if ($looseDlls.Count -gt 0) {
throw "Single-file publish left loose DLLs: $($looseDlls.FullName -join ', ')"
}
- name: Smoke-test first startup, health endpoint and graceful shutdown
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory
- name: Publish previous Windows fixture for update regression
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=0.0.1-regression `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = '0.0.1-regression'
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-x64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Add the matching Windows Core bundle
if: inputs.release_tag != ''
shell: pwsh
run: |
$bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64.zip'
$expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-core'
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$previousDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous'
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-64.zip' -OutFile $bundle
Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force
$sourceBin = Join-Path $expanded 'v2rayN-windows-64\bin'
if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) {
throw "Windows Core bundle is missing $sourceBin"
}
foreach ($directory in @($publishDirectory, $previousDirectory)) {
$destinationBin = Join-Path $directory 'bin'
New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null
Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force
}
foreach ($directory in @($publishDirectory, $previousDirectory)) {
$checkBin = Join-Path $directory 'bin'
foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) {
$required = Join-Path $checkBin $relative
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows Core asset: $required" }
}
}
$candidateBin = Join-Path $publishDirectory 'bin'
& (Join-Path $candidateBin 'xray\xray.exe') -version
if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $candidateBin 'sing_box\sing-box.exe') version
if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $candidateBin 'mihomo\mihomo.exe') -v
if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" }
- name: Smoke-test Windows backup restore handoff and Core intent recovery
if: inputs.release_tag != ''
shell: pwsh
run: |
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 `
-Mode restore `
-PublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64')
- name: Smoke-test Windows self-update replacement and rollback
if: inputs.release_tag != ''
shell: pwsh
run: |
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 `
-Mode update `
-PreviousPublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-previous') `
-CandidatePublishDirectory (Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64')
- name: Package and smoke-test Windows full-install and app-only ZIPs
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64'
$dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-x64-dist'
python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-x64 --publish $publishDirectory --dist $dist
$fullZip = Join-Path $dist 'v2rayN-windows-64-WebAPI.zip'
$appOnlyZip = Join-Path $dist 'v2rayN-windows-64-WebAPI-update.zip'
if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) {
throw 'Windows release ZIPs were not created.'
}
$extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-64-WebAPI-smoke'
Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted
"Windows full-install ZIP: $fullZip"
"Windows app-only update ZIP: $appOnlyZip"
- uses: actions/upload-artifact@v7
if: inputs.release_tag != ''
with:
name: v2rayN.WebAPI-win-x64
path: ${{ runner.temp }}/v2rayN.WebAPI-win-x64-dist/*.zip
if-no-files-found: error
# The self-update replacement smoke stays on win-x64: the updater logic is RID-generic
# and the per-RID PE/ELF checks are covered by unit tests. This job proves the win-arm64
# build, first startup, health endpoint and graceful shutdown on native ARM64 Windows.
windows-arm64:
name: Windows ARM64 WebAPI build and runtime smoke
needs: identity
runs-on: windows-11-arm
env:
TUNIT_MAX_PARALLEL_TESTS: 1
WEB_VERSION: ${{ needs.identity.outputs.version }}
WEB_COMMIT: ${{ github.sha }}
WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
WEB_REPOSITORY: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Restore WebAPI
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj
- name: Restore WebAPI.Tests
shell: pwsh
run: dotnet restore v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj
- name: Restore ServiceLib.Tests
shell: pwsh
run: dotnet restore v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj
- name: Build WebAPI
shell: pwsh
run: dotnet build v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj --configuration Release --no-restore
- name: Run WebAPI.Tests
shell: pwsh
run: dotnet test --project v2rayN/v2rayN.WebAPI/Tests/v2rayN.WebAPI.Tests.csproj --configuration Release --no-restore
- name: Run ServiceLib.Tests
shell: pwsh
run: dotnet test --project v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj --configuration Release --no-restore
- name: Publish win-arm64 self-contained single-file WebAPI
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
dotnet publish v2rayN/v2rayN.WebAPI/v2rayN.WebAPI.csproj `
--configuration Release `
--runtime win-arm64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:WebVersion=$env:WEB_VERSION `
-p:WebCommit=$env:WEB_COMMIT `
-p:WebBuildDate=$env:WEB_BUILD_DATE `
-p:WebRepository=$env:WEB_REPOSITORY `
--output $publishDirectory
$identity = [ordered]@{
product = 'v2rayN.WebAPI'
version = $env:WEB_VERSION
commit = $env:WEB_COMMIT
buildDate = $env:WEB_BUILD_DATE
rid = 'win-arm64'
}
$json = $identity | ConvertTo-Json -Compress
[IO.File]::WriteAllText((Join-Path $publishDirectory 'v2rayN.WebAPI.build.json'), $json, [Text.UTF8Encoding]::new($false))
- name: Smoke-test first startup, health endpoint and graceful shutdown
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $publishDirectory
- name: Add the matching Windows ARM64 Core bundle
if: inputs.release_tag != ''
shell: pwsh
run: |
$bundle = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64.zip'
$expanded = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-core'
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-windows-arm64.zip' -OutFile $bundle
Expand-Archive -LiteralPath $bundle -DestinationPath $expanded -Force
$sourceBin = Join-Path $expanded 'v2rayN-windows-arm64\bin'
if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) {
throw "Windows ARM64 Core bundle is missing $sourceBin"
}
$destinationBin = Join-Path $publishDirectory 'bin'
New-Item -ItemType Directory -Path $destinationBin -Force | Out-Null
Copy-Item -Path (Join-Path $sourceBin '*') -Destination $destinationBin -Recurse -Force
foreach ($relative in @('xray\xray.exe','sing_box\sing-box.exe','mihomo\mihomo.exe','geoip.dat','geosite.dat','geoip.metadb','Country.mmdb')) {
$required = Join-Path $destinationBin $relative
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing Windows ARM64 Core asset: $required" }
}
& (Join-Path $destinationBin 'xray\xray.exe') -version
if ($LASTEXITCODE -ne 0) { throw "xray.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $destinationBin 'sing_box\sing-box.exe') version
if ($LASTEXITCODE -ne 0) { throw "sing-box.exe smoke failed: $LASTEXITCODE" }
& (Join-Path $destinationBin 'mihomo\mihomo.exe') -v
if ($LASTEXITCODE -ne 0) { throw "mihomo.exe smoke failed: $LASTEXITCODE" }
- name: Package Windows ARM64 full-install and app-only ZIPs
if: inputs.release_tag != ''
shell: pwsh
run: |
$publishDirectory = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64'
$dist = Join-Path $env:RUNNER_TEMP 'v2rayN.WebAPI-win-arm64-dist'
python v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid win-arm64 --publish $publishDirectory --dist $dist
$fullZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI.zip'
$appOnlyZip = Join-Path $dist 'v2rayN-windows-arm64-WebAPI-update.zip'
if (-not (Test-Path -LiteralPath $fullZip -PathType Leaf) -or -not (Test-Path -LiteralPath $appOnlyZip -PathType Leaf)) {
throw 'Windows ARM64 release ZIPs were not created.'
}
$extracted = Join-Path $env:RUNNER_TEMP 'v2rayN-windows-arm64-WebAPI-smoke'
Expand-Archive -LiteralPath $fullZip -DestinationPath $extracted -Force
& ./v2rayN/v2rayN.WebAPI/Tests/Integration/Test-Windows.ps1 -Mode startup -PublishDirectory $extracted
"Windows ARM64 full-install ZIP: $fullZip"
"Windows ARM64 app-only update ZIP: $appOnlyZip"
- uses: actions/upload-artifact@v7
if: inputs.release_tag != ''
with:
name: v2rayN.WebAPI-win-arm64
path: ${{ runner.temp }}/v2rayN.WebAPI-win-arm64-dist/*.zip
if-no-files-found: error
native:
name: Native API build (${{ matrix.rid }})
needs: identity
strategy:
fail-fast: false
matrix:
# Development builds keep x64 coverage; releases also validate ARM64.
# Only tested Linux RIDs are enabled; native RID/runner pairs are independent
# of Desktop workflows and the container platform matrix.
include: >-
${{ fromJSON(needs.identity.outputs.release == 'true'
&& '[{"rid":"linux-x64","runner":"ubuntu-24.04"},{"rid":"linux-arm64","runner":"ubuntu-24.04-arm"}]'
|| '[{"rid":"linux-x64","runner":"ubuntu-24.04"}]') }}
runs-on: ${{ matrix.runner }}
env:
RID: ${{ matrix.rid }}
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'
- name: Publish native API
run: bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID"
- name: Verify native executable
run: test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI"
- name: Add the matching Linux Core bundle
if: needs.identity.outputs.release == 'true'
run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin"
- name: Package API install and self-update ZIPs
if: needs.identity.outputs.release == 'true'
run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist
- name: Smoke-test bundled Cores
if: needs.identity.outputs.release == 'true'
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/bin/xray/xray" -version
"$publish/bin/sing_box/sing-box" version
"$publish/bin/mihomo/mihomo" -v
- uses: actions/upload-artifact@v7
if: needs.identity.outputs.release == 'true'
with:
name: v2rayN.WebAPI-${{ matrix.rid }}
path: dist/*.zip
if-no-files-found: error
# linux-riscv64 runs natively on the RISE RISC-V runners with the community
# xujiegb/dotnet-riscv SDK; single-file publish is verified to work there.
native-riscv64:
name: Native API build (linux-riscv64)
needs: identity
if: needs.identity.outputs.release == 'true'
runs-on: ubuntu-24.04-riscv
container: debian:13
env:
RID: linux-riscv64
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
steps:
- name: Prepare tools (Debian)
shell: bash
run: |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || \
apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu72
- name: Checkout repo
shell: bash
env: { GITHUB_TOKEN: "${{ github.token }}" }
run: |
set -euo pipefail
rm -rf ./*
git init .
git config --global --add safe.directory "$PWD"
git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install community .NET SDK (linux-riscv64)
shell: bash
run: |
set -euo pipefail
SDK=dotnet-sdk-10.0.111-linux-riscv64.tar.gz
curl -fL "https://github.com/xujiegb/dotnet-riscv/releases/download/10.0.111/$SDK" -o /tmp/$SDK
mkdir -p "$HOME/.dotnet"
tar -C "$HOME/.dotnet" -xzf /tmp/$SDK
echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV"
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$PATH"
dotnet --info
- name: Publish native API
shell: bash
run: |
set -euo pipefail
export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$PATH"
bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh "$RID"
test -x "v2rayN/v2rayN.WebAPI/publish/$RID/v2rayN.WebAPI"
- name: Add the matching Linux Core bundle
shell: bash
run: sh v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "v2rayN/v2rayN.WebAPI/publish/$RID/bin"
- name: Package API install and self-update ZIPs
shell: bash
run: python3 v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "v2rayN/v2rayN.WebAPI/publish/$RID" --dist dist
- name: Smoke-test bundled Cores
shell: bash
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/bin/xray/xray" -version
"$publish/bin/sing_box/sing-box" version
"$publish/bin/mihomo/mihomo" -v
- name: Smoke-test the published API (native riscv64)
shell: bash
run: |
set -euo pipefail
publish="v2rayN/v2rayN.WebAPI/publish/$RID"
"$publish/v2rayN.WebAPI" --help | head -n 5
export V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef
export ASPNETCORE_URLS=http://127.0.0.1:5080
cd "$publish"
./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 &
app_pid=$!
echo "$app_pid" > /tmp/smoke.pid
echo "riscv64 API smoke application PID: $app_pid"
ok=0
for i in $(seq 1 90); do
if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
if ! kill -0 "$app_pid" 2>/dev/null; then break; fi
sleep 1
done
if [ "$ok" != 1 ]; then
echo 'riscv64 API smoke did not become healthy'
tail -n 60 /tmp/smoke.log || true
kill -TERM "$app_pid" 2>/dev/null || true
sleep 1
kill -KILL "$app_pid" 2>/dev/null || true
exit 1
fi
kill -TERM "$app_pid"
stopped=0
for i in $(seq 1 30); do
if ! kill -0 "$app_pid" 2>/dev/null; then stopped=1; break; fi
sleep 1
done
if [ "$stopped" != 1 ]; then
echo 'riscv64 API smoke did not exit within 30s after SIGTERM'
tail -n 60 /tmp/smoke.log || true
kill -KILL "$app_pid" 2>/dev/null || true
exit 1
fi
set +e
wait "$app_pid"
exit_code=$?
set -e
if [ "$exit_code" -ne 0 ]; then
echo "riscv64 API smoke exited with status $exit_code after SIGTERM"
tail -n 60 /tmp/smoke.log || true
exit 1
fi
echo 'linux-riscv64 native API smoke OK'
- uses: actions/upload-artifact@v7
with:
name: v2rayN.WebAPI-linux-riscv64
path: dist/*.zip
if-no-files-found: error
# linux-loongarch64 publishes inside the upstream QEMU loong64 VM with the official
# Loongson .NET SDK; the VM runs the native smoke before the host packages the ZIPs.
native-loong64:
name: Native API build (linux-loongarch64)
needs: identity
if: needs.identity.outputs.release == 'true'
runs-on: ubuntu-26.04-arm
env:
RID: linux-loongarch64
V2RAYN_WEB_VERSION: ${{ needs.identity.outputs.version }}
V2RAYN_WEB_REPOSITORY: ${{ github.repository }}
V2RAYN_WEB_COMMIT: ${{ github.sha }}
V2RAYN_WEB_BUILD_DATE: ${{ needs.identity.outputs.build_date }}
QCOW2_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/debian13-loong64.qcow2
EFI_CODE_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-code.fd
EFI_VARS_URL: https://github.com/xujiegb/debian-loong64-qcow2/releases/download/13.5/edk2-loongarch64-vars.fd
QCOW2_IMAGE: debian13-loong64.qcow2
EFI_CODE: edk2-loongarch64-code.fd
EFI_VARS: edk2-loongarch64-vars.fd
QEMU_VERSION: 10.2.4
steps:
- name: Prepare host tools
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y git rsync qemu-utils qemu-system-common expect wget curl ca-certificates libfdt1 libglib2.0-0 libpixman-1-0 libslirp0 virtiofsd
sudo ln -sf /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
virtiofsd --version || virtiofsd --help | head
- name: Host clone repo
shell: bash
env: { GITHUB_TOKEN: "${{ github.token }}" }
run: |
set -euo pipefail
rm -rf repo
git init repo
git -C repo config --global --add safe.directory "$PWD/repo" || true
git -C repo remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git -C repo fetch --depth=1 origin "${GITHUB_SHA}"
git -C repo checkout FETCH_HEAD
- name: Download QEMU prebuild
run: |
set -euo pipefail
wget -O qemu-linux-arm64-v8.2.tar.gz "https://github.com/xujiegb/qemu-linux-prebuild/releases/download/${QEMU_VERSION}/qemu-linux-arm64-v8.2.tar.gz"
tar -xzf qemu-linux-arm64-v8.2.tar.gz
mkdir -p "$HOME/qemu-install"
rsync -a qemu-linux-arm64-v8.2/ "$HOME/qemu-install/"
"$HOME/qemu-install/bin/qemu-system-loongarch64" --version
- name: Download loong64 qcow2 and EFI firmware
shell: bash
run: |
set -euo pipefail
wget -O "$QCOW2_IMAGE" "$QCOW2_URL"
wget -O "$EFI_CODE" "$EFI_CODE_URL"
wget -O "$EFI_VARS" "$EFI_VARS_URL"
qemu-img info "$QCOW2_IMAGE"
- name: Stage the VM build script and the matching Core bundle
shell: bash
run: |
set -euo pipefail
cat > webapi-loong.env <<EOF
WEB_VERSION=${V2RAYN_WEB_VERSION}
WEB_REPOSITORY=${V2RAYN_WEB_REPOSITORY}
WEB_COMMIT=${V2RAYN_WEB_COMMIT}
WEB_BUILD_DATE=${V2RAYN_WEB_BUILD_DATE}
EOF
cat > webapi-loong-run.sh <<'SCRIPT'
#!/usr/bin/env bash
set +e
set -o pipefail
source /workspace/webapi-loong.env
export DOTNET_ROOT=/root/.dotnet
export PATH="$DOTNET_ROOT:$PATH"
mkdir -p /root/.dotnet
SDK=dotnet-sdk-10.0.111-linux-loongarch64.tar.gz
curl -fL "https://github.com/loongson/dotnet/releases/download/v10.0.111-loongarch64/$SDK" -o /tmp/$SDK || exit 9
tar -C /root/.dotnet -xzf /tmp/$SDK || exit 9
dotnet --info >/dev/null 2>&1 || exit 9
cd /root/v2rayN-src || exit 9
mkdir -p /workspace/publish/linux-loongarch64
V2RAYN_WEB_VERSION="$WEB_VERSION" V2RAYN_WEB_REPOSITORY="$WEB_REPOSITORY" V2RAYN_WEB_COMMIT="$WEB_COMMIT" V2RAYN_WEB_BUILD_DATE="$WEB_BUILD_DATE" \
bash v2rayN/v2rayN.WebAPI/Scripts/publish-native.sh linux-loongarch64 /workspace/publish/linux-loongarch64 2>&1 | tee /tmp/publish.log
rc=$?
if [ $rc -eq 0 ]; then
mkdir -p /workspace/publish/linux-loongarch64/bin
cp -a /workspace/core-bundle/. /workspace/publish/linux-loongarch64/bin/
# Smoke a VM-local copy: the shared publish tree must stay exactly the packaging
# input, without runtime lock/data files owned by the VM user.
rm -rf /root/websmoke
cp -a /workspace/publish/linux-loongarch64 /root/websmoke
cd /root/websmoke || exit 9
./bin/xray/xray -version >/dev/null || rc=9
./bin/sing_box/sing-box version >/dev/null || rc=9
./bin/mihomo/mihomo -v >/dev/null || rc=9
./v2rayN.WebAPI --help >/dev/null || rc=9
V2RAYN_WEB_API_KEY=ci-smoke-key-0123456789abcdef ASPNETCORE_URLS=http://127.0.0.1:5080 ./v2rayN.WebAPI --foreground --no-open > /tmp/smoke.log 2>&1 &
APP=$!
echo "$APP" > /tmp/smoke.pid
ok=0
for i in $(seq 1 90); do
if curl --noproxy '*' -fsS http://127.0.0.1:5080/api/health >/dev/null 2>&1; then ok=1; break; fi
if ! kill -0 "$APP" 2>/dev/null; then break; fi
sleep 1
done
if [ "$ok" != 1 ]; then
echo 'loongarch64 API smoke did not become healthy'
tail -n 60 /tmp/smoke.log
kill -KILL "$APP" 2>/dev/null || true
rc=9
fi
if [ "$rc" = 0 ]; then
kill -TERM "$APP"
exited=0
for i in $(seq 1 60); do
if ! kill -0 "$APP" 2>/dev/null; then exited=1; break; fi
sleep 1
done
if [ "$exited" != 1 ]; then
echo 'loongarch64 API smoke did not exit within 60s after SIGTERM'
tail -n 60 /tmp/smoke.log
kill -KILL "$APP" 2>/dev/null || true
rc=9
else
wait "$APP"
app_rc=$?
if [ "$app_rc" != 0 ]; then
echo "loongarch64 API smoke exited with status $app_rc after SIGTERM"
tail -n 60 /tmp/smoke.log
rc=9
fi
fi
fi
fi
if [ "$rc" != 0 ]; then echo '=== publish.log ==='; tail -n 120 /tmp/publish.log 2>/dev/null; fi
echo "__WEBAPI_DONE__$rc"
SCRIPT
bash repo/v2rayN/v2rayN.WebAPI/Scripts/fetch-core-bundle.sh "$RID" "$GITHUB_WORKSPACE/core-bundle"
ls -la "$GITHUB_WORKSPACE/core-bundle" | head -n 15
- name: Build WebAPI loongarch64 in the VM (publish + native smoke)
shell: bash
timeout-minutes: 180
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/dist"
export VIRTIOFSD_SOCKET="$RUNNER_TEMP/virtiofs-webapi-loongarch64.sock"
rm -f "$VIRTIOFSD_SOCKET"
sudo virtiofsd --socket-path="$VIRTIOFSD_SOCKET" --shared-dir="$GITHUB_WORKSPACE" --cache=auto &
VIRTIOFSD_PID=$!
trap 'sudo kill "$VIRTIOFSD_PID" 2>/dev/null || true; rm -f "$VIRTIOFSD_SOCKET"' EXIT
for i in {1..100}; do
[[ -S "$VIRTIOFSD_SOCKET" ]] && break
sleep 0.1
done
[[ -S "$VIRTIOFSD_SOCKET" ]] || { echo "virtiofsd socket not created"; exit 1; }
sudo chown "$USER:$(id -gn)" "$VIRTIOFSD_SOCKET" || true
sudo chmod 660 "$VIRTIOFSD_SOCKET" || true
expect <<'EOF'
log_user 1
set timeout 10800
set qemu_bin "$env(HOME)/qemu-install/bin/qemu-system-loongarch64"
set qemu_rom_dir "$env(HOME)/qemu-install/share/qemu"
set virtiofs_socket $env(VIRTIOFSD_SOCKET)
set qcow2 $env(QCOW2_IMAGE)
set efi_code $env(EFI_CODE)
set efi_vars $env(EFI_VARS)
proc wait_prompt {} {
expect {
-re "__CI_PROMPT__# " {}
timeout { exit 1 }
eof { exit 1 }
}
}
proc run_cmd {cmd} {
send -- "$cmd\r"
wait_prompt
}
spawn $qemu_bin \
-L $qemu_rom_dir \
-machine virt \
-accel tcg,thread=multi,tb-size=2048 \
-cpu la464 \
-m 9216 \
-object memory-backend-memfd,id=mem0,size=9216M,share=on \
-numa node,memdev=mem0 \
-smp 4 \
-drive if=pflash,format=raw,unit=0,file=$efi_code,readonly=on \
-drive if=pflash,format=raw,unit=1,file=$efi_vars \
-device virtio-blk-pci,drive=hd0,bootindex=1 \
-drive if=none,media=disk,id=hd0,file=$qcow2,format=qcow2 \
-netdev user,id=net0 \
-device virtio-net-pci-non-transitional,netdev=net0 \
-chardev socket,id=charfs0,path=$virtiofs_socket \
-device vhost-user-fs-pci,chardev=charfs0,tag=workspace \
-display none \
-serial stdio \
-monitor none
expect -re "login:|debian login:"
send -- "root\r"
expect -re "Password:|密码:|密码:"
send -- "password\r"
expect {
-re "# " {}
timeout { exit 1 }
eof { exit 1 }
}
send -- "export TERM=dumb; export PS1='__CI_PROMPT__# '\r"
wait_prompt
run_cmd "mkdir -p /workspace"
run_cmd "mount -t virtiofs workspace /workspace"
run_cmd "IFACE=\$(ip -o link show | awk -F': ' '\$2 != \"lo\" {print \$2; exit}') ; ip link set \$IFACE up || true"
run_cmd "dhclient \$IFACE || true"
run_cmd "printf 'nameserver 10.0.2.3\nnameserver 1.1.1.1\n' >/etc/resolv.conf"
run_cmd "apt-get update || apt-get update || apt-get update"
run_cmd "DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1 libicu76 || DEBIAN_FRONTEND=noninteractive apt-get install -y sudo git rsync findutils tar gzip unzip which curl jq wget file ca-certificates python3 libc6-dev libgcc-s1 libstdc++6 zlib1g libatomic1"
run_cmd "rm -rf /root/v2rayN-src"
run_cmd "rsync -a /workspace/repo/ /root/v2rayN-src/"
run_cmd "cd /root/v2rayN-src && git config --global --add safe.directory /root/v2rayN-src"
send -- "bash /workspace/webapi-loong-run.sh\r"
expect {
-re "__WEBAPI_DONE__0" { send -- "poweroff\r" }
-re "__WEBAPI_DONE__" { exit 1 }
timeout { exit 1 }
eof { exit 1 }
}
EOF
- name: Verify the loongarch64 publish tree
shell: bash
run: |
set -euo pipefail
publish="$GITHUB_WORKSPACE/publish/linux-loongarch64"
test -x "$publish/v2rayN.WebAPI"
test -f "$publish/v2rayN.WebAPI.build.json"
unreadable="$(find "$publish" ! -readable -print -quit)"
if [ -n "$unreadable" ]; then
echo "The loongarch64 publish tree contains unreadable entries:"
find "$publish" ! -readable -print | head -n 20
exit 1
fi
echo "loongarch64 publish tree is complete and readable"
- name: Package API install and self-update ZIPs
shell: bash
run: |
set -euo pipefail
python3 repo/v2rayN/v2rayN.WebAPI/Scripts/web-release-assets.py package --rid "$RID" --publish "$GITHUB_WORKSPACE/publish/linux-loongarch64" --dist "$GITHUB_WORKSPACE/dist"
ls -la "$GITHUB_WORKSPACE/dist"
- uses: actions/upload-artifact@v7
with:
name: v2rayN.WebAPI-linux-loongarch64
path: dist/*.zip
if-no-files-found: error
container:
name: API Containerfile (${{ matrix.rid }})
needs: identity
strategy:
fail-fast: false
matrix:
# Development builds keep amd64 coverage; releases also validate ARM64.
include: >-
${{ fromJSON(needs.identity.outputs.release == 'true'
&& '[{"rid":"linux-x64","platform":"linux/amd64"},{"rid":"linux-arm64","platform":"linux/arm64"}]'
|| '[{"rid":"linux-x64","platform":"linux/amd64"}]') }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v4
- 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@v8
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:
+16
View File
@@ -29,6 +29,22 @@ jobs:
release_tag: ${{ inputs.release_tag }}
secrets: inherit
build-aot:
uses: ./.github/workflows/build.yml
with:
target: linux
isAot: true
release-aot-zip:
if: inputs.release_tag != ''
needs: build-aot
uses: ./.github/workflows/package-zip.yml
with:
target: linux
release_tag: ${{ inputs.release_tag }}
isAot: true
secrets: inherit
deb:
name: build and release deb x64 & arm64
if: (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
+16
View File
@@ -29,6 +29,22 @@ jobs:
release_tag: ${{ inputs.release_tag }}
secrets: inherit
build-aot:
uses: ./.github/workflows/build.yml
with:
target: macos
isAot: true
release-aot-zip:
if: inputs.release_tag != ''
needs: build-aot
uses: ./.github/workflows/package-zip.yml
with:
target: macos
release_tag: ${{ inputs.release_tag }}
isAot: true
secrets: inherit
dmg:
name: package and release macOS dmg
if: inputs.release_tag != ''
@@ -28,3 +28,19 @@ jobs:
target: windows-desktop
release_tag: ${{ inputs.release_tag }}
secrets: inherit
build-aot:
uses: ./.github/workflows/build.yml
with:
target: windows
isAot: true
release-aot-zip:
if: inputs.release_tag != ''
needs: build-aot
uses: ./.github/workflows/package-zip.yml
with:
target: windows-desktop
release_tag: ${{ inputs.release_tag }}
isAot: true
secrets: inherit
+1 -1
View File
@@ -146,7 +146,7 @@ jobs:
$xrayTag = "v$xrayVer"
$singTag = "v$singVer"
$xrayUrl = "https://github.com/autorepobot/Xray/releases/download/$xrayTag/Xray-windows-32.zip"
$xrayUrl = "https://github.com/XTLS/Xray-core/releases/download/$xrayTag/Xray-windows-32.zip"
$singUrl = "https://github.com/SagerNet/sing-box/releases/download/$singTag/sing-box-$singVer-windows-386.zip"
Write-Host "Bundled Xray version: $xrayVer"
+53 -9
View File
@@ -10,6 +10,10 @@ on:
required: false
type: string
default: './v2rayN.Desktop/v2rayN.Desktop.csproj'
isAot:
required: false
type: boolean
default: false
jobs:
build:
@@ -27,7 +31,6 @@ jobs:
)
}}
env:
Output: "${{ github.workspace }}/${{ matrix.arch }}"
RID: |-
${{
case(
@@ -41,6 +44,7 @@ jobs:
${{
case(
inputs.target == 'windows', '-p:EnableWindowsTargeting=true',
inputs.target == 'linux', (matrix.arch == 'arm64' && '-p:LinkerFlavor=lld -p:ObjCopyName=llvm-objcopy' || ''),
''
)
}}
@@ -71,25 +75,65 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.1xx
dotnet-version: 10.0.x
- name: Build v2rayN
- name: Install dependencies
shell: bash
working-directory: ./v2rayN
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o $Output
if: inputs.target == 'linux' && inputs.isAot
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev build-essential
- name: Install dependencies cross-arch
shell: bash
if: inputs.target == 'linux' && inputs.isAot && matrix.arch == 'arm64'
run: |
sudo apt-get update
sudo apt-get install -y lld llvm binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross
- name: Set AOT Environment Variable
shell: bash
run: |
if [ "${{ inputs.isAot }}" = "true" ]; then
echo "DOTNET_AOT_ARG=-p:IsAot=true" >> $GITHUB_ENV
else
echo "DOTNET_AOT_ARG=-p:IsAot=false" >> $GITHUB_ENV
fi
- name: Set OUTPUT Environment Variable
shell: bash
run: |
RAW_PATH="${{ github.workspace }}/${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}"
echo "OUTPUT=${RAW_PATH//\\//}" >> "$GITHUB_ENV"
- name: Build AmazTool
shell: bash
working-directory: ./v2rayN
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt -o $Output
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt ${{ env.DOTNET_AOT_ARG }} -o ${{ env.OUTPUT }}
- name: Build v2rayN WPF
shell: bash
if: inputs.project == './v2rayN/v2rayN.csproj'
working-directory: ./v2rayN
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o ${{ env.OUTPUT }}
- name: Build v2rayN Avalonia
shell: bash
if: inputs.project == './v2rayN.Desktop/v2rayN.Desktop.csproj'
working-directory: ./v2rayN
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt ${{ env.DOTNET_AOT_ARG }} -o ${{ env.OUTPUT }}
- name: Remove Debug Symbols
shell: bash
run: |
find ${{ matrix.arch }} -type f -name '*.pdb' -delete
find ${{ env.OUTPUT }} -type f -name '*.pdb' -delete
find ${{ env.OUTPUT }} -type f -name '*.debug' -delete
find ${{ env.OUTPUT }} -type f -name '*.dbg' -delete
find ${{ env.OUTPUT }} -type f -name '*.sym' -delete
find ${{ env.OUTPUT }} -name '*.dSYM' -exec rm -rf {} +
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.arch }}
path: ${{ matrix.arch }}
name: ${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
path: ${{ env.OUTPUT }}
+49 -10
View File
@@ -9,6 +9,10 @@ on:
target: # windows linux macos windows-desktop
required: true
type: string
isAot:
required: false
type: boolean
default: false
secrets:
GPG_PRIVATE_KEY:
required: true
@@ -43,27 +47,62 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Set package variables
id: package-vars
shell: bash
env:
INPUT_TARGET: ${{ inputs.target }}
INPUT_IS_AOT: ${{ inputs.isAot }}
run: |
set -euo pipefail
package_dir="v2rayN-${Target}-${Arch}"
package_file="${package_dir}.zip"
build_artifact_name="${{ matrix.arch }}"
rename_suffix=""
if [[ "$INPUT_IS_AOT" == "true" ]]; then
build_artifact_name="${build_artifact_name}-aot"
rename_suffix="-aot"
fi
if [[ "$INPUT_TARGET" == "windows-desktop" ]]; then
rename_suffix="-desktop${rename_suffix}"
fi
final_package_file="${package_dir}${rename_suffix}.zip"
{
echo "build_artifact_name=${build_artifact_name}"
echo "package_dir=${package_dir}"
echo "package_file=${package_file}"
echo "final_package_file=${final_package_file}"
echo "rename_suffix=${rename_suffix}"
} >> "$GITHUB_OUTPUT"
- name: Restore build artifacts
uses: actions/download-artifact@v8
with:
name: ${{ matrix.arch }}
path: v2rayN-${{ env.Target }}-${{ env.Arch }}
name: ${{ steps.package-vars.outputs.build_artifact_name }}
path: ${{ steps.package-vars.outputs.package_dir }}
- name: Get v2rayN-core-bin
run: wget -nv -O v2rayN-$Target-$Arch.zip "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-$Target-$Arch.zip"
run: |
wget -nv -O "${{ steps.package-vars.outputs.package_file }}" "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/v2rayN-$Target-$Arch.zip"
- name: Package zip archive
run: 7z a -tZip v2rayN-$Target-$Arch.zip v2rayN-$Target-$Arch -mx1
shell: bash
run: |
7z a -tZip "${{ steps.package-vars.outputs.package_file }}" "${{ steps.package-vars.outputs.package_dir }}" -mx1
- name: Rename windows-desktop
if: inputs.target == 'windows-desktop'
run: mv "v2rayN-$Target-$Arch.zip" "v2rayN-$Target-$Arch-desktop.zip"
- name: Rename package archive
if: steps.package-vars.outputs.rename_suffix != ''
shell: bash
run: |
mv "${{ steps.package-vars.outputs.package_file }}" \
"${{ steps.package-vars.outputs.package_dir }}${{ steps.package-vars.outputs.rename_suffix }}.zip"
- name: Upload zip artifact
uses: actions/upload-artifact@v7
with:
name: release-${{ inputs.target }}-${{ matrix.arch }}
path: v2rayN*.zip
name: release-${{ inputs.target }}-${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
path: ${{ steps.package-vars.outputs.final_package_file }}
if-no-files-found: error
release:
@@ -75,7 +114,7 @@ jobs:
uses: ./.github/workflows/upload-sign.yml
with:
release_tag: ${{ inputs.release_tag }}
artifact_name: release-${{ inputs.target }}-${{ matrix.arch }}
artifact_name: release-${{ inputs.target }}-${{ matrix.arch }}${{ inputs.isAot && '-aot' || '' }}
file_pattern: '*.zip'
secrets:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
+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"
+20 -5
View File
@@ -5,16 +5,31 @@
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Update="Resx\Resource.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resource.Designer.cs</LastGenOutput>
</EmbeddedResource>
<Compile Update="Resx\Resource.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resource.resx</DependentUpon>
</Compile>
<EmbeddedResource Update="Resx\Resource.resx">
<SubType>Designer</SubType>
<LastGenOutput>Resource.Designer.cs</LastGenOutput>
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resx\ResUI.zh-Hans.resx">
<SubType>Designer</SubType>
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resx\ResUI.zh-Hant.resx">
<SubType>Designer</SubType>
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
<Target Name="RemoveAotSatelliteOutputs" AfterTargets="ComputeFilesToPublish" Condition="'$(PublishAot)' == 'true'">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Contains('resources.dll'))" />
</ItemGroup>
</Target>
</Project>
+1 -1
View File
@@ -19,7 +19,7 @@ namespace AmazTool.Resx {
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
// (以 /str 作为命令选项),或重新生成 VS 项目。
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resource {
+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()
+11 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>7.25.0</Version>
<Version>7.25.6</Version>
</PropertyGroup>
<PropertyGroup>
@@ -29,4 +29,14 @@
<PublishSingleFile>true</PublishSingleFile>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<IsAot Condition="'$(IsAot)' == ''">false</IsAot>
</PropertyGroup>
<PropertyGroup Condition="'$(IsAot)' == 'true'">
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<DefineConstants>$(DefineConstants);V2RAYN_IS_AOT</DefineConstants>
</PropertyGroup>
</Project>
+11 -13
View File
@@ -6,29 +6,27 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.4" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
<PackageVersion Include="IPNetwork2" Version="4.3.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageVersion Include="ProDataGrid" Version="12.1.0.6" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.6" />
<PackageVersion Include="CliWrap" Version="3.10.5" />
<PackageVersion Include="Downloader" Version="5.9.6" />
<PackageVersion Include="Downloader" Version="5.10.2" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.2" />
<PackageVersion Include="MaterialDesignThemes" Version="5.4.0" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="ReactiveUI" Version="24.2.0" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="ReactiveUI.WPF" Version="24.2.0" />
<PackageVersion Include="ReactiveUI" Version="26.0.1" />
<PackageVersion Include="ReactiveUI.WPF" Version="26.0.1" />
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="12.1.0.1" />
<PackageVersion Include="NLog" Version="6.2.0" />
<PackageVersion Include="NLog" Version="6.2.2" />
<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.65.68" />
<PackageVersion Include="TUnit.Assertions.Should" Version="1.65.38-beta" />
<PackageVersion Include="Repobot.SQLite.Unofficial" Version="3.54.1" />
<PackageVersion Include="TUnit" Version="1.73.19" />
<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" />
@@ -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()
{
@@ -2,6 +2,43 @@ namespace ServiceLib.Tests.CoreConfig.V2ray;
public class CoreConfigV2rayServiceTests
{
[Test]
[Arguments(null)]
[Arguments("")]
[Arguments("AGH+DQBdAAAgACBAi73MnT3WWXWj4M9MUiTygpjSPUfM/Rj3Iasve4uFQAAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAA5vdXRlci5lY2gudGVzdAAA")]
[Arguments("https://dns.example.com/dns-query")]
[Arguments("udp://127.0.0.1:5353")]
[Arguments("ech.example.com+https://dns.example.com/dns-query")]
public async Task GenerateClientConfigContent_Tls_ShouldPreserveEchConfigWithoutLegacyQueryOption(string? echConfigList)
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
node.StreamSecurity = Global.StreamSecurity;
node.Sni = "ech.example.com";
node.EchConfigList = echConfigList;
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue();
var generatedConfig = JsonNode.Parse(result.Data!.ToString()!)!;
var proxy = generatedConfig["outbounds"]!.AsArray()
.First(outbound => outbound!["tag"]!.GetValue<string>() == Global.ProxyTag)!;
var tls = proxy["streamSettings"]!["tlsSettings"]!.AsObject();
await tls["serverName"]!.GetValue<string>().Should().BeEqualTo("ech.example.com");
if (string.IsNullOrEmpty(echConfigList))
{
await tls.ContainsKey("echConfigList").Should().BeFalse();
}
else
{
await tls["echConfigList"]!.GetValue<string>().Should().BeEqualTo(echConfigList);
}
await tls.ContainsKey("echForceQuery").Should().BeFalse();
}
[Test]
public async Task GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
{
@@ -21,6 +58,61 @@ public class CoreConfigV2rayServiceTests
await v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
}
[Test]
public async Task GenerateClientConfigContent_RealityFragment_ShouldUsePacketRangeOnlyForReality()
{
static ProfileItem CreateVlessNode(string indexId, string streamSecurity)
{
var node = new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.VLESS,
CoreType = ECoreType.Xray,
Remarks = indexId,
Address = "example.com",
Port = 443,
Password = "01234567-89ab-cdef-0123-456789abcdef",
Network = nameof(ETransport.raw),
StreamSecurity = streamSecurity,
PublicKey = "test-public-key",
Sni = "example.com",
Fingerprint = "chrome",
};
node.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None });
node.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None });
return node;
}
static string GetFragmentPackets(Outbounds4Ray outbound)
{
var finalmask = JsonUtils.ParseJson(JsonUtils.Serialize(outbound.streamSettings!.finalmask));
return finalmask!["tcp"]![0]!["settings"]!["packets"]!.GetValue<string>();
}
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
config.CoreBasicItem.EnableFragment = true;
CoreConfigTestFactory.BindAppManagerConfig(config);
var tlsNode = CreateVlessNode("tls-node", Global.StreamSecurity);
var realityNode = CreateVlessNode("reality-node", Global.StreamSecurityReality);
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group", "group",
[tlsNode.IndexId, realityNode.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
context.AllProxiesMap[tlsNode.IndexId] = tlsNode;
context.AllProxiesMap[realityNode.IndexId] = realityNode;
context.AllProxiesMap[group.IndexId] = group;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
await result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var tlsOutbound = cfg.outbounds.Single(o => o.streamSettings?.security == Global.StreamSecurity);
var realityOutbound = cfg.outbounds.Single(o => o.streamSettings?.security == Global.StreamSecurityReality);
await GetFragmentPackets(tlsOutbound).Should().BeEqualTo("tlshello");
await GetFragmentPackets(realityOutbound).Should().BeEqualTo("1-3");
}
[Test]
public async Task GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
{
@@ -50,8 +142,6 @@ public class CoreConfigV2rayServiceTests
await headers["Set-Cookie"]!.AsArray()
.Select(item => item!.GetValue<string>())
.Should().BeEquivalentTo(["a=1", "b=2"]);
await outbound.settings.servers.Should().BeNull();
await outbound.settings.vnext.Should().BeNull();
}
[Test]
@@ -589,13 +679,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");
@@ -613,6 +701,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()
{
@@ -714,6 +822,5 @@ public class CoreConfigV2rayServiceTests
var proxyOutbound = cfg!.outbounds.FirstOrDefault(o => o.tag == Global.ProxyTag);
await proxyOutbound.Should().NotBeNull();
await proxyOutbound!.protocol.Should().BeEqualTo("shadowsocks");
await proxyOutbound.settings.servers.Should().NotBeNull();
}
}
@@ -0,0 +1,77 @@
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 oldProfile = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, $"profile-{Guid.NewGuid():N}", "old");
oldProfile.Subid = subId;
oldProfile.IsSub = true;
await SQLiteHelper.Instance.InsertAsync(oldProfile);
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);
await profiles.Any(profile => profile.IndexId == oldProfile.IndexId).Should().BeFalse();
foreach (var profile in profiles)
{
await profile.ConfigType.Should().BeEqualTo(EConfigType.Custom);
await profile.PreSocksPort.Should().BeEqualTo(10808);
}
}
[Test]
public async Task AddBatchServers_UnsuccessfulSubscriptionUpdate_PreservesExistingProfiles()
{
var config = CoreConfigTestFactory.CreateConfig();
CoreConfigTestFactory.BindAppManagerConfig(config);
SQLiteHelper.Instance.CreateTable<SubItem>();
SQLiteHelper.Instance.CreateTable<ProfileItem>();
var subId = $"sub-failed-{Guid.NewGuid():N}";
await SQLiteHelper.Instance.ReplaceAsync(new SubItem
{
Id = subId,
Remarks = "sub",
Url = "http://127.0.0.1/sub",
});
var existing = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, $"profile-{Guid.NewGuid():N}", "existing");
existing.Subid = subId;
existing.IsSub = true;
await SQLiteHelper.Instance.InsertAsync(existing);
var invalidSubscription = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("not a valid profile"));
var count = await ConfigHandler.AddBatchServers(config, invalidSubscription, subId, true);
var profiles = await AppManager.Instance.ProfileItems(subId) ?? [];
await (count < 1).Should().BeTrue();
await profiles.Should().HaveCount(1);
await profiles[0].IndexId.Should().BeEqualTo(existing.IndexId);
}
}
@@ -0,0 +1,77 @@
namespace ServiceLib.Tests.Helper;
public class HttpRequestHeadersHelperTests
{
[Test]
public async Task TryParse_ShouldAcceptEmptySettingsForExistingSubscriptions()
{
foreach (var json in new string?[] { null, "", " \r\n ", "{}" })
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers.Count.Should().BeEqualTo(0);
}
}
[Test]
public async Task TryParse_ShouldPreserveValuesAndUseCaseInsensitiveNames()
{
const string json = """
{
"X-hwid": "my_test_device",
"Authorization": "Bearer test:token",
"accept": "application/json",
"Content-Type": "application/json",
"X-Empty": ""
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
await headers["x-HWID"].Should().BeEqualTo("my_test_device");
await headers["AUTHORIZATION"].Should().BeEqualTo("Bearer test:token");
await headers["Accept"].Should().BeEqualTo("application/json");
await headers["Content-Type"].Should().BeEqualTo("application/json");
await headers["X-Empty"].Should().BeEqualTo("");
}
[Test]
[Arguments("not-json")]
[Arguments("null")]
[Arguments("[]")]
[Arguments("{\"X-Test\": 1}")]
[Arguments("{\"X-Test\": null}")]
[Arguments("{\"X-Test\": [\"one\", \"two\"]}")]
[Arguments("{\"X-Test\": \"one\", \"X-Test\": \"two\"}")]
[Arguments("{\"Accept\": \"one\", \"accept\": \"two\"}")]
[Arguments("{\"Bad Header\": \"value\"}")]
[Arguments("{\"Bad:Header\": \"value\"}")]
[Arguments("{\"\": \"value\"}")]
[Arguments("{\"X-Test\": \"one\\r\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\nInjected: two\"}")]
[Arguments("{\"X-Test\": \"one\\u0000two\"}")]
public async Task TryParse_ShouldRejectInvalidHeadersWithoutReturningPartialSettings(string json)
{
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeFalse();
await headers.Count.Should().BeEqualTo(0);
}
[Test]
public async Task RequestHeaders_ShouldSurviveDatabaseMigrationAndEditing()
{
using var database = new SQLiteConnection(":memory:", false);
database.Execute("CREATE TABLE SubItem (Id TEXT PRIMARY KEY, Remarks TEXT, Url TEXT)");
database.Execute("INSERT INTO SubItem (Id, Remarks, Url) VALUES (?, ?, ?)", "existing", "Existing", "https://example.com/sub");
database.CreateTable<SubItem>();
var item = database.Find<SubItem>("existing");
await HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var oldHeaders).Should().BeTrue();
await oldHeaders.Count.Should().BeEqualTo(0);
item.RequestHeaders = "{\"X-hwid\":\"my_device\"}";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders);
item.RequestHeaders = "";
database.Update(item);
await database.Find<SubItem>(item.Id).RequestHeaders.Should().BeEqualTo("");
}
}
@@ -0,0 +1,36 @@
namespace ServiceLib.Tests.Helper;
public class RegexGuardTests
{
[Test]
public async Task IsRegexMatch_NormalPattern_ShouldMatch()
{
await Utils.IsRegexMatch("HK-node-01", "HK|香港").Should().BeTrue();
await Utils.IsRegexMatch("JP-node-01", "HK|香港").Should().BeFalse();
}
[Test]
public async Task IsRegexMatch_EmptyPattern_ShouldPassThrough()
{
await Utils.IsRegexMatch("anything", "").Should().BeTrue();
await Utils.IsRegexMatch("anything", null).Should().BeTrue();
}
[Test]
public async Task IsRegexMatch_InvalidPattern_ShouldFailOpen()
{
await Utils.IsRegexMatch("node-01", "([unclosed").Should().BeTrue();
}
[Test]
public async Task IsRegexMatch_EvilPattern_ShouldTimeoutAndFailOpen()
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = Utils.IsRegexMatch(new string('a', 30) + "!", "(a+)+$");
sw.Stop();
await result.Should().BeTrue();
await (sw.Elapsed < TimeSpan.FromSeconds(30)).Should().BeTrue().Because(
$"evil pattern must be cut off by timeout, took {sw.Elapsed}");
}
}
@@ -0,0 +1,132 @@
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 RunConcurrentUpdates(8, 10, (_, index) => manager.SetTestDelay($"same{index}", index));
var items = await manager.GetProfileExs();
await items.Count.Should().BeEqualTo(10);
await items.Select(t => t.IndexId).Distinct().Count().Should().BeEqualTo(10);
}
[Test]
public async Task SetTestResults_ConcurrentUpdates_ShouldKeepAllValuesOnOneEntry()
{
var manager = CreateManager();
Action<string>[] updates =
[
indexId => manager.SetTestDelay(indexId, 123),
indexId => manager.SetTestSpeed(indexId, 45.6m),
indexId => manager.SetTestMessage(indexId, "result"),
indexId => manager.SetTestIpInfo(indexId, "203.0.113.1"),
indexId => manager.SetSort(indexId, 7)
];
await RunConcurrentUpdates(updates.Length, 100, (worker, index) => updates[worker]($"profile{index}"));
var items = await manager.GetProfileExs();
await items.Count.Should().BeEqualTo(100);
await items.Select(item => item.IndexId).Distinct().Count().Should().BeEqualTo(100);
foreach (var item in items)
{
await item.Delay.Should().BeEqualTo(123);
await item.Speed.Should().BeEqualTo(45.6m);
await item.Message.Should().BeEqualTo("result");
await item.IpInfo.Should().BeEqualTo("203.0.113.1");
await item.Sort.Should().BeEqualTo(7);
}
}
private static async Task RunConcurrentUpdates(int workers, int indexes, Action<int, int> update)
{
using var barrier = new Barrier(workers);
var tasks = Enumerable.Range(0, workers).Select(worker => Task.Factory.StartNew(() =>
{
for (var index = 0; index < indexes; index++)
{
if (!barrier.SignalAndWait(TimeSpan.FromSeconds(10)))
{
throw new TimeoutException("Concurrent update workers did not synchronize.");
}
update(worker, index);
}
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
await Task.WhenAll(tasks);
}
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;
}
}
@@ -4,6 +4,7 @@
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
@@ -0,0 +1,159 @@
namespace ServiceLib.Tests.Services;
public class DownloadServiceHeadersTests
{
[Test]
[Arguments(false, false)]
[Arguments(false, true)]
[Arguments(true, false)]
[Arguments(true, true)]
public async Task TryDownloadString_ShouldSendCustomHeadersThroughBothDownloaders(bool useProxy, bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
const string json = """
{
"accept": "application/json",
"user-agent": "CustomSubscriptionClient/1.0",
"authorization": "Bearer test-token",
"X-hwid": "test-device",
"Cookie": "session=test",
"Content-Type": "application/json"
}
""";
await HttpRequestHeadersHelper.TryParse(json, out var headers).Should().BeTrue();
var service = new DownloadService { AcceptHeader = "*/*", RequestHeaders = headers };
var uri = new UriBuilder(useProxy ? "http://subscription.invalid/sub" : server.Url)
{
UserName = "user",
Password = "password"
}.Uri;
IWebProxy? proxy = useProxy ? new WebProxy(server.Url) : null;
var content = await service.TryDownloadString(uri.AbsoluteUri, proxy, "OriginalClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
await (server.Requests.Count >= (failFirstRequest ? 2 : 1)).Should().BeTrue();
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("application/json");
await request["User-Agent"].Should().BeEqualTo("CustomSubscriptionClient/1.0");
await request["Authorization"].Should().BeEqualTo("Bearer test-token");
await request["X-hwid"].Should().BeEqualTo("test-device");
await request["Cookie"].Should().BeEqualTo("session=test");
await request["Content-Type"].Should().BeEqualTo("application/json");
}
}
[Test]
[Arguments(false)]
[Arguments(true)]
public async Task TryDownloadString_ShouldKeepDefaultAcceptUserAgentAndBasicAuth(bool failFirstRequest)
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer(failFirstRequest);
var service = new DownloadService { AcceptHeader = "*/*" };
var uri = new UriBuilder(server.Url) { UserName = "user", Password = "password" }.Uri;
var content = await service.TryDownloadString(uri.AbsoluteUri, (IWebProxy?)null, "ExistingClient/1.0").WaitAsync(TimeSpan.FromSeconds(20));
await content.Should().BeEqualTo(SubscriptionHttpServer.Body);
foreach (var request in server.Requests)
{
await request["Accept"].Should().BeEqualTo("*/*");
await request["User-Agent"].Should().BeEqualTo("ExistingClient/1.0");
await request["Authorization"].Should().BeEqualTo("Basic dXNlcjpwYXNzd29yZA==");
await request.ContainsKey("X-hwid").Should().BeFalse();
}
}
[Test]
public async Task TryDownloadString_ShouldNotShareHeadersWithOtherDownloads()
{
await CertPemManager.Instance.Init(new Config { GuiItem = new GUIItem() });
await using var server = new SubscriptionHttpServer();
var subscription = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = new Dictionary<string, string> { ["X-hwid"] = "first-device" }
};
var ordinaryDownload = new DownloadService();
await (await subscription.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
await (await ordinaryDownload.TryDownloadString(server.Url, (IWebProxy?)null, "TestClient/1.0")).Should().BeEqualTo(SubscriptionHttpServer.Body);
var requests = server.Requests.ToArray();
await requests.Length.Should().BeEqualTo(2);
await requests[0]["X-hwid"].Should().BeEqualTo("first-device");
await requests[1].ContainsKey("X-hwid").Should().BeFalse();
await requests[1].ContainsKey("Accept").Should().BeFalse();
}
private sealed class SubscriptionHttpServer : IAsyncDisposable
{
public const string Body = "subscription-test-content";
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _cancellation = new();
private readonly Task _serverTask;
private readonly bool _failFirstRequest;
public string Url { get; }
public ConcurrentQueue<Dictionary<string, string>> Requests { get; } = new();
public SubscriptionHttpServer(bool failFirstRequest = false)
{
_failFirstRequest = failFirstRequest;
_listener.Start();
Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/subscription";
_serverTask = ServeAsync();
}
private async Task ServeAsync()
{
var cancellationToken = _cancellation.Token;
while (!cancellationToken.IsCancellationRequested)
{
using var client = await _listener.AcceptTcpClientAsync(cancellationToken);
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
var requestLine = await reader.ReadLineAsync(cancellationToken);
if (requestLine == null)
{
continue;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (await reader.ReadLineAsync(cancellationToken) is { Length: > 0 } line)
{
var separator = line.IndexOf(':');
var name = line.Substring(0, separator);
var value = line.Substring(separator + 1).Trim();
headers[name] = headers.TryGetValue(name, out var previous) ? $"{previous}, {value}" : value;
}
Requests.Enqueue(headers);
var status = _failFirstRequest && Requests.Count == 1 ? "503 Service Unavailable" : "200 OK";
var body = requestLine.StartsWith("HEAD ") ? "" : Body;
var response = $"HTTP/1.1 {status}\r\nContent-Length: {Body.Length}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken);
}
}
public async ValueTask DisposeAsync()
{
await _cancellation.CancelAsync();
_listener.Stop();
try
{
await _serverTask;
}
catch (OperationCanceledException)
{
}
finally
{
_cancellation.Dispose();
}
}
}
}
@@ -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])!;
}
}
@@ -0,0 +1,26 @@
namespace ServiceLib.Tests.Services;
public class SpeedtestServiceResultTests
{
[Test]
[Arguments(ESpeedActionType.Speedtest, 0)]
[Arguments(ESpeedActionType.Mixedtest, 0)]
[Arguments(ESpeedActionType.Realping, 12.5)]
public async Task StartingATest_ShouldClearTheLastSpeedOfSpeedTests(ESpeedActionType actionType, double expected)
{
var indexId = Guid.NewGuid().ToString();
ProfileExManager.Instance.SetTestSpeed(indexId, 12.5m);
await SetTestResult([new() { IndexId = indexId }], actionType, ResUI.Speedtesting);
var profileEx = (await ProfileExManager.Instance.GetProfileExs()).First(t => t.IndexId == indexId);
await profileEx.Speed.Should().BeEqualTo((decimal)expected);
}
private static Task SetTestResult(List<ServerTestItem> items, ESpeedActionType actionType, string message)
{
var service = new SpeedtestService(new Config { SpeedTestItem = new() }, _ => Task.CompletedTask);
var method = typeof(SpeedtestService).GetMethod("SetTestResultAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
return (Task)method.Invoke(service, [items, actionType, message])!;
}
}
+117 -96
View File
@@ -2,16 +2,21 @@ namespace ServiceLib.UdpTest;
public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable
{
private bool _initialized;
private IPEndPoint _relayEndPoint;
private TcpClient _tcpClient;
private UdpClient _udpClient;
private IPEndPoint _relayEndPoint;
private bool _initialized = false;
public void Dispose()
{
_tcpClient?.Dispose();
_udpClient?.Dispose();
}
/// <summary>
/// Send UDP data to a remote endpoint (IP address)
/// </summary>
public async Task SendAsync(IPEndPoint remote, byte[] data)
public async Task SendAsync(IPEndPoint remote, byte[] data, CancellationToken ct = default)
{
var addrData = new Socks5AddressData
{
@@ -19,10 +24,10 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
? Socks5AddressData.AddrTypeIPv4
: Socks5AddressData.AddrTypeIPv6,
Host = remote.Address.ToString(),
Port = (ushort)remote.Port
Port = (ushort)remote.Port,
};
var packet = BuildSocks5UdpPacket(addrData, data);
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
await _udpClient.SendAsync(packet.AsMemory(), _relayEndPoint, ct);
}
/// <summary>
@@ -31,7 +36,8 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
/// <param name="host">Domain name or IP address</param>
/// <param name="port">Port number</param>
/// <param name="data">Data to send</param>
public async Task SendAsync(string host, ushort port, byte[] data)
/// <param name="ct">Cancellation token</param>
public async Task SendAsync(string host, ushort port, byte[] data, CancellationToken ct = default)
{
var addrData = new Socks5AddressData();
@@ -53,7 +59,8 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
addrData.Port = port;
var packet = BuildSocks5UdpPacket(addrData, data);
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
// await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
await _udpClient.SendAsync(packet.AsMemory(), _relayEndPoint, ct);
}
/// <summary>
@@ -69,16 +76,6 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
return (remote, payload);
}
/// <summary>
/// Represents a remote endpoint that can be either an IP address or a domain name
/// </summary>
public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain)
{
public string Host { get; set; } = host;
public ushort Port { get; set; } = port;
public bool IsDomain { get; set; } = isDomain;
}
private static byte[] BuildSocks5UdpPacket(Socks5AddressData addressData, byte[] data)
{
using var ms = new MemoryStream();
@@ -104,6 +101,11 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
throw new ArgumentException("Invalid SOCKS5 UDP packet: too short");
}
if (packet[0] != 0x00 || packet[1] != 0x00)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: RSV field must be 0");
}
var offset = 0;
// RSV (2 bytes) - Reserved field, skip
@@ -196,83 +198,34 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
return (remote, data);
}
public void Dispose()
/// <summary>
/// Represents a remote endpoint that can be either an IP address or a domain name
/// </summary>
public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain)
{
_tcpClient.Dispose();
_udpClient.Dispose();
public string Host { get; set; } = host;
public ushort Port { get; set; } = port;
public bool IsDomain { get; set; } = isDomain;
}
#region SOCKS5 Connection Handling
private const byte Socks5Version = 0x05;
private const byte SocksCmdUdpAssociate = 0x03;
public async Task<bool> EstablishUdpAssociationAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
Dispose();
_initialized = false;
}
_udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
_tcpClient = new TcpClient();
try
{
await _tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false);
}
catch (SocketException)
{
return false;
}
var tcpControlStream = _tcpClient.GetStream();
byte[] handshakeRequest = [Socks5Version, 0x01, 0x00];
await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false);
var handshakeResponse = new byte[2];
if (await tcpControlStream.ReadAsync(handshakeResponse, cancellationToken).ConfigureAwait(false) < 2 ||
handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00)
{
return false;
}
var clientAddrForSocks = new Socks5AddressData
{
AddressType = Socks5AddressData.AddrTypeIPv4,
Host = "0.0.0.0",
Port = 0
};
using var udpAssociateReqMs = new MemoryStream();
udpAssociateReqMs.WriteByte(Socks5Version);
udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate);
udpAssociateReqMs.WriteByte(0x00);
udpAssociateReqMs.Write(clientAddrForSocks.ToBytes());
await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false);
var verRepRsv = new byte[3];
if (await tcpControlStream.ReadAsync(verRepRsv, cancellationToken).ConfigureAwait(false) < 3 ||
verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00)
{
return false;
}
var proxyRelaySocksAddr =
await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false);
if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp))
{
return false;
}
_relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port);
_initialized = true;
return true;
}
#endregion SOCKS5 Connection Handling
#region SOCKS5 Address Handling
private static async Task<bool> TryReadExactlyAsync(
Stream stream,
Memory<byte> buffer,
CancellationToken cancellationToken)
{
try
{
await stream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
return true;
}
catch (EndOfStreamException)
{
return false;
}
}
private class Socks5AddressData
{
public const byte AddrTypeIPv4 = 0x01;
@@ -343,7 +296,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
var typeByte = new byte[1];
try
{
if (await stream.ReadAsync(typeByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
if (!await TryReadExactlyAsync(stream, typeByte, ct).ConfigureAwait(false))
{
return null;
}
@@ -353,7 +306,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
{
case AddrTypeIPv4:
var ipv4Bytes = new byte[4];
if (await stream.ReadAsync(ipv4Bytes.AsMemory(0, 4), ct).ConfigureAwait(false) < 4)
if (!await TryReadExactlyAsync(stream, ipv4Bytes, ct).ConfigureAwait(false))
{
return null;
}
@@ -363,7 +316,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
case AddrTypeDomain:
var lenByte = new byte[1];
if (await stream.ReadAsync(lenByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
if (!await TryReadExactlyAsync(stream, lenByte, ct).ConfigureAwait(false))
{
return null;
}
@@ -375,8 +328,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
else
{
var domainBytes = new byte[lenByte[0]];
if (await stream.ReadAsync(domainBytes.AsMemory(0, domainBytes.Length), ct)
.ConfigureAwait(false) < domainBytes.Length)
if (!await TryReadExactlyAsync(stream, domainBytes, ct).ConfigureAwait(false))
{
return null;
}
@@ -388,7 +340,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
case AddrTypeIPv6:
var ipv6Bytes = new byte[16];
if (await stream.ReadAsync(ipv6Bytes.AsMemory(0, 16), ct).ConfigureAwait(false) < 16)
if (!await TryReadExactlyAsync(stream, ipv6Bytes, ct).ConfigureAwait(false))
{
return null;
}
@@ -401,7 +353,7 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
}
var portBytes = new byte[2];
if (await stream.ReadAsync(portBytes.AsMemory(0, 2), ct).ConfigureAwait(false) < 2)
if (!await TryReadExactlyAsync(stream, portBytes, ct).ConfigureAwait(false))
{
return null;
}
@@ -417,4 +369,73 @@ public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposabl
}
#endregion SOCKS5 Address Handling
#region SOCKS5 Connection Handling
private const byte Socks5Version = 0x05;
private const byte SocksCmdUdpAssociate = 0x03;
public async Task<bool> EstablishUdpAssociationAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
Dispose();
_initialized = false;
}
_udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
_tcpClient = new TcpClient();
try
{
await _tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false);
}
catch (SocketException)
{
return false;
}
var tcpControlStream = _tcpClient.GetStream();
byte[] handshakeRequest = [Socks5Version, 0x01, 0x00];
await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false);
var handshakeResponse = new byte[2];
if (!await TryReadExactlyAsync(tcpControlStream, handshakeResponse, cancellationToken).ConfigureAwait(false) ||
handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00)
{
return false;
}
var clientAddrForSocks = new Socks5AddressData
{
AddressType = Socks5AddressData.AddrTypeIPv4,
Host = "0.0.0.0",
Port = 0,
};
using var udpAssociateReqMs = new MemoryStream();
udpAssociateReqMs.WriteByte(Socks5Version);
udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate);
udpAssociateReqMs.WriteByte(0x00);
udpAssociateReqMs.Write(clientAddrForSocks.ToBytes());
await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false);
var verRepRsv = new byte[3];
if (!await TryReadExactlyAsync(tcpControlStream, verRepRsv, cancellationToken).ConfigureAwait(false) ||
verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00)
{
return false;
}
var proxyRelaySocksAddr =
await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false);
if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp))
{
return false;
}
_relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port);
_initialized = true;
return true;
}
#endregion SOCKS5 Connection Handling
}
@@ -13,7 +13,7 @@ public class DnsService : IUdpTest
// Question: www.google.com, Type A, Class IN
0x03, 0x77, 0x77, 0x77, 0x06, 0x67, 0x6F, 0x6F,
0x67, 0x6C, 0x65, 0x03, 0x63, 0x6F, 0x6D, 0x00,
0x00, 0x01, 0x00, 0x01
0x00, 0x01, 0x00, 0x01,
];
public byte[] BuildUdpRequestPacket()
@@ -17,13 +17,13 @@ public class McBeService : IUdpTest
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
// Client GUID (random 16 bytes)
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D, 0x1F, 0x4E,
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B,
];
private static readonly byte[] McBeMagicBytes =
[
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78,
];
private static readonly List<string> ValidGameModes =
@@ -31,7 +31,7 @@ public class McBeService : IUdpTest
"Survival",
"Creative",
"Adventure",
"Spectator"
"Spectator",
];
public byte[] BuildUdpRequestPacket()
@@ -61,6 +61,10 @@ public class McBeService : IUdpTest
return false; // Magic bytes do not match
}
var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]);
if (mcbeResponseBytes.Length < 35 + stringLength)
{
return false; // Not enough data for the string
}
var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray());
var stringParts = stringData.Split(';');
// check Game Mode str
@@ -31,10 +31,7 @@ public class StunService : IUdpTest
if (stunResponseBytes.Length >= 2)
{
var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1];
if (messageType is 0x0101 or 0x0111)
{
return true;
}
return messageType is 0x0101 or 0x0111;
}
return true;
+21 -21
View File
@@ -5,7 +5,6 @@ namespace ServiceLib.UdpTest;
public class UdpTestService
{
private const string DefaultUdpTestType = "ntp";
private readonly IUdpTest _udpTest;
private static readonly IReadOnlyDictionary<string, Func<IUdpTest>> UdpTestFactories =
new Dictionary<string, Func<IUdpTest>>(StringComparer.OrdinalIgnoreCase)
@@ -16,6 +15,8 @@ public class UdpTestService
["mcbe"] = () => new McBeService(),
};
private readonly IUdpTest _udpTest;
private UdpTestService(IUdpTest udpTest)
{
_udpTest = udpTest;
@@ -88,24 +89,26 @@ public class UdpTestService
return (targetServerHost, _udpTest.GetDefaultTargetPort());
}
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout)
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port,
CancellationToken ct = default)
{
using var cts = new CancellationTokenSource(operationTimeout);
var cancellationToken = cts.Token;
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
var linkedCt = linkedCts.Token;
var udpRequestPacket = _udpTest.BuildUdpRequestPacket();
if (udpRequestPacket == null || udpRequestPacket.Length == 0)
{
throw new InvalidOperationException("Failed to build UDP request packet.");
}
using var channel = new Socks5UdpChannel("127.0.0.1", socks5Port);
if (!await channel.EstablishUdpAssociationAsync(cancellationToken).ConfigureAwait(false))
if (!await channel.EstablishUdpAssociationAsync(linkedCt).ConfigureAwait(false))
{
throw new Exception("Failed to establish UDP association with SOCKS5 proxy.");
}
var (targetHost, targetPort) = ParseHostAndPort(targetServerHost);
byte[] udpReceiveResult = null;
byte[]? validUdpReceiveResult = null;
// Get minimum round trip time from two attempts
var roundTripTime = TimeSpan.MaxValue;
@@ -116,11 +119,15 @@ public class UdpTestService
{
var stopwatch = new Stopwatch();
stopwatch.Start();
await channel.SendAsync(targetHost, targetPort, udpRequestPacket).ConfigureAwait(false);
var (_, receiveResult) = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
await channel.SendAsync(targetHost, targetPort, udpRequestPacket, linkedCt).ConfigureAwait(false);
var (_, receiveResult) = await channel.ReceiveAsync(linkedCt).ConfigureAwait(false);
stopwatch.Stop();
udpReceiveResult = receiveResult;
if (!_udpTest.VerifyAndExtractUdpResponse(receiveResult))
{
continue;
}
validUdpReceiveResult = receiveResult;
var currentRoundTripTime = stopwatch.Elapsed;
if (currentRoundTripTime < roundTripTime)
@@ -128,27 +135,20 @@ public class UdpTestService
roundTripTime = currentRoundTripTime;
}
}
catch
{
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
}
}
if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2)
catch (OperationCanceledException)
{
throw new Exception("Received NTP response is too short.");
// Timeout, continue to next attempt
}
}
if (udpReceiveResult != null && _udpTest.VerifyAndExtractUdpResponse(udpReceiveResult))
if (validUdpReceiveResult != null)
{
return roundTripTime;
}
else
{
throw new Exception("Failed to verify and extract UDP response.");
}
}
}
@@ -8,8 +8,13 @@ public class BulkObservableCollection<T> : ObservableCollection<T>
{
}
public BulkObservableCollection(IEnumerable<T> collection) : base(collection) { }
public BulkObservableCollection(List<T> list) : base(list) { }
public BulkObservableCollection(IEnumerable<T> collection) : base(collection)
{
}
public BulkObservableCollection(List<T> list) : base(list)
{
}
protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
{
+3 -1
View File
@@ -1,6 +1,8 @@
namespace ServiceLib.Base;
public class MyReactiveObject : ReactiveObject
public class MyReactiveObject : ReactiveObject, IActivatableViewModel
{
protected static Config? _config;
public ViewModelActivator Activator { get; } = new();
}
+11 -17
View File
@@ -136,29 +136,23 @@ public static class Extension
.Replace("\n", replacement);
}
public static async Task<TOutput> HandleSafe<TInput, TOutput>(
this Interaction<TInput, TOutput> interaction,
TInput input,
public static IObservable<TOutput> HandleSafe<TInput, TOutput>(
this Interaction<TInput, TOutput> interaction, TInput input,
TOutput defaultValue = default!,
[CallerMemberName] string memberName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{
try
return Signal.Defer(() => interaction.WhenHandled(input))
.Catch<TOutput, UnhandledInteractionException<TInput, TOutput>>(ex =>
{
return await interaction.Handle(input);
}
catch (UnhandledInteractionException<TInput, TOutput> ex)
Logging.SaveLog($"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}", ex);
return Signal.Return(defaultValue);
})
.Catch<TOutput, Exception>(ex =>
{
var title = $"Unhandled interaction exception in {memberName} at {filePath}:{lineNumber}";
Logging.SaveLog(title, ex);
return defaultValue;
}
catch (Exception ex)
{
var title = $"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}";
Logging.SaveLog(title, ex);
return defaultValue;
}
Logging.SaveLog($"Exception occurred while handling interaction in {memberName} at {filePath}:{lineNumber}, input: {input}", ex);
return Signal.Return(defaultValue);
});
}
}
+121 -10
View File
@@ -7,40 +7,45 @@ public class JsonUtils
private static readonly JsonSerializerOptions _defaultDeserializeOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
ReadCommentHandling = JsonCommentHandling.Skip,
TypeInfoResolver = AppJsonContext.Default,
};
private static readonly JsonSerializerOptions _defaultSerializeOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = AppJsonContext.Default,
};
private static readonly JsonSerializerOptions _defaultSerializeNoIndentedOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = AppJsonContext.Default,
};
private static readonly JsonSerializerOptions _nullValueSerializeOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = AppJsonContext.Default,
};
private static readonly JsonSerializerOptions _nullValueSerializeNoIndentedOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = AppJsonContext.Default,
};
private static readonly JsonDocumentOptions _defaultDocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
@@ -109,7 +114,7 @@ public class JsonUtils
/// <param name="indented"></param>
/// <param name="nullValue"></param>
/// <returns></returns>
public static string Serialize(object? obj, bool indented = true, bool nullValue = false)
public static string Serialize<T>(T? obj, bool indented = true, bool nullValue = false)
{
var result = string.Empty;
try
@@ -140,7 +145,7 @@ public class JsonUtils
/// <param name="obj"></param>
/// <param name="options"></param>
/// <returns></returns>
public static string Serialize(object? obj, JsonSerializerOptions? options)
public static string SerializeCamelCase<T>(T? obj)
{
var result = string.Empty;
try
@@ -149,7 +154,14 @@ public class JsonUtils
{
return result;
}
result = JsonSerializer.Serialize(obj, options ?? _defaultSerializeOptions);
var options = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = AppJsonContext.Default,
};
result = JsonSerializer.Serialize(obj, options);
}
catch (Exception ex)
{
@@ -162,9 +174,108 @@ public class JsonUtils
/// SerializeToNode
/// </summary>
/// <param name="obj"></param>
/// <param name="nullValue"></param>
/// <returns></returns>
public static JsonNode? SerializeToNode(object? obj, JsonSerializerOptions? options = null)
public static JsonNode? SerializeToNode<T>(T? obj, bool nullValue = false)
{
var options = nullValue ? _nullValueSerializeOptions : _defaultSerializeOptions;
return JsonSerializer.SerializeToNode(obj, options);
}
public static JsonArray? SerializeToArray(IEnumerable<string>? values)
{
if (values is null)
{
return null;
}
var array = new JsonArray();
foreach (var value in values)
{
var valueNode = JsonSerializer.SerializeToNode(value, _defaultSerializeOptions);
array.Add(valueNode);
}
return array;
}
}
[JsonSerializable(typeof(Config))]
[JsonSerializable(typeof(RulesItem))]
[JsonSerializable(typeof(ProfileItem))]
[JsonSerializable(typeof(List<RulesItem>))]
[JsonSerializable(typeof(List<ProfileItem>))]
[JsonSerializable(typeof(ProfileItemModel))]
[JsonSerializable(typeof(List<ProfileItemModel>))]
[JsonSerializable(typeof(RoutingTemplate))]
[JsonSerializable(typeof(DNSItem))]
[JsonSerializable(typeof(SimpleDNSItem))]
[JsonSerializable(typeof(IPAPIInfo))]
[JsonSerializable(typeof(SsSIP008))]
[JsonSerializable(typeof(List<SsServer>))]
[JsonSerializable(typeof(SsServer))]
[JsonSerializable(typeof(VmessQRCode))]
[JsonSerializable(typeof(ClashProxies))]
[JsonSerializable(typeof(ClashProviders))]
[JsonSerializable(typeof(ClashConnections))]
[JsonSerializable(typeof(ProtocolExtraItem))]
[JsonSerializable(typeof(TransportExtraItem))]
[JsonSerializable(typeof(TrafficItem))]
[JsonSerializable(typeof(V2rayMetricsVars))]
[JsonSerializable(typeof(V2rayMetricsVarsLink))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(ServerStatItem))]
[JsonSerializable(typeof(WebDavItem))]
[JsonSerializable(typeof(KeyEventItem))]
[JsonSerializable(typeof(SubItem))]
[JsonSerializable(typeof(SingboxConfig))]
[JsonSerializable(typeof(Dns4Sbox))]
[JsonSerializable(typeof(Inbound4Sbox))]
[JsonSerializable(typeof(List<Inbound4Sbox>))]
[JsonSerializable(typeof(Endpoints4Sbox))]
[JsonSerializable(typeof(List<Endpoints4Sbox>))]
[JsonSerializable(typeof(Outbound4Sbox))]
[JsonSerializable(typeof(List<Outbound4Sbox>))]
[JsonSerializable(typeof(Rule4Sbox))]
[JsonSerializable(typeof(Ruleset4Sbox))]
[JsonSerializable(typeof(List<Rule4Sbox>))]
[JsonSerializable(typeof(List<Ruleset4Sbox>))]
[JsonSerializable(typeof(V2rayConfig))]
[JsonSerializable(typeof(Dns4Ray))]
[JsonSerializable(typeof(DnsServer4Ray))]
[JsonSerializable(typeof(Inbounds4Ray))]
[JsonSerializable(typeof(List<Inbounds4Ray>))]
[JsonSerializable(typeof(Outbounds4Ray))]
[JsonSerializable(typeof(List<Outbounds4Ray>))]
[JsonSerializable(typeof(RulesItem4Ray))]
[JsonSerializable(typeof(List<RulesItem4Ray>))]
[JsonSerializable(typeof(Finalmask4Ray))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(long))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(decimal))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(DateTimeOffset))]
[JsonSerializable(typeof(TimeSpan))]
[JsonSerializable(typeof(int?))]
[JsonSerializable(typeof(long?))]
[JsonSerializable(typeof(double?))]
[JsonSerializable(typeof(bool?))]
[JsonSerializable(typeof(DateTime?))]
[JsonSerializable(typeof(Guid?))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(int[]))]
[JsonSerializable(typeof(byte[]))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(List<int>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, object>))]
[JsonSerializable(typeof(object))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(JsonDocument))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(JsonArray))]
internal partial class AppJsonContext : JsonSerializerContext;
+71 -9
View File
@@ -348,10 +348,9 @@ public class Utils
.ToList());
}
public static List<string> GetEnumNames<TEnum>() where TEnum : Enum
public static List<string> GetEnumNames<TEnum>() where TEnum : struct, Enum
{
return Enum.GetValues(typeof(TEnum))
.Cast<TEnum>()
return Enum.GetValues<TEnum>()
.Select(e => e.ToString())
.ToList();
}
@@ -501,6 +500,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
@@ -726,6 +750,40 @@ public class Utils
return false;
}
/// <summary>
/// Regex match with a timeout guard. Filter patterns can come from user
/// input or subscription content while the tested text (remarks, log
/// messages) is attacker-influenced, so an evil pattern like (a+)+$
/// would otherwise hang the caller (ReDoS). On timeout or invalid
/// pattern, fail open (return true) so no node/message is silently
/// dropped; the incident is logged.
/// </summary>
public static bool IsRegexMatch(string? input, string? pattern, int timeoutSeconds = 2)
{
if (pattern.IsNullOrEmpty())
{
return true;
}
if (input.IsNullOrEmpty())
{
return false;
}
try
{
return Regex.IsMatch(input, pattern, RegexOptions.None, TimeSpan.FromSeconds(timeoutSeconds));
}
catch (RegexMatchTimeoutException ex)
{
Logging.SaveLog("IsRegexMatch timeout", ex);
return true;
}
catch (ArgumentException ex)
{
Logging.SaveLog("IsRegexMatch invalid pattern", ex);
return true;
}
}
#endregion Data Checks
#region Speed Test
@@ -1018,12 +1076,12 @@ public class Utils
return new Dictionary<string, string>();
}
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg)
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg, CancellationToken cancellationToken = default)
{
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null);
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null, cancellationToken);
}
public static async Task<string?> GetCliWrapOutput(string filePath, IEnumerable<string>? args)
public static async Task<string?> GetCliWrapOutput(string filePath, IEnumerable<string>? args, CancellationToken cancellationToken = default)
{
try
{
@@ -1040,7 +1098,7 @@ public class Utils
}
}
var result = await cmd.ExecuteBufferedAsync();
var result = await cmd.ExecuteBufferedAsync(cancellationToken);
if (result.IsSuccess)
{
return result.StandardOutput ?? "";
@@ -1072,7 +1130,7 @@ public class Utils
}
//Check if it is installed by Windows WinGet
if (IsWindows() && basePath.Contains("Users") && basePath.Contains("WinGet"))
if (IsWindows() && basePath.Contains("WinGet"))
{
return false;
}
@@ -1382,7 +1440,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;
}
}
+38 -21
View File
@@ -1,6 +1,5 @@
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using YamlDotNet.RepresentationModel;
namespace ServiceLib.Common;
@@ -13,45 +12,57 @@ public class YamlUtils
/// <summary>
/// Deserialize
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="str"></param>
/// <returns></returns>
public static T FromYaml<T>(string str)
public static YamlMappingNode FromYaml(string str)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
.Build();
//var deserializer = new DeserializerBuilder()
// .WithNamingConvention(PascalCaseNamingConvention.Instance)
// .Build();
//try
//{
// var obj = deserializer.Deserialize<T>(str);
// return obj;
//}
//catch (Exception ex)
//{
// Logging.SaveLog(_tag, ex);
// return deserializer.Deserialize<T>("");
//}
try
{
var obj = deserializer.Deserialize<T>(str);
return obj;
var yaml = new YamlStream();
using var reader = new StringReader(str);
yaml.Load(reader);
var rootNode = (YamlMappingNode)yaml.Documents[0].RootNode;
return rootNode;
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return deserializer.Deserialize<T>("");
return new YamlMappingNode();
}
}
/// <summary>
/// Serialize
/// </summary>
/// <param name="obj"></param>
/// <param name="node"></param>
/// <returns></returns>
public static string ToYaml(object? obj)
public static string ToYaml(YamlMappingNode? node)
{
var result = string.Empty;
if (obj == null)
if (node == null)
{
return result;
}
var serializer = new SerializerBuilder()
.WithNamingConvention(HyphenatedNamingConvention.Instance)
.Build();
try
{
result = serializer.Serialize(obj);
var doc = new YamlDocument(node);
var stream = new YamlStream(doc);
using var writer = new StringWriter();
stream.Save(writer, assignAnchors: false);
result = writer.ToString();
}
catch (Exception ex)
{
@@ -64,9 +75,15 @@ public class YamlUtils
{
try
{
var mergingParser = new MergingParser(new Parser(new StringReader(str)));
var obj = new DeserializerBuilder().Build().Deserialize(mergingParser);
return ToYaml(obj);
using var reader = new StringReader(str);
var parser = new Parser(reader);
var mergingParser = new MergingParser(parser);
var yamlStream = new YamlStream();
yamlStream.Load(mergingParser);
using var writer = new StringWriter();
yamlStream.Save(writer, assignAnchors: false);
return writer.ToString();
}
catch (Exception ex)
{
+1
View File
@@ -15,6 +15,7 @@ public enum EConfigType
Anytls = 11,
Naive = 12,
Outbound = 13,
MASQUE = 14,
PolicyGroup = 101,
ProxyChain = 102,
}
+28 -12
View File
@@ -42,6 +42,7 @@ public class Global
public const string SingboxFakeIPFilterFileName = NamespaceSample + "singbox_fakeip_filter";
public const string ChromeRootCertFileName = NamespaceSample + "chrome_roots_pem";
public const string MozillaRootCertFileName = NamespaceSample + "mozilla_roots_pem";
public const string TaskXmlTemplateFileName = NamespaceSample + "TaskXmlTemplate";
public const string ChromeRootProvider = "chrome";
public const string MozillaRootProvider = "mozilla";
@@ -92,10 +93,16 @@ public class Global
public const string XrayLocalAsset = "XRAY_LOCATION_ASSET";
public const string XrayLocalCert = "XRAY_LOCATION_CERT";
public const int SpeedTestPageSize = 1000;
public const int SpeedTestConcurrencyCountMin = 10;
public const string LinuxBash = "/bin/bash";
public const string StringTrue = "true";
public const string StringFalse = "false";
public const int SqliteMaxBatchSize = 10000;
public static readonly TimeSpan LocalFetch = TimeSpan.FromSeconds(5);
public static readonly TimeSpan DirectFetch = TimeSpan.FromSeconds(10);
public static readonly TimeSpan ProxyFetch = TimeSpan.FromSeconds(30);
public static readonly TimeSpan DirectDownloadConnect = TimeSpan.FromSeconds(5);
public static readonly TimeSpan ProxyDownloadConnect = TimeSpan.FromSeconds(10);
public const string SingboxDirectDNSTagPrefix = "direct-dns-";
public const string SingboxRemoteDNSTagPrefix = "remote-dns-";
@@ -255,7 +262,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 =
@@ -365,6 +373,7 @@ public class Global
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.WireGuard,
EConfigType.MASQUE,
EConfigType.SOCKS,
EConfigType.HTTP,
];
@@ -380,6 +389,7 @@ public class Global
EConfigType.Anytls,
EConfigType.Naive,
EConfigType.WireGuard,
EConfigType.MASQUE,
EConfigType.SOCKS,
EConfigType.HTTP,
];
@@ -489,16 +499,22 @@ public class Global
"localhost"
];
public static readonly List<LanguageOption> LanguageOptions =
[
new("zh-Hans", "简体中文"),
new("zh-Hant", "繁體中文"),
new("en", "English"),
new("fa", "فارسی"),
new("fr", "Français"),
new("ru", "Русский"),
new("hu", "Magyar"),
new("id", "Bahasa Indonesia"),
new("az", "Azərbaycan dili")
];
public static readonly List<string> Languages =
[
"zh-Hans",
"zh-Hant",
"en",
"fa",
"fr",
"ru",
"hu",
"id"
.. LanguageOptions.Select(t => t.Value)
];
public static readonly List<string> Alpns =
@@ -641,10 +657,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" },
+5 -5
View File
@@ -6,11 +6,6 @@ global using System.Diagnostics;
global using System.Net;
global using System.Net.NetworkInformation;
global using System.Net.Sockets;
global using ReactiveUI.Primitives;
global using ReactiveUI.Primitives.Concurrency;
global using ReactiveUI.Primitives.Extensions;
global using ReactiveUI.Primitives.Disposables;
global using ReactiveUI.Primitives.Signals;
global using System.Reflection;
global using System.Runtime.InteropServices;
global using System.Runtime.Versioning;
@@ -22,6 +17,11 @@ global using System.Text.Json.Nodes;
global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using ReactiveUI;
global using ReactiveUI.Primitives;
global using ReactiveUI.Primitives.Concurrency;
global using ReactiveUI.Primitives.Disposables;
global using ReactiveUI.Primitives.Extensions;
global using ReactiveUI.Primitives.Signals;
global using ReactiveUI.SourceGenerators;
global using ServiceLib.Base;
global using ServiceLib.Common;
@@ -87,6 +87,7 @@ public static class AutoStartupHandler
[SupportedOSPlatform("windows")]
public static void AutoStartTaskService(string taskName, string fileName, string description)
{
#if false
if (taskName.IsNullOrEmpty())
{
return;
@@ -117,6 +118,78 @@ public static class AutoStartupHandler
task.Actions.Add(new Microsoft.Win32.TaskScheduler.ExecAction(fileName.AppendQuotes(), null, Path.GetDirectoryName(fileName)));
taskService.RootFolder.RegisterTaskDefinition(taskName, task);
#endif
if (string.IsNullOrEmpty(taskName))
{
return;
}
RunSchtasks($"/Delete /TN \"{taskName}\" /F");
if (string.IsNullOrEmpty(fileName))
{
return;
}
var workingDir = Path.GetDirectoryName(fileName) ?? string.Empty;
var taskXml = EmbedUtils.GetEmbedText(Global.TaskXmlTemplateFileName);
taskXml = taskXml
.Replace("{{DESCRIPTION}}", System.Security.SecurityElement.Escape(description ?? ""))
.Replace("{{EXEC_PATH}}", System.Security.SecurityElement.Escape(fileName ?? ""))
.Replace("{{ARGUMENTS}}", string.Empty)
.Replace("{{WORKING_DIRECTORY}}", System.Security.SecurityElement.Escape(workingDir ?? ""));
var taskXmlFileName = Utils.GetTempPath($"{Utils.GetGuid(false)}.xml");
File.WriteAllText(taskXmlFileName, taskXml);
RunSchtasks(
"/Create",
"/TN", taskName,
"/XML", taskXmlFileName,
"/F"
);
}
private static bool RunSchtasks(params string[] arguments)
{
var psi = new ProcessStartInfo
{
FileName = "schtasks.exe",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var argument in arguments)
{
psi.ArgumentList.Add(argument);
}
using var process = Process.Start(psi);
if (process == null)
{
return false;
}
process.WaitForExit();
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
if (!string.IsNullOrEmpty(output))
{
Logging.SaveLog($"RunSchtasks output: {output}");
}
if (!string.IsNullOrEmpty(error))
{
Logging.SaveLog($"RunSchtasks error: {error}");
}
return process.ExitCode == 0;
}
private static string GetAutoRunNameWindows()
@@ -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] : []
};
+108 -18
View File
@@ -136,10 +136,7 @@ public static class ConfigHandler
{
config.SpeedTestItem.SpeedPingTestUrl = Global.SpeedPingTestUrls.First();
}
if (config.SpeedTestItem.MixedConcurrencyCount < 1)
{
config.SpeedTestItem.MixedConcurrencyCount = 5;
}
config.SpeedTestItem.MixedConcurrencyCount = Math.Max(config.SpeedTestItem.MixedConcurrencyCount, Global.SpeedTestConcurrencyCountMin);
if (config.SpeedTestItem.UdpTestTarget.IsNullOrEmpty())
{
config.SpeedTestItem.UdpTestTarget = Global.UdpTestTargets.First();
@@ -295,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;
@@ -471,12 +469,12 @@ public static class ConfigHandler
/// Supports moving to top, up, down, bottom or specific position
/// </summary>
/// <param name="config">Current configuration</param>
/// <param name="lstProfile">List of server profiles</param>
/// <param name="lstProfile">List of server profile index ids</param>
/// <param name="index">Index of the server to move</param>
/// <param name="eMove">Direction to move the server</param>
/// <param name="pos">Target position when using EMove.Position</param>
/// <returns>0 if successful, -1 if failed</returns>
public static async Task<int> MoveServer(Config config, List<ProfileItem> lstProfile, int index, EMove eMove, int pos = -1)
public static async Task<int> MoveServer(Config config, List<string> lstProfile, int index, EMove eMove, int pos = -1)
{
var count = lstProfile.Count;
if (index < 0 || index > lstProfile.Count - 1)
@@ -486,7 +484,7 @@ public static class ConfigHandler
for (var i = 0; i < lstProfile.Count; i++)
{
ProfileExManager.Instance.SetSort(lstProfile[i].IndexId, (i + 1) * 10);
ProfileExManager.Instance.SetSort(lstProfile[i], (i + 1) * 10);
}
var sort = 0;
@@ -498,7 +496,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile.First().IndexId) - 1;
sort = ProfileExManager.Instance.GetSort(lstProfile.First()) - 1;
break;
}
@@ -508,7 +506,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1].IndexId) - 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[index - 1]) - 1;
break;
}
@@ -519,7 +517,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1].IndexId) + 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[index + 1]) + 1;
break;
}
@@ -529,7 +527,7 @@ public static class ConfigHandler
{
return 0;
}
sort = ProfileExManager.Instance.GetSort(lstProfile[^1].IndexId) + 1;
sort = ProfileExManager.Instance.GetSort(lstProfile[^1]) + 1;
break;
}
@@ -538,10 +536,71 @@ public static class ConfigHandler
break;
}
ProfileExManager.Instance.SetSort(lstProfile[index].IndexId, sort);
ProfileExManager.Instance.SetSort(lstProfile[index], sort);
return await Task.FromResult(0);
}
public static Task<int> MoveServersTo(
Config config,
IReadOnlyList<string> orderedProfileIds,
IReadOnlyList<int> sourceIndices,
int insertIndex)
{
ArgumentNullException.ThrowIfNull(orderedProfileIds);
ArgumentNullException.ThrowIfNull(sourceIndices);
if (orderedProfileIds.Count == 0 ||
sourceIndices.Count == 0 ||
insertIndex < 0 ||
insertIndex > orderedProfileIds.Count)
{
return Task.FromResult(-1);
}
int[] movingIndices = sourceIndices
.Distinct()
.OrderBy(index => index)
.ToArray();
if (movingIndices.Length != sourceIndices.Count ||
movingIndices.Any(index =>
index < 0 || index >= orderedProfileIds.Count))
{
return Task.FromResult(-1);
}
var movingIndexSet = movingIndices.ToHashSet();
var movingIds = movingIndices
.Select(index => orderedProfileIds[index])
.ToList();
var remainingIds = orderedProfileIds
.Where((_, index) => !movingIndexSet.Contains(index))
.ToList();
if (insertIndex > remainingIds.Count)
{
return Task.FromResult(-1);
}
remainingIds.InsertRange(insertIndex, movingIds);
if (remainingIds.Count != orderedProfileIds.Count)
{
return Task.FromResult(-1);
}
for (var index = 0; index < remainingIds.Count; index++)
{
ProfileExManager.Instance.SetSort(
remainingIds[index],
(index + 1) * 10);
}
return Task.FromResult(0);
}
/// <summary>
/// Add a custom server configuration from a file
/// Copies the configuration file to the app's config directory
@@ -995,6 +1054,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
@@ -1528,7 +1610,7 @@ public static class ConfigHandler
p != null &&
p.IsValid() &&
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
(extraItem.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extraItem.Filter))
Utils.IsRegexMatch(p.Remarks, extraItem.Filter)
)
.ToList() ?? [];
if (matchedChildProfiles.Count == 0)
@@ -1667,7 +1749,7 @@ public static class ConfigHandler
//exist sub items //filter
if (isSub && subid.IsNotEmpty() && subFilter.IsNotEmpty())
{
if (!Regex.IsMatch(profileItem.Remarks, subFilter))
if (!Utils.IsRegexMatch(profileItem.Remarks, subFilter))
{
continue;
}
@@ -1687,6 +1769,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,
};
@@ -1756,7 +1839,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;
@@ -1802,7 +1885,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;
@@ -1816,7 +1899,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)
@@ -1826,6 +1910,7 @@ public static class ConfigHandler
if (it.ConfigType == EConfigType.Custom)
{
it.PreSocksPort = subItem?.PreSocksPort;
if (await AddCustomServer(config, it, true) == 0)
{
count++;
@@ -2049,7 +2134,6 @@ public static class ConfigHandler
{
lstOriSub = await AppManager.Instance.ProfileItems(subid);
activeProfile = lstOriSub?.FirstOrDefault(t => t.IndexId == config.IndexId);
await RemoveServersViaSubid(config, subid, true);
}
var counter = 0;
@@ -2109,6 +2193,11 @@ public static class ConfigHandler
counter = await AddBatchServers4Custom(config, strData, subid, isSub);
}
if (counter > 0 && lstOriSub is { Count: > 0 })
{
await RemoveServers(config, lstOriSub.Where(item => item.IsSub).ToList());
}
//Select active node
if (activeProfile != null)
{
@@ -2202,6 +2291,7 @@ public static class ConfigHandler
item.Enabled = subItem.Enabled;
item.AutoUpdateInterval = subItem.AutoUpdateInterval;
item.UserAgent = subItem.UserAgent;
item.RequestHeaders = subItem.RequestHeaders;
item.Sort = subItem.Sort;
item.Filter = subItem.Filter;
item.UpdateTime = subItem.UpdateTime;
+19 -8
View File
@@ -66,34 +66,41 @@ public static class ConnectionHandler
/// <summary>
/// Measures response time by sending HTTP requests through proxy.
/// </summary>
public static async Task<int> GetRealPingTime(IWebProxy? webProxy, int downloadTimeout = 9)
public static async Task<int> GetRealPingTime(IWebProxy? webProxy, CancellationToken cancellationToken = default)
{
var url = AppManager.Instance.Config.SpeedTestItem.SpeedPingTestUrl;
var responseTime = -1;
try
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(downloadTimeout));
using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(Global.LocalFetch);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var linkedToken = linkedCts.Token;
using var client = new HttpClient(new SocketsHttpHandler()
{
Proxy = webProxy,
UseProxy = webProxy != null,
ConnectTimeout = TimeSpan.FromSeconds(3)
ConnectTimeout = Global.LocalFetch,
});
List<int> oneTime = [];
for (var i = 0; i < 2; i++)
{
var timer = Stopwatch.StartNew();
await client.GetAsync(url, cts.Token).ConfigureAwait(false);
await client.GetAsync(url, linkedToken).ConfigureAwait(false);
timer.Stop();
oneTime.Add((int)timer.Elapsed.TotalMilliseconds);
await Task.Delay(100, cts.Token);
await Task.Delay(100, linkedToken);
}
responseTime = oneTime.Where(x => x > 0).OrderBy(x => x).FirstOrDefault();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// Ignore
}
return responseTime;
}
@@ -101,7 +108,7 @@ public static class ConnectionHandler
/// <summary>
/// Gets IP and country information through specified proxy.
/// </summary>
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy)
public static async Task<IpInfoResult?> GetIPInfo(IWebProxy? webProxy, CancellationToken cancellationToken = default)
{
try
{
@@ -112,7 +119,7 @@ public static class ConnectionHandler
}
var downloadHandle = new DownloadService();
var result = await downloadHandle.TryDownloadString(url, webProxy, "");
var result = await downloadHandle.TryDownloadString(url, webProxy, "", cancellationToken);
if (result == null)
{
return null;
@@ -129,6 +136,10 @@ public static class ConnectionHandler
return new IpInfoResult(country, ip);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
+4 -24
View File
@@ -80,12 +80,7 @@ public class BaseFmt
{
var node = JsonUtils.ParseJson(item.Finalmask);
var finalmask = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
? JsonUtils.Serialize(node, indented: false, nullValue: true)
: item.Finalmask;
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
}
@@ -154,12 +149,7 @@ public class BaseFmt
{
var node = JsonUtils.ParseJson(transport.XhttpExtra);
var extra = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
? JsonUtils.Serialize(node, indented: false, nullValue: true)
: transport.XhttpExtra;
dicQuery.Add("extra", UrlEncodeSafe(extra));
}
@@ -215,12 +205,7 @@ public class BaseFmt
{
var node = JsonUtils.ParseJson(finalmaskDecoded);
item.Finalmask = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
? JsonUtils.Serialize(node, indented: true, nullValue: true)
: finalmaskDecoded;
}
else
@@ -278,12 +263,7 @@ public class BaseFmt
var node = JsonUtils.ParseJson(xhttpExtra);
if (node != null)
{
xhttpExtra = JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
xhttpExtra = JsonUtils.Serialize(node, indented: true, nullValue: true);
}
}
+1 -6
View File
@@ -179,12 +179,7 @@ public class InnerFmt : BaseFmt
if (jsonObj.TryGetPropertyValue("CustomOutboundObj", out var customOutboundNode)
&& customOutboundNode is JsonObject customOutboundObj)
{
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var customOutboundContent = JsonUtils.Serialize(customOutboundObj, indented: true, nullValue: true);
customOutboundFilePath = WriteAllText(customOutboundContent);
jsonObj.Remove("CustomOutboundObj");
}
@@ -24,6 +24,19 @@ public class WireguardFmt : BaseFmt
var query = Utils.ParseQueryString(url.Query);
var finalmaskDecoded = GetQueryDecoded(query, "fm");
if (finalmaskDecoded.IsNotEmpty())
{
var node = JsonUtils.ParseJson(finalmaskDecoded);
item.Finalmask = node != null
? JsonUtils.Serialize(node, indented: true, nullValue: true)
: finalmaskDecoded;
}
else
{
item.Finalmask = string.Empty;
}
item.SetProtocolExtra(item.GetProtocolExtra() with
{
WgPublicKey = GetQueryDecoded(query, "publickey"),
@@ -76,6 +89,14 @@ public class WireguardFmt : BaseFmt
{
dicQuery.Add("dns", Utils.UrlEncode(protoExtra.WgDns));
}
if (item.Finalmask.IsNotEmpty())
{
var node = JsonUtils.ParseJson(item.Finalmask);
var finalmask = node != null
? JsonUtils.Serialize(node, indented: false, nullValue: true)
: item.Finalmask;
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
}
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Password, dicQuery, remark);
}
@@ -31,7 +31,7 @@ public static class SubscriptionHandler
}
// Create download handler
var downloadHandle = CreateDownloadHandler(hashCode, updateFunc);
var downloadHandle = CreateDownloadHandler(item, hashCode, updateFunc);
await updateFunc?.Invoke(false, $"{hashCode}{ResUI.MsgStartGettingSubscriptions}");
// Get all subscription content (main subscription + additional subscriptions)
@@ -80,9 +80,18 @@ public static class SubscriptionHandler
return true;
}
private static DownloadService CreateDownloadHandler(string hashCode, Func<bool, string, Task> updateFunc)
private static DownloadService CreateDownloadHandler(SubItem item, string hashCode, Func<bool, string, Task> updateFunc)
{
var downloadHandle = new DownloadService { AcceptHeader = "*/*" };
if (!HttpRequestHeadersHelper.TryParse(item.RequestHeaders, out var requestHeaders))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
var downloadHandle = new DownloadService
{
AcceptHeader = "*/*",
RequestHeaders = requestHeaders
};
downloadHandle.Error += (sender2, args) =>
{
updateFunc?.Invoke(false, $"{hashCode}{args.GetException().Message}");
@@ -133,7 +133,7 @@ public static class ProxySettingWindows
list.dwOptionCount = options.Length;
list.dwOptionError = 0;
var optSize = Marshal.SizeOf(typeof(InternetConnectionOption));
var optSize = Marshal.SizeOf<InternetConnectionOption>();
// make a pointer out of all that ...
var optionsPtr = Marshal.AllocCoTaskMem(optSize * options.Length); // !! remember to deallocate memory 4
// copy the array over into that spot in memory ...
@@ -207,17 +207,17 @@ public static class ProxySettingWindows
var entries = 0;
// attempt to query with 1 entry buffer
var rasEntryNames = new RASENTRYNAME[1];
var bufferSize = Marshal.SizeOf(typeof(RASENTRYNAME));
rasEntryNames[0].dwSize = Marshal.SizeOf(typeof(RASENTRYNAME));
var bufferSize = Marshal.SizeOf<RASENTRYNAME>();
rasEntryNames[0].dwSize = Marshal.SizeOf<RASENTRYNAME>();
var result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
// increase buffer if the buffer is not large enough
if (result == (uint)ErrorCode.ERROR_BUFFER_TOO_SMALL)
{
rasEntryNames = new RASENTRYNAME[bufferSize / Marshal.SizeOf(typeof(RASENTRYNAME))];
rasEntryNames = new RASENTRYNAME[bufferSize / Marshal.SizeOf<RASENTRYNAME>()];
for (var i = 0; i < rasEntryNames.Length; i++)
{
rasEntryNames[i].dwSize = Marshal.SizeOf(typeof(RASENTRYNAME));
rasEntryNames[i].dwSize = Marshal.SizeOf<RASENTRYNAME>();
}
result = NativeMethods.RasEnumEntries(null, null, rasEntryNames, ref bufferSize, ref entries);
@@ -258,7 +258,7 @@ public static class ProxySettingWindows
static InternetConnectionOption()
{
Size = Marshal.SizeOf(typeof(InternetConnectionOption));
Size = Marshal.SizeOf<InternetConnectionOption>();
}
// Nested Types
@@ -3,6 +3,7 @@ namespace ServiceLib.Handler.SysProxy;
public static class SysProxyHandler
{
private static readonly string _tag = "SysProxyHandler";
private static readonly Lazy<PacManager> _pacManager = new(() => new PacManager());
public static async Task<bool> UpdateSysProxy(Config config, bool forceDisable)
{
@@ -56,7 +57,7 @@ public static class SysProxyHandler
if (type != ESysProxyType.Pac && Utils.IsWindows())
{
PacManager.Instance.Stop();
_pacManager.Value.Stop();
}
}
catch (Exception ex)
@@ -110,7 +111,7 @@ public static class SysProxyHandler
private static async Task SetWindowsProxyPac(int port)
{
var portPac = AppManager.Instance.GetLocalPort(EInboundProtocol.pac);
await PacManager.Instance.StartAsync(port, portPac);
await _pacManager.Value.StartAsync(port, portPac);
var strProxy = $"{Global.HttpProtocol}{Global.Loopback}:{portPac}/pac?t={DateTime.Now.Ticks}";
ProxySettingWindows.SetProxy(strProxy, "", 4);
}
+29 -34
View File
@@ -8,15 +8,14 @@ public class DownloaderHelper
private static readonly Lazy<DownloaderHelper> _instance = new(() => new());
public static DownloaderHelper Instance => _instance.Value;
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent, int timeout)
public async Task<string?> DownloadStringAsync(IWebProxy? webProxy, string url, string? userAgent,
IReadOnlyDictionary<string, string>? requestHeaders = null, string? acceptHeader = null, CancellationToken cancellationToken = default)
{
if (url.IsNullOrEmpty())
{
return null;
}
var connectTimeout = Math.Clamp(timeout / 5, 2, 5);
Uri uri = new(url);
//Authorization Header
var headers = new WebHeaderCollection();
@@ -28,16 +27,16 @@ public class DownloaderHelper
var requestConfiguration = new RequestConfiguration()
{
Headers = headers,
Accept = acceptHeader,
UserAgent = userAgent,
ConnectTimeout = connectTimeout * 1000,
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy
};
var downloadOpt = new DownloadConfiguration()
{
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
CustomHttpMessageHandlerFactory = () => HttpRequestHeadersHelper.CreateHandler(GetSocketsHttpHandler(requestConfiguration), requestHeaders),
};
await using var downloader = new Downloader.DownloadService(downloadOpt);
@@ -49,31 +48,26 @@ public class DownloaderHelper
}
};
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cancellationToken);
using StreamReader reader = new(stream);
return await reader.ReadToEndAsync(cts.Token);
return await reader.ReadToEndAsync(cancellationToken);
}
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, IProgress<string> progress, int timeout)
public async Task DownloadDataAsync4Speed(IWebProxy webProxy, string url, Action<string> onProgress, CancellationToken cancellationToken = default)
{
if (url.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(url));
}
var connectTimeout = Math.Clamp(timeout / 5, 2, 5);
var requestConfiguration = new RequestConfiguration()
{
ConnectTimeout = connectTimeout * 1000,
ConnectTimeout = GetConnectTimeoutMs(true),
Proxy = webProxy
};
var downloadOpt = new DownloadConfiguration()
{
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration = requestConfiguration,
CustomHttpMessageHandlerFactory = () => GetSocketsHttpHandler(requestConfiguration),
@@ -86,8 +80,10 @@ public class DownloaderHelper
downloader.DownloadProgressChanged += (sender, value) =>
{
if (progress != null && value.BytesPerSecondSpeed > 0)
if (!(value.BytesPerSecondSpeed > 0))
{
return;
}
hasValue = true;
if (value.BytesPerSecondSpeed > maxSpeed)
{
@@ -99,36 +95,30 @@ public class DownloaderHelper
{
lastUpdateTime = DateTime.Now;
var speed = (maxSpeed / 1000 / 1000).ToString("#0.0");
progress.Report(speed);
}
onProgress.Invoke(speed);
}
};
downloader.DownloadFileCompleted += (sender, value) =>
{
if (progress != null)
{
if (hasValue && maxSpeed > 0)
{
var finalSpeed = (maxSpeed / 1000 / 1000).ToString("#0.0");
progress.Report(finalSpeed);
onProgress.Invoke(finalSpeed);
}
else if (value.Error != null)
{
progress.Report(value.Error?.Message);
onProgress.Invoke(value.Error?.Message);
}
else
{
progress.Report("0");
}
onProgress.Invoke("0");
}
};
//progress.Report("......");
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cts.Token);
//progress.Invoke("......");
await using var stream = await downloader.DownloadFileTaskAsync(address: url, cancellationToken);
}
public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
public async Task DownloadFileAsync(IWebProxy? webProxy, FileDownloadRequest request, Action<FileDownloadState> onProgress, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.FilePath.IsNullOrEmpty())
@@ -145,9 +135,9 @@ public class DownloaderHelper
Request = request,
};
var requestConfiguration = new RequestConfiguration()
var requestConfiguration = new RequestConfiguration
{
ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy,
};
var downloadOpt = new DownloadConfiguration()
@@ -194,7 +184,7 @@ public class DownloaderHelper
await downloader.DownloadFileTaskAsync(request.FileUrl, request.FilePath, cancellationToken);
}
public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, TimeSpan connectTimeout, CancellationToken cancellationToken = default)
public async Task DownloadSmallFilesAsync(IWebProxy? webProxy, List<FileDownloadRequest> requests, Action<ReadOnlyMemory<FileDownloadState>> onProgress, CancellationToken cancellationToken = default)
{
if (requests is not { Count: > 0 })
{
@@ -213,7 +203,7 @@ public class DownloaderHelper
var requestConfiguration = new RequestConfiguration()
{
ConnectTimeout = (int)connectTimeout.TotalMilliseconds,
ConnectTimeout = GetConnectTimeoutMs(webProxy != null),
Proxy = webProxy,
KeepAlive = true,
@@ -223,7 +213,7 @@ public class DownloaderHelper
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = 4,
//CancellationToken = cancellationToken,
CancellationToken = cancellationToken,
};
await Parallel.ForEachAsync(Enumerable.Range(0, requests.Count), parallelOptions, async (index, parallelCancellationToken) =>
@@ -342,4 +332,9 @@ public class DownloaderHelper
return handler;
}
private int GetConnectTimeoutMs(bool isProxy)
{
return (int)(isProxy ? Global.ProxyDownloadConnect : Global.DirectDownloadConnect).TotalMilliseconds;
}
}
+7 -3
View File
@@ -22,7 +22,7 @@ public class HttpClientHelper
this.httpClient = httpClient;
}
public async Task<string?> TryGetAsync(string url)
public async Task<string?> TryGetAsync(string url, CancellationToken cancellationToken = default)
{
if (url.IsNullOrEmpty())
{
@@ -31,8 +31,12 @@ public class HttpClientHelper
try
{
var response = await httpClient.GetAsync(url);
return await response.Content.ReadAsStringAsync();
var response = await httpClient.GetAsync(url, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
@@ -0,0 +1,88 @@
namespace ServiceLib.Helper;
public static class HttpRequestHeadersHelper
{
public static bool TryParse(string? json, out Dictionary<string, string> headers)
{
headers = new(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(json))
{
return true;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var request = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var property in document.RootElement.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.String
|| !parsed.TryAdd(property.Name, property.Value.GetString()!)
|| !TryAddHeader(request, property.Name, parsed[property.Name]))
{
return false;
}
}
headers = parsed;
return true;
}
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException)
{
return false;
}
}
public static HttpMessageHandler CreateHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string>? headers)
{
return headers is { Count: > 0 } ? new RequestHeadersHandler(innerHandler, headers) : innerHandler;
}
private static bool TryAddHeader(HttpRequestMessage request, string name, string value)
{
if (value.Any(c => char.IsControl(c) && c != '\t'))
{
return false;
}
return request.Headers.TryAddWithoutValidation(name, value)
|| request.Content!.Headers.TryAddWithoutValidation(name, value);
}
private sealed class RequestHeadersHandler(HttpMessageHandler innerHandler, IReadOnlyDictionary<string, string> headers)
: DelegatingHandler(innerHandler)
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using var customHeaders = new HttpRequestMessage { Content = new ByteArrayContent([]) };
foreach (var header in headers)
{
if (!TryAddHeader(customHeaders, header.Key, header.Value))
{
throw new FormatException(ResUI.SubRequestHeadersInvalid);
}
}
// Apply after each downloader's defaults, replacing headers without changing their values.
foreach (var header in customHeaders.Headers.NonValidated)
{
request.Headers.Remove(header.Key);
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in customHeaders.Content.Headers.NonValidated)
{
request.Content ??= new ByteArrayContent([]);
request.Content.Headers.Remove(header.Key);
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return base.SendAsync(request, cancellationToken);
}
}
}
+16 -3
View File
@@ -75,15 +75,28 @@ public sealed class SQLiteHelper
public async Task DisposeDbConnectionAsync()
{
await Task.Factory.StartNew(() =>
await Task.Run(() =>
{
try
{
_db?.Close();
_db?.Dispose();
}
finally
{
_db = null;
}
_dbAsync?.GetConnection()?.Close();
_dbAsync?.GetConnection()?.Dispose();
try
{
var conn = _dbAsync?.GetConnection();
conn?.Close();
conn?.Dispose();
}
finally
{
_dbAsync = null;
}
});
}
}
+8 -10
View File
@@ -28,14 +28,13 @@ public class CertPemManager
/// Get certificate in PEM format from a server with CA pinning validation
/// </summary>
public async Task<(string?, string?)> GetCertPemAsync(string target, string serverName,
List<string>? verifyPeerCertByName = null, int timeout = 4)
List<string>? verifyPeerCertByName = null)
{
try
{
var (domain, _, port, _) = Utils.ParseUrl(target);
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var cts = new CancellationTokenSource(Global.LocalFetch);
using var client = new TcpClient();
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
@@ -63,8 +62,8 @@ public class CertPemManager
}
catch (OperationCanceledException)
{
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
return (null, $"Connection timeout after {timeout} seconds");
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds"));
return (null, $"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds");
}
catch (Exception ex)
{
@@ -77,15 +76,14 @@ public class CertPemManager
/// Get certificate chain in PEM format from a server with CA pinning validation
/// </summary>
public async Task<(List<string>, string?)> GetCertChainPemAsync(string target, string serverName,
List<string>? verifyPeerCertByName = null, int timeout = 4)
List<string>? verifyPeerCertByName = null)
{
var pemList = new List<string>();
try
{
var (domain, _, port, _) = Utils.ParseUrl(target);
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var cts = new CancellationTokenSource(Global.LocalFetch);
using var client = new TcpClient();
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
@@ -116,8 +114,8 @@ public class CertPemManager
}
catch (OperationCanceledException)
{
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
return (pemList, $"Connection timeout after {timeout} seconds");
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds"));
return (pemList, $"Connection timeout after {Global.LocalFetch.TotalSeconds} seconds");
}
catch (Exception ex)
{
+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",
},
+2 -2
View File
@@ -216,7 +216,7 @@ public class CoreManager
await _updateFunc?.Invoke(notify, msg);
}
private static async Task WaitForProxyPort(CoreConfigContext? preContext, int timeoutMs = 5000)
private static async Task WaitForProxyPort(CoreConfigContext? preContext)
{
if (preContext is null)
{
@@ -227,7 +227,7 @@ public class CoreManager
return;
}
using var rootCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeoutMs));
using var rootCts = new CancellationTokenSource(Global.LocalFetch);
var rootToken = rootCts.Token;
var port = preContext.Node.Port;
@@ -119,7 +119,7 @@ public class GroupProfileManager
p != null &&
p.IsValid() &&
(!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) &&
(extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter))
Utils.IsRegexMatch(p.Remarks, extra.Filter)
)
.ToList() ?? [];
}
+46 -55
View File
@@ -2,36 +2,37 @@ namespace ServiceLib.Manager;
public class PacManager
{
private static readonly Lazy<PacManager> _instance = new(() => new PacManager());
public static PacManager Instance => _instance.Value;
private int _httpPort;
private const string Tag = "PacManager";
private CancellationTokenSource? _cts;
private int _pacPort;
private TcpListener? _tcpListener;
private byte[] _writeContent;
private bool _isRunning;
private bool _needRestart = true;
private byte[] _writeContent = [];
public async Task StartAsync(int httpPort, int pacPort)
{
_needRestart = httpPort != _httpPort || pacPort != _pacPort || !_isRunning;
var content = await InitText(httpPort);
_writeContent = content;
_httpPort = httpPort;
_pacPort = pacPort;
await InitText();
if (_needRestart)
if (_tcpListener is not null && _pacPort == pacPort)
{
return;
}
Stop();
RunListener();
}
var cts = new CancellationTokenSource();
var listener = TcpListener.Create(pacPort);
listener.Start();
_cts = cts;
_pacPort = pacPort;
_tcpListener = listener;
_ = ListenLoopAsync(listener, cts.Token);
}
private async Task InitText()
private async Task<byte[]> InitText(int httpPort)
{
var customSystemProxyPacPath = AppManager.Instance.Config.SystemProxyItem?.CustomSystemProxyPacPath;
var fileName = (customSystemProxyPacPath.IsNotEmpty() && File.Exists(customSystemProxyPacPath))
var customSystemProxyPacPath = AppManager.Instance.Config.SystemProxyItem.CustomSystemProxyPacPath;
var fileName = customSystemProxyPacPath.IsNotEmpty() && File.Exists(customSystemProxyPacPath)
? customSystemProxyPacPath
: Path.Combine(Utils.GetConfigPath(), "pac.txt");
@@ -45,7 +46,7 @@ public class PacManager
}
var pacText = await File.ReadAllTextAsync(fileName);
pacText = pacText.Replace("__PROXY__", $"PROXY 127.0.0.1:{_httpPort};DIRECT;");
pacText = pacText.Replace("__PROXY__", $"PROXY 127.0.0.1:{httpPort};DIRECT;");
var sb = new StringBuilder();
sb.AppendLine("HTTP/1.0 200 OK");
@@ -54,59 +55,49 @@ public class PacManager
sb.AppendLine("Content-Length:" + Encoding.UTF8.GetByteCount(pacText));
sb.AppendLine();
sb.Append(pacText);
_writeContent = Encoding.UTF8.GetBytes(sb.ToString());
return Encoding.UTF8.GetBytes(sb.ToString());
}
private void RunListener()
private async Task ListenLoopAsync(TcpListener listener, CancellationToken token)
{
_tcpListener = TcpListener.Create(_pacPort);
_isRunning = true;
_tcpListener.Start();
Task.Factory.StartNew(async () =>
var buffer = new byte[1024];
try
{
while (_isRunning)
while (!token.IsCancellationRequested)
{
try
{
if (!_tcpListener.Pending())
{
await Task.Delay(10);
continue;
}
var client = await _tcpListener.AcceptTcpClientAsync();
await Task.Run(() => WriteContent(client));
using var client = await listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
await using var stream = client.GetStream();
_ = await stream.ReadAsync(buffer, token).ConfigureAwait(false);
await stream.WriteAsync(_writeContent, token).ConfigureAwait(false);
await stream.FlushAsync(token).ConfigureAwait(false);
}
catch
{
// ignored
}
}
}, TaskCreationOptions.LongRunning);
}
private void WriteContent(TcpClient client)
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
var stream = client.GetStream();
stream.Write(_writeContent, 0, _writeContent.Length);
stream.Flush();
}
catch (Exception ex)
{
Logging.SaveLog(Tag, ex);
}
finally
{
listener.Stop();
}
}
public void Stop()
{
if (_tcpListener == null)
{
return;
}
try
{
_isRunning = false;
_tcpListener.Stop();
_cts?.Cancel();
_tcpListener?.Stop();
_cts?.Dispose();
_cts = null;
_pacPort = 0;
_tcpListener = null;
}
catch
{
// ignored
}
}
}
+32 -8
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,14 +114,27 @@ 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 ");
lock (_profileExLock)
{
_lstProfileEx = [];
}
}
public async Task SaveTo()
{
+4 -10
View File
@@ -12,23 +12,17 @@ public class TaskManager
_config = config;
_updateFunc = updateFunc;
_ = Task.Factory.StartNew(
ScheduledTasks,
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
Task.Run(ScheduledTasks);
}
private async Task ScheduledTasks()
{
Logging.SaveLog("Setup Scheduled Tasks");
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
var numOfExecuted = 1;
while (true)
while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
{
//1 minute
await Task.Delay(1000 * 60);
//Execute once 1 minute
try
{
@@ -140,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);
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Configs;
[Serializable]
public class Config
{
#region property
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Configs;
[Serializable]
public class CoreBasicItem
{
public bool LogEnabled { get; set; }
@@ -22,7 +21,6 @@ public class CoreBasicItem
public bool EnableCacheFile4Sbox { get; set; } = true;
}
[Serializable]
public class InItem
{
public int LocalPort { get; set; }
@@ -38,7 +36,6 @@ public class InItem
public bool SecondLocalPortEnabled { get; set; }
}
[Serializable]
public class KcpItem
{
public int Mtu { get; set; }
@@ -54,7 +51,6 @@ public class KcpItem
public int MaxSendingWindow { get; set; }
}
[Serializable]
public class GrpcItem
{
public int? IdleTimeout { get; set; }
@@ -63,7 +59,6 @@ public class GrpcItem
public int? InitialWindowsSize { get; set; }
}
[Serializable]
public class GUIItem
{
public bool AutoRun { get; set; }
@@ -77,14 +72,12 @@ public class GUIItem
public string? RootCertProvider { get; set; }
}
[Serializable]
public class MsgUIItem
{
public string? MainMsgFilter { get; set; }
public bool? AutoRefresh { get; set; }
}
[Serializable]
public class UIItem
{
public bool EnableAutoAdjustMainLvColWidth { get; set; }
@@ -106,7 +99,6 @@ public class UIItem
public bool HideColumnIpInfo { get; set; }
}
[Serializable]
public class ConstItem
{
public string? SubConvertUrl { get; set; }
@@ -115,7 +107,6 @@ public class ConstItem
public string? RouteRulesTemplateSourceUrl { get; set; }
}
[Serializable]
public class KeyEventItem
{
public EGlobalHotkey EGlobalHotkey { get; set; }
@@ -129,7 +120,6 @@ public class KeyEventItem
public int? KeyCode { get; set; }
}
[Serializable]
public class CoreTypeItem
{
public EConfigType ConfigType { get; set; }
@@ -137,7 +127,6 @@ public class CoreTypeItem
public ECoreType CoreType { get; set; }
}
[Serializable]
public class TunModeItem
{
public bool EnableTun { get; set; }
@@ -153,7 +142,6 @@ public class TunModeItem
public string IPv6Address { get; set; }
}
[Serializable]
public class SpeedTestItem
{
public int SpeedTestTimeout { get; set; }
@@ -166,7 +154,6 @@ public class SpeedTestItem
public int? SpeedTestDelayInterval { get; set; }
}
[Serializable]
public class RoutingBasicItem
{
public string DomainStrategy { get; set; }
@@ -174,7 +161,6 @@ public class RoutingBasicItem
public string RoutingIndexId { get; set; }
}
[Serializable]
public class ColumnItem
{
public string Name { get; set; }
@@ -182,7 +168,6 @@ public class ColumnItem
public int Index { get; set; }
}
[Serializable]
public class Mux4RayItem
{
public int? Concurrency { get; set; }
@@ -190,7 +175,6 @@ public class Mux4RayItem
public string? XudpProxyUDP443 { get; set; }
}
[Serializable]
public class Mux4SboxItem
{
public string Protocol { get; set; }
@@ -198,7 +182,6 @@ public class Mux4SboxItem
public bool? Padding { get; set; }
}
[Serializable]
public class HysteriaItem
{
public int UpMbps { get; set; }
@@ -206,20 +189,18 @@ public class HysteriaItem
public int HopInterval { get; set; } = Global.Hysteria2DefaultHopInt;
}
[Serializable]
public class ClashUIItem
{
public bool EnableIPv6 { get; set; }
public bool EnableMixinContent { get; set; }
public int ProxiesSorting { get; set; }
public bool ProxiesAutoRefresh { get; set; }
public int ProxiesAutoDelayTestInterval { get; set; } = 10;
public int ProxiesRefreshInterval { get; set; } = 2;
public bool ConnectionsAutoRefresh { get; set; }
public int ConnectionsRefreshInterval { get; set; } = 2;
public List<ColumnItem> ConnectionsColumnItem { get; set; }
}
[Serializable]
public class SystemProxyItem
{
public ESysProxyType SysProxyType { get; set; }
@@ -230,7 +211,6 @@ public class SystemProxyItem
public string? CustomSystemProxyScriptPath { get; set; }
}
[Serializable]
public class WebDavItem
{
public string? Url { get; set; }
@@ -239,15 +219,13 @@ public class WebDavItem
public string? DirName { get; set; }
}
[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]
public class Fragment4RayItem
{
public string? Packets { get; set; }
@@ -262,7 +240,6 @@ public class Fragment4RayItem
// migration end
}
[Serializable]
public class WindowSizeItem
{
public string TypeName { get; set; }
@@ -270,7 +247,6 @@ public class WindowSizeItem
public int Height { get; set; }
}
[Serializable]
public class SimpleDNSItem
{
public bool? UseSystemHosts { get; set; }
@@ -293,7 +269,6 @@ public class SimpleDNSItem
public bool? EnableHappyEyeballs { get; set; }
}
[Serializable]
public class HappyEyeballs4RayItem
{
public int? TryDelayMs { get; set; }
@@ -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.
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.CoreConfigs;
[Serializable]
public class CoreInfo
{
public ECoreType CoreType { get; set; }
@@ -44,7 +44,6 @@ public class Route4Sbox
public string? final { get; set; }
}
[Serializable]
public class Rule4Sbox
{
public string? outbound { get; set; }
@@ -103,7 +102,6 @@ public class Rule4Sbox
public bool? speculative { get; set; }
}
[Serializable]
public class Inbound4Sbox
{
public string type { get; set; }
@@ -128,8 +126,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 +167,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
@@ -230,7 +230,7 @@ public class Reality4Sbox
public class Transport4Sbox
{
public string? type { get; set; }
public object? host { get; set; }
public JsonNode? host { get; set; }
public string? path { get; set; }
public Headers4Sbox? headers { get; set; }
@@ -271,10 +271,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 +357,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; }
}
@@ -3,7 +3,7 @@ namespace ServiceLib.Models.CoreConfigs;
public class V2rayConfig
{
public Log4Ray log { get; set; }
public object dns { get; set; }
public JsonObject? dns { get; set; }
public FakeDns4Ray? fakedns { get; set; }
public List<Inbounds4Ray> inbounds { get; set; }
public List<Outbounds4Ray> outbounds { get; set; }
@@ -75,8 +75,6 @@ public class Inboundsettings4Ray
public string? address { get; set; }
public List<UsersItem4Ray>? clients { get; set; }
public string? decryption { get; set; }
public bool? allowTransparent { get; set; }
@@ -94,21 +92,10 @@ public class Inboundsettings4Ray
public string? autoOutboundsInterface { get; set; }
public List<string>? dns { get; set; }
}
public class UsersItem4Ray
{
public string? id { get; set; }
public bool? autoSystemDnsToGateway { get; set; }
public int? alterId { get; set; }
public string? email { get; set; }
public string? security { get; set; }
public string? encryption { get; set; }
public string? flow { get; set; }
public List<string>? autoSystemWfpBlockLeak { get; set; }
}
public class Sniffing4Ray
@@ -137,17 +124,13 @@ public class Outbounds4Ray
public class Outboundsettings4Ray
{
public List<VnextItem4Ray>? vnext { get; set; }
public List<ServersItem4Ray>? servers { get; set; }
public Response4Ray? response { get; set; }
public int? userLevel { get; set; }
public string? secretKey { get; set; }
public object? address { get; set; }
public JsonNode? address { get; set; }
public int? port { get; set; }
@@ -159,7 +142,7 @@ public class Outboundsettings4Ray
public string? email { get; set; }
public object? headers { get; set; }
public JsonNode? headers { get; set; }
public List<WireguardPeer4Ray>? peers { get; set; }
@@ -174,29 +157,16 @@ public class Outboundsettings4Ray
public int? version { get; set; }
public List<string>? remoteDNS { get; set; }
}
public class WireguardPeer4Ray
{
public string endpoint { get; set; }
public string publicKey { get; set; }
public string? preSharedKey { get; set; }
}
public string? id { get; set; }
public class VnextItem4Ray
{
public string address { get; set; }
public int? alterId { get; set; }
public int port { get; set; }
public string? security { get; set; }
public List<UsersItem4Ray> users { get; set; }
}
public string? encryption { get; set; }
public class ServersItem4Ray
{
public string email { get; set; }
public string address { get; set; }
public string? flow { get; set; }
public string? method { get; set; }
@@ -204,24 +174,14 @@ public class ServersItem4Ray
public string? password { get; set; }
public int port { get; set; }
public int? level { get; set; }
public string flow { get; set; }
public bool? uot { get; set; }
public List<SocksUsersItem4Ray> users { get; set; }
}
public class SocksUsersItem4Ray
public class WireguardPeer4Ray
{
public string user { get; set; }
public string pass { get; set; }
public int? level { get; set; }
public string endpoint { get; set; }
public string publicKey { get; set; }
public string? preSharedKey { get; set; }
}
public class Mux4Ray
@@ -239,8 +199,8 @@ public class Response4Ray
public class Dns4Ray
{
public Dictionary<string, object>? hosts { get; set; }
public List<object> servers { get; set; }
public Dictionary<string, JsonNode>? hosts { get; set; }
public List<JsonNode> servers { get; set; }
public bool? serveStale { get; set; }
public bool? enableParallelQuery { get; set; }
public string? queryStrategy { get; set; }
@@ -266,7 +226,6 @@ public class Routing4Ray
public List<BalancersItem4Ray>? balancers { get; set; }
}
[Serializable]
public class RulesItem4Ray
{
public string? type { get; set; }
@@ -359,8 +318,9 @@ public class StreamSettings4Ray
public GrpcSettings4Ray? grpcSettings { get; set; }
public HysteriaSettings4Ray? hysteriaSettings { get; set; }
public MasqueSettings4Ray? masqueSettings { get; set; }
public object? finalmask { get; set; }
public JsonObject? finalmask { get; set; }
public Sockopt4Ray? sockopt { get; set; }
}
@@ -383,7 +343,6 @@ public class TlsSettings4Ray
public string? pinnedPeerCertSha256 { get; set; }
public bool? disableSystemRoot { get; set; }
public string? echConfigList { get; set; }
public string? echForceQuery { get; set; }
public Sockopt4Ray? echSockopt { get; set; }
}
@@ -402,9 +361,9 @@ public class Header4Ray
{
public string type { get; set; }
public object request { get; set; }
public JsonObject request { get; set; }
public object response { get; set; }
public JsonObject response { get; set; }
}
public class KcpSettings4Ray
@@ -450,7 +409,7 @@ public class XhttpSettings4Ray
public string? path { get; set; }
public string? host { get; set; }
public string? mode { get; set; }
public object? extra { get; set; }
public JsonObject? extra { get; set; }
}
public class HttpSettings4Ray
@@ -487,6 +446,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; }
@@ -13,4 +13,5 @@ public class ClashConnectionModel
public double Time { get; set; }
public string? Elapsed { get; set; }
public string? Chain { get; set; }
public string? ProcessPath { get; set; }
}
@@ -1,36 +1,37 @@
namespace ServiceLib.Models.Dto;
public class ClashConnections
public record ClashConnections
{
public ulong downloadTotal { get; set; }
public ulong uploadTotal { get; set; }
public List<ConnectionItem>? connections { get; set; }
public ulong downloadTotal { get; init; }
public ulong uploadTotal { get; init; }
public List<ConnectionItem>? connections { get; init; }
}
public class ConnectionItem
public record ConnectionItem
{
public string? id { get; set; }
public MetadataItem? metadata { get; set; }
public ulong upload { get; set; }
public ulong download { get; set; }
public DateTime start { get; set; }
public List<string>? chains { get; set; }
public string? rule { get; set; }
public string? rulePayload { get; set; }
public string? id { get; init; }
public MetadataItem? metadata { get; init; }
public ulong upload { get; init; }
public ulong download { get; init; }
public DateTime start { get; init; }
public List<string>? chains { get; init; }
public string? rule { get; init; }
public string? rulePayload { get; init; }
}
public class MetadataItem
public record MetadataItem
{
public string? network { get; set; }
public string? type { get; set; }
public string? sourceIP { get; set; }
public string? destinationIP { get; set; }
public string? sourcePort { get; set; }
public string? destinationPort { get; set; }
public string? host { get; set; }
public string? nsMode { get; set; }
public object? uid { get; set; }
public string? process { get; set; }
public string? processPath { get; set; }
public string? remoteDestination { get; set; }
public string? network { get; init; }
public string? type { get; init; }
public string? sourceIP { get; init; }
public string? destinationIP { get; init; }
public string? sourcePort { get; init; }
public string? destinationPort { get; init; }
public string? host { get; init; }
public string? nsMode { get; init; }
public object? uid { get; init; }
public string? process { get; init; }
public string? processPath { get; init; }
public string? remoteDestination { get; init; }
public string? sniffHost { get; init; }
}
@@ -1,11 +1,10 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public partial class ClashProxyModel : ReactiveObject
{
public required string Name { get; set; }
public string Name { get; set; }
public required string Type { get; set; }
public string Type { get; set; }
public string? Now { get; set; }
@@ -0,0 +1,3 @@
namespace ServiceLib.Models.Dto;
public sealed record LanguageOption(string Value, string Display);
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public partial class ProfileItemModel : ReactiveObject
{
public bool IsActive { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class RoutingItemModel : RoutingItem
{
}
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class RoutingTemplate
{
public string Version { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class RulesItemModel : RulesItem
{
public string InboundTags { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class ServerSpeedItem : ServerStatItem
{
public long ProxyUp { get; set; }
@@ -12,7 +11,6 @@ public class ServerSpeedItem : ServerStatItem
public long DirectDown { get; set; }
}
[Serializable]
public class TrafficItem
{
public ulong Up { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class ServerTestItem
{
public string? IndexId { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Dto;
[Serializable]
public class SpeedTestResult
{
public string? IndexId { get; set; }
-1
View File
@@ -5,7 +5,6 @@ public class SsSIP008
public List<SsServer>? servers { get; set; }
}
[Serializable]
public class SsServer
{
public string? remarks { get; set; }
@@ -3,7 +3,6 @@ namespace ServiceLib.Models.Dto;
/// <summary>
/// https://github.com/2dust/v2rayN/wiki/
/// </summary>
[Serializable]
public class VmessQRCode
{
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)]
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class DNSItem
{
[PrimaryKey]
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class FullConfigTemplateItem
{
[PrimaryKey]
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class ProfileExItem
{
[PrimaryKey]
@@ -1,7 +1,6 @@
namespace ServiceLib.Models.Entities;
[Obsolete("Use ProtocolExtraItem instead.")]
[Serializable]
public class ProfileGroupItem
{
[PrimaryKey]
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class ProfileItem
{
private ProtocolExtraItem? _protocolExtraCache;
@@ -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; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class RoutingItem
{
[PrimaryKey]
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class RulesItem
{
public string Id { get; set; }
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class ServerStatItem
{
[PrimaryKey]
+2 -1
View File
@@ -1,6 +1,5 @@
namespace ServiceLib.Models.Entities;
[Serializable]
public class SubItem
{
[PrimaryKey]
@@ -16,6 +15,8 @@ public class SubItem
public string UserAgent { get; set; } = string.Empty;
public string? RequestHeaders { get; set; }
public int Sort { get; set; }
public string? Filter { get; set; }
+73 -10
View File
@@ -555,6 +555,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 HTTP headers (JSON) 的本地化字符串。
/// </summary>
public static string LvRequestHeaders {
get {
return ResourceManager.GetString("LvRequestHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 Type 的本地化字符串。
/// </summary>
@@ -780,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>
@@ -915,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>
@@ -951,6 +978,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Columns 的本地化字符串。
/// </summary>
public static string menuColumns {
get {
return ResourceManager.GetString("menuColumns", resourceCulture);
}
}
/// <summary>
/// 查找类似 Close Connection 的本地化字符串。
/// </summary>
@@ -2140,7 +2176,7 @@ namespace ServiceLib.Resx {
}
/// <summary>
/// 查找类似 Not Support 的本地化字符串。
/// 查找类似 Not Supported 的本地化字符串。
/// </summary>
public static string MsgNotSupport {
get {
@@ -2634,6 +2670,24 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersInvalid {
get {
return ResourceManager.GetString("SubRequestHeadersInvalid", resourceCulture);
}
}
/// <summary>
/// 查找类似 Optional. Example: {&quot;X-hwid&quot;: &quot;my_device&quot;}. Values override default headers for all URLs in this group, including the subscription conversion service. 的本地化字符串。
/// </summary>
public static string SubRequestHeadersTips {
get {
return ResourceManager.GetString("SubRequestHeadersTips", resourceCulture);
}
}
/// <summary>
/// 查找类似 For group please leave blank here 的本地化字符串。
/// </summary>
@@ -4239,15 +4293,6 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Update via proxy 的本地化字符串。
/// </summary>
public static string TbSettingsEnableUpdateViaProxy {
get {
return ResourceManager.GetString("TbSettingsEnableUpdateViaProxy", resourceCulture);
}
}
/// <summary>
/// 查找类似 Enable sorting Configurations by drag-n-drop (requires restart) 的本地化字符串。
/// </summary>
@@ -4284,6 +4329,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Update via proxy 的本地化字符串。
/// </summary>
public static string TbSettingsEnableUpdateViaProxy {
get {
return ResourceManager.GetString("TbSettingsEnableUpdateViaProxy", resourceCulture);
}
}
/// <summary>
/// 查找类似 Exception 的本地化字符串。
/// </summary>
@@ -4995,6 +5049,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Process 的本地化字符串。
/// </summary>
public static string TbSortingProcess {
get {
return ResourceManager.GetString("TbSortingProcess", resourceCulture);
}
}
/// <summary>
/// 查找类似 Time 的本地化字符串。
/// </summary>
File diff suppressed because it is too large Load Diff
+22 -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>
@@ -1783,7 +1786,7 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<value>Only Check</value>
</data>
<data name="MsgNotSupport" xml:space="preserve">
<value>Not Support</value>
<value>Not Supported</value>
</data>
<data name="LvTestIpInfo" xml:space="preserve">
<value>IP Info</value>
@@ -1887,4 +1890,22 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux setting</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP headers (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>Optional. Example: {"X-hwid": "my_device"}. Values override default headers for all URLs in this group, including the subscription conversion service.</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>Invalid HTTP headers. Use a JSON object with unique header names and string values. Header names and values must not contain line breaks.</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>Process</value>
</data>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>Add [MASQUE]</value>
</data>
<data name="menuColumns" xml:space="preserve">
<value>Columns</value>
</data>
</root>
+38 -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>
@@ -693,6 +696,9 @@
<data name="TbSettingsEnableCheckPreReleaseUpdate" xml:space="preserve">
<value>Проверить наличие предварительных обновлений</value>
</data>
<data name="TbSettingsEnableUpdateViaProxy" xml:space="preserve">
<value>Обновлять через прокси</value>
</data>
<data name="TbSettingsException" xml:space="preserve">
<value>Исключение</value>
</data>
@@ -1020,6 +1026,15 @@
<data name="TbSettingsMux4SboxProtocol" xml:space="preserve">
<value>Протокол Mux для sing-box</value>
</data>
<data name="TbSettingsMux4RayConcurrency" xml:space="preserve">
<value>Лимит дочерних соединений Mux для Xray</value>
</data>
<data name="TbSettingsMux4RayXudpConcurrency" xml:space="preserve">
<value>Лимит дочерних XUDP-соединений Mux для Xray</value>
</data>
<data name="TbSettingsMux4RayXudpProxyUDP443" xml:space="preserve">
<value>Обработка UDP443 (QUIC) в Mux для Xray</value>
</data>
<data name="TbRoutingRuleProcess" xml:space="preserve">
<value>Процесс (Linux/Windows)</value>
</data>
@@ -1768,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>
@@ -1869,4 +1884,25 @@
<data name="TbBlockAAAAQueriesTips" xml:space="preserve">
<value>При включении блокирует DNS-запросы IPv6</value>
</data>
<data name="TbDNS" xml:space="preserve">
<value>DNS</value>
</data>
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Настройки Mux для Xray</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP-заголовки (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>Необязательно. Пример: {"X-hwid": "my_device"}. Заданные значения переопределяют заголовки по умолчанию для всех URL этой группы, включая сервис конвертации подписок</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>Некорректные HTTP-заголовки. Используйте JSON-объект с уникальными именами заголовков и строковыми значениями. Имена и значения заголовков не должны содержать переносов строк</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>Процесс</value>
</data>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>Добавить сервер [MASQUE]</value>
</data>
</root>
+21
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>
@@ -1879,4 +1882,22 @@
<data name="TbSettingsMux4Ray" xml:space="preserve">
<value>Xray Mux 设置</value>
</data>
<data name="LvRequestHeaders" xml:space="preserve">
<value>HTTP 请求头 (JSON)</value>
</data>
<data name="SubRequestHeadersTips" xml:space="preserve">
<value>可选,示例:{"X-hwid": "my_device"}。自定义值覆盖默认请求头,应用于本组所有订阅地址(包括订阅转换服务)。</value>
</data>
<data name="SubRequestHeadersInvalid" xml:space="preserve">
<value>HTTP 请求头无效。请使用 JSON 对象,键名不能重复,值必须为字符串;请求头名称和值不能包含换行符。</value>
</data>
<data name="TbSortingProcess" xml:space="preserve">
<value>进程</value>
</data>
<data name="menuAddMasqueServer" xml:space="preserve">
<value>添加 [MASQUE]</value>
</data>
<data name="menuColumns" xml:space="preserve">
<value>列</value>
</data>
</root>

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