Compare commits

...

426 Commits

Author SHA1 Message Date
2dust eee43288a4 up 7.21.3 2026-05-10 17:58:35 +08:00
2dust 61ff871dd2 Update GlobalHotKeys 2026-05-10 17:54:50 +08:00
DHR60 f8bc706cda Fix (#9271)
* Fix

* Fix res and uri

* Fix core config
2026-05-10 14:21:51 +08:00
2dust e7973840ce up 7.21.2 2026-05-08 20:11:01 +08:00
VinnyTheFemboy 212071681d Fix bind interface handling in desktop and sing-box (#9258)
* Fix desktop bind interface setting

* Fix sing-box bind interface config

* Update CoreConfigSingboxServiceTests.cs

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-05-08 19:34:25 +08:00
tt2563 0f9bfeb275 Update Traditional Chinese translation (#9253)
* Update traditional Chinese translations in ResUI

 Update Traditional Chinese translation

* Update binding interface tip for clarity
2026-05-08 19:13:05 +08:00
VinnyTheFemboy f5059f1165 Fix sing-box TUN custom config inbound (#9259) 2026-05-08 19:12:09 +08:00
DHR60 2dc967bc04 Fix (#9251) 2026-05-07 10:11:41 +08:00
DHR60 75ea81dd69 Inner uri (#9245)
* Inner uri import and export

* Add tests

* Fix

* Compress export length
2026-05-06 20:33:35 +08:00
DHR60 d13f7a4db6 Fix json compact (#9246) 2026-05-06 15:36:26 +08:00
DHR60 f073b14fcc Add PR test (#9244)
* Add pr test

* Fix
2026-05-06 15:35:53 +08:00
DHR60 1a44af33d0 Wireguard (#9241)
* Adjust mtu

* Add Wireguard PresharedKey

* WireGuard config import

* AI opt

* Opt
2026-05-06 15:35:10 +08:00
DHR60 8450f2e420 Add bind interface (#9222) 2026-05-04 14:38:32 +08:00
DHR60 37ef25cbfe Fix (#9235)
* Perf routing

* GeoPrefix move to Global

* Fix negative rules

* Fix
2026-05-03 16:09:47 +08:00
DHR60 0fac18ba95 Fix (#9230) 2026-05-02 19:01:51 +08:00
DHR60 3ccd59d1dc Fix (#9224) 2026-05-02 19:01:31 +08:00
2dust 6c38a08f12 up 7.21.1 2026-04-30 20:13:34 +08:00
2dust f8f7fee461 Disable auto-adjust main list column width desktop 2026-04-30 10:03:10 +08:00
2dust 3e157b0d62 Update 'CheckServerSettings' message 2026-04-29 20:23:08 +08:00
Miheichev Aleksandr Sergeevich 49d197e37f i18n(ru): translate new strings and fix terminology casing (#9207)
Translate 11 new strings introduced in upstream and fix casing of
several technical terms in ResUI.ru.resx.

New translations (9 existing + 2 added):
- TbAllowInsecureCertFetch, TbAllowInsecureCertFetchTips — insecure
  certificate fetch checkbox and MITM warning tooltip
- TbHost — host field label
- TbSettingsDefUserAgentTips — updated transport list
  (raw/http, ws, gRPC, xhttp)
- TbSettingsSendThrough, TbSettingsSendThroughTip,
  FillCorrectSendThroughIPv4 — local outbound IPv4 setting
- TbSettingsUdpTestUrl, menuUdpTestServer — UDP test URL and menu item
- TransportExtra, TransportExtraTip — XHTTP Extra raw JSON

Terminology fixes:
- "V2ray" → "v2ray" (core name is lowercase by convention)
- "[Anytls]" → "[AnyTLS]" (canonical protocol spelling)
- "LAN порт" → "LAN-порт" (hyphen per Russian grammar)

All translations use canonical casing for protocols (VMess, VLESS,
Shadowsocks, Trojan, WireGuard, Hysteria, TUIC, AnyTLS), cores
(Xray, sing-box, mihomo, v2ray) and abbreviations (TLS, DNS, UUID,
HTTP, IPv4, MTU, TUN, PAC, SOCKS, gRPC, XHTTP).
2026-04-29 14:43:35 +08:00
DHR60 b6f2912f29 Remove EchForceQuery (#9214) 2026-04-29 14:43:13 +08:00
2dust 05e349e45c Code clean 2026-04-26 19:24:57 +08:00
2dust ae662a628d Remove TbSettingsRemoteDNS and update DNS doc 2026-04-26 17:50:10 +08:00
2dust 6e85f79852 Preserve complex profile items during deduplication
https://github.com/2dust/v2rayN/issues/9184
2026-04-25 10:55:51 +08:00
DHR60 0af29f50c0 UDP Test (#8999)
* UDP Test

Increases UDP test timeout

Pref exception

Fix

Add Minecraft Bedrock Edition Test

* Optimization

* Refactor

* Rename
2026-04-25 10:45:45 +08:00
DHR60 ee6ae3d91d Add kcp mtu (#9178)
* Add kcp mtu

* Typo
2026-04-25 10:39:39 +08:00
2dust c4e071cac3 up 7.21.0 2026-04-24 14:39:38 +08:00
2dust 798831128a Update Directory.Packages.props 2026-04-24 14:34:05 +08:00
DHR60 a2de087aef Fix (#9171) 2026-04-21 17:17:25 +08:00
DHR60 89bc012c95 Fix (#9166) 2026-04-21 09:51:28 +08:00
2dust 39ef5d8174 Default to first sub; update SubIndexId on delete
https://github.com/2dust/v2rayN/issues/9151
2026-04-20 19:23:45 +08:00
DHR60 90b055e364 Remove legacy sing-box dns compatibility (#9163) 2026-04-20 18:58:56 +08:00
DHR60 d67321eed0 Add more test (#9162)
* Add test
Add more test and fmt test

* Update to xunit.v3
2026-04-20 18:58:36 +08:00
Bonjour LI 67592d1922 CI: Sleep 0-2s for race condition between matrix jobs (#9161) 2026-04-20 18:58:26 +08:00
DHR60 c5db319e0e URL test apply fragment (#9157) 2026-04-20 09:19:26 +08:00
Bonjour LI 6a7b359fcc CI: Explicitly define GitHub token permissions in config (#9155)
GitHub has updated its behavior: new forks no longer inherit the original repository's GitHub token permissions. 
To ensure consistent access, this change explicitly defines the required permissions in the configuration file.
2026-04-20 09:15:23 +08:00
DHR60 25d7f393b6 Fetch cert allow insecure (#8998) 2026-04-19 17:16:06 +08:00
DHR60 171ed6f58f Custom MessageBoxDialog (#9147) 2026-04-19 17:06:15 +08:00
Mangoo b604a5b787 fix: remove Save/Cancel from routing settings, save edits immediately (#9133) 2026-04-19 14:25:53 +08:00
DHR60 35b98f945f Support new fragment (#9122) 2026-04-19 13:42:55 +08:00
DHR60 cabd0df282 Support kcp cwndMultiplier (#9113) 2026-04-18 19:19:03 +08:00
DHR60 eeecef4db9 Fix (#9143)
* Adjust XHTTP style

* Add xray tun custom support
2026-04-18 19:18:17 +08:00
DHR60 021e64e20b Fix (#9141) 2026-04-18 15:23:42 +08:00
JieXu 452478434c Fix & Update (#9126)
* Update build-linux.yml

* Update build-all.yml

* Update build-linux.yml

* Update build-linux.yml

* Update build-linux.yml

* Update OptionSettingWindow.axaml.cs

* Update ResUI.fr.resx

* Update package-rhel-riscv.sh

* Update build-linux.yml

* Update build-linux.yml

* Update build-all.yml

---------

Co-authored-by: xujie86 <167618598+xujie86@users.noreply.github.com>
2026-04-17 15:29:51 +08:00
DHR60 5305b0843b Fix (#9128) 2026-04-17 13:36:42 +08:00
DHR60 9f0ef36cc0 Refactor Transport (#9004)
* Refactor transport

* Rename tcp to raw

* Fix

* Fix

Fix raw http ui

Fill xhttp default mode

Fix share uri

Remove RawHost

Fix singbox tcp http path

Fix vmess share uri

* Tidy Resx

* Fix

* Rename TransportExtra to TransportExtraItem

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-16 20:21:10 +08:00
DHR60 494b35c1f7 Add xray tun support (#9063)
* Add xray tun support

* Revert mtu list
2026-04-16 19:17:44 +08:00
2dust 177ad7db3d up 7.20.4 2026-04-16 13:39:52 +08:00
JieXu 3bda022574 Add RiscV64 RPM build && Update (#9114)
* Create package-rhel-riscv.sh

* Update build-linux.yml

* Update UpdateService.cs

* Update CoreInfo.cs

* Add RiscV64 download URLs for various platforms

* Update build-linux.yml

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update proxy_set_linux_sh

---------

Co-authored-by: xujie86 <167618598+xujie86@users.noreply.github.com>
2026-04-15 20:50:27 +08:00
DHR60 2ea9c5a2ff Fix mux (#9117) 2026-04-15 19:06:26 +08:00
DHR60 3cb2869920 Fix (#9110) 2026-04-15 19:06:03 +08:00
Bonjour LI 43dcb90632 CI Workflow fix: rpm/deb runs-on (#9106) 2026-04-14 14:29:52 +08:00
Bonjour LI e56fca05fc Refactor and Optimize CI Workflow (#9083)
* Refactor and Optimize CI Workflow

* Refactor and Optimize CI Workflow (Added input option value annotations for clarity.)
2026-04-14 09:41:07 +08:00
DHR60 495b5db4f1 Adjust DNS priority order (#9091) 2026-04-13 10:59:37 +08:00
DHR60 dea143b20d Add sing-box ua (#9087)
* Add sing-box ua

* Add curl and fix
2026-04-13 10:58:40 +08:00
2dust 0db611b7a9 up 7.20.3 2026-04-12 10:00:43 +08:00
2dust c3d67d186a Update xunit.runner and fix socks port names 2026-04-12 10:00:14 +08:00
DHR60 6b07ca63a0 Add sing-box send-through (#9081)
* Add sing-box send-through

* Perf resx
2026-04-12 09:25:17 +08:00
renwofei423 6c8f22ab86 feat(send-through): 支持配置本地出站地址 (#8946)
* feat(send-through): 支持配置本地出站地址

为 Xray 增加 SendThrough 配置项,允许指定本机 IPv4 作为出站源地址。

- 在核心设置页新增 SendThrough 输入框及中英文提示文案
- 保存配置时校验并持久化本机 IPv4 地址
- 生成 Xray 配置时为所有 outbound 写入 sendThrough 字段

影响说明:
- 仅对 Xray 生效,留空时不设置该字段

* fix(send-through): limit sendThrough to remote egress outbounds
2026-04-11 21:02:43 +08:00
DHR60 49f65579aa Replace protect-ss with protect-socks (#9052) 2026-04-11 19:45:23 +08:00
dependabot[bot] a69e407bda Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#9073)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  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-04-11 19:42:39 +08:00
taskmgr818 96e5c11fc7 Replace 100MB Cloudflare speedtest URL with 99MB (#9076) 2026-04-11 19:39:25 +08:00
2dust 53041906b3 Use LibraryImport for native APIs 2026-04-06 20:16:30 +08:00
2dust f2d929f40e Update GlobalHotKeys 2026-04-06 20:00:48 +08:00
2dust 1160d8c154 Add Windows screen QR scan support for avalonia 2026-04-06 15:34:25 +08:00
2dust 75f2cbaef9 Code clean 2026-04-05 17:18:28 +08:00
2dust 55b08de5fe up 7.20.2 2026-04-04 20:36:18 +08:00
2dust 14d598a232 Update Directory.Packages.props 2026-04-04 20:35:11 +08:00
nirvanalinlei b3102b34b3 修复 sing-box TUN 模式下 relay 出站的 mux 问题 (#9018)
* Fix sing-box selector generation for dynamic group tags

* 修复 TUN 模式下 DNS 失败及 Windows 提权重启后 TUN 状态丢失

* 按上游意见回滚 TUN 提权时保留启用状态的改动
2026-04-02 20:47:34 +08:00
Miheichev Aleksandr Sergeevich b556adaa09 i18n(ru): complete and improve Russian localization (#9021)
Translate 9 previously untranslated strings and improve 104 existing
translations in ResUI.ru.resx for accuracy, consistency and
naturalness.

New translations:
- TbCustomDnsRay, TbCustomDnsSingbox — custom DNS labels
- TbIcmpRoutingPolicy, TbInsecureConcurrency, TbLegacyProtect, TbUot
- TbUsername, menuEditFormat, menuEditPaste

Key improvements:
- Unify "перезагрузка" → "перезапуск" for app restart context
- Fix grammar: missing spaces before parentheses, hyphen in
  "по умолчанию", capitalization
- Restore lost parts of original strings (Enter hint, restart notes,
  process in auto-sort list)
- Fix incorrect translations: "системные узлы" → "системный файл
  hosts", "PublicKey" → "Открытый ключ", "Режим VPN" → "Включить TUN"
- Improve naturalness: "Обнулить" → "Сбросить", "Просмотр" → "Обзор",
  "Содействие" → "Продвижение"
- Align checkbox labels to infinitive form per Russian UI conventions
- Standardize terminology across all proxy, routing, DNS settings

15 universal technical abbreviations (LAN, UUID, MTU, TLS, ALPN, SNI,
FakeIP, Bootstrap DNS, etc.) intentionally kept as-is.
2026-04-01 09:47:15 +08:00
DHR60 fce86e1434 Finalmask check (#9024) 2026-03-31 19:33:25 +08:00
DHR60 70003e8a81 Report node validator result (#9025) 2026-03-31 19:30:11 +08:00
JieXu e19b000081 fix (#9017)
* Update package-rhel.sh

* Enhance download_singbox function to handle cronet
2026-03-31 19:26:53 +08:00
2dust 7329dbae11 up 7.20.1 2026-03-30 20:46:52 +08:00
nirvanalinlei 695a073cd6 Fix sing-box selector generation for dynamic group tags (#9015) 2026-03-30 20:12:36 +08:00
DHR60 01c85adedf Show clash ui when tun enabled (#9010) 2026-03-30 20:09:10 +08:00
2dust 2caf8ea14f Bug fix
https://github.com/2dust/v2rayN/issues/9016
2026-03-30 19:49:37 +08:00
2dust 1090afd774 up 7.20.0 2026-03-29 15:05:55 +08:00
2dust c758c5abf9 Update Directory.Packages.props 2026-03-29 15:05:30 +08:00
DHR60 c61b023ab3 Allow enable legacy process name tun protect (#9005) 2026-03-29 14:44:04 +08:00
DHR60 80178aeb2f Fix xhttp dialer proxy (#8993) 2026-03-26 19:55:53 +08:00
DHR60 005cb620ec Add xhttp download protect (#8987) 2026-03-24 19:18:54 +08:00
DHR60 7ddb46e74d Xray browser header masquerading (#8981) 2026-03-24 11:25:02 +08:00
DHR60 ad11a7e6a5 Support new hysteria2 stream settings (#8908)
* Support new hysteria2 stream settings

* Fix

* Sync
2026-03-24 11:24:42 +08:00
DHR60 92c8c1463c Fix (#8985) 2026-03-24 11:24:26 +08:00
DHR60 661affd6a5 Tag add remark (#8980) 2026-03-23 09:12:14 +08:00
DHR60 14c5b92423 Fix edge case (#8979) 2026-03-23 09:11:33 +08:00
tt2563 74e5ead1ed Update Traditional Chinese translation (#8973)
* Update Traditional Chinese translation

* Update confirmation message for removing nodes
2026-03-22 14:33:47 +08:00
DHR60 2caec729fc Protect xhttp split address (#8970) 2026-03-21 19:59:52 +08:00
DHR60 194c240243 Add ICMP routing (#8894) 2026-03-21 19:55:34 +08:00
DHR60 db9fe9c5ea Fix (#8972) 2026-03-21 19:47:04 +08:00
DHR60 bbfd93f5a3 Add sing-box ech query server name (#8869) 2026-03-21 16:55:43 +08:00
DHR60 04783ecf44 Add NaiveProxy support (#8819)
* Add UoT support

* Add NaiveProxy support

* Fix
2026-03-21 16:54:36 +08:00
2dust 5fbcc46013 up 7.19.5 2026-03-20 17:59:19 +08:00
2dust 90f7b8b751 Update Directory.Packages.props 2026-03-20 16:45:55 +08:00
DHR60 dd94199bbb Json editor with syntax highlighting (#8932)
* Add json editor

* Replace JsonEditor with TextBox

* Replace JsonEditor with TextBox

* Remove TextMateSharp.Grammars

* Fix two way bind

* Update ResUI.ru.resx

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-03-20 16:32:02 +08:00
Miheichev Aleksandr Sergeevich 0cec5986cd i18n(ru): translate 68 untranslated strings in Russian localization (#8931)
Complete the Russian (ru) localization by translating all remaining
English strings in ResUI.ru.resx. Categories include:
- Policy Group and Proxy Chain UI elements
- Routing and DNS settings/tips
- Certificate Pinning UI
- Core error and warning messages
- Server list and menu items
- System proxy and miscellaneous settings

5 universal technical abbreviations (LAN, UUID, User-Agent, MTU, TLS)
are intentionally kept as-is per standard Russian IT terminology.
2026-03-20 16:19:37 +08:00
JieXu a2929c6086 Update package-debian.sh (#8941)
* Update package-debian.sh

* Update build-linux.yml

* Update package-debian.sh

* Update package-debian.sh

* Update package-debian.sh

* Update package-rhel.sh

* Update package-debian.sh

* Update package-debian.sh

* Update package-rhel.sh

* Update package-debian.sh

* Update package-rhel.sh

* Update package-debian.sh

* Update package-rhel.sh

* Update package-rhel.sh

* Update package-rhel.sh

* Update package-debian.sh

* Update package-rhel.sh
2026-03-20 14:25:42 +08:00
2dust eb0ef90ed2 Bug fix
https://github.com/2dust/v2rayN/issues/8906
2026-03-15 20:11:45 +08:00
2dust 214a09bc48 up 7.19.4 2026-03-13 10:44:20 +08:00
2dust e6af9ab342 Fix
https://github.com/2dust/v2rayN/issues/8916
2026-03-13 10:36:58 +08:00
DHR60 0f4031f445 Update dep (#8926) 2026-03-12 20:45:28 +08:00
2dust 5cf3d6eff6 Modify routing rule process name description 2026-03-12 17:19:30 +08:00
2dust 17ed26cd06 Improve profile matching for Subscription, remove old option 2026-03-12 14:56:01 +08:00
2dust 5e18567ce6 Modify routing rule process name description 2026-03-12 14:21:21 +08:00
2dust 06cec89ec9 up 7.19.3 2026-03-10 17:38:03 +08:00
2dust 26f65dd3b2 Code cleanup: pattern matching and minor fixes 2026-03-10 17:19:21 +08:00
2dust 0c2114d2e1 Refactor SRS rule collection and add DNS parsing 2026-03-10 15:29:36 +08:00
DHR60 4af528f8e2 Typo (#8917) 2026-03-10 14:10:23 +08:00
DHR60 588e82f0d9 Tun ech protect (#8915) 2026-03-10 09:16:16 +08:00
DHR60 0c13488410 Fix (#8914)
* Fix

* Fix
2026-03-10 09:14:57 +08:00
DHR60 a88396c11d Fix custom config sub chain (#8913) 2026-03-10 09:13:52 +08:00
DHR60 ef5fee9975 Fix (#8909) 2026-03-08 19:00:27 +08:00
2dust df800a60c2 Persist DataGrid column layout for ClashConnectionsView 2026-03-08 18:59:56 +08:00
2dust 679bd8afcc Persist DataGrid column layout for ClashConnectionsView
https://github.com/2dust/v2rayN/issues/8893
2026-03-08 17:57:09 +08:00
DHR60 66e1aeae1f Fix speedtest core type (#8900)
* Fix speedtest core type

* Simplify code
2026-03-07 16:36:03 +08:00
dependabot[bot] e03c22092f Bump actions/setup-dotnet from 5.0.1 to 5.2.0 (#8891)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.0.1 to 5.2.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.0.1...v5.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.2.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-03-07 16:33:41 +08:00
2dust c0aa829abb up 7.19.2 2026-03-05 09:49:46 +08:00
DHR60 b8f7cc0768 Fix hosts matching (#8890)
* Fix hosts matching

* Fix hosts resolve rule

* Fix
2026-03-04 20:13:06 +08:00
2dust 81da72bb39 Fix
https://github.com/2dust/v2rayN/issues/8881
2026-03-04 19:11:31 +08:00
2dust d9201157c8 Bug fix
https://github.com/2dust/v2rayN/issues/8875
2026-03-04 18:58:59 +08:00
2dust e179d5bc42 Fix
https://github.com/2dust/v2rayN/issues/8870
2026-03-03 16:03:23 +08:00
2dust 4d2f32099e Bug fix
https://github.com/2dust/v2rayN/issues/8879
2026-03-03 10:07:48 +08:00
2dust f24a79aa2c Bug fix
https://github.com/2dust/v2rayN/issues/8875
2026-03-02 20:18:07 +08:00
2dust 9a3604e89b Bug fix
https://github.com/2dust/v2rayN/issues/8874
2026-03-02 20:00:02 +08:00
DHR60 fd7cf0d453 Fix xray custom dns (#8872) 2026-03-02 19:46:01 +08:00
tt2563 65cf782eb0 更新繁體中文翻譯 (#8873)
Co-authored-by: ertet <sfsa@sdaf.cc>
2026-03-02 19:44:53 +08:00
2dust bfa9eaa5ec up 7.19.1 2026-03-02 09:31:57 +08:00
2dust cea725ae3d Update Directory.Packages.props 2026-03-02 09:31:34 +08:00
DHR60 c9df9a0001 Fix (#8868) 2026-03-01 19:44:44 +08:00
DHR60 56f1794e47 Fix DNS rule (#8866) 2026-03-01 18:38:16 +08:00
DHR60 a71ebbd01c Optimize (#8862)
* Relax group type restrictions

* Optimize db read
2026-03-01 17:41:59 +08:00
DHR60 9f6237fb21 Speedtest respect user-set core type (#8856)
* Respect user-set core type

* Allow test group
2026-03-01 16:11:40 +08:00
DHR60 99d67ca3f1 Fix DNS routing (#8841) 2026-03-01 14:41:02 +08:00
2dust d56e896f07 Ignore json file extensions in IsDomain 2026-03-01 14:20:05 +08:00
DHR60 6b4ae5a386 Fix (#8864)
* Fix

* Build all contexts

* Notify validator result

* Fix
2026-03-01 13:47:48 +08:00
DHR60 a3ff31088e Perf Policy Group generate (#8855)
* Perf Policy Group generate

* Scroll to new group node

* Add region group

* I18n

* Fix

* Fix

* Move default filter to Global

* Default Filter List

* Increase UI column widths for AddGroupServerWindow

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-03-01 10:05:03 +08:00
DHR60 584e538623 Refactor node pre check (#8848)
* Refactor node pre check

* Rename method

* I18n

* Add remark not found warnings

* Fix

* Fix

* Fix

* Update v2rayN/ServiceLib/Handler/CoreConfigHandler.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-28 16:31:02 +08:00
JieXu 67c4ae02ba Fix bugs (#8854)
* Update package-rhel.sh

* Update package-debian.sh

* Update ResUI.fr.resx

* Update package-rhel.sh

* Update package-rhel.sh
2026-02-27 15:15:31 +08:00
2dust ed1275e29f Upgrade Downloader to 4.1.1 and update API usage 2026-02-27 11:21:51 +08:00
dependabot[bot] 0cf07e925f Bump actions/download-artifact from 7 to 8 (#8851)
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-02-27 11:04:20 +08:00
dependabot[bot] 49e487886d Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#8850)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6.0.0...v7.0.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  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-02-27 11:04:16 +08:00
DHR60 ad07f281c7 Fix routing (#8849)
Fix
2026-02-27 11:04:00 +08:00
2dust f98f517368 up 7.19.0 2026-02-26 19:58:34 +08:00
2dust 7931058342 Centralize active network info retrieval 2026-02-26 17:34:57 +08:00
2dust b53507f486 Update Directory.Packages.props 2026-02-26 15:40:03 +08:00
DHR60 68ea10158a Fix udphop (#8843) 2026-02-26 15:08:50 +08:00
DHR60 2f35e7a99c Fix fragment (#8840) 2026-02-26 15:07:24 +08:00
DHR60 3c1ecf085b Tun protect (#8779)
* Tun protect

* Remove EnableExInbound

* Fix

* Fix balancer tag

* Fix

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-02-26 15:06:17 +08:00
DHR60 3a5293bf87 Fix cert separator (#8837) 2026-02-25 19:30:29 +08:00
DHR60 ac43bb051d Node test with sub chain (#8778) 2026-02-25 19:28:20 +08:00
DHR60 7b31bcdd9f Add Finalmask support (#8820)
* Add Finalmask support

* UI
2026-02-24 21:09:17 +08:00
DHR60 aea7078e95 Add IP cert support (#8833) 2026-02-24 19:58:23 +08:00
DHR60 9c82df5b49 Refactor core config gen (#8768)
* Refactor core config gen

* Update tag naming

* Support sing-box 1.11 DNS

* Fix

* Optimize ProfileItem acquisition speed

* Fix

* Fix
2026-02-24 19:48:59 +08:00
JieXu b5800f7dfc Update SingboxDnsService.cs (#8775)
* Update Utils.cs

* Update SingboxDnsService.cs

* Update package-rhel.sh

* Update package-rhel.sh

* Withdraw
2026-02-07 19:31:35 +08:00
DHR60 0f3a3eac02 Group preview (#8760)
* Group Preview

* Fix
2026-02-06 14:33:58 +08:00
2dust 54608ab2b9 Adjust GroupProfileManager 2026-02-06 14:15:21 +08:00
2dust 6167624443 Rename ProfileItems to ProfileModels and refactor 2026-02-06 13:50:47 +08:00
2dust 7a58e78381 Refactor profile migration and add group handling 2026-02-06 11:45:26 +08:00
DHR60 677e81f9a7 Refactor profile item config (#8659)
* Refactor

* Add hysteria2 bandwidth and hop interval support

* Upgrade config version and rename

* Refactor id and security

* Refactor flow

* Fix hy2 bbr

* Fix warning CS0618

* Remove unused code

* Fix hy2 migrate

* Fix

* Refactor

* Refactor ProfileItem protocol extra handling

* Refactor, use record instead of class

* Hy2 SalamanderPass

* Fix Tuic

* Fix group

* Fix Tuic

* Fix hy2 Brutal Bandwidth

* Clean Code

* Fix

* Support interval range

* Add Username

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-02-05 19:48:33 +08:00
DHR60 d9843dc775 Add DNS Hosts features (#8756) 2026-02-05 17:05:27 +08:00
2dust bceebc1661 Add process to routing domains
https://github.com/2dust/v2rayN/issues/8757
2026-02-05 16:52:48 +08:00
2dust fdb733fa72 up 7.18.0 2026-02-04 15:19:54 +08:00
2dust 8d01d8fda5 Remove windows-64-SelfContained build/artifact steps 2026-02-04 15:13:10 +08:00
DHR60 018d541910 Update CA List (#8755) 2026-02-04 14:35:40 +08:00
DHR60 7e2e66bb0e Add DNS features (#8729)
* Simplify DNS Settings

* Add ParallelQuery and ServeStale features

* Fix

* Add Tips

* Simplify Predefined Hosts
2026-02-04 14:35:26 +08:00
2dust 3cb640c16b Add IP validation and improve hosts parsing
https://github.com/2dust/v2rayN/issues/8752
2026-02-04 14:33:34 +08:00
DHR60 fdde837698 Add xray v26.1.31 finalmask support (#8732)
* Add xray v26.1.31 hysteria2 support

* Add xray v26.1.31 mkcp support
2026-02-04 10:34:07 +08:00
2dust c7afef3d70 Update Directory.Packages.props 2026-02-04 10:15:40 +08:00
2dust 19d4f1fa83 Enable sudo for mihomo core in TUN mode
https://github.com/2dust/v2rayN/issues/8673
2026-02-04 10:15:36 +08:00
2dust 7678ad9095 up 7.17.3 2026-02-01 15:49:00 +08:00
dependabot[bot] 585c24526f Bump actions/checkout from 6.0.1 to 6.0.2 (#8694)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.0.1...v6.0.2)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.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-02-01 15:35:19 +08:00
DHR60 eb0f5bafde Disable insecure when cert pinned (#8733) 2026-02-01 15:34:04 +08:00
JieXu d589713fd5 Update build-linux.yml (#8724) 2026-01-31 11:40:21 +08:00
DHR60 4550ddb14e Bug fix (#8728) 2026-01-31 10:34:25 +08:00
雨落 ffe401a26d Accept hosts.ics as a host file on windows. (#8714)
Signed-off-by: 秋雨落 <i@rain.cx>
2026-01-30 15:32:06 +08:00
2dust 8774e302b2 up 7.17.2 2026-01-30 10:43:01 +08:00
2dust df016dd55c Bug fix
https://github.com/2dust/v2rayN/issues/8720
2026-01-30 10:35:00 +08:00
2dust 9ea80671d3 up 7.17.1 2026-01-18 19:32:44 +08:00
2dust 449849d8e8 Update Directory.Packages.props 2026-01-18 19:25:13 +08:00
DHR60 03b62b3d78 Fix (#8658) 2026-01-17 19:35:56 +08:00
DHR60 9f9b90cb97 Add hysteria2 uri cert sha pinning support (#8657) 2026-01-17 16:22:26 +08:00
DHR60 c42dcd2876 Add process matching rules support (#8643)
* Add process matching rules support

* Fix
2026-01-17 16:08:36 +08:00
2dust 2fefafdd37 Add support for CoreType7 (Hysteria2) in option settings 2026-01-17 16:06:29 +08:00
DHR60 2c9a90c878 Add xray hysteria2 outbound support (#8630) 2026-01-17 15:49:44 +08:00
DHR60 4e5f1838a2 Add Cert SHA-256 pinning support (#8613) 2026-01-17 15:42:40 +08:00
2dust a45a1dc982 Ensure WebDAV base URL ends with trailing slash 2026-01-17 15:08:08 +08:00
2dust fe183798b6 Refactor child item aggregation in managers 2026-01-13 20:24:52 +08:00
2dust 947c84cf10 Refactor 'Move to Group' menu in ProfilesView 2026-01-10 15:14:58 +08:00
2dust 9c74b51d74 up 7.17.0 2026-01-09 18:44:28 +08:00
2dust abd962ab31 Update Global.cs 2026-01-08 17:15:59 +08:00
DHR60 f3b894015e Add sing-box ech support (#8603)
* Add sing-box ech support

* Support group config type

* Simplified code
2026-01-08 13:56:45 +08:00
2dust 4562d4cf00 Add ECH config support to profile and UI
Introduces EchConfigList and EchForceQuery fields to ProfileItem and V2rayConfig models, updates related handlers and services to process these fields, and extends the AddServerWindow UI to allow user input for ECH configuration. Also adds localization entries for the new fields and updates extension methods for string handling.
2026-01-07 11:34:13 +08:00
JieXu bc36cf8a47 Code Clean (#8586) 2026-01-05 09:56:43 +08:00
Kazuto Iris cbdfe2e15a fix: Fix failure to follow system theme changes (#8584)
Fix the issue where the application failed to sync with system dark/light mode changes in specific scenarios such as triggering system theme switching via scheduled tasks while waking from hibernation, caused by the unreliable HWND hook implementation that missed critical events.
2026-01-05 09:56:33 +08:00
2dust 68583e20bc Update package versions in Directory.Packages.props 2026-01-03 19:06:17 +08:00
DHR60 6d6459b009 Fix edge cases (#8564) 2026-01-03 10:20:27 +08:00
2dust 807562b69e Set all .NET publish tasks to self-contained 2025-12-28 14:10:00 +08:00
2dust 654d7d83d0 up 7.16.9 2025-12-25 18:34:10 +08:00
2dust 027252e687 Move ShowInTaskbar and RunningCoreType to AppManager 2025-12-24 16:01:28 +08:00
2dust 5478c90180 Bug fix
https://github.com/2dust/v2rayN/issues/8515
2025-12-24 14:19:36 +08:00
DHR60 28f30d7e97 Revert "Add TLS ALPN check for WS (#8469)" (#8517)
This reverts commit 6e27dca6cd.
2025-12-24 13:38:08 +08:00
2dust ae7d54c2e5 up 7.16.8 2025-12-22 19:04:36 +08:00
2dust 56d0d65b06 Reduce minimum width of MainWindow 2025-12-22 19:03:47 +08:00
2dust 5e8e189c27 Increase MenuItemHeight to 32 in App.xaml 2025-12-21 18:53:09 +08:00
2dust 3fee86d44a Add context menu to subscription DataGrid 2025-12-21 18:53:00 +08:00
2dust dd77eb79c6 Remove .NET self-contained zip check in UpdateService 2025-12-20 14:47:40 +08:00
2dust d26a2559a6 up 7.16.7 2025-12-20 14:12:41 +08:00
2dust e5ba1759aa Update Directory.Packages.props 2025-12-20 14:12:16 +08:00
dependabot[bot] bfdee37cc1 Bump actions/upload-artifact from 5.0.0 to 6.0.0 (#8493)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  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>
2025-12-16 09:19:09 +08:00
dependabot[bot] cf89cfcd95 Bump actions/download-artifact from 6 to 7 (#8492)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  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>
2025-12-16 09:19:00 +08:00
2dust 39a988c704 Update Directory.Packages.props 2025-12-13 15:18:52 +08:00
JieXu 2b28254fbc Update ResUI.fr.resx (#8472) 2025-12-10 17:56:19 +08:00
DHR60 6e27dca6cd Add TLS ALPN check for WS (#8469) 2025-12-09 20:22:13 +08:00
DHR60 7cee98887b Refactor Node Precheck (#8464) 2025-12-09 20:03:07 +08:00
DHR60 3885ff8b31 Fix Shadowsocks Fmt (#8462) 2025-12-08 19:55:27 +08:00
2dust 12abf383e9 up 7.16.6 2025-12-07 15:32:45 +08:00
2dust 5bef02bd6d Code clean 2025-12-07 15:32:03 +08:00
2dust 592f1260b5 Remove Cloudflare IP API URL from IPAPIUrls
https://github.com/2dust/v2rayN/issues/8441
2025-12-07 15:24:54 +08:00
2dust 18303688d7 Refactor AddGroupServerWindow tab controls layout 2025-12-07 15:22:40 +08:00
2dust 5c4b7f6636 Update Directory.Packages.props 2025-12-07 15:22:19 +08:00
tt2563 37cce2fa35 「desktop版本-啟用連線資訊測試位址自訂輸入」 (#8456) 2025-12-07 15:21:11 +08:00
dependabot[bot] 6f8b65c75b Bump actions/checkout from 6.0.0 to 6.0.1 (#8437)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  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>
2025-12-04 18:24:14 +08:00
2dust 83c63b914a up 7.16.5 2025-11-29 19:59:48 +08:00
DHR60 1ca2485d2a Fix (#8407) 2025-11-29 19:58:51 +08:00
2dust cc4154bb0d Increase UI grid column widths and font size options 2025-11-28 20:31:40 +08:00
2dust d7f77f220c Improve group text description 2025-11-27 19:55:33 +08:00
JieXu 86f45d103d Update build-linux.yml to use Red Hat UBI image (#8392) 2025-11-27 15:01:44 +08:00
2dust 0077751f75 Add subscription delete functionality to ProfilesView 2025-11-26 20:11:14 +08:00
dependabot[bot] fa2b4b3dc9 Bump actions/setup-dotnet from 5.0.0 to 5.0.1 (#8387)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.0.1
  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>
2025-11-26 14:31:34 +08:00
DHR60 23cacb8339 Format imported xhttp extra (#8390) 2025-11-26 14:31:14 +08:00
DHR60 9ffa6a4eb6 Remove formatted spaces from extra JSON before URL encoding (#8385) 2025-11-25 17:40:41 +08:00
2dust 386209b835 Fix 2025-11-24 19:12:49 +08:00
jiuqianyuan 830dc89c32 Fix: tcping high latency and speedtest displayed 0 (#8374)
* Fix: High latency in tcping test due to thread blocking

* Fix: download to fast, speed displayed as 0.

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2025-11-24 19:01:04 +08:00
2dust 693afe3560 up 7.16.4 2025-11-23 14:33:01 +08:00
2dust 95361e8b65 Simplify configuration-related resource strings 2025-11-23 14:31:29 +08:00
2dust 3ff7299aca Refactor update result handling and model 2025-11-23 14:06:34 +08:00
DHR60 34fc4de0c2 Avoid xray warning (#8369) 2025-11-22 19:17:18 +08:00
DHR60 91536d3923 Fix sing-box ws (#8367)
* Fix sing-box ws

* Parse eh
2025-11-22 16:40:07 +08:00
2dust 6b87c09a96 Add confirmation before removing duplicate servers
https://github.com/2dust/v2rayN/issues/8365
2025-11-22 10:20:16 +08:00
2dust ecaac2ac61 Fix
https://github.com/2dust/v2rayN/discussions/8366
2025-11-22 10:16:53 +08:00
2dust ad74b1584d Refactor dialog layouts 2025-11-21 20:30:41 +08:00
dependabot[bot] 514d76953a Bump actions/checkout from 5.0.1 to 6.0.0 (#8359)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.1 to 6.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5.0.1...v6.0.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.0
  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>
2025-11-21 15:57:37 +08:00
DHR60 5b82f17995 Fix (#8363)
* Fix

* AI-optimized code
2025-11-21 15:56:42 +08:00
2dust 20ce35bc30 Update dotnet publish syntax in build workflows 2025-11-20 20:04:14 +08:00
2dust c0fca0dddd Update build script and remove global.json 2025-11-20 19:56:14 +08:00
2dust 2ba896e17e Update Directory.Packages.props 2025-11-20 19:39:29 +08:00
DHR60 f61e6d8c63 perf: Shadowsocks (#8352)
* perf: Shadowsocks

* stricter plugin name fix for SIP002 URI

* Fix
2025-11-20 19:35:57 +08:00
2dust d3e2e55ecf Add global.json for SDK configuration
Introduces a global.json file specifying the .NET SDK version (8.0.416) and disables rollForward to ensure consistent SDK usage across environments.
2025-11-20 10:12:44 +08:00
2dust 30e663cd4f up 7.16.3 2025-11-19 17:15:35 +08:00
JieXu 054efeb32c [PR]改进Emoji字体兼容性 (#8346)
* Update AppBuilderExtension.cs

* Update package-rhel.sh

* Update package-debian.sh

* Update AppBuilderExtension.cs

* Update AppBuilderExtension.cs

* Update AppBuilderExtension.cs

* Withdraw

* Update AppBuilderExtension.cs
2025-11-19 16:47:00 +08:00
2dust 2ebd2b28a8 Support Backspace for remove actions in UI lists
Changed key handling and menu input gestures to allow Backspace (in addition to Delete) for removing items in server, profile, and routing rule lists. This improves usability and consistency across both Avalonia and WPF views.
2025-11-18 16:54:42 +08:00
dependabot[bot] 84f812c8ee Bump actions/checkout from 5.0.0 to 5.0.1 (#8341)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.1
  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>
2025-11-18 16:15:59 +08:00
2dust b6ee40ab8d Update Directory.Packages.props 2025-11-18 16:15:19 +08:00
2dust 7f24f4a15f Remove shortcut hints from menu translations
Shortcut key hints (e.g., '(Ctrl+C)', '(Delete)') were removed from various menu item translations in resource files for all supported languages. This improves consistency and clarity in UI text across the application.
2025-11-18 16:00:02 +08:00
2dust 0d307671d1 Bug fix
https://github.com/2dust/v2rayN/issues/8267
2025-11-17 17:44:39 +08:00
Harry Huang 8ea5a57988 Optimize speedtest (#8325)
* Optimize stop-speedtest tip display

* Fix speedtest termination latency
2025-11-16 14:58:55 +08:00
Harry Huang 4fb41aeca1 Remove redundant string operation (#8324) 2025-11-16 14:21:34 +08:00
2dust 3f0bcf7b83 up 7.16.2 2025-11-14 19:46:54 +08:00
2dust 7e712fcdeb Refactor menu layouts in window views 2025-11-14 19:44:09 +08:00
2dust e634e6dae3 Code clean 2025-11-13 20:31:02 +08:00
2dust 24f8d767b1 Update routing version prefix to V4 2025-11-12 19:34:04 +08:00
2dust 31a8ddef23 Update Directory.Packages.props 2025-11-12 19:33:32 +08:00
MkQtS 30e9e64fd5 Simplify sing-box rules for domain_suffix (#8306)
adapt to new domain_suffix behavior since sing-box 1.9.0
2025-11-12 19:08:57 +08:00
MkQtS f677934257 Proxy all Google domains (#8287)
* Proxy all Google domains

Default geosite-cn(dat/srs) used by v2rayN contains google@cn, which performs poorly in certain user environments.

* Resolve all Google domains via remote server

* fix typo

* Add google to default geofiles
2025-11-12 19:08:36 +08:00
JieXu df7ca81837 Update package-osx.sh (#8303) 2025-11-11 19:31:06 +08:00
tt2563 53bd03dea2 更新繁體中文翻譯 (#8302)
Co-authored-by: tes2511 <tes2511@user.user>
2025-11-11 19:30:41 +08:00
JieXu 1f8dd1a52d Update ResUI.fr.resx (#8297) 2025-11-10 19:59:29 +08:00
2dust d5460d758b up 7.16.1 2025-11-09 15:17:08 +08:00
2dust 6e38357b7d Add macOS Dock visibility option to settings 2025-11-09 14:47:53 +08:00
DHR60 1990850d9a Optimize Cert Pinning (#8282) 2025-11-09 11:20:30 +08:00
2dust e6cb146671 Refactor UI platform visibility to use ViewModel properties 2025-11-09 11:11:23 +08:00
2dust 4da59cd767 Rename IsOSX to IsMacOS in Utils and usages 2025-11-09 10:52:46 +08:00
2dust e20c11c1a7 Refactor reload logic with semaphore for concurrency 2025-11-08 20:48:55 +08:00
2dust a6af95e083 Bug fix
https://github.com/2dust/v2rayN/issues/8276
2025-11-08 20:10:20 +08:00
2dust 6f06b16c76 up 7.16.0 2025-11-08 11:29:18 +08:00
2dust 70ddf4ecfc Add allowInsecure and insecure to the shared URI
https://github.com/2dust/v2rayN/issues/8267
2025-11-08 11:14:01 +08:00
JieXu 187356cb9e Update ResUI.fr.resx (#8270) 2025-11-08 11:10:04 +08:00
2dust 32583ea8b3 Bug fix
Replaced direct assignments to BlReloadEnabled with a new SetReloadEnabled method that schedules updates on the main thread.
2025-11-07 21:06:43 +08:00
2dust 69797c10f2 Update ConfigHandler.cs 2025-11-07 19:52:03 +08:00
2dust ddc8c9b1cd Add support for custom PAC and proxy script paths
Introduces options to specify custom PAC file and system proxy script paths for system proxy settings. Updates configuration models, view models, UI bindings, and logic for Linux/OSX proxy handling and PAC management to use these custom paths if provided. Also adds UI elements and localization for the new settings.
2025-11-07 19:28:16 +08:00
2dust 753e7b81b6 Add timeout and error handling to certificate fetching 2025-11-04 20:43:51 +08:00
2dust 725b094fb1 Update Directory.Packages.props 2025-11-04 20:43:28 +08:00
2dust 6de5a5215d Refactor code
Renamed FileManager to FileUtils and updated all references accordingly. Moved SemanticVersion to the Models namespace. Replaced WindowsJob with WindowsJobService, relocating and updating the implementation. Updated usages in CoreManager and related classes to use the new service and utility names. These changes improve code organization and naming consistency.
2025-11-03 20:01:36 +08:00
2dust 8d86aa2b72 Refactor ping and HTTP helpers, update usages
Moved GetRealPingTime from HttpClientHelper to ConnectionHandler and refactored related methods for clarity. Removed unused and redundant HTTP helper methods. Updated DownloadService and SpeedtestService to use the new method signatures. Simplified UpdateService constructor using primary constructor syntax.
2025-11-03 19:41:02 +08:00
DHR60 1aee3950f4 Fix (#8244) 2025-11-03 19:18:18 +08:00
2dust 091b79f7cf Refactor UpdateService to use instance config and callbacks 2025-11-02 21:04:23 +08:00
DHR60 ed2c77062e Disallow insecure when pinned cert (#8239) 2025-11-02 19:01:58 +08:00
JieXu 8b1105c7e2 Update ResUI.fr.resx (#8238) 2025-11-02 18:58:50 +08:00
2dust 11c203ad19 Update UI and localization for policy group 2025-11-02 16:24:57 +08:00
2dust ab6a6b879e Code clean 2025-11-02 15:25:41 +08:00
DHR60 b218f0b501 Cert Pinning (#8234)
* Cert Pinning

* Cert Chain Pinning

* Add Trusted Ca Pinning

* Tip

* Perf UI
2025-11-02 15:17:47 +08:00
2dust 7b5686cd8f In the policy group, automatically add filtered configurations from the subscription group.
https://github.com/2dust/v2rayN/issues/8214
2025-11-01 21:13:39 +08:00
2dust d727ff40bb Code clean 2025-10-31 20:25:54 +08:00
2dust 1b5069a933 Code clean 2025-10-31 20:25:50 +08:00
2dust 18ea6fdc00 Code clean 2025-10-31 20:25:45 +08:00
2dust 67494108ad up 7.15.7 2025-10-31 19:05:57 +08:00
2dust 38b2a7d2ca Rename QRCodeWindowsUtils 2025-10-31 17:50:28 +08:00
dependabot[bot] bf3703bca1 Bump actions/download-artifact from 4 to 6 (#8225)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  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>
2025-10-30 20:36:43 +08:00
2dust 554632cc07 Bug fix
https://github.com/2dust/v2rayN/issues/8207
2025-10-30 20:34:47 +08:00
2dust 12fc3e9566 Bug fix 2025-10-30 20:00:15 +08:00
2dust c2ef3a4a8c Add Design.IsDesignMode to the desktop version 2025-10-29 20:21:41 +08:00
2dust 86eb8297dd Update JsonUtils.cs 2025-10-29 20:20:44 +08:00
2dust c63d4e83f9 Use OperatingSystem replace RuntimeInformation 2025-10-29 20:20:40 +08:00
JieXu bf1fb0f92e RPM file remove x86-64-v1 Support. Update French translation. (#8216)
* Update ResUI.fr.resx

* Update build-linux.yml

* Update package-rhel.sh

* 更新 build-linux.yml

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx
2025-10-29 09:21:37 +08:00
dependabot[bot] 3c4865982b Bump actions/upload-artifact from 4.6.2 to 5.0.0 (#8211)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...v5.0.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 5.0.0
  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>
2025-10-28 13:55:57 +08:00
Aron Yang 22c233f0cd Fix TUN mode cleanup on Linux/macOS (#8202) 2025-10-26 11:09:23 +08:00
JieXu b2d6282755 Remove AppImage. Update package.sh (#8201)
* Refactor AppRun script generation in packaging

* Update minimum kernel version requirement to 6.13

* Update minimum kernel version requirement to 5.14

* Revise runtime dependencies with version constraints

Updated runtime dependencies for package-rhel.sh to include version constraints and additional requirements.

* Modify package dependencies in package-debian.sh

Updated package dependencies to include libc6, fontconfig, coreutils, and bash.

* Remove AppImage packaging and upload steps

Removed AppImage packaging and upload steps from the workflow.

* Delete package-appimage.sh

* Simplify environment checks in Utils.cs

Removed checks for APPIMAGE environment variable and mount path.

* Update v2rayN.slnx

* Remove package scripts from v2rayN solution

Removed references to package-appimage.sh and pkg2appimage.yml from the solution file.
2025-10-26 10:13:49 +08:00
2dust c8d89e3dce Adjusted the items in the configuration right-click menu 2025-10-25 20:10:42 +08:00
DHR60 d3b1810eab Update Directory.Packages.props (#8191) 2025-10-25 10:27:31 +08:00
JieXu 51409a3e28 Add French support | Ajouter le support du français | 添加法语支持 (#8186)
* Add files via upload

* Add files via upload

* Update Global.cs

* Add French resource file to project

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Update ResUI.fr.resx

* Delete v2rayN/AmazTool/Resx/Resource.fr.resx
2025-10-24 19:38:40 +08:00
2dust 1a0f50a41e up 7.15.6 2025-10-24 14:22:35 +08:00
2dust 83d4a9c18e Update Directory.Packages.props 2025-10-24 14:22:05 +08:00
2dust b4c20e7b81 Bug fix
https://github.com/2dust/v2rayN/discussions/8168
2025-10-23 19:03:16 +08:00
DHR60 7c76308c93 Fix (#8180) 2025-10-23 17:58:02 +08:00
DHR60 f28fa31c14 Fix tcp dns (#8179) 2025-10-23 17:57:47 +08:00
DHR60 bbedc4dbb1 Fix (#8175) 2025-10-23 09:10:21 +08:00
DHR60 ecf42cb85d Fix dns (#8174) 2025-10-23 09:09:26 +08:00
2dust e4701d6703 Add one-click test of real connection delay 2025-10-22 19:54:24 +08:00
mlds23 54a47d00a3 更新繁體中文翻譯 (#8166) 2025-10-22 17:00:19 +08:00
DHR60 964572817b Fix (#8161) 2025-10-22 09:07:53 +08:00
2dust 10358064dc up 7.15.5 2025-10-21 20:52:02 +08:00
2dust 6a19896915 Optimize the desktop version icon 2025-10-21 20:00:02 +08:00
DHR60 07e173eab1 Bootstrap DNS (#8160)
Also fix the handling of IPv6 domains
2025-10-21 17:28:48 +08:00
JieXu 91bca3a7ae Update Bug report (#8157)
* Update package-rhel.sh

* Update package-rhel.sh

* Update 01_bug_report.yml

* Fix formatting in bug report issue template

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update bug report template to remove checkmark

Removed the checkmark from the bug report template.

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Update bug report template header formatting

* Update 01_bug_report.yml

* Update 01_bug_report.yml

* Fix placeholder text in bug report template

* Update 01_bug_report.yml

* Create config.yml for issue templates

Add issue template configuration with contact links.

* Update config.yml

* Update config.yml

* Update config.yml
2025-10-21 09:29:52 +08:00
2dust 20260412a7 Remove Enable Security Protocol TLS v1.3 (subscription/update)
The TLS version is automatically negotiated by the operating system stack; by default it selects the highest version supported by both endpoints.
2025-10-20 20:17:13 +08:00
2dust bca030002f Adjust some texts in Simplified Chinese. Please wait for other languages. 2025-10-19 19:43:31 +08:00
2dust 479bf8e037 Adjusted the items in the configuration right-click menu 2025-10-19 16:55:21 +08:00
2dust cb5069bcfc Optimize and improve GlobalUsings 2025-10-19 14:13:26 +08:00
2dust eb1339f2f5 Optimize and improve GlobalUsings 2025-10-19 13:54:40 +08:00
2dust b66bfabd21 Optimize and improve GlobalUsings 2025-10-19 11:42:32 +08:00
2dust 3555d861ae Optimization and improvement 2025-10-19 11:05:28 +08:00
2dust f39bc6d3b0 up 7.15.4 2025-10-17 20:59:27 +08:00
2dust 0c0ecc359b Fix
https://github.com/2dust/v2rayN/issues/8129
2025-10-16 12:18:14 +08:00
2dust 68713e7b77 MB/s 2025-10-15 19:46:10 +08:00
2dust 899b3fc97b up 7.15.3 2025-10-14 19:52:42 +08:00
DHR60 a1490d0ac1 Update singbox_fakeip_filter (#8117) 2025-10-12 09:59:30 +08:00
DHR60 b23f49ffce Remove unnecessary settings (#8107) 2025-10-11 19:22:26 +08:00
2dust 9a9e28e494 Update GlobalHotKeys 2025-10-10 19:25:54 +08:00
2dust 65ee5eb510 Fix,remove NaiveproxyFmt HysteriaFmt ,adjust ClashFmt
https://github.com/2dust/v2rayN/issues/8102
2025-10-10 17:12:45 +08:00
DHR60 1f42d32e1a Fix Freedom Resolver (#8100) 2025-10-10 16:58:18 +08:00
2dust f2ed8c1d6b up 7.15.2 2025-10-09 20:29:45 +08:00
2dust 308b216d1b Adjust ActionPrecheckManager 2025-10-09 20:29:25 +08:00
2dust c713f5c8f5 Update Directory.Packages.props 2025-10-09 20:22:41 +08:00
2dust 6771eb25d1 Adjust ActionPrecheckManager 2025-10-09 20:22:35 +08:00
2dust 91af50f99a Optimize code ,add IsGroupType extension. Adjust EConfigType 2025-10-08 17:13:54 +08:00
2dust a559586e71 Code clean 2025-10-08 15:48:51 +08:00
2dust 929520775d Bug fix 2025-10-08 15:48:45 +08:00
2dust 4eaf31bbf8 Fix
https://github.com/2dust/v2rayN/issues/8092
2025-10-08 14:36:47 +08:00
2dust 1607525539 Optimize the ruletype UI 2025-10-08 14:12:16 +08:00
DHR60 31b5b4ca0c Add rule type selection to routing rules (#8007)
* Add rule type selection to routing rules

* Use enum

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2025-10-08 10:40:26 +08:00
2dust 64c7fea2bc Update Directory.Packages.props 2025-10-07 19:17:35 +08:00
2dust f76fd364a2 Rename ProfileGroupItem.ParentIndexId to IndexId
Because ProfileGroupItem is an extension of ProfileItem, it is better to name the fields the same way.
2025-10-07 14:01:36 +08:00
2dust 0a1d6db9d1 Add cycle check for AddGroupServerViewModel 2025-10-07 13:54:31 +08:00
2dust 7a750a127e Rename ActionPrecheckService 2025-10-07 13:53:33 +08:00
2dust fce4a7b74c Optimization and improvement, tray, etc.
https://github.com/2dust/v2rayN/pull/8083
2025-10-07 11:16:20 +08:00
DHR60 fec7353703 PreCheck (#7902)
* PreCheck

* Fix
2025-10-07 10:03:20 +08:00
Weheal 40c90d5b3b Fix: AutoHideStartup's bug of displaying window before hiding it. (#8083)
* Fix: AutoHideStartup's bug of displaying window before hiding it.

* Disable AutoHideStartup for Linux

* Revert "Disable AutoHideStartup for Linux"

This reverts commit 09f27e3455.

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2025-10-07 10:02:53 +08:00
2dust 9c58fec8d4 Bug fix 2025-10-05 19:55:52 +08:00
DHR60 11343a30fd Multi profile (#7929)
* Multi Profile

* VM and wpf

* avalonia

* Fix right click not working

* Exclude specific profile types from selection

* Rename

* Add Policy Group support

* Add generate policy group

* Adjust UI

* Add Proxy Chain support

* Fix

* Add fallback support

* Add PolicyGroup include other Group support

* Add group in traffic splitting support

* Avoid duplicate tags

* Refactor

* Adjust chained proxy, actual outbound is at the top

Based on actual network flow instead of data packets

* Add helper function

* Refactor

* Add chain selection control to group outbounds

* Avoid self-reference

* Fix

* Improves Tun2Socks address handling

* Avoids circular dependency in profile groups

Adds cycle detection to prevent infinite loops when evaluating profile groups.

This ensures that profile group configurations don't result in stack overflow errors when groups reference each other, directly or indirectly.

* Fix

* Fix

* Update ProfileGroupItem.cs

* Refactor

* Remove unnecessary checks

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2025-10-05 16:27:34 +08:00
2dust 3693a7fee6 up 7.15.1 2025-10-05 09:22:47 +08:00
2dust a452bbe140 Fix
https://github.com/2dust/v2rayN/issues/8061
2025-10-04 19:54:15 +08:00
DHR60 185c5e4bfb Fix (#8057) 2025-10-04 16:17:39 +08:00
2dust bbe64aa970 Remove AutoCompleteBox
https://github.com/2dust/v2rayN/pull/8067
2025-10-04 16:16:32 +08:00
DHR60 513662d89a Use editable ComboBox instead of AutoCompleteBox (#8067)
* Update Avalonia

* Use editable ComboBox instead of AutoCompleteBox
2025-10-04 15:18:37 +08:00
2dust 22f0d04f01 Fix
https://github.com/2dust/v2rayN/issues/8060
2025-10-03 14:13:03 +08:00
2dust d7c5161431 Optimize and improve 2025-10-02 19:55:49 +08:00
2dust 12cc09d0c9 Bug fix 2025-10-01 20:17:26 +08:00
2dust 5b12c36da5 Optimize and improve, encapsulate ProcessService 2025-10-01 19:49:28 +08:00
DHR60 e970372a9f Fix some minor UI bugs (#8053) 2025-10-01 16:47:22 +08:00
2dust 5d6c5da9d9 up 7.15.0 2025-09-28 19:12:58 +08:00
2dust ade2db3903 Code clean 2025-09-28 19:12:17 +08:00
Wydy 7f07279a4c Update pac (#7991) 2025-09-28 19:08:29 +08:00
2dust b25d4d57bd Fix ProfilesSelectWindow 2025-09-27 19:46:31 +08:00
2dust 46edd8f9a4 Bug fix 2025-09-27 18:07:20 +08:00
JieXu ebb95b5ee8 Update MsgView.axaml.cs (#8042) 2025-09-27 17:02:49 +08:00
2dust dc4611a258 Adjust qrcode width 2025-09-26 20:36:27 +08:00
2dust 03d5b7a05b Bug fix 2025-09-26 17:11:48 +08:00
2dust a652fd879b Added simple highlight function to the message view 2025-09-26 15:29:46 +08:00
2dust 326bf334e7 Optimize and improve MsgView 2025-09-26 15:07:33 +08:00
JieXu 21a773f400 Update MsgView.axaml.cs Plan C (#8035)
* Add avaloniaEdit for test

* Adjust avaloniaEdit

* Optimize and improve message function

* Update build-linux.yml

* Update MsgView.axaml

* Update MsgView.axaml.cs

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2025-09-26 13:55:35 +08:00
2dust d86003df55 Optimize and improve the Subject 2025-09-25 10:56:10 +08:00
2dust faff8e4ea2 Remove secret data from mihomo configuration 2025-09-24 18:41:00 +08:00
2dust 6b85aa0b03 Remove Splat.NLog package 2025-09-24 10:57:23 +08:00
2dust 671678724b Optimization and improvement, using event subscribers 2025-09-24 10:57:06 +08:00
2dust e96a4818c4 Optimization and improvement 2025-09-23 15:31:19 +08:00
2dust 0377e7ce19 Optimization and improvement, using event subscribers 2025-09-23 14:27:42 +08:00
2dust 6929886b3e Optimization and improvement, using event subscribers 2025-09-23 12:08:43 +08:00
2dust 721d70c8c7 Update Directory.Packages.props 2025-09-23 11:39:57 +08:00
2dust 27b45aee83 Optimization and improvement, using event subscribers 2025-09-23 11:39:55 +08:00
2dust 18ac76e683 up 7.14.12 2025-09-21 14:50:01 +08:00
2dust 3e1e23a524 Update Directory.Packages.props 2025-09-21 14:48:54 +08:00
2dust 534c7ab444 Optimize and improve QR code display 2025-09-21 14:35:49 +08:00
2dust c2c13ad318 Create v2rayN.slnx
https://github.com/2dust/v2rayN/pull/7969
2025-09-21 12:12:24 +08:00
2dust 3a21596d95 Fix node domain resolving in TUN mode
https://github.com/2dust/v2rayN/pull/7989
2025-09-21 12:05:06 +08:00
2dust ef30d389dc up 7.14.11 2025-09-20 14:06:55 +08:00
2dust bf8783fed7 Update CheckUpdateViewModel.cs 2025-09-20 14:06:41 +08:00
DHR60 4e042295d2 Add global fakeip and fakeip filter (#7919) 2025-09-13 14:55:30 +08:00
2dust 33d9c5db6c up GlobalUsings 2025-09-13 14:46:35 +08:00
DHR60 cb182125f6 Fix (#7946)
https://github.com/2dust/v2rayN/pull/7937
2025-09-13 11:13:09 +08:00
2dust ec627bdb82 up 7.14.10 2025-09-13 09:53:03 +08:00
2dust 4606e78570 Update Directory.Packages.props 2025-09-13 09:46:28 +08:00
2dust f00e968b8f Bug fix
https://github.com/2dust/v2rayN/issues/7944
2025-09-13 09:41:34 +08:00
DHR60 a87a015c03 Fix some minor UI bugs (#7941) 2025-09-12 20:28:24 +08:00
2dust c559914ff7 Fix
https://github.com/2dust/v2rayN/issues/7938
2025-09-12 17:01:53 +08:00
2dust 436d95576e Optimization and improvement JsonUtils 2025-09-12 16:45:55 +08:00
DHR60 54e83391d0 Pre-resolve to apply hosts (#7937) 2025-09-12 16:28:31 +08:00
JieXu 3e0578f775 Update CheckUpdateViewModel.cs (#7932)
* Update CheckUpdateViewModel.cs

* Update Utils.cs

* Update Utils.cs

* Update Utils.cs

* Update CheckUpdateViewModel.cs

* Update CheckUpdateViewModel.cs

* Update Utils.cs
2025-09-12 16:24:59 +08:00
2dust 29a5abf4d6 Optimization and improvement 2025-09-10 19:43:11 +08:00
2dust b54c67d6f1 up 7.14.9 2025-09-09 20:18:55 +08:00
2dust b49486cc23 Update ProfilesSelectWindow.axaml 2025-09-09 20:00:00 +08:00
JieXu b95830b3d5 Update package-rhel.sh package-debian.sh MainWindowViewModel.cs (#7910)
* Update package-rhel.sh

* Update package-rhel.sh

* Update package-rhel.sh

* Update package-rhel.sh

* Update MainWindowViewModel.cs

* Update package-rhel.sh

* Update package-debian.sh
2025-09-09 19:51:10 +08:00
2dust 8e0c5cb9aa Bug fix
https://github.com/2dust/v2rayN/issues/7914
2025-09-09 17:55:15 +08:00
2dust 6ffb3bd30c up 7.14.8 2025-09-08 18:48:56 +08:00
2dust 2826444ffc Code clean 2025-09-08 18:45:21 +08:00
JieXu 56c3e9c46d Fix package-appimage.sh bugs. (#7904)
* Update package-appimage.sh

* Delete pkg2appimage.yml
2025-09-08 18:02:54 +08:00
th1nker 0770e30034 fix: 修正获取系统hosts (#7903)
- 修复当host的记录存在行尾注释时,无法将其添加到dns.host中

示例hosts
```
127.0.0.1 test1.com
127.0.0.1 test2.com # test
```
在之前仅仅会添加`127.0.0.1 test1.com`这条记录,而忽略另一条
2025-09-08 18:02:44 +08:00
DHR60 04195c2957 Profiles Select Window (#7891)
* Profiles Select Window

* Sort

* wpf

* avalonia

* Allow single select

* Fix

* Add Config Type Filter

* Remove unnecessary
2025-09-07 18:58:59 +08:00
JieXu d18d74ac1c Update package-debian.sh (#7899) 2025-09-07 16:39:54 +08:00
2dust 6391667c15 up 7.14.7 2025-09-06 16:59:54 +08:00
2dust 7f26445327 Update Directory.Packages.props 2025-09-06 16:59:38 +08:00
2dust 291d4bd8e5 Update Directory.Packages.props 2025-09-06 16:52:57 +08:00
dependabot[bot] f2f3a7eb5f Bump actions/setup-dotnet from 4.3.1 to 5.0.0 (#7883)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4.3.1 to 5.0.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4.3.1...v5.0.0)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.0.0
  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>
2025-09-06 15:46:34 +08:00
JieXu e7609619d4 Update package-debian.sh (#7888) 2025-09-06 15:46:20 +08:00
DHR60 84bf9ecfaf Fix DNS Regional Presets (#7885) 2025-09-05 18:11:16 +08:00
2dust a2917b3ce8 Update Directory.Packages.props 2025-09-04 20:50:25 +08:00
316 changed files with 26165 additions and 9084 deletions
+41 -4
View File
@@ -3,6 +3,18 @@ description: 在提出问题前请先自行排除服务器端问题和升级到
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
### 报告 Bug 前请务必确认以下事项:
> ** **
> **✓ 已自行排除服务器端问题。**
> **✓ 已升级到最新客户端版本。**
> **✓ 已通过搜索确认没有人提出过相同问题。**
> **✓ 已确认自己的电脑系统环境是受支持的。**
---
- type: input
id: "os-version"
attributes:
@@ -10,6 +22,7 @@ body:
description: "操作系统和版本"
validations:
required: true
- type: input
id: "expectation"
attributes:
@@ -17,6 +30,7 @@ body:
description: "描述你认为应该发生什么"
validations:
required: true
- type: textarea
id: "describe-the-bug"
attributes:
@@ -24,22 +38,34 @@ body:
description: "描述实际发生了什么"
validations:
required: true
- type: textarea
id: "reproduction-method"
attributes:
label: "复现方法"
description: "在BUG出现前执行了哪些操作"
placeholder: 标序号
placeholder: "标序号"
validations:
required: true
- type: textarea
id: "log"
id: "gui-log"
attributes:
label: "日志信息"
label: "软件日志"
description: "位置在软件当前目录下的guiLogs"
placeholder: 在日志开始和结束位置粘贴冒号后的内容```
placeholder: "在日志开始和结束位置粘贴冒号后的内容到这:"
validations:
required: true
- type: textarea
id: "core-log"
attributes:
label: "内核日志"
description: "位置在软件主界面的信息框内"
placeholder: "在信息框内鼠标右键复制全部信息粘贴在这:"
validations:
required: true
- type: textarea
id: "more"
attributes:
@@ -47,6 +73,7 @@ body:
description: "可选"
validations:
required: false
- type: checkboxes
id: "latest-version"
attributes:
@@ -55,6 +82,7 @@ body:
options:
- label:
required: true
- type: checkboxes
id: "issues"
attributes:
@@ -63,3 +91,12 @@ body:
options:
- label:
required: true
- type: checkboxes
id: "system-version"
attributes:
label: "我确认系统版本是受支持的"
description: "否则请切换后尝试"
options:
- label:
required: true
+9
View File
@@ -0,0 +1,9 @@
blank_issues_enabled: false
contact_links:
- name: Discussions / 讨论区
url: https://github.com/2dust/v2rayN/discussions
about: 使用问题或需要帮助请前往 Discussions。
- name: Wiki / 使用说明
url: https://github.com/2dust/v2rayN/wiki
about: 查看常见问题和使用文档。
+12 -9
View File
@@ -7,13 +7,16 @@ on:
required: false
type: string
permissions:
actions: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Trigger build windows
if: github.event.inputs.release_tag != ''
if: inputs.release_tag != ''
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
@@ -22,12 +25,12 @@ jobs:
-d "{
\"ref\": \"master\",
\"inputs\": {
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
\"release_tag\": \"${{ inputs.release_tag }}\"
}
}"
- name: Trigger build linux
if: github.event.inputs.release_tag != ''
if: inputs.release_tag != ''
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
@@ -36,12 +39,12 @@ jobs:
-d "{
\"ref\": \"master\",
\"inputs\": {
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
\"release_tag\": \"${{ inputs.release_tag }}\"
}
}"
- name: Trigger build osx
if: github.event.inputs.release_tag != ''
if: inputs.release_tag != ''
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
@@ -50,12 +53,12 @@ jobs:
-d "{
\"ref\": \"master\",
\"inputs\": {
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
\"release_tag\": \"${{ inputs.release_tag }}\"
}
}"
- name: Trigger build windows desktop
if: github.event.inputs.release_tag != ''
if: inputs.release_tag != ''
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
@@ -64,6 +67,6 @@ jobs:
-d "{
\"ref\": \"master\",
\"inputs\": {
\"release_tag\": \"${{ github.event.inputs.release_tag }}\"
\"release_tag\": \"${{ inputs.release_tag }}\"
}
}"
}"
+208 -90
View File
@@ -10,126 +10,244 @@ on:
branches:
- master
env:
OutputArch: "linux-64"
OutputArchArm: "linux-arm64"
OutputPath64: "${{ github.workspace }}/v2rayN/Release/linux-64"
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/linux-arm64"
permissions:
contents: write
jobs:
build:
strategy:
matrix:
configuration: [Release]
uses: ./.github/workflows/build.yml
with:
target: linux
runs-on: ubuntu-22.04
release-zip:
if: inputs.release_tag != ''
needs: build
uses: ./.github/workflows/package-zip.yml
with:
target: linux
release_tag: ${{ inputs.release_tag }}
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/'))
runs-on: ubuntu-24.04
container: debian:13
env:
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
- 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 desktop-file-utils xdg-utils fakeroot dpkg-dev \
libc6 libgcc-s1 libstdc++6 zlib1g libicu-dev libssl-dev
- name: Checkout repo (for scripts)
uses: actions/checkout@v6.0.2
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup
uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: '8.0.x'
- name: Ensure script permissions
run: chmod 755 package-debian.sh
- name: Build
- name: Package DEB (Debian-family)
run: ./package-debian.sh "${RELEASE_TAG}" --arch all
- name: Collect DEBs into workspace
run: |
cd v2rayN
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r linux-x64 --self-contained=true -o $OutputPath64
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r linux-arm64 --self-contained=true -o $OutputPathArm64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r linux-x64 --self-contained=true -p:PublishTrimmed=true -o $OutputPath64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r linux-arm64 --self-contained=true -p:PublishTrimmed=true -o $OutputPathArm64
mkdir -p "$GITHUB_WORKSPACE/dist/deb"
rsync -av "$HOME/debbuild/" "$GITHUB_WORKSPACE/dist/deb/" || true
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_amd64.deb" \
-exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-64.deb" \; || true
find "$GITHUB_WORKSPACE/dist/deb" -name "v2rayn_*_arm64.deb" \
-exec mv {} "$GITHUB_WORKSPACE/dist/deb/v2rayN-linux-arm64.deb" \; || true
echo "==== Dist tree ===="
ls -R "$GITHUB_WORKSPACE/dist/deb" || true
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: v2rayN-linux
path: |
${{ github.workspace }}/v2rayN/Release/linux*
# release debian package
- name: Package debian
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-debian.sh
./package-debian.sh $OutputArch $OutputPath64 ${{ github.event.inputs.release_tag }}
./package-debian.sh $OutputArchArm $OutputPathArm64 ${{ github.event.inputs.release_tag }}
- name: Upload deb to release
- name: Upload DEBs to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.deb
tag: ${{ github.event.inputs.release_tag }}
file: dist/deb/**/*.deb
tag: ${{ env.RELEASE_TAG }}
file_glob: true
prerelease: true
- name: Package AppImage
if: github.event.inputs.release_tag != ''
rpm:
name: build and release rpm x64 & arm64
if: |
(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
runs-on: ubuntu-24.04
container: registry.access.redhat.com/ubi10/ubi
env:
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
steps:
- name: Prepare tools (Red Hat)
shell: bash
run: |
chmod a+x package-appimage.sh
./package-appimage.sh
set -euo pipefail
- name: Upload AppImage to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
. /etc/os-release
EL_MAJOR="${VERSION_ID%%.*}"
echo "EL_MAJOR=${EL_MAJOR}"
dnf -y makecache || true
command -v curl >/dev/null || dnf -y install curl ca-certificates
ARCH="$(uname -m)"
case "$ARCH" in x86_64|aarch64) ;; *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac
install_epel_from_dir() {
local base="$1" rpm
echo "Try: $base"
rpm="$(
{
curl -fsSL "$base/Packages/" 2>/dev/null
curl -fsSL "$base/Packages/e/" 2>/dev/null | sed 's|href="|href="e/|'
} |
sed -n 's/.*href="\([^"]*epel-release-[^"]*\.noarch\.rpm\)".*/\1/p' |
sort -V | tail -n1
)" || true
if [[ -n "$rpm" ]]; then
dnf -y install "$base/Packages/$rpm"
return 0
fi
return 1
}
FEDORA="https://dl.fedoraproject.org/pub/epel/epel-release-latest-${EL_MAJOR}.noarch.rpm"
echo "Try Fedora: $FEDORA"
if curl -fsSLI "$FEDORA" >/dev/null; then
dnf -y install "$FEDORA"
else
ROCKY="https://dl.rockylinux.org/pub/rocky/${EL_MAJOR}/extras/${ARCH}/os"
if install_epel_from_dir "$ROCKY"; then
:
else
ALMA="https://repo.almalinux.org/almalinux/${EL_MAJOR}/extras/${ARCH}/os"
if install_epel_from_dir "$ALMA"; then
:
else
echo "EPEL bootstrap failed (Fedora/Rocky/Alma)"
exit 1
fi
fi
fi
dnf -y install sudo git rpm-build rpmdevtools dnf-plugins-core \
rsync findutils tar gzip unzip which
dnf repolist | grep -i epel || true
- name: Checkout repo (for scripts)
uses: actions/checkout@v6.0.2
with:
file: ${{ github.workspace }}/v2rayN*.AppImage
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
submodules: 'recursive'
fetch-depth: '0'
# release zip archive
- name: Package release zip archive
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-release-zip.sh
./package-release-zip.sh $OutputArch $OutputPath64
./package-release-zip.sh $OutputArchArm $OutputPathArm64
- name: Upload zip archive to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.zip
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
- name: Ensure script permissions
run: chmod 755 package-rhel.sh
# release RHEL package
- name: Package RPM (RHEL-family)
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-rhel.sh
# Build for both x86_64 and aarch64 in one go (explicit version passed; no --buildfrom)
./package-rhel.sh "${{ github.event.inputs.release_tag }}" --arch all
run: ./package-rhel.sh "${RELEASE_TAG}" --arch all
- name: Collect RPMs into workspace
if: github.event.inputs.release_tag != ''
run: |
mkdir -p "${{ github.workspace }}/dist/rpm"
rsync -av "$HOME/rpmbuild/RPMS/" "${{ github.workspace }}/dist/rpm/"
# Rename to requested filenames
find "${{ github.workspace }}/dist/rpm" -name "v2rayN-*-1.x86_64.rpm" -exec mv {} "${{ github.workspace }}/dist/rpm/v2rayN-linux-rhel-x64.rpm" \; || true
find "${{ github.workspace }}/dist/rpm" -name "v2rayN-*-1.aarch64.rpm" -exec mv {} "${{ github.workspace }}/dist/rpm/v2rayN-linux-rhel-arm64.rpm" \; || true
- name: Upload RPM artifacts
if: github.event.inputs.release_tag != ''
uses: actions/upload-artifact@v4.6.2
with:
name: v2rayN-rpm
path: |
${{ github.workspace }}/dist/rpm/**/*.rpm
mkdir -p "$GITHUB_WORKSPACE/dist/rpm"
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm/" || true
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.x86_64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-64.rpm" \; || true
find "$GITHUB_WORKSPACE/dist/rpm" -name "v2rayN-*-1*.aarch64.rpm" -exec mv {} "$GITHUB_WORKSPACE/dist/rpm/v2rayN-linux-rhel-arm64.rpm" \; || true
echo "==== Dist tree ===="
ls -R "$GITHUB_WORKSPACE/dist/rpm" || true
- name: Upload RPMs to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/dist/rpm/**/*.rpm
tag: ${{ github.event.inputs.release_tag }}
file: dist/rpm/**/*.rpm
tag: ${{ env.RELEASE_TAG }}
file_glob: true
prerelease: true
rpm-riscv64:
name: build and release rpm riscv64
if: |
(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
runs-on: ubuntu-24.04-riscv
container: rockylinux/rockylinux:10
env:
RELEASE_TAG: ${{ case(inputs.release_tag != '', inputs.release_tag, github.ref_name) }}
steps:
- name: Prepare tools (Red Hat)
shell: bash
run: |
set -euo pipefail
dnf -y makecache
dnf -y install \
sudo git rpm-build rpmdevtools dnf-plugins-core \
rsync findutils tar gzip unzip which jq
- name: Checkout repo (for scripts)
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
git submodule update --init --recursive
- name: Ensure script permissions
run: chmod 755 package-rhel-riscv.sh
- name: Package RPM (RHEL-family)
run: ./package-rhel-riscv.sh "${RELEASE_TAG}"
- name: Collect RPMs into workspace
run: |
mkdir -p "$GITHUB_WORKSPACE/dist/rpm-riscv64"
rsync -av "$HOME/rpmbuild/RPMS/" "$GITHUB_WORKSPACE/dist/rpm-riscv64/" || true
find "$GITHUB_WORKSPACE/dist/rpm-riscv64" -name "*.riscv64.rpm" \
-exec mv {} "$GITHUB_WORKSPACE/dist/rpm-riscv64/v2rayN-linux-rhel-riscv64.rpm" \; || true
echo "==== Dist tree ===="
ls -R "$GITHUB_WORKSPACE/dist/rpm-riscv64" || true
- name: Upload RPMs to release
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
shopt -s globstar nullglob
files=(dist/rpm-riscv64/**/*.rpm)
(( ${#files[@]} )) || { echo "No RPMs found."; exit 1; }
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
upload_url="$(curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$api" | jq -r '.upload_url // empty' | sed 's/{?name,label}//')"
[[ "$upload_url" ]] || { echo "Release upload URL not found: ${RELEASE_TAG}"; exit 1; }
for f in "${files[@]}"; do
echo "Uploading ${f##*/}"
curl -fsSL -X POST \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$f" \
"${upload_url}?name=${f##*/}"
done
+44 -57
View File
@@ -10,78 +10,65 @@ on:
branches:
- master
env:
OutputArch: "macos-64"
OutputArchArm: "macos-arm64"
OutputPath64: "${{ github.workspace }}/v2rayN/Release/macos-64"
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/macos-arm64"
permissions:
contents: write
jobs:
build:
uses: ./.github/workflows/build.yml
with:
target: macos
release-zip:
if: inputs.release_tag != ''
needs: build
uses: ./.github/workflows/package-zip.yml
with:
target: macos
release_tag: ${{ inputs.release_tag }}
dmg:
name: package and release macOS dmg
if: inputs.release_tag != ''
needs: build
strategy:
matrix:
configuration: [Release]
arch: [ x64, arm64 ]
runs-on: macos-latest
env:
Arch: |-
${{
case(
matrix.arch == 'x64', '64',
matrix.arch
)
}}
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
with:
submodules: 'recursive'
fetch-depth: '0'
uses: actions/checkout@v6.0.2
- name: Setup
uses: actions/setup-dotnet@v4.3.1
- name: Restore build artifacts
uses: actions/download-artifact@v8
with:
dotnet-version: '8.0.x'
- name: Build
run: |
cd v2rayN
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r osx-x64 --self-contained=true -o $OutputPath64
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r osx-arm64 --self-contained=true -o $OutputPathArm64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r osx-x64 --self-contained=true -p:PublishTrimmed=true -o $OutputPath64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r osx-arm64 --self-contained=true -p:PublishTrimmed=true -o $OutputPathArm64
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: v2rayN-macos
path: |
${{ github.workspace }}/v2rayN/Release/macos*
name: ${{ matrix.arch }}
path: v2rayN-macos-${{ env.Arch }}
# release osx package
- name: Package osx
if: github.event.inputs.release_tag != ''
run: |
brew install create-dmg
chmod 755 package-osx.sh
./package-osx.sh $OutputArch $OutputPath64 ${{ github.event.inputs.release_tag }}
./package-osx.sh $OutputArchArm $OutputPathArm64 ${{ github.event.inputs.release_tag }}
- name: Setup create-dmg
run: brew install create-dmg
- name: Ensure script permissions
run: chmod 755 package-osx.sh
- name: Package dmg
run: ./package-osx.sh macos-$Arch v2rayN-macos-$Arch ${{ inputs.release_tag }}
- name: Sleep for race condition between matrix jobs
run: sleep $(awk 'BEGIN { srand(); printf "%.3f", rand()*2 }')
- name: Upload dmg to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.dmg
tag: ${{ github.event.inputs.release_tag }}
tag: ${{ inputs.release_tag }}
file_glob: true
prerelease: true
# release zip archive
- name: Package release zip archive
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-release-zip.sh
./package-release-zip.sh $OutputArch $OutputPath64
./package-release-zip.sh $OutputArchArm $OutputPathArm64
- name: Upload zip archive to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.zip
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
+12 -55
View File
@@ -10,62 +10,19 @@ on:
branches:
- master
env:
OutputArch: "windows-64"
OutputArchArm: "windows-arm64"
OutputPath64: "${{ github.workspace }}/v2rayN/Release/windows-64"
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/windows-arm64"
permissions:
contents: write
jobs:
build:
strategy:
matrix:
configuration: [Release]
uses: ./.github/workflows/build.yml
with:
target: windows
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup
uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: '8.0.x'
- name: Build
run: |
cd v2rayN
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPath64
dotnet publish ./v2rayN.Desktop/v2rayN.Desktop.csproj -c Release -r win-arm64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPathArm64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPath64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-arm64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPathArm64
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: v2rayN-windows-desktop
path: |
${{ github.workspace }}/v2rayN/Release/windows*
# release zip archive
- name: Package release zip archive
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-release-zip.sh
./package-release-zip.sh $OutputArch $OutputPath64
mv "v2rayN-${OutputArch}.zip" "v2rayN-${OutputArch}-desktop.zip"
./package-release-zip.sh $OutputArchArm $OutputPathArm64
mv "v2rayN-${OutputArchArm}.zip" "v2rayN-${OutputArchArm}-desktop.zip"
- name: Upload zip archive to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.zip
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
release-zip:
if: inputs.release_tag != ''
needs: build
uses: ./.github/workflows/package-zip.yml
with:
target: windows-desktop
release_tag: ${{ inputs.release_tag }}
+13 -55
View File
@@ -10,62 +10,20 @@ on:
branches:
- master
env:
OutputArch: "windows-64"
OutputArchArm: "windows-arm64"
OutputPath64: "${{ github.workspace }}/v2rayN/Release/windows-64"
OutputPathArm64: "${{ github.workspace }}/v2rayN/Release/windows-arm64"
OutputPath64Sc: "${{ github.workspace }}/v2rayN/Release/windows-64-SelfContained"
permissions:
contents: write
jobs:
build:
strategy:
matrix:
configuration: [Release]
uses: ./.github/workflows/build.yml
with:
target: windows
project: ./v2rayN/v2rayN.csproj
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5.0.0
- name: Setup
uses: actions/setup-dotnet@v4.3.1
with:
dotnet-version: '8.0.x'
- name: Build
run: |
cd v2rayN
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-x64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPath64
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-arm64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPathArm64
dotnet publish ./v2rayN/v2rayN.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -o $OutputPath64Sc
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPath64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-arm64 --self-contained=false -p:EnableWindowsTargeting=true -o $OutputPathArm64
dotnet publish ./AmazTool/AmazTool.csproj -c Release -r win-x64 --self-contained=true -p:EnableWindowsTargeting=true -p:PublishTrimmed=true -o $OutputPath64Sc
- name: Upload build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: v2rayN-windows
path: |
${{ github.workspace }}/v2rayN/Release/windows*
# release zip archive
- name: Package release zip archive
if: github.event.inputs.release_tag != ''
run: |
chmod 755 package-release-zip.sh
./package-release-zip.sh $OutputArch $OutputPath64
./package-release-zip.sh $OutputArchArm $OutputPathArm64
./package-release-zip.sh "windows-64-SelfContained" $OutputPath64Sc
- name: Upload zip archive to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/v2rayN*.zip
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
release-zip:
if: inputs.release_tag != ''
needs: build
uses: ./.github/workflows/package-zip.yml
with:
target: windows
release_tag: ${{ inputs.release_tag }}
+71
View File
@@ -0,0 +1,71 @@
name: build
on:
workflow_call:
inputs:
target: # windows linux macos
required: true
type: string
project:
required: false
type: string
default: './v2rayN.Desktop/v2rayN.Desktop.csproj'
jobs:
build:
name: build x64 arm64
strategy:
matrix:
arch: [ x64, arm64 ]
runs-on: |-
${{
case(
inputs.target == 'macos', 'macos-latest',
inputs.target == 'linux', 'ubuntu-24.04',
'ubuntu-latest'
)
}}
env:
Output: "${{ github.workspace }}/${{ matrix.arch }}"
RID: |-
${{
case(
inputs.target == 'macos', format('osx-{0}', matrix.arch),
inputs.target == 'windows', format('win-{0}', matrix.arch),
format('{0}-{1}', inputs.target, matrix.arch)
)
}}
Project: ${{ inputs.project }}
ExtOpt: |-
${{
case(
inputs.target == 'windows', '-p:EnableWindowsTargeting=true',
''
)
}}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup .NET
uses: actions/setup-dotnet@v5.2.0
with:
dotnet-version: '8.0.x'
- name: Build v2rayN
working-directory: ./v2rayN
run: dotnet publish $Project -c Release -r $RID -p:SelfContained=true $ExtOpt -o $Output
- name: Build AmazTool
working-directory: ./v2rayN
run: dotnet publish ./AmazTool/AmazTool.csproj -c Release -r $RID -p:SelfContained=true -p:PublishTrimmed=true $ExtOpt -o $Output
- name: Upload build artifacts
uses: actions/upload-artifact@v7.0.1
with:
name: ${{ matrix.arch }}
path: ${{ matrix.arch }}
+67
View File
@@ -0,0 +1,67 @@
name: package and release Zip
on:
workflow_call:
inputs:
release_tag:
required: true
type: string
target: # windows linux macos windows-desktop
required: true
type: string
permissions:
contents: write
jobs:
package:
name: package x64 arm64
strategy:
matrix:
arch: [ x64, arm64 ]
runs-on: ubuntu-latest
env:
Target: |-
${{
case(
inputs.target == 'windows-desktop', 'windows',
inputs.target
)
}}
Arch: |-
${{
case(
matrix.arch == 'x64', '64',
matrix.arch
)
}}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Restore build artifacts
uses: actions/download-artifact@v8
with:
name: ${{ matrix.arch }}
path: v2rayN-${{ env.Target }}-${{ env.Arch }}
- 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"
- name: Package zip archive
run: 7z a -tZip v2rayN-$Target-$Arch.zip v2rayN-$Target-$Arch -mx1
- name: Rename windows-desktop
if: inputs.target == 'windows-desktop'
run: mv "v2rayN-$Target-$Arch.zip" "v2rayN-$Target-$Arch-desktop.zip"
- name: Sleep for race condition between matrix jobs
run: sleep $(awk 'BEGIN { srand(); printf "%.3f", rand()*2 }')
- name: Upload zip archive to release
uses: svenstaro/upload-release-action@v2
with:
file: ${{ github.workspace }}/v2rayN*.zip
tag: ${{ inputs.release_tag }}
file_glob: true
prerelease: true
+29
View File
@@ -0,0 +1,29 @@
name: Code Test
on:
pull_request:
branches:
- master
paths:
- 'v2rayN/ServiceLib/Services/CoreConfig/**'
- 'v2rayN/ServiceLib/Handler/Fmt/**'
- '.github/workflows/test.yml'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup .NET
uses: actions/setup-dotnet@v5.2.0
with:
dotnet-version: '8.0.x'
- name: Test Code
working-directory: ./v2rayN
run: dotnet test ./ServiceLib.Tests
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
sudo apt update -y
sudo apt install -y libfuse2
wget -O pkg2appimage https://github.com/AppImageCommunity/pkg2appimage/releases/download/continuous/pkg2appimage-1eceb30-x86_64.AppImage
chmod a+x pkg2appimage
export AppImageOutputArch=$OutputArch
export OutputPath=$OutputPath64
./pkg2appimage ./pkg2appimage.yml
mv out/*.AppImage v2rayN-${AppImageOutputArch}.AppImage
export AppImageOutputArch=$OutputArchArm
export OutputPath=$OutputPathArm64
./pkg2appimage ./pkg2appimage.yml
mv out/*.AppImage v2rayN-${AppImageOutputArch}.AppImage
+597 -43
View File
@@ -1,58 +1,612 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
Arch="$1"
OutputPath="$2"
Version="$3"
# Require Debian base branch
. /etc/os-release
FileName="v2rayN-${Arch}.zip"
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
7z x $FileName
cp -rf v2rayN-${Arch}/* $OutputPath
case "${ID:-}" in
debian)
echo "Detected supported system: ${NAME:-$ID} ${VERSION_ID:-}"
;;
*)
echo "Unsupported system: ${NAME:-unknown} (${ID:-unknown})."
echo "This script only supports: Debian."
exit 1
;;
esac
PackagePath="v2rayN-Package-${Arch}"
mkdir -p "${PackagePath}/DEBIAN"
mkdir -p "${PackagePath}/opt"
cp -rf $OutputPath "${PackagePath}/opt/v2rayN"
echo "When this file exists, app will not store configs under this folder" > "${PackagePath}/opt/v2rayN/NotStoreConfigHere.txt"
# Kernel version
MIN_KERNEL="6.11"
CURRENT_KERNEL="$(uname -r)"
if [ $Arch = "linux-64" ]; then
Arch2="amd64"
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$CURRENT_KERNEL" | sort -V | head -n1)"
if [[ "$lowest" != "$MIN_KERNEL" ]]; then
echo "Kernel $CURRENT_KERNEL is below $MIN_KERNEL"
exit 1
fi
echo "[OK] Kernel $CURRENT_KERNEL verified."
# Config & Parse arguments
VERSION_ARG="${1:-}" # Pass version number like 7.13.8, or leave empty
WITH_CORE="both" # Default: bundle both xray+sing-box
FORCE_NETCORE=0 # --netcore => skip archive bundle, use separate downloads
ARCH_OVERRIDE="" # --arch x64|arm64|all (optional compile target)
BUILD_FROM="" # --buildfrom 1|2|3 to select channel non-interactively
# If the first argument starts with --, do not treat it as a version number
if [[ "${VERSION_ARG:-}" == --* ]]; then
VERSION_ARG=""
fi
# Take the first non --* argument as version, discard it
if [[ -n "${VERSION_ARG:-}" ]]; then shift || true; fi
# Parse remaining optional arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--with-core) WITH_CORE="${2:-both}"; shift 2;;
--xray-ver) XRAY_VER="${2:-}"; shift 2;;
--singbox-ver) SING_VER="${2:-}"; shift 2;;
--netcore) FORCE_NETCORE=1; shift;;
--arch) ARCH_OVERRIDE="${2:-}"; shift 2;;
--buildfrom) BUILD_FROM="${2:-}"; shift 2;;
*)
if [[ -z "${VERSION_ARG:-}" ]]; then VERSION_ARG="$1"; fi
shift;;
esac
done
# Conflict: version number AND --buildfrom cannot be used together
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
echo "You cannot specify both an explicit version and --buildfrom at the same time."
echo " Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
exit 1
fi
# Check and install dependencies
host_arch="$(uname -m)"
[[ "$host_arch" == "aarch64" || "$host_arch" == "x86_64" ]] || { echo "Only supports aarch64 / x86_64"; exit 1; }
install_ok=0
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get -y install \
curl unzip tar jq rsync ca-certificates git dpkg-dev fakeroot file \
desktop-file-utils xdg-utils wget
if [[ "$host_arch" == "aarch64" ]]; then
sudo dpkg --add-architecture amd64 || true
sudo apt-get update
sudo apt-get -y install \
libc6:amd64 libgcc-s1:amd64 libstdc++6:amd64 zlib1g:amd64 libfontconfig1:amd64
elif [[ "$host_arch" == "x86_64" ]]; then
sudo dpkg --add-architecture arm64 || true
sudo apt-get update
sudo apt-get -y install \
libc6:arm64 libgcc-s1:arm64 libstdc++6:arm64 zlib1g:arm64 libfontconfig1:arm64
fi
# Install .NET SDK 8 via official script
wget -q https://dot.net/v1/dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 8.0 --install-dir "$HOME/.dotnet"
export PATH="$HOME/.dotnet:$PATH"
export DOTNET_ROOT="$HOME/.dotnet"
dotnet --info >/dev/null 2>&1 && install_ok=1
fi
if [[ "$install_ok" -ne 1 ]]; then
echo "Could not auto-install dependencies for '$ID'. Make sure these are available:"
echo "dotnet-sdk 8.x, curl, unzip, tar, rsync, git, dpkg-deb, desktop-file-utils, xdg-utils"
exit 1
fi
# Root directory
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Git submodules (best effort)
if [[ -f .gitmodules ]]; then
git submodule sync --recursive || true
git submodule update --init --recursive || true
fi
# Locate project
PROJECT="v2rayN.Desktop/v2rayN.Desktop.csproj"
if [[ ! -f "$PROJECT" ]]; then
PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
fi
[[ -f "$PROJECT" ]] || { echo "v2rayN.Desktop.csproj not found"; exit 1; }
choose_channel() {
# If --buildfrom provided, map it directly and skip interaction.
if [[ -n "${BUILD_FROM:-}" ]]; then
case "$BUILD_FROM" in
1) echo "latest"; return 0;;
2) echo "prerelease"; return 0;;
3) echo "keep"; return 0;;
*) echo "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." >&2; exit 1;;
esac
fi
# Print menu to stderr and read from /dev/tty so stdout only carries the token.
local ch="latest" sel=""
if [[ -t 0 ]]; then
echo "[?] Choose v2rayN release channel:" >&2
echo " 1) Latest (stable) [default]" >&2
echo " 2) Pre-release (preview)" >&2
echo " 3) Keep current (do nothing)" >&2
printf "Enter 1, 2 or 3 [default 1]: " >&2
if read -r sel </dev/tty; then
case "${sel:-}" in
2) ch="prerelease" ;;
3) ch="keep" ;;
esac
fi
fi
echo "$ch"
}
get_latest_tag_latest() {
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
| jq -re '.tag_name' \
| sed 's/^v//'
}
get_latest_tag_prerelease() {
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
| sed 's/^v//'
}
git_try_checkout() {
local want="$1" ref=""
if git rev-parse --git-dir >/dev/null 2>&1; then
git fetch --tags --force --prune --depth=1 || true
if git rev-parse "refs/tags/${want}" >/dev/null 2>&1; then
ref="${want}"
fi
if [[ -n "$ref" ]]; then
echo "[OK] Found ref '${ref}', checking out..."
git checkout -f "${ref}"
if [[ -f .gitmodules ]]; then
git submodule sync --recursive || true
git submodule update --init --recursive || true
fi
return 0
fi
fi
return 1
}
apply_channel_or_keep() {
local ch="$1" tag
if [[ "$ch" == "keep" ]]; then
echo "[*] Keep current repository state (no checkout)."
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
VERSION="${VERSION#v}"
return 0
fi
echo "[*] Resolving ${ch} tag from GitHub releases..."
if [[ "$ch" == "prerelease" ]]; then
tag="$(get_latest_tag_prerelease || true)"
else
tag="$(get_latest_tag_latest || true)"
fi
[[ -n "$tag" ]] || { echo "Failed to resolve latest tag for channel '${ch}'."; exit 1; }
echo "[*] Latest tag for '${ch}': ${tag}"
git_try_checkout "$tag" || { echo "Failed to checkout '${tag}'."; exit 1; }
VERSION="${tag#v}"
}
if git rev-parse --git-dir >/dev/null 2>&1; then
if [[ -n "${VERSION_ARG:-}" ]]; then
clean_ver="${VERSION_ARG#v}"
if git_try_checkout "$clean_ver"; then
VERSION="$clean_ver"
else
echo "[WARN] Tag '${VERSION_ARG}' not found."
ch="$(choose_channel)"
apply_channel_or_keep "$ch"
fi
else
ch="$(choose_channel)"
apply_channel_or_keep "$ch"
fi
else
Arch2="arm64"
echo "Current directory is not a git repo; proceeding on current tree."
VERSION="${VERSION_ARG:-0.0.0}"
fi
echo $Arch2
# basic
cat >"${PackagePath}/DEBIAN/control" <<-EOF
Package: v2rayN
Version: $Version
Architecture: $Arch2
Maintainer: https://github.com/2dust/v2rayN
Description: A GUI client for Windows and Linux, support Xray core and sing-box-core and others
VERSION="${VERSION#v}"
echo "[*] GUI version resolved as: ${VERSION}"
download_xray() {
local outdir="$1" rid="$2" ver="${XRAY_VER:-}" url tmp zipname="xray.zip"
mkdir -p "$outdir"
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' | sed -E 's/.*"v([^"]+)".*/\1/' | head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
if [[ "$rid" == "linux-arm64" ]]; then
url="https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip"
else
url="https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip"
fi
echo "[+] Download xray: $url"
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$zipname"
unzip -q "$tmp/$zipname" -d "$tmp"
install -m 755 "$tmp/xray" "$outdir/xray"
rm -rf "$tmp"
}
download_singbox() {
# Download sing-box
local outdir="$1" rid="$2" ver="${SING_VER:-}" url tmp tarname="singbox.tar.gz" bin cronet
mkdir -p "$outdir"
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' \
| sed -E 's/.*"v([^"]+)".*/\1/' \
| head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
if [[ "$rid" == "linux-arm64" ]]; then
url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz"
else
url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz"
fi
echo "[+] Download sing-box: $url"
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$tarname"
tar -C "$tmp" -xzf "$tmp/$tarname"
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
install -m 755 "$bin" "$outdir/sing-box"
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so"
rm -rf "$tmp"
}
unify_geo_layout() {
local outroot="$1"
mkdir -p "$outroot/bin"
local names=(
"geosite.dat"
"geoip.dat"
"geoip-only-cn-private.dat"
"Country.mmdb"
"geoip.metadb"
)
for n in "${names[@]}"; do
if [[ -f "$outroot/bin/xray/$n" ]]; then
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
fi
done
}
download_geo_assets() {
local outroot="$1"
local bin_dir="$outroot/bin"
local srss_dir="$bin_dir/srss"
mkdir -p "$bin_dir" "$srss_dir"
echo "[+] Download Xray Geo to ${bin_dir}"
curl -fsSL -o "$bin_dir/geosite.dat" \
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
curl -fsSL -o "$bin_dir/geoip.dat" \
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" \
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
curl -fsSL -o "$bin_dir/Country.mmdb" \
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
echo "[+] Download sing-box rule DB & rule-sets"
curl -fsSL -o "$bin_dir/geoip.metadb" \
"https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb" || true
for f in \
geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs \
geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
curl -fsSL -o "$srss_dir/$f" \
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geoip/$f" || true
done
for f in \
geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs \
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
curl -fsSL -o "$srss_dir/$f" \
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geosite/$f" || true
done
unify_geo_layout "$outroot"
}
download_v2rayn_bundle() {
local outroot="$1" rid="$2"
local url=""
if [[ "$rid" == "linux-arm64" ]]; then
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip"
else
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip"
fi
echo "[+] Try v2rayN bundle archive: $url"
local tmp zipname
tmp="$(mktemp -d)"; zipname="$tmp/v2rayn.zip"
curl -fL "$url" -o "$zipname" || { echo "[!] Bundle download failed"; return 1; }
unzip -q "$zipname" -d "$tmp" || { echo "[!] Bundle unzip failed"; return 1; }
if [[ -d "$tmp/bin" ]]; then
mkdir -p "$outroot/bin"
rsync -a "$tmp/bin/" "$outroot/bin/"
else
rsync -a "$tmp/" "$outroot/"
fi
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
local nested_dir
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
mkdir -p "$outroot/bin"
rsync -a "$nested_dir/bin/" "$outroot/bin/"
rm -rf "$nested_dir"
fi
# Unify to bin/
unify_geo_layout "$outroot"
echo "[+] Bundle extracted to $outroot"
}
BUILT_DEBS=()
BUILT_ALL=0
OUTPUT_DIR="$HOME/debbuild"
mkdir -p "$OUTPUT_DIR"
build_for_arch() {
local short="$1"
local rid deb_arch outdir_name
case "$short" in
x64) rid="linux-x64"; deb_arch="amd64"; outdir_name="amd64" ;;
arm64) rid="linux-arm64"; deb_arch="arm64"; outdir_name="arm64" ;;
*) echo "Unknown arch '$short' (use x64|arm64)"; return 1 ;;
esac
echo "[*] Building for target: $short (RID=$rid, DEB arch=$deb_arch)"
dotnet clean "$PROJECT" -c Release
rm -rf "$(dirname "$PROJECT")/bin/Release/net8.0" || true
dotnet restore "$PROJECT"
dotnet publish "$PROJECT" \
-c Release -r "$rid" \
-p:PublishSingleFile=false \
-p:SelfContained=true
local RID_DIR="$rid"
local PUBDIR
PUBDIR="$(dirname "$PROJECT")/bin/Release/net8.0/${RID_DIR}/publish"
[[ -d "$PUBDIR" ]] || { echo "Publish directory not found: $PUBDIR"; return 1; }
local WORKDIR PKGROOT STAGE DEBIAN_DIR
WORKDIR="$(mktemp -d)"
PKGROOT="v2rayN-publish"
STAGE="$WORKDIR/${PKGROOT}_${VERSION}_${deb_arch}"
DEBIAN_DIR="$STAGE/DEBIAN"
mkdir -p "$STAGE/opt/v2rayN"
mkdir -p "$STAGE/usr/bin"
mkdir -p "$STAGE/usr/share/applications"
mkdir -p "$STAGE/usr/share/icons/hicolor/256x256/apps"
mkdir -p "$DEBIAN_DIR"
# Stage publish content from source build
cp -a "$PUBDIR/." "$STAGE/opt/v2rayN/"
local ICON_CANDIDATE
PROJECT_DIR="$(cd "$(dirname "$PROJECT")" && pwd)"
ICON_CANDIDATE="$PROJECT_DIR/v2rayN.png"
[[ -f "$ICON_CANDIDATE" ]] && cp "$ICON_CANDIDATE" "$STAGE/usr/share/icons/hicolor/256x256/apps/v2rayn.png" || true
mkdir -p "$STAGE/opt/v2rayN/bin/xray" "$STAGE/opt/v2rayN/bin/sing_box"
fetch_separate_cores_and_rules() {
local outroot="$1"
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
download_xray "$outroot/bin/xray" "$RID_DIR" || echo "[!] xray download failed (skipped)"
fi
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
download_singbox "$outroot/bin/sing_box" "$RID_DIR" || echo "[!] sing-box download failed (skipped)"
fi
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
}
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
if download_v2rayn_bundle "$STAGE/opt/v2rayN" "$RID_DIR"; then
echo "[*] Using v2rayN bundle bin assets."
else
echo "[*] Bundle failed, fallback to separate core + rules."
fetch_separate_cores_and_rules "$STAGE/opt/v2rayN"
fi
else
echo "[*] --netcore specified: use separate core + rules."
fetch_separate_cores_and_rules "$STAGE/opt/v2rayN"
fi
# Wrapper
install -m 755 /dev/stdin "$STAGE/usr/bin/v2rayn" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
DIR="/opt/v2rayN"
cd "$DIR"
if [[ -x "$DIR/v2rayN" ]]; then
exec "$DIR/v2rayN" "$@"
fi
for dll in v2rayN.Desktop.dll v2rayN.dll; do
if [[ -f "$DIR/$dll" ]]; then
exec /usr/bin/dotnet "$DIR/$dll" "$@"
fi
done
echo "v2rayN launcher: no executable found in $DIR" >&2
ls -l "$DIR" >&2 || true
exit 1
EOF
cat >"${PackagePath}/DEBIAN/postinst" <<-EOF
if [ ! -s /usr/share/applications/v2rayN.desktop ]; then
cat >/usr/share/applications/v2rayN.desktop<<-END
SHLIBS_DEPENDS=""
EXTRA_DEPENDS="libc6 (>= 2.34), fontconfig (>= 2.13.1), desktop-file-utils (>= 0.26), xdg-utils (>= 1.1.3), coreutils (>= 8.32), bash (>= 5.1), libfreetype6 (>= 2.11)"
mkdir -p "$WORKDIR/debian"
cat > "$WORKDIR/debian/control" <<EOF
Source: v2rayn
Section: net
Priority: optional
Maintainer: 2dust <noreply@github.com>
Standards-Version: 4.7.0
Package: v2rayn
Architecture: ${deb_arch}
Description: v2rayN
EOF
local SYS_LIBDIR=""
local SYS_USRLIBDIR=""
multiarch="$(dpkg-architecture -a"$deb_arch" -qDEB_HOST_MULTIARCH)"
SYS_LIBDIR="/lib/$multiarch"
SYS_USRLIBDIR="/usr/lib/$multiarch"
: > "$DEBIAN_DIR/substvars"
mapfile -t ELF_FILES < <(
find "$STAGE/opt/v2rayN" -type f \( -name "*.so*" -o -perm -111 \) ! -name 'libcoreclrtraceptprovider.so'
)
if [[ "${#ELF_FILES[@]}" -gt 0 ]]; then
(
cd "$WORKDIR"
dpkg-shlibdeps \
-l"$STAGE/opt/v2rayN" \
-l"$SYS_LIBDIR" \
-l"$SYS_USRLIBDIR" \
-T"$DEBIAN_DIR/substvars" \
"${ELF_FILES[@]}"
) >/dev/null 2>&1 || true
fi
SHLIBS_DEPENDS="$(sed -n 's/^shlibs:Depends=//p' "$DEBIAN_DIR/substvars" | head -n1 || true)"
if [[ -n "$SHLIBS_DEPENDS" ]]; then
SHLIBS_DEPENDS="$(echo "$SHLIBS_DEPENDS" \
| sed -E 's/ *\([^)]*\)//g' \
| sed -E 's/ *, */, /g' \
| sed -E 's/^, *//; s/, *$//')"
FINAL_DEPENDS="${SHLIBS_DEPENDS}, ${EXTRA_DEPENDS}"
else
FINAL_DEPENDS="${EXTRA_DEPENDS}"
fi
# Desktop file
install -m 644 /dev/stdin "$STAGE/usr/share/applications/v2rayn.desktop" <<'EOF'
[Desktop Entry]
Name=v2rayN
Comment=A GUI client for Windows and Linux, support Xray core and sing-box-core and others
Exec=/opt/v2rayN/v2rayN
Icon=/opt/v2rayN/v2rayN.png
Terminal=false
Type=Application
Categories=Network;Application;
END
fi
update-desktop-database
Name=v2rayN
Comment=v2rayN for Debian GNU Linux
Exec=v2rayn
Icon=v2rayn
Terminal=false
Categories=Network;
EOF
sudo chmod 0755 "${PackagePath}/DEBIAN/postinst"
sudo chmod 0755 "${PackagePath}/opt/v2rayN/v2rayN"
sudo chmod 0755 "${PackagePath}/opt/v2rayN/AmazTool"
# Control file
cat > "$DEBIAN_DIR/control" <<EOF
Package: v2rayn
Version: ${VERSION}
Architecture: ${deb_arch}
Maintainer: 2dust <noreply@github.com>
Homepage: https://github.com/2dust/v2rayN
Section: net
Priority: optional
Depends: ${FINAL_DEPENDS}
Description: v2rayN (Avalonia) GUI client for Linux
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 /
Shadowsocks / tuic / WireGuard.
EOF
# desktop && PATH
# postinst
install -m 755 /dev/stdin "$DEBIAN_DIR/postinst" <<'EOF'
#!/bin/sh
set -e
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
fi
exit 0
EOF
sudo dpkg-deb -Zxz --build $PackagePath
sudo mv "${PackagePath}.deb" "v2rayN-${Arch}.deb"
# postrm
install -m 755 /dev/stdin "$DEBIAN_DIR/postrm" <<'EOF'
#!/bin/sh
set -e
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f /usr/share/icons/hicolor >/dev/null 2>&1 || true
fi
exit 0
EOF
# Normalize permissions
find "$STAGE/opt/v2rayN" -type d -exec chmod 0755 {} +
find "$STAGE/opt/v2rayN" -type f -exec chmod 0644 {} +
[[ -f "$STAGE/opt/v2rayN/v2rayN" ]] && chmod 0755 "$STAGE/opt/v2rayN/v2rayN" || true
local deb_out
deb_out="$OUTPUT_DIR/v2rayn_${VERSION}_${deb_arch}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$deb_out"
echo "Build done for $short. DEB at:"
echo " $deb_out"
BUILT_DEBS+=("$deb_out")
rm -rf "$WORKDIR"
}
case "${ARCH_OVERRIDE:-}" in
all) targets=(x64 arm64); BUILT_ALL=1 ;;
x64|amd64) targets=(x64) ;;
arm64|aarch64) targets=(arm64) ;;
"") targets=($([[ "$host_arch" == "aarch64" ]] && echo arm64 || echo x64)) ;;
*) echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all."; exit 1 ;;
esac
for arch in "${targets[@]}"; do
build_for_arch "$arch"
done
echo ""
echo "================ Build Summary ================="
if [[ "${#BUILT_DEBS[@]}" -gt 0 ]]; then
echo "Output directory: $OUTPUT_DIR"
for pkg in "${BUILT_DEBS[@]}"; do
echo "$pkg"
done
else
echo "No DEBs detected in summary (check build logs above)."
fi
echo "==============================================="
+3 -1
View File
@@ -43,6 +43,8 @@ cat >"$PackagePath/v2rayN.app/Contents/Info.plist" <<-EOF
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>12.7</string>
</dict>
</plist>
EOF
@@ -55,4 +57,4 @@ create-dmg \
--hide-extension "v2rayN.app" \
--app-drop-link 500 185 \
"v2rayN-${Arch}.dmg" \
"$PackagePath/v2rayN.app"
"$PackagePath/v2rayN.app"
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
Arch="$1"
OutputPath="$2"
OutputArch="v2rayN-${Arch}"
FileName="v2rayN-${Arch}.zip"
wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/$FileName"
ZipPath64="./$OutputArch"
mkdir $ZipPath64
cp -rf $OutputPath "$ZipPath64/$OutputArch"
7z a -tZip $FileName "$ZipPath64/$OutputArch" -mx1
+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env bash
set -euo pipefail
# Require Red Hat base branch
. /etc/os-release
case "${ID:-}" in
rhel|rocky|almalinux|fedora|centos)
echo "Detected supported system: ${NAME:-$ID} ${VERSION_ID:-}"
;;
*)
echo "Unsupported system: ${NAME:-unknown} (${ID:-unknown})."
echo "This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
exit 1
;;
esac
# Kernel version
MIN_KERNEL="5.10"
CURRENT_KERNEL="$(uname -r)"
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$CURRENT_KERNEL" | sort -V | head -n1)"
if [[ "$lowest" != "$MIN_KERNEL" ]]; then
echo "Kernel $CURRENT_KERNEL is below $MIN_KERNEL"
exit 1
fi
echo "[OK] Kernel $CURRENT_KERNEL verified."
# Config & Parse arguments
VERSION_ARG="${1:-}" # Pass version number like 7.13.8, or leave empty
WITH_CORE="both" # Default: bundle both xray+sing-box
FORCE_NETCORE=0 # --netcore => skip archive bundle, use separate downloads
BUILD_FROM="" # --buildfrom 1|2|3 to select channel non-interactively
DOTNET_RISCV_VERSION="10.0.105"
DOTNET_RISCV_BASE="https://github.com/filipnavara/dotnet-riscv/releases/download"
DOTNET_RISCV_FILE="dotnet-sdk-${DOTNET_RISCV_VERSION}-linux-riscv64.tar.gz"
DOTNET_SDK_URL="${DOTNET_RISCV_BASE}/${DOTNET_RISCV_VERSION}/${DOTNET_RISCV_FILE}"
SKIA_VER="${SKIA_VER:-3.119.2}"
HARFBUZZ_VER="${HARFBUZZ_VER:-8.3.1.1}"
# If the first argument starts with --, do not treat it as a version number
if [[ "${VERSION_ARG:-}" == --* ]]; then
VERSION_ARG=""
fi
# Take the first non --* argument as version, discard it
if [[ -n "${VERSION_ARG:-}" ]]; then shift || true; fi
# Parse remaining optional arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--with-core) WITH_CORE="${2:-both}"; shift 2;;
--xray-ver) XRAY_VER="${2:-}"; shift 2;;
--singbox-ver) SING_VER="${2:-}"; shift 2;;
--netcore) FORCE_NETCORE=1; shift;;
--buildfrom) BUILD_FROM="${2:-}"; shift 2;;
*)
if [[ -z "${VERSION_ARG:-}" ]]; then VERSION_ARG="$1"; fi
shift;;
esac
done
# Conflict: version number AND --buildfrom cannot be used together
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
echo "You cannot specify both an explicit version and --buildfrom at the same time."
echo " Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
exit 1
fi
apply_riscv_patch() {
# Upgrade net8.0 -> net10.0
find . -type f \( -name "*.csproj" -o -name "*.props" -o -name "*.targets" \) \
-exec sed -i 's/net8\.0/net10.0/g' {} +
# Patch all Directory.Packages.props for SkiaSharp/HarfBuzzSharp
while IFS= read -r -d '' f; do
# replace existing versions if present
sed -i \
-e "s#<PackageVersion Include=\"SkiaSharp\" Version=\"[^\"]*\" */>#<PackageVersion Include=\"SkiaSharp\" Version=\"$SKIA_VER\" />#g" \
-e "s#<PackageVersion Include=\"SkiaSharp.NativeAssets.Linux\" Version=\"[^\"]*\" */>#<PackageVersion Include=\"SkiaSharp.NativeAssets.Linux\" Version=\"$SKIA_VER\" />#g" \
-e "s#<PackageVersion Include=\"HarfBuzzSharp\" Version=\"[^\"]*\" */>#<PackageVersion Include=\"HarfBuzzSharp\" Version=\"$HARFBUZZ_VER\" />#g" \
-e "s#<PackageVersion Include=\"HarfBuzzSharp.NativeAssets.Linux\" Version=\"[^\"]*\" */>#<PackageVersion Include=\"HarfBuzzSharp.NativeAssets.Linux\" Version=\"$HARFBUZZ_VER\" />#g" \
"$f"
grep -q 'PackageVersion Include="SkiaSharp"' "$f" || \
sed -i "/<\/ItemGroup>/i\ <PackageVersion Include=\"SkiaSharp\" Version=\"$SKIA_VER\" />" "$f"
grep -q 'PackageVersion Include="SkiaSharp.NativeAssets.Linux"' "$f" || \
sed -i "/<\/ItemGroup>/i\ <PackageVersion Include=\"SkiaSharp.NativeAssets.Linux\" Version=\"$SKIA_VER\" />" "$f"
grep -q 'PackageVersion Include="HarfBuzzSharp"' "$f" || \
sed -i "/<\/ItemGroup>/i\ <PackageVersion Include=\"HarfBuzzSharp\" Version=\"$HARFBUZZ_VER\" />" "$f"
grep -q 'PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux"' "$f" || \
sed -i "/<\/ItemGroup>/i\ <PackageVersion Include=\"HarfBuzzSharp.NativeAssets.Linux\" Version=\"$HARFBUZZ_VER\" />" "$f"
done < <(find . -type f -name 'Directory.Packages.props' -print0)
# Patch SDK bundled RIDs
f="$(find "$DOTNET_ROOT/sdk/$(dotnet --version)" -type f -name 'Microsoft.NETCoreSdk.BundledVersions.props' | head -n1 || true)"
[[ -f "$f" ]] && sed -i \
-e 's/linux-arm64/&;linux-riscv64/g' \
-e 's/linux-musl-arm64/&;linux-musl-riscv64/g' \
"$f"
}
build_sqlite_native_riscv64() {
local outdir="$1"
local workdir sqlite_year sqlite_ver sqlite_zip srcdir
mkdir -p "$outdir"
workdir="$(mktemp -d)"
# SQLite 3.51.3 amalgamation
sqlite_year="2026"
sqlite_ver="3510300"
sqlite_zip="sqlite-amalgamation-${sqlite_ver}.zip"
echo "[+] Download SQLite amalgamation: ${sqlite_zip}"
curl -fL "https://www.sqlite.org/${sqlite_year}/${sqlite_zip}" -o "${workdir}/${sqlite_zip}"
unzip -q "${workdir}/${sqlite_zip}" -d "$workdir"
srcdir="$(find "$workdir" -maxdepth 1 -type d -name 'sqlite-amalgamation-*' | head -n1 || true)"
[[ -n "$srcdir" ]] || { echo "[!] SQLite source unpack failed"; rm -rf "$workdir"; return 1; }
echo "[+] Build libe_sqlite3.so for riscv64"
gcc -shared -fPIC -O2 \
-DSQLITE_THREADSAFE=1 \
-DSQLITE_ENABLE_FTS5 \
-DSQLITE_ENABLE_RTREE \
-DSQLITE_ENABLE_JSON1 \
-o "${outdir}/libe_sqlite3.so" "${srcdir}/sqlite3.c" -ldl -lpthread
rm -rf "$workdir"
}
copy_skiasharp_native_riscv64() {
local outdir="$1"
local skia_so=""
local harfbuzz_so=""
mkdir -p "$outdir"
skia_so="$(find "$HOME/.nuget/packages" -path "*/skiasharp.nativeassets.linux/${SKIA_VER}/runtimes/linux-riscv64/native/libSkiaSharp.so" | head -n1 || true)"
if [[ -z "$skia_so" ]]; then
skia_so="$(find "$HOME/.nuget/packages" -path "*/runtimes/linux-riscv64/native/libSkiaSharp.so" | head -n1 || true)"
fi
harfbuzz_so="$(find "$HOME/.nuget/packages" -path "*/harfbuzzsharp.nativeassets.linux/${HARFBUZZ_VER}/runtimes/linux-riscv64/native/libHarfBuzzSharp.so" | head -n1 || true)"
if [[ -z "$harfbuzz_so" ]]; then
harfbuzz_so="$(find "$HOME/.nuget/packages" -path "*/runtimes/linux-riscv64/native/libHarfBuzzSharp.so" | head -n1 || true)"
fi
if [[ -n "$skia_so" && -f "$skia_so" ]]; then
echo "[+] Copy libSkiaSharp.so from NuGet cache"
install -m 755 "$skia_so" "$outdir/libSkiaSharp.so"
else
echo "[WARN] libSkiaSharp.so for linux-riscv64 not found in NuGet cache"
fi
if [[ -n "$harfbuzz_so" && -f "$harfbuzz_so" ]]; then
echo "[+] Copy libHarfBuzzSharp.so from NuGet cache"
install -m 755 "$harfbuzz_so" "$outdir/libHarfBuzzSharp.so"
else
echo "[WARN] libHarfBuzzSharp.so for linux-riscv64 not found in NuGet cache"
fi
}
# Check and install dependencies
host_arch="$(uname -m)"
[[ "$host_arch" == "riscv64" ]] || { echo "Only supports riscv64"; exit 1; }
install_ok=0
if command -v dnf >/dev/null 2>&1; then
sudo dnf -y install \
rpm-build rpmdevtools curl unzip tar jq rsync git python3 gcc make \
glibc-devel kernel-headers libatomic file ca-certificates libicu\
&& install_ok=1
mkdir -p "$HOME/.dotnet"
tmp_dotnet="$(mktemp -d)"
curl -fL "$DOTNET_SDK_URL" -o "$tmp_dotnet/$DOTNET_RISCV_FILE"
tar -C "$HOME/.dotnet" -xzf "$tmp_dotnet/$DOTNET_RISCV_FILE"
rm -rf "$tmp_dotnet"
export PATH="$HOME/.dotnet:$PATH"
export DOTNET_ROOT="$HOME/.dotnet"
dotnet --info >/dev/null 2>&1 || install_ok=0
fi
if [[ "$install_ok" -ne 1 ]]; then
echo "Could not auto-install dependencies for '$ID'. Make sure these are available:"
echo "dotnet-riscv SDK, curl, unzip, tar, rsync, git, python3, gcc, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
exit 1
fi
# Root directory
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Git submodules (best effort)
if [[ -f .gitmodules ]]; then
git submodule sync --recursive || true
git submodule update --init --recursive || true
fi
# Locate project
PROJECT="v2rayN.Desktop/v2rayN.Desktop.csproj"
if [[ ! -f "$PROJECT" ]]; then
PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
fi
[[ -f "$PROJECT" ]] || { echo "v2rayN.Desktop.csproj not found"; exit 1; }
choose_channel() {
# If --buildfrom provided, map it directly and skip interaction.
if [[ -n "${BUILD_FROM:-}" ]]; then
case "$BUILD_FROM" in
1) echo "latest"; return 0;;
2) echo "prerelease"; return 0;;
3) echo "keep"; return 0;;
*) echo "[ERROR] Invalid --buildfrom value: ${BUILD_FROM}. Use 1|2|3." >&2; exit 1;;
esac
fi
# Print menu to stderr and read from /dev/tty so stdout only carries the token.
local ch="latest" sel=""
if [[ -t 0 ]]; then
echo "[?] Choose v2rayN release channel:" >&2
echo " 1) Latest (stable) [default]" >&2
echo " 2) Pre-release (preview)" >&2
echo " 3) Keep current (do nothing)" >&2
printf "Enter 1, 2 or 3 [default 1]: " >&2
if read -r sel </dev/tty; then
case "${sel:-}" in
2) ch="prerelease" ;;
3) ch="keep" ;;
esac
fi
fi
echo "$ch"
}
get_latest_tag_latest() {
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
| jq -re '.tag_name' \
| sed 's/^v//'
}
get_latest_tag_prerelease() {
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
| sed 's/^v//'
}
git_try_checkout() {
# Try a series of refs and checkout when found.
local want="$1" ref=""
if git rev-parse --git-dir >/dev/null 2>&1; then
git fetch --tags --force --prune --depth=1 || true
if git rev-parse "refs/tags/${want}" >/dev/null 2>&1; then
ref="${want}"
fi
if [[ -n "$ref" ]]; then
echo "[OK] Found ref '${ref}', checking out..."
git checkout -f "${ref}"
if [[ -f .gitmodules ]]; then
git submodule sync --recursive || true
git submodule update --init --recursive || true
fi
return 0
fi
fi
return 1
}
apply_channel_or_keep() {
local ch="$1" tag
if [[ "$ch" == "keep" ]]; then
echo "[*] Keep current repository state (no checkout)."
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
VERSION="${VERSION#v}"
return 0
fi
echo "[*] Resolving ${ch} tag from GitHub releases..."
if [[ "$ch" == "prerelease" ]]; then
tag="$(get_latest_tag_prerelease || true)"
else
tag="$(get_latest_tag_latest || true)"
fi
[[ -n "$tag" ]] || { echo "Failed to resolve latest tag for channel '${ch}'."; exit 1; }
echo "[*] Latest tag for '${ch}': ${tag}"
git_try_checkout "$tag" || { echo "Failed to checkout '${tag}'."; exit 1; }
VERSION="${tag#v}"
}
if git rev-parse --git-dir >/dev/null 2>&1; then
if [[ -n "${VERSION_ARG:-}" ]]; then
clean_ver="${VERSION_ARG#v}"
if git_try_checkout "$clean_ver"; then
VERSION="$clean_ver"
else
echo "[WARN] Tag '${VERSION_ARG}' not found."
ch="$(choose_channel)"
apply_channel_or_keep "$ch"
fi
else
ch="$(choose_channel)"
apply_channel_or_keep "$ch"
fi
else
echo "Current directory is not a git repo; proceeding on current tree."
VERSION="${VERSION_ARG:-0.0.0}"
fi
VERSION="${VERSION#v}"
echo "[*] GUI version resolved as: ${VERSION}"
# riscv64 patch
apply_riscv_patch
# Helpers for core
download_xray() {
# Download Xray core
local outdir="$1" rid="$2" ver="${XRAY_VER:-}" url="" tmp zipname="xray.zip"
mkdir -p "$outdir"
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' | sed -E 's/.*"v([^"]+)".*/\1/' | head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
if [[ "$rid" == "linux-riscv64" ]]; then
url="https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-riscv64.zip"
fi
[[ -n "$url" ]] || { echo "[xray] Unsupported RID: $rid"; return 1; }
echo "[+] Download xray: $url"
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$zipname"
unzip -q "$tmp/$zipname" -d "$tmp"
install -m 755 "$tmp/xray" "$outdir/xray"
rm -rf "$tmp"
}
download_singbox() {
# Download sing-box
local outdir="$1" rid="$2" ver="${SING_VER:-}" url="" tmp tarname="singbox.tar.gz" bin cronet
mkdir -p "$outdir"
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' \
| sed -E 's/.*"v([^"]+)".*/\1/' \
| head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
if [[ "$rid" == "linux-riscv64" ]]; then
url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-riscv64.tar.gz"
fi
[[ -n "$url" ]] || { echo "[sing-box] Unsupported RID: $rid"; return 1; }
echo "[+] Download sing-box: $url"
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$tarname"
tar -C "$tmp" -xzf "$tmp/$tarname"
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
install -m 755 "$bin" "$outdir/sing-box"
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so"
rm -rf "$tmp"
}
# Move geo files to outroot/bin
unify_geo_layout() {
local outroot="$1"
mkdir -p "$outroot/bin"
local names=( \
"geosite.dat" \
"geoip.dat" \
"geoip-only-cn-private.dat" \
"Country.mmdb" \
"geoip.metadb" \
)
for n in "${names[@]}"; do
if [[ -f "$outroot/bin/xray/$n" ]]; then
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
fi
done
}
# Download geo/rule assets
download_geo_assets() {
local outroot="$1"
local bin_dir="$outroot/bin"
local srss_dir="$bin_dir/srss"
mkdir -p "$bin_dir" "$srss_dir"
echo "[+] Download Xray Geo to ${bin_dir}"
curl -fsSL -o "$bin_dir/geosite.dat" \
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geosite.dat"
curl -fsSL -o "$bin_dir/geoip.dat" \
"https://github.com/Loyalsoldier/V2ray-rules-dat/releases/latest/download/geoip.dat"
curl -fsSL -o "$bin_dir/geoip-only-cn-private.dat" \
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat"
curl -fsSL -o "$bin_dir/Country.mmdb" \
"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
echo "[+] Download sing-box rule DB & rule-sets"
curl -fsSL -o "$bin_dir/geoip.metadb" \
"https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.metadb" || true
for f in \
geoip-private.srs geoip-cn.srs geoip-facebook.srs geoip-fastly.srs \
geoip-google.srs geoip-netflix.srs geoip-telegram.srs geoip-twitter.srs; do
curl -fsSL -o "$srss_dir/$f" \
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geoip/$f" || true
done
for f in \
geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs \
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
curl -fsSL -o "$srss_dir/$f" \
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geosite/$f" || true
done
# Unify to bin
unify_geo_layout "$outroot"
}
# Prefer the prebuilt v2rayN core bundle; then unify geo layout
download_v2rayn_bundle() {
local outroot="$1" rid="$2"
local url=""
if [[ "$rid" == "linux-riscv64" ]]; then
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-riscv64.zip"
fi
[[ -n "$url" ]] || { echo "[!] Bundle unsupported RID: $rid"; return 1; }
echo "[+] Try v2rayN bundle archive: $url"
local tmp zipname
tmp="$(mktemp -d)"; zipname="$tmp/v2rayn.zip"
curl -fL "$url" -o "$zipname" || { echo "[!] Bundle download failed"; return 1; }
unzip -q "$zipname" -d "$tmp" || { echo "[!] Bundle unzip failed"; return 1; }
if [[ -d "$tmp/bin" ]]; then
mkdir -p "$outroot/bin"
rsync -a "$tmp/bin/" "$outroot/bin/"
else
rsync -a "$tmp/" "$outroot/"
fi
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
local nested_dir
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
mkdir -p "$outroot/bin"
rsync -a "$nested_dir/bin/" "$outroot/bin/"
rm -rf "$nested_dir"
fi
# Unify to bin/
unify_geo_layout "$outroot"
echo "[+] Bundle extracted to $outroot"
}
# ===== Build results collection ========================================================
BUILT_RPMS=()
# ===== Build (single-arch) function ====================================================
build_for_arch() {
# $1: target short arch: riscv64
local short="$1"
local rid rpm_target archdir
case "$short" in
riscv64) rid="linux-riscv64"; rpm_target="riscv64"; archdir="riscv64" ;;
*) echo "Unknown arch '$short' (use riscv64)"; return 1;;
esac
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
# .NET publish (self-contained) for this RID
dotnet clean "$PROJECT" -c Release -p:TargetFramework=net10.0
rm -rf "$(dirname "$PROJECT")/bin/Release/net10.0" || true
dotnet restore "$PROJECT" -r "$rid" -p:TargetFramework=net10.0
dotnet publish "$PROJECT" \
-c Release -r "$rid" \
-p:TargetFramework=net10.0 \
-p:PublishSingleFile=false \
-p:SelfContained=true
# Per-arch variables (scoped)
local RID_DIR="$rid"
local PUBDIR
PUBDIR="$(dirname "$PROJECT")/bin/Release/net10.0/${RID_DIR}/publish"
[[ -d "$PUBDIR" ]] || { echo "Publish directory not found: $PUBDIR"; return 1; }
# Per-arch working area
local PKGROOT="v2rayN-publish"
local WORKDIR
WORKDIR="$(mktemp -d)"
trap '[[ -n "${WORKDIR:-}" ]] && rm -rf "$WORKDIR"' RETURN
# rpmbuild topdir selection
local TOPDIR SPECDIR SOURCEDIR PROJECT_DIR
rpmdev-setuptree
TOPDIR="${HOME}/rpmbuild"
SPECDIR="${TOPDIR}/SPECS"
SOURCEDIR="${TOPDIR}/SOURCES"
# Stage publish content
mkdir -p "$WORKDIR/$PKGROOT"
cp -a "$PUBDIR/." "$WORKDIR/$PKGROOT/"
copy_skiasharp_native_riscv64 "$WORKDIR/$PKGROOT" || echo "[!] SkiaSharp native copy failed (skipped)"
build_sqlite_native_riscv64 "$WORKDIR/$PKGROOT" || echo "[!] sqlite native build failed (skipped)"
# Required icon
local ICON_CANDIDATE
PROJECT_DIR="$(cd "$(dirname "$PROJECT")" && pwd)"
ICON_CANDIDATE="$PROJECT_DIR/v2rayN.png"
[[ -f "$ICON_CANDIDATE" ]] || { echo "Required icon not found: $ICON_CANDIDATE"; return 1; }
cp "$ICON_CANDIDATE" "$WORKDIR/$PKGROOT/v2rayn.png"
# Prepare bin structure
mkdir -p "$WORKDIR/$PKGROOT/bin/xray" "$WORKDIR/$PKGROOT/bin/sing_box"
# Bundle / cores per-arch
fetch_separate_cores_and_rules() {
local outroot="$1"
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
download_xray "$outroot/bin/xray" "$RID_DIR" || echo "[!] xray download failed (skipped)"
fi
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
download_singbox "$outroot/bin/sing_box" "$RID_DIR" || echo "[!] sing-box download failed (skipped)"
fi
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
}
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
if download_v2rayn_bundle "$WORKDIR/$PKGROOT" "$RID_DIR"; then
echo "[*] Using v2rayN bundle archive."
else
echo "[*] Bundle failed, fallback to separate core + rules."
fetch_separate_cores_and_rules "$WORKDIR/$PKGROOT"
fi
else
echo "[*] --netcore specified: use separate core + rules."
fetch_separate_cores_and_rules "$WORKDIR/$PKGROOT"
fi
# Tarball
mkdir -p "$SOURCEDIR"
tar -C "$WORKDIR" -czf "$SOURCEDIR/$PKGROOT.tar.gz" "$PKGROOT"
# SPEC
local SPECFILE="$SPECDIR/v2rayN.spec"
mkdir -p "$SPECDIR"
cat > "$SPECFILE" <<'SPEC'
%global debug_package %{nil}
%undefine _debuginfo_subpackages
%undefine _debugsource_packages
# Ignore outdated LTTng dependencies incorrectly reported by the .NET runtime (to avoid installation failures)
%global __requires_exclude ^liblttng-ust\.so\..*$
Name: v2rayN
Version: __VERSION__
Release: 1%{?dist}
Summary: v2rayN (Avalonia) GUI client for Linux (riscv64)
License: GPL-3.0-only
URL: https://github.com/2dust/v2rayN
BugURL: https://github.com/2dust/v2rayN/issues
ExclusiveArch: riscv64
Source0: __PKGROOT__.tar.gz
# Runtime dependencies (Avalonia / X11 / Fonts / GL)
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
Requires: glibc >= 2.34
Requires: fontconfig >= 2.13.1
Requires: desktop-file-utils >= 0.26
Requires: xdg-utils >= 1.1.3
Requires: coreutils >= 8.32
Requires: bash >= 5.1
Requires: freetype >= 2.10
%description
v2rayN Linux for Red Hat Enterprise Linux
Support vless / vmess / Trojan / http / socks / Anytls / Hysteria2 / Shadowsocks / tuic / WireGuard
Support Red Hat Enterprise Linux / Fedora Linux / Rocky Linux / AlmaLinux / CentOS
For more information, Please visit our website
https://github.com/2dust/v2rayN
%prep
%setup -q -n __PKGROOT__
%build
# no build
%install
install -dm0755 %{buildroot}/opt/v2rayN
cp -a * %{buildroot}/opt/v2rayN/
# Normalize permissions
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
[ -f %{buildroot}/opt/v2rayN/libSkiaSharp.so ] && chmod 0755 %{buildroot}/opt/v2rayN/libSkiaSharp.so || :
[ -f %{buildroot}/opt/v2rayN/libHarfBuzzSharp.so ] && chmod 0755 %{buildroot}/opt/v2rayN/libHarfBuzzSharp.so || :
[ -f %{buildroot}/opt/v2rayN/libe_sqlite3.so ] && chmod 0755 %{buildroot}/opt/v2rayN/libe_sqlite3.so || :
# Launcher (prefer native ELF first, then DLL fallback)
install -dm0755 %{buildroot}%{_bindir}
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
#!/usr/bin/bash
set -euo pipefail
DIR="/opt/v2rayN"
export LD_LIBRARY_PATH="$DIR:${LD_LIBRARY_PATH:-}"
# Prefer native apphost
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
# DLL fallback
for dll in v2rayN.Desktop.dll v2rayN.dll; do
if [[ -f "$DIR/$dll" ]]; then exec /usr/bin/dotnet "$DIR/$dll" "$@"; fi
done
echo "v2rayN launcher: no executable found in $DIR" >&2
ls -l "$DIR" >&2 || true
exit 1
EOF
# Desktop file
install -dm0755 %{buildroot}%{_datadir}/applications
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
[Desktop Entry]
Type=Application
Name=v2rayN
Comment=v2rayN for Red Hat Enterprise Linux
Exec=v2rayn
Icon=v2rayn
Terminal=false
Categories=Network;
EOF
# Icon
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
%post
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
%postun
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
/usr/bin/gtk-update-icon-cache -f %{_datadir}/icons/hicolor >/dev/null 2>&1 || true
%files
%{_bindir}/v2rayn
/opt/v2rayN
%{_datadir}/applications/v2rayn.desktop
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
SPEC
# Replace placeholders
sed -i "s/__VERSION__/${VERSION}/g" "$SPECFILE"
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$SPECFILE"
# Build RPM for this arch
rpmbuild -ba "$SPECFILE" --target "$rpm_target"
echo "Build done for $short. RPM at:"
local f
for f in "${TOPDIR}/RPMS/${archdir}/v2rayN-${VERSION}-1"*.rpm; do
[[ -e "$f" ]] || continue
echo " $f"
BUILT_RPMS+=("$f")
done
}
# ===== Arch selection and build orchestration =========================================
targets=(riscv64)
for arch in "${targets[@]}"; do
build_for_arch "$arch"
done
echo ""
echo "================ Build Summary ================"
if [[ "${#BUILT_RPMS[@]}" -gt 0 ]]; then
for rp in "${BUILT_RPMS[@]}"; do
echo "$rp"
done
else
echo "No RPMs detected in summary (check build logs above)."
fi
echo "=============================================="
+176 -401
View File
@@ -1,28 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# ===== Require Red Hat Enterprise Linux/RockyLinux/AlmaLinux/CentOS OR Ubuntu/Debian ====
if [[ -r /etc/os-release ]]; then
. /etc/os-release
case "$ID" in
rhel|rocky|almalinux|centos|ubuntu|debian)
echo "[OK] Detected supported system: $NAME $VERSION_ID"
;;
*)
echo "[ERROR] Unsupported system: $NAME ($ID)."
echo "This script only supports Red Hat Enterprise Linux/RockyLinux/AlmaLinux/CentOS or Ubuntu/Debian."
exit 1
;;
esac
else
echo "[ERROR] Cannot detect system (missing /etc/os-release)."
exit 1
# Require Red Hat base branch
. /etc/os-release
case "${ID:-}" in
rhel|rocky|almalinux|fedora|centos)
echo "Detected supported system: ${NAME:-$ID} ${VERSION_ID:-}"
;;
*)
echo "Unsupported system: ${NAME:-unknown} (${ID:-unknown})."
echo "This script only supports: RHEL / Rocky / AlmaLinux / Fedora / CentOS."
exit 1
;;
esac
# Kernel version
MIN_KERNEL="6.11"
CURRENT_KERNEL="$(uname -r)"
lowest="$(printf '%s\n%s\n' "$MIN_KERNEL" "$CURRENT_KERNEL" | sort -V | head -n1)"
if [[ "$lowest" != "$MIN_KERNEL" ]]; then
echo "Kernel $CURRENT_KERNEL is below $MIN_KERNEL"
exit 1
fi
# ===== Config & Parse arguments =========================================================
echo "[OK] Kernel $CURRENT_KERNEL verified."
# Config & Parse arguments
VERSION_ARG="${1:-}" # Pass version number like 7.13.8, or leave empty
WITH_CORE="both" # Default: bundle both xray+sing-box
AUTOSTART=0 # 1 = enable system-wide autostart (/etc/xdg/autostart)
FORCE_NETCORE=0 # --netcore => skip archive bundle, use separate downloads
ARCH_OVERRIDE="" # --arch x64|arm64|all (optional compile target)
BUILD_FROM="" # --buildfrom 1|2|3 to select channel non-interactively
@@ -38,7 +46,6 @@ if [[ -n "${VERSION_ARG:-}" ]]; then shift || true; fi
while [[ $# -gt 0 ]]; do
case "$1" in
--with-core) WITH_CORE="${2:-both}"; shift 2;;
--autostart) AUTOSTART=1; shift;;
--xray-ver) XRAY_VER="${2:-}"; shift 2;;
--singbox-ver) SING_VER="${2:-}"; shift 2;;
--netcore) FORCE_NETCORE=1; shift;;
@@ -52,92 +59,28 @@ done
# Conflict: version number AND --buildfrom cannot be used together
if [[ -n "${VERSION_ARG:-}" && -n "${BUILD_FROM:-}" ]]; then
echo "[ERROR] You cannot specify both an explicit version and --buildfrom at the same time."
echo "You cannot specify both an explicit version and --buildfrom at the same time."
echo " Provide either a version (e.g. 7.14.0) OR --buildfrom 1|2|3."
exit 1
fi
# ===== Environment check + Dependencies ========================================
# Check and install dependencies
host_arch="$(uname -m)"
[[ "$host_arch" == "aarch64" || "$host_arch" == "x86_64" ]] || { echo "Only supports aarch64 / x86_64"; exit 1; }
install_ok=0
case "$ID" in
# ------------------------------ RHEL family (UNCHANGED) ------------------------------
rhel|rocky|almalinux|centos)
if command -v dnf >/dev/null 2>&1; then
sudo dnf -y install dotnet-sdk-8.0 rpm-build rpmdevtools curl unzip tar rsync || \
sudo dnf -y install dotnet-sdk rpm-build rpmdevtools curl unzip tar rsync
install_ok=1
elif command -v yum >/dev/null 2>&1; then
sudo yum -y install dotnet-sdk-8.0 rpm-build rpmdevtools curl unzip tar rsync || \
sudo yum -y install dotnet-sdk rpm-build rpmdevtools curl unzip tar rsync
install_ok=1
fi
;;
# ------------------------------ Ubuntu ----------------------------------------------
ubuntu)
sudo apt-get update
# Ensure 'universe' (Ubuntu) to get 'rpm'
if ! apt-cache policy | grep -q '^500 .*ubuntu.com/ubuntu.* universe'; then
sudo apt-get -y install software-properties-common || true
sudo add-apt-repository -y universe || true
sudo apt-get update
fi
# Base tools + rpm (provides rpmbuild)
sudo apt-get -y install curl unzip tar rsync rpm || true
# Cross-arch binutils so strip matches target arch + objdump for brp scripts
sudo apt-get -y install binutils binutils-x86-64-linux-gnu binutils-aarch64-linux-gnu || true
# rpmbuild presence check
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "[ERROR] 'rpmbuild' not found after installing 'rpm'."
echo " Please ensure the 'rpm' package is available from your repos (universe on Ubuntu)."
exit 1
fi
# .NET SDK 8 (best effort via apt)
if ! command -v dotnet >/dev/null 2>&1; then
sudo apt-get -y install dotnet-sdk-8.0 || true
sudo apt-get -y install dotnet-sdk-8 || true
sudo apt-get -y install dotnet-sdk || true
fi
install_ok=1
;;
# ------------------------------ Debian (KEEP, with local dotnet install) ------------
debian)
sudo apt-get update
# Base tools + rpm (provides rpmbuild on Debian) + objdump/strip
sudo apt-get -y install curl unzip tar rsync rpm binutils || true
# rpmbuild presence check
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "[ERROR] 'rpmbuild' not found after installing 'rpm'."
echo " Please ensure 'rpm' is available from Debian repos."
exit 1
fi
# Try apt for dotnet; fallback to official installer into $HOME/.dotnet
if ! command -v dotnet >/dev/null 2>&1; then
echo "[INFO] 'dotnet' not found. Installing .NET 8 SDK locally to \$HOME/.dotnet ..."
tmp="$(mktemp -d)"; trap '[[ -n "${tmp:-}" ]] && rm -rf "$tmp"' RETURN
curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$tmp/dotnet-install.sh"
bash "$tmp/dotnet-install.sh" --channel 8.0 --install-dir "$HOME/.dotnet"
export PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH"
export DOTNET_ROOT="$HOME/.dotnet"
if ! command -v dotnet >/dev/null 2>&1; then
echo "[ERROR] dotnet installation failed."
exit 1
fi
fi
install_ok=1
;;
esac
if [[ "$install_ok" -ne 1 ]]; then
echo "[WARN] Could not auto-install dependencies for '$ID'. Make sure these are available:"
echo " dotnet-sdk 8.x, curl, unzip, tar, rsync, rpm, rpmdevtools, rpm-build (on RPM-based distros)"
if command -v dnf >/dev/null 2>&1; then
sudo dnf -y install rpm-build rpmdevtools curl unzip tar jq rsync dotnet-sdk-8.0 \
&& install_ok=1
fi
command -v curl >/dev/null
if [[ "$install_ok" -ne 1 ]]; then
echo "Could not auto-install dependencies for '$ID'. Make sure these are available:"
echo "dotnet-sdk 8.x, curl, unzip, tar, rsync, rpm, rpmdevtools, rpm-build (on Red Hat branch)"
fi
# Root directory = the script's location
# Root directory
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
@@ -147,16 +90,13 @@ if [[ -f .gitmodules ]]; then
git submodule update --init --recursive || true
fi
# ===== Locate project ================================================================
# Locate project
PROJECT="v2rayN.Desktop/v2rayN.Desktop.csproj"
if [[ ! -f "$PROJECT" ]]; then
PROJECT="$(find . -maxdepth 3 -name 'v2rayN.Desktop.csproj' | head -n1 || true)"
fi
[[ -f "$PROJECT" ]] || { echo "v2rayN.Desktop.csproj not found"; exit 1; }
# ===== Resolve GUI version & auto checkout ============================================
VERSION=""
choose_channel() {
# If --buildfrom provided, map it directly and skip interaction.
if [[ -n "${BUILD_FROM:-}" ]]; then
@@ -170,60 +110,35 @@ choose_channel() {
# Print menu to stderr and read from /dev/tty so stdout only carries the token.
local ch="latest" sel=""
if [[ -t 0 ]]; then
echo "[?] Choose v2rayN release channel:" >&2
echo " 1) Latest (stable) [default]" >&2
echo " 2) Pre-release (preview)" >&2
echo " 3) Keep current (do nothing)" >&2
printf "Enter 1, 2 or 3 [default 1]: " >&2
if read -r sel </dev/tty; then
case "${sel:-}" in
2) ch="prerelease" ;;
3) ch="keep" ;;
*) ch="latest" ;;
esac
else
ch="latest"
fi
else
ch="latest"
fi
echo "$ch"
}
get_latest_tag_latest() {
# Resolve /releases/latest → tag_name
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases/latest" \
| grep -Eo '"tag_name":\s*"v?[^"]+"' \
| head -n1 \
| sed -E 's/.*"tag_name":\s*"v?([^"]+)".*/\1/'
| jq -re '.tag_name' \
| sed 's/^v//'
}
get_latest_tag_prerelease() {
# Resolve newest prerelease=true tag; prefer jq, fallback to sed/grep (no awk)
local json tag
json="$(curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20")" || return 1
# 1) Use jq if present
if command -v jq >/dev/null 2>&1; then
tag="$(printf '%s' "$json" \
| jq -r '[.[] | select(.prerelease==true)][0].tag_name' 2>/dev/null \
| sed 's/^v//')" || true
fi
# 2) Fallback to sed/grep only
if [[ -z "${tag:-}" || "${tag:-}" == "null" ]]; then
tag="$(printf '%s' "$json" \
| tr '\n' ' ' \
| sed 's/},[[:space:]]*{/\n/g' \
| grep -m1 -E '"prerelease"[[:space:]]*:[[:space:]]*true' \
| grep -Eo '"tag_name"[[:space:]]*:[[:space:]]*"v?[^"]+"' \
| head -n1 \
| sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/')" || true
fi
[[ -n "${tag:-}" && "${tag:-}" != "null" ]] || return 1
printf '%s\n' "$tag"
curl -fsSL "https://api.github.com/repos/2dust/v2rayN/releases?per_page=20" \
| jq -re 'first(.[] | select(.prerelease == true) | .tag_name)' \
| sed 's/^v//'
}
git_try_checkout() {
@@ -231,11 +146,7 @@ git_try_checkout() {
local want="$1" ref=""
if git rev-parse --git-dir >/dev/null 2>&1; then
git fetch --tags --force --prune --depth=1 || true
if git rev-parse "refs/tags/v${want}" >/dev/null 2>&1; then
ref="v${want}"
elif git rev-parse "refs/tags/${want}" >/dev/null 2>&1; then
ref="${want}"
elif git rev-parse --verify "${want}" >/dev/null 2>&1; then
if git rev-parse "refs/tags/${want}" >/dev/null 2>&1; then
ref="${want}"
fi
if [[ -n "$ref" ]]; then
@@ -251,164 +162,121 @@ git_try_checkout() {
return 1
}
apply_channel_or_keep() {
local ch="$1" tag
if [[ "$ch" == "keep" ]]; then
echo "[*] Keep current repository state (no checkout)."
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo '0.0.0+git')"
VERSION="${VERSION#v}"
return 0
fi
echo "[*] Resolving ${ch} tag from GitHub releases..."
if [[ "$ch" == "prerelease" ]]; then
tag="$(get_latest_tag_prerelease || true)"
else
tag="$(get_latest_tag_latest || true)"
fi
[[ -n "$tag" ]] || { echo "Failed to resolve latest tag for channel '${ch}'."; exit 1; }
echo "[*] Latest tag for '${ch}': ${tag}"
git_try_checkout "$tag" || { echo "Failed to checkout '${tag}'."; exit 1; }
VERSION="${tag#v}"
}
if git rev-parse --git-dir >/dev/null 2>&1; then
if [[ -n "${VERSION_ARG:-}" ]]; then
echo "[*] Trying to switch v2rayN repo to version: ${VERSION_ARG}"
if git_try_checkout "${VERSION_ARG#v}"; then
VERSION="${VERSION_ARG#v}"
clean_ver="${VERSION_ARG#v}"
if git_try_checkout "$clean_ver"; then
VERSION="$clean_ver"
else
echo "[WARN] Tag '${VERSION_ARG}' not found."
ch="$(choose_channel)"
if [[ "$ch" == "keep" ]]; then
echo "[*] Keep current repository state (no checkout)."
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
VERSION="$(git describe --tags --abbrev=0)"
else
VERSION="0.0.0+git"
fi
VERSION="${VERSION#v}"
else
echo "[*] Resolving ${ch} tag from GitHub releases..."
tag=""
if [[ "$ch" == "prerelease" ]]; then
tag="$(get_latest_tag_prerelease || true)"
if [[ -z "$tag" ]]; then
echo "[WARN] Failed to resolve prerelease tag, falling back to latest."
tag="$(get_latest_tag_latest || true)"
fi
else
tag="$(get_latest_tag_latest || true)"
fi
[[ -n "$tag" ]] || { echo "[ERROR] Failed to resolve latest tag for channel '${ch}'."; exit 1; }
echo "[*] Latest tag for '${ch}': ${tag}"
git_try_checkout "$tag" || { echo "[ERROR] Failed to checkout '${tag}'."; exit 1; }
VERSION="${tag#v}"
fi
apply_channel_or_keep "$ch"
fi
else
ch="$(choose_channel)"
if [[ "$ch" == "keep" ]]; then
echo "[*] Keep current repository state (no checkout)."
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
VERSION="$(git describe --tags --abbrev=0)"
else
VERSION="0.0.0+git"
fi
VERSION="${VERSION#v}"
else
echo "[*] Resolving ${ch} tag from GitHub releases..."
tag=""
if [[ "$ch" == "prerelease" ]]; then
tag="$(get_latest_tag_prerelease || true)"
if [[ -z "$tag" ]]; then
echo "[WARN] Failed to resolve prerelease tag, falling back to latest."
tag="$(get_latest_tag_latest || true)"
fi
else
tag="$(get_latest_tag_latest || true)"
fi
[[ -n "$tag" ]] || { echo "[ERROR] Failed to resolve latest tag for channel '${ch}'."; exit 1; }
echo "[*] Latest tag for '${ch}': ${tag}"
git_try_checkout "$tag" || { echo "[ERROR] Failed to checkout '${tag}'."; exit 1; }
VERSION="${tag#v}"
fi
apply_channel_or_keep "$ch"
fi
else
echo "[WARN] Current directory is not a git repo; cannot checkout version. Proceeding on current tree."
VERSION="${VERSION_ARG:-}"
if [[ -z "$VERSION" ]]; then
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
VERSION="$(git describe --tags --abbrev=0)"
else
VERSION="0.0.0+git"
fi
fi
VERSION="${VERSION#v}"
echo "Current directory is not a git repo; proceeding on current tree."
VERSION="${VERSION_ARG:-0.0.0}"
fi
VERSION="${VERSION#v}"
echo "[*] GUI version resolved as: ${VERSION}"
# ===== Helpers for core/rules download (use RID_DIR for arch sync) =====================
# Helpers for core
download_xray() {
# Download Xray core and install to outdir/xray
local outdir="$1" ver="${XRAY_VER:-}" url tmp zipname="xray.zip"
# Download Xray core
local outdir="$1" rid="$2" ver="${XRAY_VER:-}" url tmp zipname="xray.zip"
mkdir -p "$outdir"
if [[ -n "${XRAY_VER:-}" ]]; then ver="${XRAY_VER}"; fi
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/XTLS/Xray-core/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' | sed -E 's/.*"v([^"]+)".*/\1/' | head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[xray] Failed to get version"; return 1; }
if [[ "$RID_DIR" == "linux-arm64" ]]; then
if [[ "$rid" == "linux-arm64" ]]; then
url="https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-arm64-v8a.zip"
else
url="https://github.com/XTLS/Xray-core/releases/download/v${ver}/Xray-linux-64.zip"
fi
echo "[+] Download xray: $url"
tmp="$(mktemp -d)"; trap '[[ -n "${tmp:-}" ]] && rm -rf "$tmp"' RETURN
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$zipname"
unzip -q "$tmp/$zipname" -d "$tmp"
install -Dm755 "$tmp/xray" "$outdir/xray"
install -m 755 "$tmp/xray" "$outdir/xray"
rm -rf "$tmp"
}
download_singbox() {
# Download sing-box core and install to outdir/sing-box
local outdir="$1" ver="${SING_VER:-}" url tmp tarname="singbox.tar.gz" bin
# Download sing-box
local outdir="$1" rid="$2" ver="${SING_VER:-}" url tmp tarname="singbox.tar.gz" bin cronet
mkdir -p "$outdir"
if [[ -n "${SING_VER:-}" ]]; then ver="${SING_VER}"; fi
if [[ -z "$ver" ]]; then
ver="$(curl -fsSL https://api.github.com/repos/SagerNet/sing-box/releases/latest \
| grep -Eo '"tag_name":\s*"v[^"]+"' | sed -E 's/.*"v([^"]+)".*/\1/' | head -n1)" || true
| grep -Eo '"tag_name":\s*"v[^"]+"' \
| sed -E 's/.*"v([^"]+)".*/\1/' \
| head -n1)" || true
fi
[[ -n "$ver" ]] || { echo "[sing-box] Failed to get version"; return 1; }
if [[ "$RID_DIR" == "linux-arm64" ]]; then
if [[ "$rid" == "linux-arm64" ]]; then
url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-arm64.tar.gz"
else
url="https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box-${ver}-linux-amd64.tar.gz"
fi
echo "[+] Download sing-box: $url"
tmp="$(mktemp -d)"; trap '[[ -n "${tmp:-}" ]] && rm -rf "$tmp"' RETURN
tmp="$(mktemp -d)"
curl -fL "$url" -o "$tmp/$tarname"
tar -C "$tmp" -xzf "$tmp/$tarname"
bin="$(find "$tmp" -type f -name 'sing-box' | head -n1 || true)"
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; return 1; }
install -Dm755 "$bin" "$outdir/sing-box"
[[ -n "$bin" ]] || { echo "[!] sing-box unpack failed"; rm -rf "$tmp"; return 1; }
install -m 755 "$bin" "$outdir/sing-box"
cronet="$(find "$tmp" -type f -name 'libcronet*.so*' | head -n1 || true)"
[[ -n "$cronet" ]] && install -m 644 "$cronet" "$outdir/libcronet.so"
rm -rf "$tmp"
}
# ---- NEW: download_mihomo (REQUIRED in --netcore mode) ----
download_mihomo() {
# Download mihomo into outroot/bin/mihomo/mihomo
local outroot="$1"
local url=""
if [[ "$RID_DIR" == "linux-arm64" ]]; then
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64/bin/mihomo/mihomo"
else
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64/bin/mihomo/mihomo"
fi
echo "[+] Download mihomo: $url"
mkdir -p "$outroot/bin/mihomo"
curl -fL "$url" -o "$outroot/bin/mihomo/mihomo"
chmod +x "$outroot/bin/mihomo/mihomo" || true
}
# Move geo files to a unified path: outroot/bin/xray/
# Move geo files to outroot/bin
unify_geo_layout() {
local outroot="$1"
mkdir -p "$outroot/bin/xray"
local srcs=( \
"$outroot/bin/geosite.dat" \
"$outroot/bin/geoip.dat" \
"$outroot/bin/geoip-only-cn-private.dat" \
"$outroot/bin/Country.mmdb" \
"$outroot/bin/geoip.metadb" \
mkdir -p "$outroot/bin"
local names=( \
"geosite.dat" \
"geoip.dat" \
"geoip-only-cn-private.dat" \
"Country.mmdb" \
"geoip.metadb" \
)
for s in "${srcs[@]}"; do
if [[ -f "$s" ]]; then
mv -f "$s" "$outroot/bin/xray/$(basename "$s")"
for n in "${names[@]}"; do
if [[ -f "$outroot/bin/xray/$n" ]]; then
mv -f "$outroot/bin/xray/$n" "$outroot/bin/$n"
fi
done
}
# Download geo/rule assets; then unify to bin/xray/
# Download geo/rule assets
download_geo_assets() {
local outroot="$1"
local bin_dir="$outroot/bin"
@@ -436,21 +304,21 @@ download_geo_assets() {
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geoip/$f" || true
done
for f in \
geosite-cn.srs geosite-gfw.srs geosite-greatfire.srs \
geosite-cn.srs geosite-gfw.srs geosite-google.srs geosite-greatfire.srs \
geosite-geolocation-cn.srs geosite-category-ads-all.srs geosite-private.srs; do
curl -fsSL -o "$srss_dir/$f" \
"https://raw.githubusercontent.com/2dust/sing-box-rules/rule-set-geosite/$f" || true
done
# Unify to bin/xray/
# Unify to bin
unify_geo_layout "$outroot"
}
# Prefer the prebuilt v2rayN core bundle; then unify geo layout
download_v2rayn_bundle() {
local outroot="$1"
local outroot="$1" rid="$2"
local url=""
if [[ "$RID_DIR" == "linux-arm64" ]]; then
if [[ "$rid" == "linux-arm64" ]]; then
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-arm64.zip"
else
url="https://raw.githubusercontent.com/2dust/v2rayN-core-bin/refs/heads/master/v2rayN-linux-64.zip"
@@ -469,18 +337,17 @@ download_v2rayn_bundle() {
fi
rm -f "$outroot/v2rayn.zip" 2>/dev/null || true
# keep mihomo
# find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
find "$outroot" -type d -name "mihomo" -prune -exec rm -rf {} + 2>/dev/null || true
local nested_dir
nested_dir="$(find "$outroot" -maxdepth 1 -type d -name 'v2rayN-linux-*' | head -n1 || true)"
if [[ -n "${nested_dir:-}" && -d "$nested_dir/bin" ]]; then
if [[ -n "$nested_dir" && -d "$nested_dir/bin" ]]; then
mkdir -p "$outroot/bin"
rsync -a "$nested_dir/bin/" "$outroot/bin/"
rm -rf "$nested_dir"
fi
# Unify to bin/xray/
# Unify to bin/
unify_geo_layout "$outroot"
echo "[+] Bundle extracted to $outroot"
@@ -498,7 +365,7 @@ build_for_arch() {
case "$short" in
x64) rid="linux-x64"; rpm_target="x86_64"; archdir="x86_64" ;;
arm64) rid="linux-arm64"; rpm_target="aarch64"; archdir="aarch64" ;;
*) echo "[ERROR] Unknown arch '$short' (use x64|arm64)"; return 1;;
*) echo "Unknown arch '$short' (use x64|arm64)"; return 1;;
esac
echo "[*] Building for target: $short (RID=$rid, RPM --target $rpm_target)"
@@ -511,17 +378,13 @@ build_for_arch() {
dotnet publish "$PROJECT" \
-c Release -r "$rid" \
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:IncludeNativeLibrariesForSelfExtract=true
-p:SelfContained=true
# Per-arch variables (scoped)
local RID_DIR="$rid"
local PUBDIR
PUBDIR="$(dirname "$PROJECT")/bin/Release/net8.0/${RID_DIR}/publish"
[[ -d "$PUBDIR" ]]
# Make RID_DIR visible to download helpers (they read this var)
export RID_DIR
[[ -d "$PUBDIR" ]] || { echo "Publish directory not found: $PUBDIR"; return 1; }
# Per-arch working area
local PKGROOT="v2rayN-publish"
@@ -530,58 +393,49 @@ build_for_arch() {
trap '[[ -n "${WORKDIR:-}" ]] && rm -rf "$WORKDIR"' RETURN
# rpmbuild topdir selection
local TOPDIR SPECDIR SOURCEDIR USE_TOPDIR_DEFINE
if [[ "$ID" =~ ^(rhel|rocky|almalinux|centos)$ ]]; then
rpmdev-setuptree
TOPDIR="${HOME}/rpmbuild"
SPECDIR="${TOPDIR}/SPECS"
SOURCEDIR="${TOPDIR}/SOURCES"
USE_TOPDIR_DEFINE=0
else
TOPDIR="${WORKDIR}/rpmbuild"
SPECDIR="${TOPDIR}/SPECS}"
SOURCEDIR="${TOPDIR}/SOURCES"
mkdir -p "${SPECDIR}" "${SOURCEDIR}" "${TOPDIR}/BUILD" "${TOPDIR}/RPMS" "${TOPDIR}/SRPMS"
USE_TOPDIR_DEFINE=1
fi
local TOPDIR SPECDIR SOURCEDIR
rpmdev-setuptree
TOPDIR="${HOME}/rpmbuild"
SPECDIR="${TOPDIR}/SPECS"
SOURCEDIR="${TOPDIR}/SOURCES"
# Stage publish content
mkdir -p "$WORKDIR/$PKGROOT"
cp -a "$PUBDIR/." "$WORKDIR/$PKGROOT/"
# Optional icon
# Required icon
local ICON_CANDIDATE
ICON_CANDIDATE="$(dirname "$PROJECT")/../v2rayN.Desktop/v2rayN.png"
[[ -f "$ICON_CANDIDATE" ]] && cp "$ICON_CANDIDATE" "$WORKDIR/$PKGROOT/v2rayn.png" || true
PROJECT_DIR="$(cd "$(dirname "$PROJECT")" && pwd)"
ICON_CANDIDATE="$PROJECT_DIR/v2rayN.png"
[[ -f "$ICON_CANDIDATE" ]] || { echo "Required icon not found: $ICON_CANDIDATE"; return 1; }
cp "$ICON_CANDIDATE" "$WORKDIR/$PKGROOT/v2rayn.png"
# Prepare bin structure
mkdir -p "$WORKDIR/$PKGROOT/bin/xray" "$WORKDIR/$PKGROOT/bin/sing_box"
# Bundle / cores per-arch
fetch_separate_cores_and_rules() {
local outroot="$1"
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
download_xray "$outroot/bin/xray" "$RID_DIR" || echo "[!] xray download failed (skipped)"
fi
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
download_singbox "$outroot/bin/sing_box" "$RID_DIR" || echo "[!] sing-box download failed (skipped)"
fi
download_geo_assets "$outroot" || echo "[!] Geo rules download failed (skipped)"
}
if [[ "$FORCE_NETCORE" -eq 0 ]]; then
if download_v2rayn_bundle "$WORKDIR/$PKGROOT"; then
if download_v2rayn_bundle "$WORKDIR/$PKGROOT" "$RID_DIR"; then
echo "[*] Using v2rayN bundle archive."
else
echo "[*] Bundle failed, fallback to separate core + rules."
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
download_xray "$WORKDIR/$PKGROOT/bin/xray" || echo "[!] xray download failed (skipped)"
fi
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
download_singbox "$WORKDIR/$PKGROOT/bin/sing_box" || echo "[!] sing-box download failed (skipped)"
fi
download_geo_assets "$WORKDIR/$PKGROOT" || echo "[!] Geo rules download failed (skipped)"
fetch_separate_cores_and_rules "$WORKDIR/$PKGROOT"
fi
else
echo "[*] --netcore specified: use separate core + rules."
if [[ "$WITH_CORE" == "xray" || "$WITH_CORE" == "both" ]]; then
download_xray "$WORKDIR/$PKGROOT/bin/xray" || echo "[!] xray download failed (skipped)"
fi
if [[ "$WITH_CORE" == "sing-box" || "$WITH_CORE" == "both" ]]; then
download_singbox "$WORKDIR/$PKGROOT/bin/sing_box" || echo "[!] sing-box download failed (skipped)"
fi
download_geo_assets "$WORKDIR/$PKGROOT" || echo "[!] Geo rules download failed (skipped)"
# ---- REQUIRED: always fetch mihomo in netcore mode, per-arch ----
download_mihomo "$WORKDIR/$PKGROOT" || echo "[!] mihomo download failed (skipped)"
fetch_separate_cores_and_rules "$WORKDIR/$PKGROOT"
fi
# Tarball
@@ -609,8 +463,14 @@ ExclusiveArch: aarch64 x86_64
Source0: __PKGROOT__.tar.gz
# Runtime dependencies (Avalonia / X11 / Fonts / GL)
Requires: libX11, libXrandr, libXcursor, libXi, libXext, libxcb, libXrender, libXfixes, libXinerama, libxkbcommon
Requires: fontconfig, freetype, cairo, pango, mesa-libEGL, mesa-libGL
Requires: cairo, pango, openssl, mesa-libEGL, mesa-libGL
Requires: glibc >= 2.34
Requires: fontconfig >= 2.13.1
Requires: desktop-file-utils >= 0.26
Requires: xdg-utils >= 1.1.3
Requires: coreutils >= 8.32
Requires: bash >= 5.1
Requires: freetype >= 2.10
%description
v2rayN Linux for Red Hat Enterprise Linux
@@ -629,25 +489,18 @@ https://github.com/2dust/v2rayN
install -dm0755 %{buildroot}/opt/v2rayN
cp -a * %{buildroot}/opt/v2rayN/
# Launcher (prefer native ELF first, then DLL fallback; also create Geo symlinks for the user)
# Normalize permissions
find %{buildroot}/opt/v2rayN -type d -exec chmod 0755 {} +
find %{buildroot}/opt/v2rayN -type f -exec chmod 0644 {} +
[ -f %{buildroot}/opt/v2rayN/v2rayN ] && chmod 0755 %{buildroot}/opt/v2rayN/v2rayN || :
# Launcher (prefer native ELF first, then DLL fallback)
install -dm0755 %{buildroot}%{_bindir}
cat > %{buildroot}%{_bindir}/v2rayn << 'EOF'
install -m0755 /dev/stdin %{buildroot}%{_bindir}/v2rayn << 'EOF'
#!/usr/bin/bash
set -euo pipefail
DIR="/opt/v2rayN"
# --- Symlink GEO files into user's XDG dir (first-run convenience) ---
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
USR_GEO_DIR="$XDG_DATA_HOME/v2rayN/bin"
SYS_XRAY_DIR="$DIR/bin/xray"
mkdir -p "$USR_GEO_DIR"
for f in geosite.dat geoip.dat geoip-only-cn-private.dat Country.mmdb; do
if [[ -f "$SYS_XRAY_DIR/$f" && ! -e "$USR_GEO_DIR/$f" ]]; then
ln -s "$SYS_XRAY_DIR/$f" "$USR_GEO_DIR/$f" || true
fi
done
# --- end GEO ---
# Prefer native apphost
if [[ -x "$DIR/v2rayN" ]]; then exec "$DIR/v2rayN" "$@"; fi
@@ -660,11 +513,10 @@ echo "v2rayN launcher: no executable found in $DIR" >&2
ls -l "$DIR" >&2 || true
exit 1
EOF
chmod 0755 %{buildroot}%{_bindir}/v2rayn
# Desktop file
install -dm0755 %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
install -m0644 /dev/stdin %{buildroot}%{_datadir}/applications/v2rayn.desktop << 'EOF'
[Desktop Entry]
Type=Application
Name=v2rayN
@@ -676,10 +528,8 @@ Categories=Network;
EOF
# Icon
if [ -f "%{_builddir}/__PKGROOT__/v2rayn.png" ]; then
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
fi
install -dm0755 %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
install -m0644 %{_builddir}/__PKGROOT__/v2rayn.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
%post
/usr/bin/update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || true
@@ -696,72 +546,12 @@ fi
%{_datadir}/icons/hicolor/256x256/apps/v2rayn.png
SPEC
# Autostart injection (inside %install) and %files entry
if [[ "$AUTOSTART" -eq 1 ]]; then
awk '
BEGIN{ins=0}
/^%post$/ && !ins {
print "# --- Autostart (.desktop) ---"
print "install -dm0755 %{buildroot}/etc/xdg/autostart"
print "cat > %{buildroot}/etc/xdg/autostart/v2rayn.desktop << '\''EOF'\''"
print "[Desktop Entry]"
print "Type=Application"
print "Name=v2rayN (Autostart)"
print "Exec=v2rayn"
print "X-GNOME-Autostart-enabled=true"
print "NoDisplay=false"
print "EOF"
ins=1
}
{print}
' "$SPECFILE" > "${SPECFILE}.tmp" && mv "${SPECFILE}.tmp" "$SPECFILE"
awk '
BEGIN{infiles=0; done=0}
/^%files$/ {infiles=1}
infiles && done==0 && $0 ~ /%{_datadir}\/icons\/hicolor\/256x256\/apps\/v2rayn\.png/ {
print
print "%config(noreplace) /etc/xdg/autostart/v2rayn.desktop"
done=1
next
}
{print}
' "$SPECFILE" > "${SPECFILE}.tmp" && mv "${SPECFILE}.tmp" "$SPECFILE"
fi
# Replace placeholders
sed -i "s/__VERSION__/${VERSION}/g" "$SPECFILE"
sed -i "s/__PKGROOT__/${PKGROOT}/g" "$SPECFILE"
# ----- Select proper 'strip' per target arch on Ubuntu only (cross-binutils) -----
# NOTE: We define only __strip to point to the target-arch strip.
# DO NOT override __brp_strip (it must stay the brp script path).
local STRIP_ARGS=()
if [[ "$ID" == "ubuntu" ]]; then
local STRIP_BIN=""
if [[ "$short" == "x64" ]]; then
STRIP_BIN="/usr/bin/x86_64-linux-gnu-strip"
else
STRIP_BIN="/usr/bin/aarch64-linux-gnu-strip"
fi
if [[ -x "$STRIP_BIN" ]]; then
STRIP_ARGS=( --define "__strip $STRIP_BIN" )
fi
fi
# Build RPM for this arch (force rpm --target to match compile arch)
if [[ "$USE_TOPDIR_DEFINE" -eq 1 ]]; then
rpmbuild -ba "$SPECFILE" --define "_topdir $TOPDIR" --target "$rpm_target" "${STRIP_ARGS[@]}"
else
rpmbuild -ba "$SPECFILE" --target "$rpm_target" "${STRIP_ARGS[@]}"
fi
# Copy temporary rpmbuild to ~/rpmbuild on Debian/Ubuntu path
if [[ "$USE_TOPDIR_DEFINE" -eq 1 ]]; then
mkdir -p "$HOME/rpmbuild"
rsync -a "$TOPDIR"/ "$HOME/rpmbuild"/
TOPDIR="$HOME/rpmbuild"
fi
# Build RPM for this arch
rpmbuild -ba "$SPECFILE" --target "$rpm_target"
echo "Build done for $short. RPM at:"
local f
@@ -774,33 +564,18 @@ SPEC
# ===== Arch selection and build orchestration =========================================
case "${ARCH_OVERRIDE:-}" in
"")
# No --arch: use host architecture
if [[ "$host_arch" == "aarch64" ]]; then
build_for_arch arm64
else
build_for_arch x64
fi
;;
x64|amd64)
build_for_arch x64
;;
arm64|aarch64)
build_for_arch arm64
;;
all)
BUILT_ALL=1
# Build x64 and arm64 separately; each package contains its own arch-only binaries.
build_for_arch x64
build_for_arch arm64
;;
*)
echo "[ERROR] Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all."
exit 1
;;
all) targets=(x64 arm64); BUILT_ALL=1 ;;
x64|amd64) targets=(x64) ;;
arm64|aarch64) targets=(arm64) ;;
"") targets=($([[ "$host_arch" == "aarch64" ]] && echo arm64 || echo x64)) ;;
*) echo "Unknown --arch '${ARCH_OVERRIDE}'. Use x64|arm64|all."; exit 1 ;;
esac
# ===== Final summary if building both arches ==========================================
for arch in "${targets[@]}"; do
build_for_arch "$arch"
done
# Print Both arches information
if [[ "$BUILT_ALL" -eq 1 ]]; then
echo ""
echo "================ Build Summary (both architectures) ================"
@@ -809,7 +584,7 @@ if [[ "$BUILT_ALL" -eq 1 ]]; then
echo "$rp"
done
else
echo "[WARN] No RPMs detected in summary (check build logs above)."
echo "No RPMs detected in summary (check build logs above)."
fi
echo "==================================================================="
echo "===================================================================="
fi
-37
View File
@@ -1,37 +0,0 @@
app: v2rayN
binpatch: true
ingredients:
script:
- export FileName="v2rayN-${AppImageOutputArch}.zip"
- wget -nv -O $FileName "https://github.com/2dust/v2rayN-core-bin/raw/refs/heads/master/${FileName}"
- 7z x $FileName -aoa
- cp -rf v2rayN-${AppImageOutputArch}/* $OutputPath
script:
- mkdir -p usr/bin usr/lib
- cp -rf $OutputPath usr/lib/v2rayN
- echo "When this file exists, app will not store configs under this folder" > usr/lib/v2rayN/NotStoreConfigHere.txt
- ln -sf usr/lib/v2rayN/v2rayN usr/bin/v2rayN
- chmod a+x usr/lib/v2rayN/v2rayN
- find usr -type f -exec sh -c 'file "{}" | grep -qi "executable" && chmod +x "{}"' \;
- install -Dm644 usr/lib/v2rayN/v2rayN.png v2rayN.png
- install -Dm644 usr/lib/v2rayN/v2rayN.png usr/share/pixmaps/v2rayN.png
- cat > v2rayN.desktop <<EOF
- [Desktop Entry]
- Name=v2rayN
- Comment=A GUI client for Windows and Linux, support Xray core and sing-box-core and others
- Exec=v2rayN
- Icon=v2rayN
- Terminal=false
- Type=Application
- Categories=Network;
- EOF
- install -Dm644 v2rayN.desktop usr/share/applications/v2rayN.desktop
- cat > AppRun <<\EOF
- #!/bin/sh
- HERE="$(dirname "$(readlink -f "${0}")")"
- cd ${HERE}/usr/lib/v2rayN
- exec ${HERE}/usr/lib/v2rayN/v2rayN $@
- EOF
- chmod a+x AppRun
+2 -2
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<Version>7.14.6</Version>
<Version>7.21.3</Version>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058</NoWarn>
<NoWarn>CA1031;CS1591;NU1507;CA1416;IDE0058;IDE0053;IDE0200</NoWarn>
<Nullable>annotations</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Authors>2dust</Authors>
+21 -15
View File
@@ -5,25 +5,31 @@
<CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.4" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.4" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.4" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.4" />
<PackageVersion Include="CliWrap" Version="3.9.0" />
<PackageVersion Include="Downloader" Version="4.0.3" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.3.0" />
<PackageVersion Include="MaterialDesignThemes" Version="5.2.1" />
<PackageVersion Include="MessageBox.Avalonia" Version="3.2.0" />
<PackageVersion Include="QRCoder" Version="1.6.0" />
<PackageVersion Include="ReactiveUI" Version="20.4.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="11.4.1" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.13" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.13" />
<PackageVersion Include="AwesomeAssertions" Version="9.4.0" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.11.0" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="11.4.12" />
<PackageVersion Include="CliWrap" Version="3.10.1" />
<PackageVersion Include="Downloader" Version="5.2.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageVersion Include="MaterialDesignThemes" Version="5.3.1" />
<PackageVersion Include="QRCoder" Version="1.8.0" />
<PackageVersion Include="ReactiveUI" Version="23.2.1" />
<PackageVersion Include="ReactiveUI.Fody" Version="19.5.41" />
<PackageVersion Include="ReactiveUI.WPF" Version="20.4.1" />
<PackageVersion Include="Semi.Avalonia" Version="11.2.1.9" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="11.2.1.9" />
<PackageVersion Include="Splat.NLog" Version="15.5.3" />
<PackageVersion Include="ReactiveUI.WPF" Version="23.2.1" />
<PackageVersion Include="Semi.Avalonia" Version="11.3.7.3" />
<PackageVersion Include="Semi.Avalonia.AvaloniaEdit" Version="11.2.0.2" />
<PackageVersion Include="Semi.Avalonia.DataGrid" Version="11.3.7.3" />
<PackageVersion Include="NLog" Version="6.1.2" />
<PackageVersion Include="sqlite-net-pcl" Version="1.9.172" />
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.14" />
</ItemGroup>
@@ -0,0 +1,113 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Builder;
using ServiceLib.Helper;
using ServiceLib.Models;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.Context;
public class CoreConfigContextBuilderTests
{
[Fact]
public async Task ResolveNodeAsync_DirectCycleDependency_ShouldFailWithCycleError()
{
var config = CoreConfigTestFactory.CreateConfig();
CoreConfigTestFactory.BindAppManagerConfig(config);
var groupAId = NewId("group-a");
var groupBId = NewId("group-b");
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
await UpsertProfilesAsync(groupA, groupB);
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
context.AllProxiesMap.Clear();
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeFalse();
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
}
[Fact]
public async Task ResolveNodeAsync_IndirectCycleDependency_ShouldFailWithCycleError()
{
var config = CoreConfigTestFactory.CreateConfig();
CoreConfigTestFactory.BindAppManagerConfig(config);
var groupAId = NewId("group-a");
var groupBId = NewId("group-b");
var groupCId = NewId("group-c");
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId]);
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupCId]);
var groupC = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupCId, "group-c", [groupAId]);
await UpsertProfilesAsync(groupA, groupB, groupC);
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
context.AllProxiesMap.Clear();
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeFalse();
validatorResult.Errors.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
context.AllProxiesMap.Should().NotContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupC.IndexId);
}
[Fact]
public async Task ResolveNodeAsync_CycleWithValidBranch_ShouldSkipCycleAndKeepValidChild()
{
var config = CoreConfigTestFactory.CreateConfig();
CoreConfigTestFactory.BindAppManagerConfig(config);
var groupAId = NewId("group-a");
var groupBId = NewId("group-b");
var leafId = NewId("leaf");
var groupA = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupAId, "group-a", [groupBId, leafId]);
var groupB = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, groupBId, "group-b", [groupAId]);
var leaf = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, leafId, "leaf");
await UpsertProfilesAsync(groupA, groupB, leaf);
var context = CoreConfigTestFactory.CreateContext(config, groupA, ECoreType.Xray);
context.AllProxiesMap.Clear();
var (_, validatorResult) = await CoreConfigContextBuilder.ResolveNodeAsync(context, groupA, false);
validatorResult.Success.Should().BeTrue();
validatorResult.Errors.Should().BeEmpty();
validatorResult.Warnings.Should().Contain(msg => ContainsCycleDependencyMessage(msg));
context.AllProxiesMap.Should().ContainKey(leaf.IndexId);
context.AllProxiesMap.Should().ContainKey(groupA.IndexId);
context.AllProxiesMap.Should().NotContainKey(groupB.IndexId);
groupA.GetProtocolExtra().ChildItems.Should().Be(leaf.IndexId);
}
private static string NewId(string prefix)
{
return $"{prefix}-{Guid.NewGuid():N}";
}
private static bool ContainsCycleDependencyMessage(string message)
{
return message.Contains("cycle dependency", StringComparison.OrdinalIgnoreCase)
|| message.Contains("循环依赖", StringComparison.Ordinal)
|| message.Contains("循環依賴", StringComparison.Ordinal);
}
private static async Task UpsertProfilesAsync(params ProfileItem[] profiles)
{
SQLiteHelper.Instance.CreateTable<ProfileItem>();
foreach (var profile in profiles)
{
await SQLiteHelper.Instance.ReplaceAsync(profile);
}
}
}
@@ -0,0 +1,209 @@
using System.Reflection;
using ServiceLib.Enums;
using ServiceLib.Manager;
using ServiceLib.Models;
namespace ServiceLib.Tests.CoreConfig;
internal static class CoreConfigTestFactory
{
public static void BindAppManagerConfig(Config config)
{
var field = typeof(AppManager).GetField("_config", BindingFlags.Instance | BindingFlags.NonPublic);
field?.SetValue(AppManager.Instance, config);
}
public static Config CreateConfig(ECoreType vmessCoreType = ECoreType.Xray)
{
return new Config
{
CoreBasicItem = new CoreBasicItem { Loglevel = "warning", MuxEnabled = false },
TunModeItem = new TunModeItem { EnableTun = false, IcmpRouting = "default" },
KcpItem = new KcpItem(),
GrpcItem = new GrpcItem(),
RoutingBasicItem =
new RoutingBasicItem
{
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
RoutingIndexId = string.Empty,
},
GuiItem = new GUIItem { EnableStatistics = false, DisplayRealTimeSpeed = false, EnableLog = false },
MsgUIItem = new MsgUIItem(),
UiItem =
new UIItem
{
CurrentLanguage = "en",
CurrentFontFamily = "sans",
MainColumnItem = [],
WindowSizeItem = []
},
ConstItem = new ConstItem(),
SpeedTestItem = new SpeedTestItem
{
SpeedPingTestUrl = Global.SpeedPingTestUrls.First(),
SpeedTestUrl = Global.SpeedTestUrls.First(),
SpeedTestTimeout = 10,
MixedConcurrencyCount = 1,
IPAPIUrl = string.Empty,
},
Mux4RayItem = new Mux4RayItem { Concurrency = 8, XudpConcurrency = 16, XudpProxyUDP443 = "reject" },
Mux4SboxItem = new Mux4SboxItem { Protocol = Global.SingboxMuxs.First(), MaxConnections = 8 },
HysteriaItem = new HysteriaItem { UpMbps = 100, DownMbps = 100 },
ClashUIItem = new ClashUIItem { ConnectionsColumnItem = [] },
SystemProxyItem =
new SystemProxyItem
{
SystemProxyExceptions = string.Empty,
SystemProxyAdvancedProtocol = string.Empty
},
WebDavItem = new WebDavItem(),
CheckUpdateItem = new CheckUpdateItem(),
Fragment4RayItem = new Fragment4RayItem { Packets = "tlshello", Length = "100-200", Interval = "10-20" },
Inbound =
[
new InItem
{
Protocol = nameof(EInboundProtocol.socks),
LocalPort = 10808,
UdpEnabled = true,
SniffingEnabled = true,
RouteOnly = false,
DestOverride = ["http", "tls"],
}
],
GlobalHotkeys = [],
CoreTypeItem =
[
new CoreTypeItem { ConfigType = EConfigType.VMess, CoreType = vmessCoreType }
],
SimpleDNSItem = new SimpleDNSItem
{
BootstrapDNS = Global.DomainPureIPDNSAddress.FirstOrDefault(),
ServeStale = false,
ParallelQuery = false,
Strategy4Freedom = Global.AsIs,
Strategy4Proxy = Global.AsIs,
},
IndexId = string.Empty,
SubIndexId = string.Empty,
};
}
public static ProfileItem CreateVmessNode(ECoreType coreType, string indexId = "node-1", string remarks = "demo")
{
var node = new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.VMess,
CoreType = coreType,
Remarks = remarks,
Address = "example.com",
Port = 443,
Password = Guid.NewGuid().ToString(),
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
Subid = string.Empty,
};
node.SetProtocolExtra(node.GetProtocolExtra() with { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
return node;
}
public static ProfileItem CreateSocksNode(ECoreType coreType, string indexId = "node-socks-1",
string remarks = "demo-socks")
{
return new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.SOCKS,
CoreType = coreType,
Remarks = remarks,
Address = "127.0.0.1",
Port = 1080,
Password = "pass",
Username = "user",
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
Subid = string.Empty,
};
}
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
IEnumerable<string> childIndexIds)
{
var node = new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.PolicyGroup,
CoreType = coreType,
Remarks = remarks,
};
node.SetProtocolExtra(node.GetProtocolExtra() with
{
GroupType = nameof(EConfigType.PolicyGroup),
ChildItems = string.Join(",", childIndexIds),
});
return node;
}
public static ProfileItem CreateProxyChainNode(ECoreType coreType, string indexId, string remarks,
IEnumerable<string> childIndexIds)
{
var node = new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.ProxyChain,
CoreType = coreType,
Remarks = remarks,
};
node.SetProtocolExtra(node.GetProtocolExtra() with
{
GroupType = nameof(EConfigType.ProxyChain),
ChildItems = string.Join(",", childIndexIds),
});
return node;
}
public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType)
{
return new CoreConfigContext
{
Node = node,
RunCoreType = runCoreType,
AppConfig = config,
RoutingItem = new RoutingItem
{
Id = "r1",
Remarks = "default",
RuleSet = "[]",
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
},
RawDnsItem = null,
SimpleDnsItem = config.SimpleDNSItem,
AllProxiesMap = new Dictionary<string, ProfileItem> { [node.IndexId] = node },
FullConfigTemplate = null,
IsTunEnabled = false,
ProtectDomainList = [],
};
}
public static Config CreateConfigWithDirectExpectedIPs(ECoreType coreType,
string directExpectedIPs = "192.168.0.0/16,geoip:cn")
{
var config = CreateConfig(coreType);
config.SimpleDNSItem.DirectExpectedIPs = directExpectedIPs;
return config;
}
public static Config CreateConfigWithBootstrapDNS(ECoreType coreType, string bootstrapDns = "8.8.8.8")
{
var config = CreateConfig(coreType);
config.SimpleDNSItem.BootstrapDNS = bootstrapDns;
return config;
}
}
@@ -0,0 +1,560 @@
using AwesomeAssertions;
using ServiceLib.Common;
using ServiceLib.Enums;
using ServiceLib.Manager;
using ServiceLib.Models;
using ServiceLib.Services.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.Singbox;
public class CoreConfigSingboxServiceTests
{
[Fact]
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
result.Data.Should().NotBeNull();
var singboxConfig = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString());
singboxConfig.Should().NotBeNull();
singboxConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
singboxConfig.inbounds.Should().Contain(i => i.type == nameof(EInboundProtocol.mixed));
}
[Fact]
public void GenerateClientConfigContent_TunWithLoopbackPreSocks_ShouldKeepMixedInbound()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box);
node.Address = Global.Loopback;
node.Port = 1080;
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
IsTunEnabled = true,
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.inbounds.Should().Contain(i =>
i.type == nameof(EInboundProtocol.mixed)
&& i.listen == Global.Loopback
&& i.listen_port == AppManager.Instance.GetLocalPort(EInboundProtocol.socks));
cfg.inbounds.Should().Contain(i => i.type == "tun");
}
[Fact]
public void GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.CoreBasicItem.BindInterface = "eth0";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
IsTunEnabled = true,
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var proxy = cfg.outbounds.First(o => o.tag == Global.ProxyTag);
proxy.bind_interface.Should().Be("eth0");
proxy.detour.Should().BeNullOrEmpty();
}
[Fact]
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildSelector()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
[n1.IndexId, n2.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[group.IndexId] = group;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
}
[Fact]
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDetourChain()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
[n1.IndexId, n2.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[chain.IndexId] = chain;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "socks");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o =>
o.tag == Global.ProxyTag &&
(o.detour ?? string.Empty).StartsWith("chain-proxy-1-", StringComparison.Ordinal));
}
[Fact]
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
[n1.IndexId, n2.IndexId]);
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
[chain.IndexId, n3.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.sing_box);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[n3.IndexId] = n3;
context.AllProxiesMap[chain.IndexId] = chain;
context.AllProxiesMap[group.IndexId] = group;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
}
[Fact]
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n2", "node-2");
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n3", "node-3");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.sing_box, "g1", "group",
[n1.IndexId, n2.IndexId]);
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.sing_box, "c1", "chain",
[group.IndexId, n3.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.sing_box);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[n3.IndexId] = n3;
context.AllProxiesMap[group.IndexId] = group;
context.AllProxiesMap[chain.IndexId] = chain;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.type == "selector");
cfg.outbounds.Should().Contain(o => o.tag == $"{Global.ProxyTag}-auto" && o.type == "urltest");
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
proxyCloneCount.Should().Be(2);
var allCloneDetoursPointToGroupBranches = cfg.outbounds
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
.All(o => (o.detour ?? string.Empty).StartsWith("chain-proxy-1-group-", StringComparison.Ordinal));
allCloneDetoursPointToGroupBranches.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-split-1",
Remarks = "split-direct-block",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.DirectTag,
Domain = ["full:direct.example.com"],
},
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.BlockTag,
Domain = ["full:block.example.com"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasDirectRule = cfg.route.rules.Any(r =>
r.domain != null
&& r.domain.Contains("direct.example.com")
&& r.outbound == Global.DirectTag);
hasDirectRule.Should().BeTrue();
var hasBlockRule = cfg.route.rules.Any(r =>
r.domain != null
&& r.domain.Contains("block.example.com")
&& r.action == "reject");
hasBlockRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-route", "route-node");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-split-2",
Remarks = "split-remark",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = routeNode.Remarks,
Domain = ["full:route.example.com"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
var hasRouteRule = cfg.route.rules.Any(r =>
r.domain != null
&& r.domain.Contains("route.example.com")
&& (r.outbound ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
hasRouteRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyGeoipAndCidrToDirectDnsRule()
{
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(
ECoreType.sing_box,
"192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-expected",
Remarks = "dns-direct-expected",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:cn"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains("geosite-cn") == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_BootstrapDNS_ShouldConfigurePureIPResolver()
{
var bootstrapDns = "8.8.8.8";
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.sing_box, bootstrapDns);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
config.SimpleDNSItem.BootstrapDNS.Should().Be(bootstrapDns);
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var bootstrapServer = cfg.dns.servers?.FirstOrDefault(s => s.tag == Global.SingboxLocalDNSTag);
bootstrapServer.Should().NotBeNull();
(bootstrapServer?.server ?? string.Empty).Should().Contain(bootstrapDns);
}
[Fact]
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectFinalDns()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-direct-final",
Remarks = "direct-final",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.DirectTag,
Ip = ["0.0.0.0/0"],
Port = "0-65535",
Network = "tcp,udp",
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.dns.final.Should().Be(Global.SingboxDirectDNSTag);
}
[Fact]
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedRule()
{
var config =
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-unmatched",
Remarks = "dns-direct-unmatched",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:us"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeFalse();
}
[Theory]
[InlineData("geosite:cn", "geosite-cn")]
[InlineData("geosite:geolocation-cn", "geosite-geolocation-cn")]
[InlineData("geosite:tld-cn", "geosite-tld-cn")]
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedRule(string domainTag,
string expectedRuleSetTag)
{
var config =
CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.sing_box, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-variant",
Remarks = "dns-direct-variant",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hasExpectedRule = cfg.dns.rules?.Any(r =>
r.server == Global.SingboxDirectDNSTag
&& r.ip_cidr?.Contains("192.168.0.0/16") == true
&& r.rule_set?.Contains(expectedRuleSetTag) == true
&& r.rule_set?.Contains("geoip-cn") == true) ?? false;
hasExpectedRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_Hosts_ShouldPopulateHostsServerAndDomainResolver()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
config.SimpleDNSItem.DirectDNS = "https://resolver.example/dns-query";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box);
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
var hostsServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxHostsDNSTag);
hostsServer.Should().NotBeNull();
hostsServer!.predefined.Should().ContainKey("resolver.example");
hostsServer.predefined!["resolver.example"].Should().Contain("1.1.1.1");
var directServer = cfg.dns.servers.FirstOrDefault(s => s.tag == Global.SingboxDirectDNSTag);
directServer.Should().NotBeNull();
directServer!.domain_resolver.Should().Be(Global.SingboxHostsDNSTag);
}
[Fact]
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsAndInjectLocalResolver()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateSocksNode(ECoreType.sing_box, "n-main", "main");
var rawDns = new Dns4Sbox
{
servers =
[
new Server4Sbox { tag = "remote", type = "udp", server = "8.8.8.8", detour = Global.ProxyTag, }
],
rules = [],
};
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with
{
RawDnsItem = new DNSItem
{
Id = "dns-raw-1",
Remarks = "raw",
Enabled = true,
CoreType = ECoreType.sing_box,
NormalDNS = JsonUtils.Serialize(rawDns),
DomainDNSAddress = "1.1.1.1",
}
};
var result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue($"ret msg: {result.Msg}");
var cfg = JsonUtils.Deserialize<SingboxConfig>(result.Data!.ToString())!;
cfg.dns.servers.Should().Contain(s => s.tag == "remote" && s.type == "udp" && s.server == "8.8.8.8");
cfg.dns.servers.Should().Contain(s => s.tag == Global.SingboxLocalDNSTag);
cfg.dns.rules.Should().Contain(r => r.clash_mode == ERuleMode.Global.ToString());
cfg.dns.rules.Should().Contain(r => r.clash_mode == ERuleMode.Direct.ToString());
}
}
@@ -0,0 +1,539 @@
using AwesomeAssertions;
using ServiceLib.Common;
using ServiceLib.Enums;
using ServiceLib.Models;
using ServiceLib.Services.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.CoreConfig.V2ray;
public class CoreConfigV2rayServiceTests
{
[Fact]
public void GenerateClientConfigContent_ShouldGenerateBasicProxyConfig()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray);
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
result.Data.Should().NotBeNull();
var v2rayConfig = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString());
v2rayConfig.Should().NotBeNull();
v2rayConfig!.outbounds.Should().Contain(o => o.tag == Global.ProxyTag && o.protocol == "vmess");
v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
}
[Fact]
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
[n1.IndexId, n2.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[group.IndexId] = group;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
}
[Fact]
public void GenerateClientConfigContent_ProxyChain_ShouldBuildDialerProxyChain()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[chain.IndexId] = chain;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
var hasDialerChain = cfg.outbounds.Any(o =>
o.tag == Global.ProxyTag
&& o.streamSettings is not null
&& o.streamSettings.sockopt is not null
&& (o.streamSettings.sockopt.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-",
StringComparison.Ordinal));
hasDialerChain.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_PolicyGroupWithProxyChain_ShouldBuildCombinedOutbounds()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain", [n1.IndexId, n2.IndexId]);
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
[chain.IndexId, n3.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, group, ECoreType.Xray);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[n3.IndexId] = n3;
context.AllProxiesMap[chain.IndexId] = chain;
context.AllProxiesMap[group.IndexId] = group;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("proxy-2-", StringComparison.Ordinal));
cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
}
[Fact]
public void GenerateClientConfigContent_ProxyChainWithPolicyGroup_ShouldBuildClonedChainBranches()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var n1 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n1", "node-1");
var n2 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n2", "node-2");
var n3 = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n3", "node-3");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "g1", "group",
[n1.IndexId, n2.IndexId]);
var chain = CoreConfigTestFactory.CreateProxyChainNode(ECoreType.Xray, "c1", "chain",
[group.IndexId, n3.IndexId]);
var context = CoreConfigTestFactory.CreateContext(config, chain, ECoreType.Xray);
context.AllProxiesMap[n1.IndexId] = n1;
context.AllProxiesMap[n2.IndexId] = n2;
context.AllProxiesMap[n3.IndexId] = n3;
context.AllProxiesMap[group.IndexId] = group;
context.AllProxiesMap[chain.IndexId] = chain;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-1-", StringComparison.Ordinal));
cfg.outbounds.Should().Contain(o => o.tag.StartsWith("chain-proxy-1-group-2-", StringComparison.Ordinal));
var proxyCloneCount = cfg.outbounds.Count(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal));
proxyCloneCount.Should().Be(2);
var allCloneDialersPointToGroupBranches = cfg.outbounds
.Where(o => o.tag.StartsWith("proxy-clone-", StringComparison.Ordinal))
.All(o => (o.streamSettings?.sockopt?.dialerProxy ?? string.Empty).StartsWith("chain-proxy-1-group-",
StringComparison.Ordinal));
allCloneDialersPointToGroupBranches.Should().BeTrue();
cfg.routing.balancers.Should().NotBeNull();
cfg.routing.balancers!.Should().Contain(b => b.tag == Global.ProxyTag + Global.BalancerTagSuffix);
}
[Fact]
public void GenerateClientConfigContent_RoutingSplit_DirectAndBlock_ShouldApplyRules()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-split-1",
Remarks = "split-direct-block",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.DirectTag,
Domain = ["full:direct.example.com"],
},
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.BlockTag,
Domain = ["full:block.example.com"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var hasDirectRule = cfg.routing.rules.Any(r =>
r.domain != null
&& r.domain.Contains("full:direct.example.com")
&& r.outboundTag == Global.DirectTag);
hasDirectRule.Should().BeTrue();
var hasBlockRule = cfg.routing.rules.Any(r =>
r.domain != null
&& r.domain.Contains("full:block.example.com")
&& r.outboundTag == Global.BlockTag);
hasBlockRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_RoutingSplit_ByRemark_ShouldGenerateTargetOutbound()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var routeNode = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "n-route", "route-node");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-split-2",
Remarks = "split-remark",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = routeNode.Remarks,
Domain = ["full:route.example.com"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
context.AllProxiesMap[$"remark:{routeNode.Remarks}"] = routeNode;
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var expectedPrefix = $"{routeNode.IndexId}-{Global.ProxyTag}-{routeNode.Remarks}";
cfg.outbounds.Should().Contain(o => o.tag.StartsWith(expectedPrefix, StringComparison.Ordinal));
var hasRouteRule = cfg.routing.rules.Any(r =>
r.domain != null
&& r.domain.Contains("full:route.example.com")
&& (r.outboundTag ?? string.Empty).StartsWith(expectedPrefix, StringComparison.Ordinal));
hasRouteRule.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_DirectExpectedIPs_ShouldApplyExpectedIPsToDirectDnsServer()
{
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-expected",
Remarks = "dns-direct-expected",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:cn"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
.Where(s => s is not null)
.Cast<DnsServer4Ray>()
.ToList();
var hasExpectedServer = dnsServers.Any(s =>
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
&& s.domains?.Contains("geosite:cn") == true
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
&& s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedServer.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_BootstrapDNS_ShouldApplyToDnsServerDomains()
{
var bootstrapDns = "8.8.8.8";
var config = CoreConfigTestFactory.CreateConfigWithBootstrapDNS(ECoreType.Xray, bootstrapDns);
config.SimpleDNSItem.DirectDNS = "https://dns-direct.example/dns-query";
config.SimpleDNSItem.RemoteDNS = "https://dns-remote.example/dns-query";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
.Where(s => s is not null)
.Cast<DnsServer4Ray>()
.ToList();
var hasBootstrapServer = dnsServers.Any(s =>
s.address == bootstrapDns
&& s.domains?.Contains("full:dns-direct.example") == true
&& s.domains?.Contains("full:dns-remote.example") == true);
hasBootstrapServer.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_DnsFallback_LastRuleDirect_ShouldUseDirectDnsServers()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
config.SimpleDNSItem.DirectDNS = "1.1.1.1";
config.SimpleDNSItem.RemoteDNS = "9.9.9.9";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-direct-final",
Remarks = "direct-final",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.Routing,
OutboundTag = Global.DirectTag,
Ip = ["0.0.0.0/0"],
Port = "0-65535",
Network = "tcp,udp",
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
.Where(s => s is not null)
.Cast<DnsServer4Ray>()
.ToList();
var hasDirectFallback = dnsServers.Any(s =>
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
&& s.address == "1.1.1.1");
hasDirectFallback.Should().BeTrue();
var hasRemoteFallback = dnsServers.Any(s => s.address == "9.9.9.9");
hasRemoteFallback.Should().BeFalse();
}
[Fact]
public void GenerateClientConfigContent_DirectExpectedIPs_NonMatchingRegion_ShouldNotApplyExpectedIPs()
{
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-unmatched",
Remarks = "dns-direct-unmatched",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true,
RuleType = ERuleType.DNS,
OutboundTag = Global.DirectTag,
Domain = ["geosite:us"],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
.Where(s => s is not null)
.Cast<DnsServer4Ray>()
.ToList();
var hasExpectedIPs = dnsServers.Any(s =>
s.expectedIPs?.Contains("192.168.0.0/16") == true
|| s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedIPs.Should().BeFalse();
}
[Theory]
[InlineData("geosite:cn")]
[InlineData("geosite:geolocation-cn")]
[InlineData("geosite:tld-cn")]
public void GenerateClientConfigContent_DirectExpectedIPs_RegionVariant_ShouldApplyExpectedIPs(string domainTag)
{
var config = CoreConfigTestFactory.CreateConfigWithDirectExpectedIPs(ECoreType.Xray, "192.168.0.0/16,geoip:cn");
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RoutingItem = new RoutingItem
{
Id = "r-dns-direct-variant",
Remarks = "dns-direct-variant",
RuleSet = JsonUtils.Serialize(new List<RulesItem>
{
new()
{
Enabled = true, RuleType = ERuleType.DNS, OutboundTag = Global.DirectTag, Domain = [domainTag],
}
}),
DomainStrategy = Global.AsIs,
DomainStrategy4Singbox = string.Empty,
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
var dnsServers = dns.servers
.Select(s => JsonUtils.Deserialize<DnsServer4Ray>(JsonUtils.Serialize(s)))
.Where(s => s is not null)
.Cast<DnsServer4Ray>()
.ToList();
var hasExpectedServer = dnsServers.Any(s =>
(s.tag ?? string.Empty).StartsWith(Global.DirectDnsTag, StringComparison.Ordinal)
&& s.domains?.Contains(domainTag) == true
&& s.expectedIPs?.Contains("192.168.0.0/16") == true
&& s.expectedIPs?.Contains("geoip:cn") == true);
hasExpectedServer.Should().BeTrue();
}
[Fact]
public void GenerateClientConfigContent_Hosts_ShouldPopulateDnsHosts()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
config.SimpleDNSItem.Hosts = "resolver.example 1.1.1.1";
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
dns.hosts.Should().NotBeNull();
dns.hosts!.Should().ContainKey("resolver.example");
JsonUtils.Serialize(dns.hosts!["resolver.example"]).Should().Contain("1.1.1.1");
}
[Fact]
public void GenerateClientConfigContent_RawDnsEnabled_ShouldUseCustomDnsConfig()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main");
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray) with
{
RawDnsItem = new DNSItem
{
Id = "dns-raw-1",
Remarks = "raw",
Enabled = true,
CoreType = ECoreType.Xray,
NormalDNS = "{\"servers\":[\"8.8.8.8\"],\"hosts\":{\"raw.example\":\"1.1.1.1\"}}",
DomainStrategy4Freedom = "UseIPv4",
}
};
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var dns = JsonUtils.Deserialize<Dns4Ray>(JsonUtils.Serialize(cfg.dns))!;
JsonUtils.Serialize(dns.servers).Should().Contain("8.8.8.8");
dns.hosts.Should().NotBeNull();
dns.hosts!.Should().ContainKey("raw.example");
JsonUtils.Serialize(dns.hosts!["raw.example"]).Should().Contain("1.1.1.1");
var directOutbound = cfg.outbounds.FirstOrDefault(o => o.tag == Global.DirectTag && o.protocol == "freedom");
directOutbound.Should().NotBeNull();
directOutbound!.settings.domainStrategy.Should().Be("UseIPv4");
}
}
@@ -0,0 +1,173 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Models;
using Xunit;
namespace ServiceLib.Tests.Fmt;
public class FmtHandlerTests
{
[Fact]
public void GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
{
var source = CreateVmessProfile();
var resolved = ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.VMess);
resolved.Remarks.Should().Be(source.Remarks);
resolved.Address.Should().Be(source.Address);
resolved.Port.Should().Be(source.Port);
resolved.Password.Should().Be(source.Password);
resolved.GetProtocolExtra().AlterId.Should().Be(source.GetProtocolExtra().AlterId);
}
[Fact]
public void GetShareUriAndResolveConfig_Vless_ShouldRoundTripBasicFields()
{
var source = CreateVlessProfile();
var resolved = ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.VLESS);
resolved.Remarks.Should().Be(source.Remarks);
resolved.Address.Should().Be(source.Address);
resolved.Port.Should().Be(source.Port);
resolved.Password.Should().Be(source.Password);
resolved.GetProtocolExtra().VlessEncryption.Should().Be(Global.None);
}
[Fact]
public void GetShareUriAndResolveConfig_Shadowsocks_ShouldRoundTripBasicFields()
{
var source = CreateShadowsocksProfile();
var resolved = ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.Shadowsocks);
resolved.Remarks.Should().Be(source.Remarks);
resolved.Address.Should().Be(source.Address);
resolved.Port.Should().Be(source.Port);
resolved.Password.Should().Be(source.Password);
resolved.GetProtocolExtra().SsMethod.Should().Be(source.GetProtocolExtra().SsMethod);
}
[Fact]
public void GetShareUriAndResolveConfig_Socks_ShouldRoundTripBasicFields()
{
var source = CreateSocksProfile();
var resolved = ExportThenImport(source);
resolved.ConfigType.Should().Be(EConfigType.SOCKS);
resolved.Remarks.Should().Be(source.Remarks);
resolved.Address.Should().Be(source.Address);
resolved.Port.Should().Be(source.Port);
resolved.Username.Should().Be(source.Username);
resolved.Password.Should().Be(source.Password);
}
[Fact]
public void ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
{
var resolved = FmtHandler.ResolveConfig("not-a-share-uri", out var msg);
resolved.Should().BeNull();
msg.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public void GetShareUri_UnsupportedConfigType_ShouldReturnNull()
{
var item = new ProfileItem { ConfigType = EConfigType.PolicyGroup, Remarks = "group", };
var uri = FmtHandler.GetShareUri(item);
uri.Should().BeNull();
}
private static ProfileItem ExportThenImport(ProfileItem source)
{
var uri = FmtHandler.GetShareUri(source);
uri.Should().NotBeNullOrWhiteSpace();
uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should()
.BeTrue();
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
resolved.Should().NotBeNull($"uri: {uri}, msg: {msg}");
return resolved!;
}
private static ProfileItem CreateVmessProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.VMess,
Remarks = "vmess demo",
Address = "example.com",
Port = 443,
Password = Guid.NewGuid().ToString(),
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
};
item.SetProtocolExtra(new ProtocolExtraItem { AlterId = "0", VmessSecurity = Global.DefaultSecurity, });
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
private static ProfileItem CreateVlessProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.VLESS,
Remarks = "vless demo",
Address = "vless.example",
Port = 8443,
Password = Guid.NewGuid().ToString(),
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
};
item.SetProtocolExtra(new ProtocolExtraItem { VlessEncryption = Global.None, });
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
private static ProfileItem CreateShadowsocksProfile()
{
var item = new ProfileItem
{
ConfigType = EConfigType.Shadowsocks,
Remarks = "ss demo",
Address = "1.2.3.4",
Port = 8388,
Password = "pass123",
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
};
item.SetProtocolExtra(new ProtocolExtraItem { SsMethod = "aes-128-gcm", });
item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
return item;
}
private static ProfileItem CreateSocksProfile()
{
return new ProfileItem
{
ConfigType = EConfigType.SOCKS,
Remarks = "socks demo",
Address = "127.0.0.1",
Port = 1080,
Username = "user",
Password = "pass",
};
}
}
@@ -0,0 +1,37 @@
using AwesomeAssertions;
using ServiceLib.Enums;
using ServiceLib.Handler.Fmt;
using ServiceLib.Tests.CoreConfig;
using Xunit;
namespace ServiceLib.Tests.Fmt;
public class InnerFmtTests
{
[Fact]
public void ToUriAndResolve_ShouldRoundTripPolicyGroupReferences()
{
var childA = CoreConfigTestFactory.CreateSocksNode(ECoreType.Xray, "child-a", "child-a");
var childB = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "child-b", "child-b");
var group = CoreConfigTestFactory.CreatePolicyGroupNode(ECoreType.Xray, "group-1", "group-1",
[childA.IndexId, childB.IndexId]);
group.SetProtocolExtra(group.GetProtocolExtra() with { SubChildItems = "original-sub" });
var uri = InnerFmt.ToUri([group, childA, childB]);
uri.Should().NotBeNullOrWhiteSpace();
var resolved = InnerFmt.Resolve(uri!, "sub-123");
resolved.Should().NotBeNull();
resolved.Should().HaveCount(3);
var resolvedGroup = resolved!.Single(x => x.Remarks == group.Remarks);
var resolvedChildA = resolved.Single(x => x.Remarks == childA.Remarks);
var resolvedChildB = resolved.Single(x => x.Remarks == childB.Remarks);
resolvedGroup.ConfigType.Should().Be(EConfigType.PolicyGroup);
resolvedGroup.GetProtocolExtra().SubChildItems.Should().Be("sub-123");
resolvedGroup.GetProtocolExtra().ChildItems.Should().Be($"{resolvedChildA.IndexId},{resolvedChildB.IndexId}");
}
}
@@ -0,0 +1,47 @@
using AwesomeAssertions;
using ServiceLib.Handler.Fmt;
using Xunit;
namespace ServiceLib.Tests.Fmt;
public class WireguardFmtTests
{
[Fact]
public void ResolveConfig_ShouldParsePeersAndIgnoreInlineComments()
{
const string config =
"""
[Interface]
PrivateKey = interface-private-key
Address = 10.0.0.2/32, fd00::2/128 ; inline comment
MTU = 1420
[Peer]
PublicKey = peer-public-key
PresharedKey = peer-preshared-key
Reserved = 1, 2, 3 # inline comment
Endpoint = [2001:db8::1]:51820 # inline comment
[Peer]
PublicKey = peer-public-key-2
Endpoint = example.com:12345
""";
var resolved = WireguardFmt.ResolveConfig(config);
resolved.Should().NotBeNull();
resolved.Should().HaveCount(2);
var first = resolved![0];
first.Address.Should().Be("2001:db8::1");
first.Port.Should().Be(51820);
first.Password.Should().Be("interface-private-key");
first.GetProtocolExtra().WgReserved.Should().Be("1, 2, 3");
first.GetProtocolExtra().WgInterfaceAddress.Should().Be("10.0.0.2/32, fd00::2/128");
first.GetProtocolExtra().WgMtu.Should().Be(1420);
var second = resolved[1];
second.Address.Should().Be("example.com");
second.Port.Should().Be(12345);
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
global using System.Buffers.Binary;
global using System.Diagnostics;
global using System.Net;
global using System.Net.Sockets;
global using System.Text;
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
</PropertyGroup>
</Project>
@@ -0,0 +1,420 @@
namespace ServiceLib.UdpTest;
public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable
{
private TcpClient _tcpClient;
private UdpClient _udpClient;
private IPEndPoint _relayEndPoint;
private bool _initialized = false;
/// <summary>
/// Send UDP data to a remote endpoint (IP address)
/// </summary>
public async Task SendAsync(IPEndPoint remote, byte[] data)
{
var addrData = new Socks5AddressData
{
AddressType = remote.Address.AddressFamily == AddressFamily.InterNetwork
? Socks5AddressData.AddrTypeIPv4
: Socks5AddressData.AddrTypeIPv6,
Host = remote.Address.ToString(),
Port = (ushort)remote.Port
};
var packet = BuildSocks5UdpPacket(addrData, data);
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
}
/// <summary>
/// Send UDP data to a remote endpoint (domain name or IP address)
/// </summary>
/// <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)
{
var addrData = new Socks5AddressData();
// Try to parse as IP address first
if (IPAddress.TryParse(host, out var ipAddr))
{
addrData.AddressType = ipAddr.AddressFamily == AddressFamily.InterNetwork
? Socks5AddressData.AddrTypeIPv4
: Socks5AddressData.AddrTypeIPv6;
addrData.Host = ipAddr.ToString();
}
else
{
// Treat as domain name
addrData.AddressType = Socks5AddressData.AddrTypeDomain;
addrData.Host = host;
}
addrData.Port = port;
var packet = BuildSocks5UdpPacket(addrData, data);
await _udpClient.SendAsync(packet, packet.Length, _relayEndPoint);
}
/// <summary>
/// Receive UDP data from remote endpoint
/// </summary>
/// <param name="cancellationToken">Cancellation token to cancel the receive operation</param>
/// <returns>Remote endpoint information and received data</returns>
public async Task<(Socks5RemoteEndpoint Remote, byte[] Data)> ReceiveAsync(
CancellationToken cancellationToken = default)
{
var result = await _udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
var (remote, payload) = ParseSocks5UdpPacket(result.Buffer);
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();
// RSV (2 bytes) + FRAG (1 byte) - Reserved and Fragment fields
ms.WriteByte(0x00);
ms.WriteByte(0x00);
ms.WriteByte(0x00);
// Write address (ATYP + address + port)
ms.Write(addressData.ToBytes());
// User data payload
ms.Write(data);
return ms.ToArray();
}
private static (Socks5RemoteEndpoint Remote, byte[] Data) ParseSocks5UdpPacket(byte[] packet)
{
if (packet.Length < 10) // Minimum length: RSV(2) + FRAG(1) + ATYP(1) + IPv4(4) + Port(2) = 10
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: too short");
}
var offset = 0;
// RSV (2 bytes) - Reserved field, skip
offset += 2;
// FRAG (1 byte) - Fragment number, currently only support 0 (no fragmentation)
var frag = packet[offset++];
if (frag != 0x00)
{
throw new NotSupportedException("SOCKS5 UDP fragmentation is not supported");
}
// ATYP (1 byte) - Address type
var addressType = packet[offset++];
string host;
int addressLength;
bool isDomain;
switch (addressType)
{
case Socks5AddressData.AddrTypeIPv4:
if (packet.Length < offset + 4)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv4 address incomplete");
}
var ipv4Bytes = new byte[4];
Array.Copy(packet, offset, ipv4Bytes, 0, 4);
host = new IPAddress(ipv4Bytes).ToString();
addressLength = 4;
isDomain = false;
break;
case Socks5AddressData.AddrTypeIPv6:
if (packet.Length < offset + 16)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv6 address incomplete");
}
var ipv6Bytes = new byte[16];
Array.Copy(packet, offset, ipv6Bytes, 0, 16);
host = new IPAddress(ipv6Bytes).ToString();
addressLength = 16;
isDomain = false;
break;
case Socks5AddressData.AddrTypeDomain:
if (packet.Length < offset + 1)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: domain length missing");
}
var domainLength = packet[offset++];
if (packet.Length < offset + domainLength)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: domain incomplete");
}
host = Encoding.ASCII.GetString(packet, offset, domainLength);
addressLength = domainLength;
isDomain = true;
break;
default:
throw new NotSupportedException($"Unsupported SOCKS5 address type: {addressType}");
}
offset += addressLength;
// Port (2 bytes, big-endian)
if (packet.Length < offset + 2)
{
throw new ArgumentException("Invalid SOCKS5 UDP packet: port incomplete");
}
var port = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(offset, 2));
offset += 2;
// Data (remaining bytes)
var dataLength = packet.Length - offset;
var data = new byte[dataLength];
if (dataLength > 0)
{
Array.Copy(packet, offset, data, 0, dataLength);
}
// Create remote endpoint without DNS resolution
var remote = new Socks5RemoteEndpoint(host, port, isDomain);
return (remote, data);
}
public void Dispose()
{
_tcpClient.Dispose();
_udpClient.Dispose();
}
#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 class Socks5AddressData
{
public const byte AddrTypeIPv4 = 0x01;
public const byte AddrTypeDomain = 0x03;
public const byte AddrTypeIPv6 = 0x04;
public byte AddressType { get; set; }
public string Host { get; set; } = string.Empty;
public ushort Port { get; set; }
public byte[] ToBytes()
{
using var ms = new MemoryStream();
ms.WriteByte(AddressType);
switch (AddressType)
{
case AddrTypeIPv4:
if (IPAddress.TryParse(Host, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork)
{
ms.Write(ip.GetAddressBytes(), 0, 4);
}
else
{
ms.Write([0, 0, 0, 0]);
}
break;
case AddrTypeDomain:
if (string.IsNullOrEmpty(Host))
{
ms.WriteByte(0);
}
else
{
var domainBytes = Encoding.ASCII.GetBytes(Host);
ms.WriteByte((byte)domainBytes.Length);
ms.Write(domainBytes);
}
break;
case AddrTypeIPv6:
if (IPAddress.TryParse(Host, out var ip6) && ip6.AddressFamily == AddressFamily.InterNetworkV6)
{
ms.Write(ip6.GetAddressBytes(), 0, 16);
}
else
{
ms.Write(new byte[16]);
}
break;
default:
throw new NotSupportedException($"SOCKS5 address type {AddressType} not supported.");
}
var portBytes = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(portBytes, Port);
ms.Write(portBytes);
return ms.ToArray();
}
public static async Task<Socks5AddressData?> ParseAsync(Stream stream, CancellationToken ct)
{
var addr = new Socks5AddressData();
var typeByte = new byte[1];
try
{
if (await stream.ReadAsync(typeByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
{
return null;
}
addr.AddressType = typeByte[0];
switch (addr.AddressType)
{
case AddrTypeIPv4:
var ipv4Bytes = new byte[4];
if (await stream.ReadAsync(ipv4Bytes.AsMemory(0, 4), ct).ConfigureAwait(false) < 4)
{
return null;
}
addr.Host = new IPAddress(ipv4Bytes).ToString();
break;
case AddrTypeDomain:
var lenByte = new byte[1];
if (await stream.ReadAsync(lenByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1)
{
return null;
}
if (lenByte[0] == 0)
{
addr.Host = string.Empty;
}
else
{
var domainBytes = new byte[lenByte[0]];
if (await stream.ReadAsync(domainBytes.AsMemory(0, domainBytes.Length), ct)
.ConfigureAwait(false) < domainBytes.Length)
{
return null;
}
addr.Host = Encoding.ASCII.GetString(domainBytes);
}
break;
case AddrTypeIPv6:
var ipv6Bytes = new byte[16];
if (await stream.ReadAsync(ipv6Bytes.AsMemory(0, 16), ct).ConfigureAwait(false) < 16)
{
return null;
}
addr.Host = new IPAddress(ipv6Bytes).ToString();
break;
default:
return null;
}
var portBytes = new byte[2];
if (await stream.ReadAsync(portBytes.AsMemory(0, 2), ct).ConfigureAwait(false) < 2)
{
return null;
}
addr.Port = BinaryPrimitives.ReadUInt16BigEndian(portBytes);
return addr;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
return null;
}
}
}
#endregion SOCKS5 Address Handling
}
@@ -0,0 +1,77 @@
namespace ServiceLib.UdpTest.Tester;
public class DnsService : IUdpTest
{
private const int DnsDefaultPort = 53;
private const string DnsDefaultServer = "8.8.8.8"; // Google Public DNS
private static readonly byte[] DnsQueryPacket =
[
// Header: ID=0x1234, Standard query with RD set, QDCOUNT=1
0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
// 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
];
public byte[] BuildUdpRequestPacket()
{
return (byte[])DnsQueryPacket.Clone();
}
public bool VerifyAndExtractUdpResponse(byte[] dnsResponseBytes)
{
if (dnsResponseBytes.Length < 12)
{
return false;
}
try
{
// Check transaction ID (should match 0x1234)
var transactionId = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(0, 2));
if (transactionId != 0x1234)
{
return false;
}
// Check flags - should be a response (QR=1)
var flags = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(2, 2));
if ((flags & 0x8000) == 0)
{
return false; // Not a response
}
// Check response code (RCODE) - should be 0 (no error)
if ((flags & 0x000F) != 0)
{
return false; // DNS error
}
// Check answer count
var answerCount = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(6, 2));
if (answerCount == 0)
{
return false; // No answers
}
return true;
}
catch
{
return false;
}
}
public ushort GetDefaultTargetPort()
{
return DnsDefaultPort;
}
public string GetDefaultTargetHost()
{
return DnsDefaultServer;
}
}
@@ -0,0 +1,12 @@
namespace ServiceLib.UdpTest.Tester;
public interface IUdpTest
{
public byte[] BuildUdpRequestPacket();
public bool VerifyAndExtractUdpResponse(byte[] udpResponseBytes);
public ushort GetDefaultTargetPort();
public string GetDefaultTargetHost();
}
@@ -0,0 +1,84 @@
namespace ServiceLib.UdpTest.Tester;
public class McBeService : IUdpTest
{
private const int McBeDefaultPort = 19132;
private const string McBeDefaultServer = "pms.mc-complex.com";
// 0x01 | client alive time in ms (unsigned long long) | magic | client GUID
private static readonly byte[] McBeQueryPacket =
[
// 0x01
0x01,
// Client alive time (1000 ms)
0x27, 0xC4, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00,
// Magic
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
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
];
private static readonly byte[] McBeMagicBytes =
[
0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE,
0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78
];
private static readonly List<string> ValidGameModes =
[
"Survival",
"Creative",
"Adventure",
"Spectator"
];
public byte[] BuildUdpRequestPacket()
{
return (byte[])McBeQueryPacket.Clone();
}
public bool VerifyAndExtractUdpResponse(byte[] mcbeResponseBytes)
{
// 0x1c | client alive time in ms (recorded from previous ping) |
// server GUID | Magic | string length | Edition
//
// Edition Example:
//
// MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133;
if (mcbeResponseBytes.Length < 48)
{
return false;
}
if (mcbeResponseBytes[0] != 0x1C)
{
return false; // Invalid packet type
}
var pongMagic = mcbeResponseBytes.Skip(17).Take(16).ToArray();
if (!pongMagic.SequenceEqual(McBeMagicBytes))
{
return false; // Magic bytes do not match
}
var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]);
var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray());
var stringParts = stringData.Split(';');
// check Game Mode str
var gameMode = stringParts.Length > 8 ? stringParts[8] : "";
if (!ValidGameModes.Contains(gameMode))
{
return false; // Invalid game mode
}
return true;
}
public ushort GetDefaultTargetPort()
{
return McBeDefaultPort;
}
public string GetDefaultTargetHost()
{
return McBeDefaultServer;
}
}
@@ -0,0 +1,37 @@
namespace ServiceLib.UdpTest.Tester;
public class NtpService : IUdpTest
{
private const int NtpDefaultPort = 123;
private const string NtpDefaultServer = "pool.ntp.org";
public byte[] BuildUdpRequestPacket()
{
var ntpReq = new byte[48];
ntpReq[0] = 0x23; // LI=0, VN=4, Mode=3
return ntpReq;
}
public bool VerifyAndExtractUdpResponse(byte[] ntpResponseBytes)
{
if (ntpResponseBytes.Length < 48)
{
return false;
}
if ((ntpResponseBytes[0] & 0x07) != 4)
{
return false;
}
return true;
}
public ushort GetDefaultTargetPort()
{
return NtpDefaultPort;
}
public string GetDefaultTargetHost()
{
return NtpDefaultServer;
}
}
@@ -0,0 +1,52 @@
namespace ServiceLib.UdpTest.Tester;
public class StunService : IUdpTest
{
private const int StunDefaultPort = 3478;
private const string StunDefaultServer = "stun.voztovoice.org";
private static readonly byte[] StunBindingRequestPacket =
[
// STUN Binding Request
0x00, 0x01, // Message Type: Binding Request (0x0001)
0x00, 0x00, // Message Length: 0 (no attributes)
0x21, 0x12, 0xA4, 0x42, // Magic Cookie: 0x2112A442
// Transaction ID: 96 bits (12 bytes) random
0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D,
0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE,
];
public byte[] BuildUdpRequestPacket()
{
return (byte[])StunBindingRequestPacket.Clone();
}
public bool VerifyAndExtractUdpResponse(byte[] stunResponseBytes)
{
if (stunResponseBytes.Length < 20)
{
return false;
}
if (stunResponseBytes.Length >= 2)
{
var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1];
if (messageType is 0x0101 or 0x0111)
{
return true;
}
}
return true;
}
public ushort GetDefaultTargetPort()
{
return StunDefaultPort;
}
public string GetDefaultTargetHost()
{
return StunDefaultServer;
}
}
+154
View File
@@ -0,0 +1,154 @@
using ServiceLib.UdpTest.Tester;
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)
{
["ntp"] = () => new NtpService(),
["dns"] = () => new DnsService(),
["stun"] = () => new StunService(),
["mcbe"] = () => new McBeService(),
};
private UdpTestService(IUdpTest udpTest)
{
_udpTest = udpTest;
}
public static UdpTestService Create(string? udpTestType)
{
if (string.IsNullOrEmpty(udpTestType))
{
return new UdpTestService(UdpTestFactories[DefaultUdpTestType]());
}
return UdpTestFactories.TryGetValue(udpTestType, out var factory)
? new UdpTestService(factory())
: new UdpTestService(UdpTestFactories[DefaultUdpTestType]());
}
public static UdpTestService CreateFromTarget(string? udpTestTarget, out string targetServerHost)
{
var parts = udpTestTarget?.Split(':', 2);
var udpTestType = parts?.Length > 0 ? parts[0] : DefaultUdpTestType;
var udpService = Create(udpTestType);
targetServerHost = parts?.Length > 1 && !string.IsNullOrEmpty(parts[1])
? parts[1]
: udpService._udpTest.GetDefaultTargetHost();
return udpService;
}
private (string host, ushort port) ParseHostAndPort(string targetServerHost)
{
if (string.IsNullOrEmpty(targetServerHost))
{
return (_udpTest.GetDefaultTargetHost(), _udpTest.GetDefaultTargetPort());
}
// Handle IPv6 format: [::1]:port or [2001:db8::1]:port
if (targetServerHost.StartsWith('['))
{
var closeBracketIndex = targetServerHost.IndexOf(']');
if (closeBracketIndex > 0)
{
var host = targetServerHost.Substring(1, closeBracketIndex - 1);
if (closeBracketIndex < targetServerHost.Length - 1 && targetServerHost[closeBracketIndex + 1] == ':')
{
var portStr = targetServerHost.Substring(closeBracketIndex + 2);
if (ushort.TryParse(portStr, out var port))
{
return (host, port);
}
}
return (host, _udpTest.GetDefaultTargetPort());
}
}
// Handle IPv4 or domain format: 1.1.1.1:53 or exam.com:333
var lastColonIndex = targetServerHost.LastIndexOf(':');
if (lastColonIndex > 0)
{
var host = targetServerHost.Substring(0, lastColonIndex);
var portStr = targetServerHost.Substring(lastColonIndex + 1);
if (ushort.TryParse(portStr, out var port))
{
return (host, port);
}
}
// No port specified, use default
return (targetServerHost, _udpTest.GetDefaultTargetPort());
}
public async Task<TimeSpan> SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout)
{
using var cts = new CancellationTokenSource(operationTimeout);
var cancellationToken = cts.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))
{
throw new Exception("Failed to establish UDP association with SOCKS5 proxy.");
}
var (targetHost, targetPort) = ParseHostAndPort(targetServerHost);
byte[] udpReceiveResult = null;
// Get minimum round trip time from two attempts
var roundTripTime = TimeSpan.MaxValue;
for (var attempt = 0; attempt < 2; attempt++)
{
try
{
var stopwatch = new Stopwatch();
stopwatch.Start();
await channel.SendAsync(targetHost, targetPort, udpRequestPacket).ConfigureAwait(false);
var (_, receiveResult) = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
udpReceiveResult = receiveResult;
var currentRoundTripTime = stopwatch.Elapsed;
if (currentRoundTripTime < roundTripTime)
{
roundTripTime = currentRoundTripTime;
}
}
catch
{
if (attempt == 1 && roundTripTime == TimeSpan.MaxValue)
{
throw;
}
}
}
if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2)
{
throw new Exception("Received NTP response is too short.");
}
if (udpReceiveResult != null && _udpTest.VerifyAndExtractUdpResponse(udpReceiveResult))
{
return roundTripTime;
}
else
{
throw new Exception("Failed to verify and extract UDP response.");
}
}
}
@@ -1,5 +1,3 @@
using ReactiveUI;
namespace ServiceLib.Base;
public class MyReactiveObject : ReactiveObject
-3
View File
@@ -1,6 +1,3 @@
using System.Collections.Concurrent;
using System.Reflection;
namespace ServiceLib.Common;
public static class EmbedUtils
+41 -7
View File
@@ -6,17 +6,17 @@ public static class Extension
{
public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value)
{
return string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value);
}
public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value)
{
return string.IsNullOrWhiteSpace(value);
return string.IsNullOrWhiteSpace(value) || string.IsNullOrEmpty(value);
}
public static bool IsNotEmpty([NotNullWhen(false)] this string? value)
{
return !string.IsNullOrEmpty(value);
return !string.IsNullOrWhiteSpace(value);
}
public static string? NullIfEmpty(this string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public static bool BeginWithAny(this string s, IEnumerable<char> chars)
@@ -84,4 +84,38 @@ public static class Extension
{
return source.Concat(new[] { string.Empty }).ToList();
}
public static bool IsGroupType(this EConfigType configType)
{
return configType is EConfigType.PolicyGroup or EConfigType.ProxyChain;
}
public static bool IsComplexType(this EConfigType configType)
{
return configType is EConfigType.Custom or EConfigType.PolicyGroup or EConfigType.ProxyChain;
}
/// <summary>
/// Safely adds elements from a collection to the list. Does nothing if the source is null.
/// </summary>
public static void AddRangeSafe<T>(this ICollection<T> destination, IEnumerable<T>? source)
{
ArgumentNullException.ThrowIfNull(destination);
if (source is null)
{
return;
}
if (destination is List<T> list)
{
list.AddRange(source);
return;
}
foreach (var item in source)
{
destination.Add(item);
}
}
}
@@ -1,10 +1,9 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
namespace ServiceLib.Common;
public static class FileManager
public static class FileUtils
{
private static readonly string _tag = "FileManager";
-180
View File
@@ -1,180 +0,0 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace ServiceLib.Common;
/*
* See:
* http://stackoverflow.com/questions/6266820/working-example-of-createjobobject-setinformationjobobject-pinvoke-in-net
*/
public sealed class Job : IDisposable
{
private IntPtr handle = IntPtr.Zero;
public Job()
{
handle = CreateJobObject(IntPtr.Zero, null);
var extendedInfoPtr = IntPtr.Zero;
var info = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = 0x2000
};
var extendedInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = info
};
try
{
var length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
extendedInfoPtr = Marshal.AllocHGlobal(length);
Marshal.StructureToPtr(extendedInfo, extendedInfoPtr, false);
if (!SetInformationJobObject(handle, JobObjectInfoType.ExtendedLimitInformation, extendedInfoPtr,
(uint)length))
{
throw new Exception(string.Format("Unable to set information. Error: {0}",
Marshal.GetLastWin32Error()));
}
}
finally
{
if (extendedInfoPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(extendedInfoPtr);
}
}
}
public bool AddProcess(IntPtr processHandle)
{
var succ = AssignProcessToJobObject(handle, processHandle);
if (!succ)
{
Logging.SaveLog("Failed to call AssignProcessToJobObject! GetLastError=" + Marshal.GetLastWin32Error());
}
return succ;
}
public bool AddProcess(int processId)
{
return AddProcess(Process.GetProcessById(processId).Handle);
}
#region IDisposable
private bool disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (disposed)
{
return;
}
disposed = true;
if (disposing)
{
// no managed objects to free
}
if (handle != IntPtr.Zero)
{
CloseHandle(handle);
handle = IntPtr.Zero;
}
}
~Job()
{
Dispose(false);
}
#endregion IDisposable
#region Interop
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateJobObject(IntPtr a, string? lpName);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoType infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
#endregion Interop
}
#region Helper classes
[StructLayout(LayoutKind.Sequential)]
internal struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
internal struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public uint nLength;
public IntPtr lpSecurityDescriptor;
public int bInheritHandle;
}
[StructLayout(LayoutKind.Sequential)]
internal struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
public enum JobObjectInfoType
{
AssociateCompletionPortInformation = 7,
BasicLimitInformation = 2,
BasicUIRestrictions = 4,
EndOfJobTimeInformation = 6,
ExtendedLimitInformation = 9,
SecurityLimitInformation = 5,
GroupInformation = 11
}
#endregion Helper classes
+55 -20
View File
@@ -1,23 +1,61 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace ServiceLib.Common;
public class JsonUtils
{
private static readonly string _tag = "JsonUtils";
private static readonly JsonSerializerOptions _defaultDeserializeOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private static readonly JsonSerializerOptions _defaultSerializeOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonSerializerOptions _defaultSerializeNoIndentedOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonSerializerOptions _nullValueSerializeOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonSerializerOptions _nullValueSerializeNoIndentedOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonDocumentOptions _defaultDocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip
};
/// <summary>
/// DeepCopy
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <returns></returns>
public static T DeepCopy<T>(T obj)
public static T? DeepCopy<T>(T? obj)
{
return Deserialize<T>(Serialize(obj, false))!;
if (obj is null)
{
return default;
}
return Deserialize<T>(Serialize(obj, false));
}
/// <summary>
@@ -34,11 +72,7 @@ public class JsonUtils
{
return default;
}
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<T>(strJson, options);
return JsonSerializer.Deserialize<T>(strJson, _defaultDeserializeOptions);
}
catch
{
@@ -51,7 +85,7 @@ public class JsonUtils
/// </summary>
/// <param name="strJson"></param>
/// <returns></returns>
public static JsonNode? ParseJson(string strJson)
public static JsonNode? ParseJson(string? strJson)
{
try
{
@@ -59,7 +93,7 @@ public class JsonUtils
{
return null;
}
return JsonNode.Parse(strJson);
return JsonNode.Parse(strJson, nodeOptions: null, _defaultDocumentOptions);
}
catch
{
@@ -84,11 +118,12 @@ public class JsonUtils
{
return result;
}
var options = new JsonSerializerOptions
var options = (nullValue, indented) switch
{
WriteIndented = indented,
DefaultIgnoreCondition = nullValue ? JsonIgnoreCondition.Never : JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
(true, true) => _nullValueSerializeOptions,
(true, false) => _nullValueSerializeNoIndentedOptions,
(false, true) => _defaultSerializeOptions,
_ => _defaultSerializeNoIndentedOptions
};
result = JsonSerializer.Serialize(obj, options);
}
@@ -105,7 +140,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 Serialize(object? obj, JsonSerializerOptions? options)
{
var result = string.Empty;
try
@@ -114,7 +149,7 @@ public class JsonUtils
{
return result;
}
result = JsonSerializer.Serialize(obj, options);
result = JsonSerializer.Serialize(obj, options ?? _defaultSerializeOptions);
}
catch (Exception ex)
{
+3 -116
View File
@@ -1,5 +1,3 @@
using System.Diagnostics;
namespace ServiceLib.Common;
public static class ProcUtils
@@ -48,7 +46,7 @@ public static class ProcUtils
return null;
}
public static void RebootAsAdmin(bool blAdmin = true)
public static bool RebootAsAdmin(bool blAdmin = true)
{
try
{
@@ -60,123 +58,12 @@ public static class ProcUtils
FileName = Utils.GetExePath().AppendQuotes(),
Verb = blAdmin ? "runas" : null,
};
_ = Process.Start(startInfo);
return Process.Start(startInfo) != null;
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
}
public static async Task ProcessKill(int pid)
{
try
{
await ProcessKill(Process.GetProcessById(pid), false);
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
}
public static async Task ProcessKill(Process? proc, bool review)
{
if (proc is null)
{
return;
}
GetProcessKeyInfo(proc, review, out var procId, out var fileName, out var processName);
try
{
if (Utils.IsNonWindows())
{
proc?.Kill(true);
}
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
try
{
proc?.Kill();
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
try
{
proc?.Close();
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
try
{
proc?.Dispose();
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
await Task.Delay(300);
await ProcessKillByKeyInfo(review, procId, fileName, processName);
}
private static void GetProcessKeyInfo(Process? proc, bool review, out int? procId, out string? fileName, out string? processName)
{
procId = null;
fileName = null;
processName = null;
if (!review)
{
return;
}
try
{
procId = proc?.Id;
fileName = proc?.MainModule?.FileName;
processName = proc?.ProcessName;
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
}
private static async Task ProcessKillByKeyInfo(bool review, int? procId, string? fileName, string? processName)
{
if (review && procId != null && fileName != null)
{
try
{
var lstProc = Process.GetProcessesByName(processName);
foreach (var proc2 in lstProc)
{
if (proc2.Id == procId)
{
Logging.SaveLog($"{_tag}, KillProcess not completing the job, procId");
await ProcessKill(proc2, false);
}
if (proc2.MainModule != null && proc2.MainModule?.FileName == fileName)
{
Logging.SaveLog($"{_tag}, KillProcess not completing the job, fileName");
}
}
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
return false;
}
}
}
+39 -3
View File
@@ -1,4 +1,5 @@
using QRCoder;
using QRCoder.Exceptions;
using SkiaSharp;
using ZXing.SkiaSharp;
@@ -8,10 +9,45 @@ public class QRCodeUtils
{
public static byte[]? GenQRCode(string? url)
{
if (url.IsNullOrEmpty())
{
return null;
}
using QRCodeGenerator qrGenerator = new();
using var qrCodeData = qrGenerator.CreateQrCode(url ?? string.Empty, QRCodeGenerator.ECCLevel.Q);
using PngByteQRCode qrCode = new(qrCodeData);
return qrCode.GetGraphic(20);
DataTooLongException? lastDtle = null;
var levels = new[]
{
QRCodeGenerator.ECCLevel.H,
QRCodeGenerator.ECCLevel.Q,
QRCodeGenerator.ECCLevel.M,
QRCodeGenerator.ECCLevel.L
};
foreach (var level in levels)
{
try
{
using var qrCodeData = qrGenerator.CreateQrCode(url, level);
using PngByteQRCode qrCode = new(qrCodeData);
return qrCode.GetGraphic(20);
}
catch (DataTooLongException ex)
{
lastDtle = ex;
continue;
}
catch
{
throw;
}
}
if (lastDtle != null)
{
throw lastDtle;
}
return null;
}
public static string? ParseBarcode(string? fileName)
+376 -46
View File
@@ -1,13 +1,5 @@
using System.Collections.Specialized;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
@@ -17,7 +9,7 @@ public class Utils
{
private static readonly string _tag = "Utils";
#region
#region Conversion Functions
/// <summary>
/// Convert to comma-separated string
@@ -85,13 +77,19 @@ public class Utils
/// Base64 Encode
/// </summary>
/// <param name="plainText"></param>
/// <param name="removePadding"></param>
/// <returns></returns>
public static string Base64Encode(string plainText)
public static string Base64Encode(string plainText, bool removePadding = false)
{
try
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
var base64 = Convert.ToBase64String(plainTextBytes);
if (removePadding)
{
base64 = base64.TrimEnd('=');
}
return base64;
}
catch (Exception ex)
{
@@ -112,7 +110,7 @@ public class Utils
{
if (plainText.IsNullOrEmpty())
{
return "";
return string.Empty;
}
plainText = plainText.Trim()
@@ -308,7 +306,10 @@ public class Utils
public static bool IsBase64String(string? plainText)
{
if (plainText.IsNullOrEmpty())
{
return false;
}
var buffer = new Span<byte>(new byte[plainText.Length]);
return Convert.TryFromBase64String(plainText, buffer, out var _);
}
@@ -331,9 +332,149 @@ public class Utils
.ToList();
}
#endregion
public static Dictionary<string, List<string>> ParseHostsToDictionary(string? hostsContent)
{
if (hostsContent.IsNullOrEmpty())
{
return new();
}
var userHostsMap = hostsContent
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
// skip full-line comments
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))
// ensure line still contains valid parts
.Where(line => !string.IsNullOrWhiteSpace(line) && line.Contains(' '))
.Select(line => line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries))
.Where(parts => parts.Length >= 2)
.GroupBy(parts => parts[0])
.ToDictionary(
group => group.Key,
group => group.SelectMany(parts => parts.Skip(1)).ToList()
);
#region
return userHostsMap;
}
/// <summary>
/// Parse a possibly non-standard URL into scheme, domain, port, and path.
/// If parsing fails, the entire input is returned as domain, and others are empty or zero.
/// </summary>
/// <param name="url">Input URL or string</param>
/// <returns>(domain, scheme, port, path)</returns>
public static (string domain, string scheme, int port, string path) ParseUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
return ("", "", 0, "");
}
// 1. First, try to parse using the standard Uri class.
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host))
{
var scheme = uri.Scheme;
var domain = uri.Host;
var port = uri.IsDefaultPort ? 0 : uri.Port;
var path = uri.PathAndQuery;
return (domain, scheme, port, path);
}
// 2. Try to handle more general cases with a regular expression, including non-standard schemes.
// This regex captures the scheme (optional), authority (host+port), and path (optional).
var match = Regex.Match(url, @"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):/{2,})?([^/?#]+)([^?#]*)?.*$");
if (match.Success)
{
var scheme = match.Groups[1].Value;
var authority = match.Groups[2].Value;
var path = match.Groups[3].Value;
// Remove userinfo from the authority part.
var atIndex = authority.LastIndexOf('@');
if (atIndex > 0)
{
authority = authority.Substring(atIndex + 1);
}
var (domain, port) = ParseAuthority(authority);
// If the parsed domain is empty, it means the authority part is malformed, so trigger the fallback.
if (!string.IsNullOrEmpty(domain))
{
return (domain, scheme, port, path);
}
}
// 3. If all of the above fails, execute the final fallback strategy.
return (url, "", 0, "");
}
/// <summary>
/// Helper function to parse domain and port from the authority part, with correct handling for IPv6.
/// </summary>
private static (string domain, int port) ParseAuthority(string authority)
{
if (string.IsNullOrEmpty(authority))
{
return ("", 0);
}
var port = 0;
var domain = authority;
// Handle IPv6 addresses, e.g., "[2001:db8::1]:443"
if (authority.StartsWith('[') && authority.Contains(']'))
{
var closingBracketIndex = authority.LastIndexOf(']');
if (closingBracketIndex < authority.Length - 1 && authority[closingBracketIndex + 1] == ':')
{
// Port exists
var portStr = authority.Substring(closingBracketIndex + 2);
if (int.TryParse(portStr, out var portNum))
{
port = portNum;
}
domain = authority.Substring(0, closingBracketIndex + 1);
}
else
{
// No port
domain = authority;
}
}
else // Handle IPv4 or domain names
{
var lastColonIndex = authority.LastIndexOf(':');
// Ensure there are digits after the colon and that this colon is not part of an IPv6 address.
if (lastColonIndex > 0 && lastColonIndex < authority.Length - 1 && authority.Substring(lastColonIndex + 1).All(char.IsDigit))
{
var portStr = authority.Substring(lastColonIndex + 1);
if (int.TryParse(portStr, out var portNum))
{
port = portNum;
domain = authority.Substring(0, lastColonIndex);
}
}
}
return (domain, port);
}
public static string? DomainStrategy4Sbox(string? strategy)
{
return strategy switch
{
not null when strategy.StartsWith("UseIPv4") => "prefer_ipv4",
not null when strategy.StartsWith("UseIPv6") => "prefer_ipv6",
not null when strategy.StartsWith("ForceIPv4") => "ipv4_only",
not null when strategy.StartsWith("ForceIPv6") => "ipv6_only",
_ => null
};
}
#endregion Conversion Functions
#region Data Checks
/// <summary>
/// Determine if the input is a number
@@ -356,6 +497,13 @@ public class Utils
return false;
}
var ext = Path.GetExtension(domain);
if (ext.IsNotEmpty()
&& ext[1..].ToLowerInvariant() is "json" or "txt" or "xml" or "cfg" or "ini" or "log" or "yaml" or "yml" or "toml")
{
return false;
}
return Uri.CheckHostName(domain) == UriHostNameType.Dns;
}
@@ -374,6 +522,48 @@ public class Utils
return false;
}
public static bool IsIpv4(string? ip)
{
if (ip.IsNullOrEmpty())
{
return false;
}
ip = ip.Trim();
if (!IPAddress.TryParse(ip, out var address))
{
return false;
}
return address.AddressFamily == AddressFamily.InterNetwork
&& ip.Count(c => c == '.') == 3;
}
public static bool IsIpAddress(string? ip)
{
if (ip.IsNullOrEmpty())
{
return false;
}
ip = ip.Trim();
// First, validate using built-in parser
if (!IPAddress.TryParse(ip, out var address))
{
return false;
}
// For IPv4: ensure it has exactly 3 dots (meaning 4 parts)
if (address.AddressFamily == AddressFamily.InterNetwork)
{
return ip.Count(c => c == '.') == 3;
}
// For IPv6: TryParse is already strict enough
return address.AddressFamily == AddressFamily.InterNetworkV6;
}
public static Uri? TryUri(string url)
{
try
@@ -392,40 +582,62 @@ public class Utils
{
// Loopback address check (127.0.0.1 for IPv4, ::1 for IPv6)
if (IPAddress.IsLoopback(address))
{
return true;
}
var ipBytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
{
// IPv4 private address check
if (ipBytes[0] == 10)
{
return true;
}
if (ipBytes[0] == 172 && ipBytes[1] >= 16 && ipBytes[1] <= 31)
{
return true;
}
if (ipBytes[0] == 192 && ipBytes[1] == 168)
{
return true;
}
}
else if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
// IPv6 private address check
// Link-local address fe80::/10
if (ipBytes[0] == 0xfe && (ipBytes[1] & 0xc0) == 0x80)
{
return true;
}
// Unique local address fc00::/7 (typically fd00::/8)
if ((ipBytes[0] & 0xfe) == 0xfc)
{
return true;
}
// Private portion in IPv4-mapped addresses ::ffff:0:0/96
if (address.IsIPv4MappedToIPv6)
{
var ipv4Bytes = ipBytes.Skip(12).ToArray();
if (ipv4Bytes[0] == 10)
{
return true;
}
if (ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31)
{
return true;
}
if (ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168)
{
return true;
}
}
}
}
@@ -433,20 +645,15 @@ public class Utils
return false;
}
#endregion
#endregion Data Checks
#region
#region Speed Test
private static bool PortInUse(int port)
{
try
{
List<IPEndPoint> lstIpEndPoints = new();
List<TcpConnectionInformation> lstTcpConns = new();
lstIpEndPoints.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners());
lstIpEndPoints.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners());
lstTcpConns.AddRange(IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections());
var (lstIpEndPoints, lstTcpConns) = GetActiveNetworkInfo();
if (lstIpEndPoints?.FindIndex(it => it.Port == port) >= 0)
{
@@ -488,9 +695,30 @@ public class Utils
return 59090;
}
#endregion
public static (List<IPEndPoint> endpoints, List<TcpConnectionInformation> connections) GetActiveNetworkInfo()
{
var endpoints = new List<IPEndPoint>();
var connections = new List<TcpConnectionInformation>();
#region
try
{
var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
endpoints.AddRange(ipGlobalProperties.GetActiveTcpListeners());
endpoints.AddRange(ipGlobalProperties.GetActiveUdpListeners());
connections.AddRange(ipGlobalProperties.GetActiveTcpConnections());
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
return (endpoints, connections);
}
#endregion Speed Test
#region Miscellaneous
public static bool UpgradeAppExists(out string upgradeFileName)
{
@@ -566,27 +794,65 @@ public class Utils
return Guid.TryParse(strSrc, out _);
}
public static Dictionary<string, string> GetSystemHosts()
private static Dictionary<string, string> GetSystemHosts(string hostFile)
{
var systemHosts = new Dictionary<string, string>();
var hostFile = @"C:\Windows\System32\drivers\etc\hosts";
try
{
if (File.Exists(hostFile))
if (!File.Exists(hostFile))
{
var hosts = File.ReadAllText(hostFile).Replace("\r", "");
var hostsList = hosts.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var host in hostsList)
{
if (host.StartsWith("#"))
continue;
var hostItem = host.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (hostItem.Length != 2)
continue;
systemHosts.Add(hostItem.Last(), hostItem.First());
}
return systemHosts;
}
var hosts = File.ReadAllText(hostFile).Replace("\r", "");
var hostsList = hosts.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var host in hostsList)
{
// Trim whitespace
var line = host.Trim();
// Skip comments and empty lines
if (line.IsNullOrEmpty() || line.StartsWith("#"))
{
continue;
}
// Strip inline comments
var commentIndex = line.IndexOf('#');
if (commentIndex >= 0)
{
line = line.Substring(0, commentIndex).Trim();
}
if (line.IsNullOrEmpty())
{
continue;
}
var hostItem = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (hostItem.Length < 2)
{
continue;
}
var ipAddress = hostItem[0];
var domain = hostItem[1];
// Validate IP address
if (!IsIpAddress(ipAddress))
{
continue;
}
// Validate domain name
if (domain.IsNullOrEmpty() || domain.Length > 255)
{
continue;
}
systemHosts[domain] = ipAddress;
}
return systemHosts;
}
catch (Exception ex)
{
@@ -596,6 +862,19 @@ public class Utils
return systemHosts;
}
public static Dictionary<string, string> GetSystemHosts()
{
var hosts = GetSystemHosts(@"C:\Windows\System32\drivers\etc\hosts");
var hostsIcs = GetSystemHosts(@"C:\Windows\System32\drivers\etc\hosts.ics");
foreach (var (key, value) in hostsIcs)
{
hosts[key] = value;
}
return hosts;
}
public static async Task<string?> GetCliWrapOutput(string filePath, string? arg)
{
return await GetCliWrapOutput(filePath, arg != null ? new List<string>() { arg } : null);
@@ -634,7 +913,7 @@ public class Utils
return null;
}
#endregion
#endregion Miscellaneous
#region TempPath
@@ -835,17 +1114,29 @@ public class Utils
#region Platform
public static bool IsWindows() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool IsWindows() => OperatingSystem.IsWindows();
public static bool IsLinux() => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
public static bool IsLinux() => OperatingSystem.IsLinux();
public static bool IsOSX() => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public static bool IsMacOS() => OperatingSystem.IsMacOS();
public static bool IsNonWindows() => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool IsNonWindows() => !OperatingSystem.IsWindows();
public static string GetExeName(string name)
{
return IsWindows() ? $"{name}.exe" : name;
if (name.IsNullOrEmpty() || IsNonWindows())
{
return name;
}
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
return name;
}
else
{
return $"{name}.exe";
}
}
public static bool IsAdministrator()
@@ -857,6 +1148,45 @@ public class Utils
return false;
}
public static bool IsPackagedInstall()
{
try
{
if (IsWindows() || IsMacOS())
{
return false;
}
var exePath = GetExePath();
var baseDir = string.IsNullOrEmpty(exePath) ? StartupPath() : Path.GetDirectoryName(exePath) ?? "";
var p = baseDir.Replace('\\', '/');
if (string.IsNullOrEmpty(p))
{
return false;
}
if (p.StartsWith("/opt/v2rayN", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (p.StartsWith("/usr/lib/v2rayN", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (p.StartsWith("/usr/share/v2rayN", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
catch
{
}
return false;
}
private static async Task<string?> GetLinuxUserId()
{
var arg = new List<string>() { "-c", "id -u" };
@@ -872,7 +1202,7 @@ public class Utils
if (SetUnixFileMode(fileName))
{
Logging.SaveLog($"Successfully set the file execution permission, {fileName}");
return "";
return string.Empty;
}
if (fileName.Contains(' '))
+15 -13
View File
@@ -1,5 +1,3 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Win32;
namespace ServiceLib.Common;
@@ -55,19 +53,23 @@ internal static class WindowsUtils
public static async Task RemoveTunDevice()
{
try
var tunNameList = new List<string> { "wintunsingbox_tun", "xray_tun" };
foreach (var tunName in tunNameList)
{
var sum = MD5.HashData(Encoding.UTF8.GetBytes("wintunsingbox_tun"));
var guid = new Guid(sum);
var pnpUtilPath = @"C:\Windows\System32\pnputil.exe";
var arg = $$""" /remove-device "SWD\Wintun\{{{guid}}}" """;
try
{
var sum = MD5.HashData(Encoding.UTF8.GetBytes(tunName));
var guid = new Guid(sum);
var pnpUtilPath = @"C:\Windows\System32\pnputil.exe";
var arg = $$""" /remove-device "SWD\Wintun\{{{guid}}}" """;
// Try to remove the device
_ = await Utils.GetCliWrapOutput(pnpUtilPath, arg);
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
// Try to remove the device
_ = await Utils.GetCliWrapOutput(pnpUtilPath, arg);
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
}
}
}
}
+4 -1
View File
@@ -12,5 +12,8 @@ public enum EConfigType
TUIC = 8,
WireGuard = 9,
HTTP = 10,
Anytls = 11
Anytls = 11,
Naive = 12,
PolicyGroup = 101,
ProxyChain = 102,
}
+2 -1
View File
@@ -2,8 +2,9 @@ namespace ServiceLib.Enums;
public enum EMultipleLoad
{
LeastPing,
Fallback,
Random,
RoundRobin,
LeastPing,
LeastLoad
}
+8
View File
@@ -0,0 +1,8 @@
namespace ServiceLib.Enums;
public enum ERuleType
{
ALL = 0,
Routing = 1,
DNS = 2,
}
+3 -1
View File
@@ -4,6 +4,8 @@ public enum ESpeedActionType
{
Tcping,
Realping,
UdpTest,
Speedtest,
Mixedtest
Mixedtest,
FastRealping
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace ServiceLib.Enums;
public enum ETransport
{
tcp,
raw,
kcp,
ws,
httpupgrade,
+1 -1
View File
@@ -12,7 +12,6 @@ public enum EViewAction
ProfilesFocus,
ShareSub,
ShareServer,
ShowHideWindow,
ScanScreenTask,
ScanImageTask,
BrowseServer,
@@ -24,6 +23,7 @@ public enum EViewAction
RoutingRuleDetailsWindow,
AddServerWindow,
AddServer2Window,
AddGroupServerWindow,
DNSSettingWindow,
RoutingSettingWindow,
OptionSettingWindow,
+30
View File
@@ -0,0 +1,30 @@
namespace ServiceLib.Events;
public static class AppEvents
{
public static readonly EventChannel<Unit> ReloadRequested = new();
public static readonly EventChannel<bool?> ShowHideWindowRequested = new();
public static readonly EventChannel<Unit> AddServerViaScanRequested = new();
public static readonly EventChannel<Unit> AddServerViaClipboardRequested = new();
public static readonly EventChannel<bool> SubscriptionsUpdateRequested = new();
public static readonly EventChannel<Unit> ProfilesRefreshRequested = new();
public static readonly EventChannel<Unit> SubscriptionsRefreshRequested = new();
public static readonly EventChannel<Unit> ProxiesReloadRequested = new();
public static readonly EventChannel<ServerSpeedItem> DispatcherStatisticsRequested = new();
public static readonly EventChannel<string> SendSnackMsgRequested = new();
public static readonly EventChannel<string> SendMsgViewRequested = new();
public static readonly EventChannel<Unit> AppExitRequested = new();
public static readonly EventChannel<bool> ShutdownRequested = new();
public static readonly EventChannel<Unit> AdjustMainLvColWidthRequested = new();
public static readonly EventChannel<string> SetDefaultServerRequested = new();
public static readonly EventChannel<Unit> RoutingsMenuRefreshRequested = new();
public static readonly EventChannel<Unit> TestServerRequested = new();
public static readonly EventChannel<Unit> InboundDisplayRequested = new();
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
}
+27
View File
@@ -0,0 +1,27 @@
using System.Reactive.Subjects;
namespace ServiceLib.Events;
public sealed class EventChannel<T>
{
private readonly ISubject<T> _subject = Subject.Synchronize(new Subject<T>());
public IObservable<T> AsObservable()
{
return _subject.AsObservable();
}
public void Publish(T value)
{
_subject.OnNext(value);
}
public void Publish()
{
if (typeof(T) != typeof(Unit))
{
throw new InvalidOperationException("Publish() without value is only valid for EventChannel<Unit>.");
}
_subject.OnNext((T)(object)Unit.Default);
}
}
+362 -272
View File
@@ -15,7 +15,6 @@ public class Global
public const string CoreConfigFileName = "config.json";
public const string CorePreConfigFileName = "configPre.json";
public const string CoreSpeedtestConfigFileName = "configTest{0}.json";
public const string CoreMultipleLoadConfigFileName = "configMultipleLoad.json";
public const string ClashMixinConfigFileName = "Mixin.yaml";
public const string NamespaceSample = "ServiceLib.Sample.";
@@ -25,6 +24,8 @@ public class Global
public const string V2raySampleHttpResponseFileName = NamespaceSample + "SampleHttpResponse";
public const string V2raySampleInbound = NamespaceSample + "SampleInbound";
public const string V2raySampleOutbound = NamespaceSample + "SampleOutbound";
public const string V2raySampleTunInbound = NamespaceSample + "SampleTunInbound";
public const string V2raySampleTunRules = NamespaceSample + "SampleTunRules";
public const string SingboxSampleOutbound = NamespaceSample + "SingboxSampleOutbound";
public const string CustomRoutingFileName = NamespaceSample + "custom_routing_";
public const string TunSingboxDNSFileName = NamespaceSample + "tun_singbox_dns";
@@ -40,15 +41,21 @@ public class Global
public const string ProxySetLinuxShellFileName = NamespaceSample + "proxy_set_linux_sh";
public const string KillAsSudoOSXShellFileName = NamespaceSample + "kill_as_sudo_osx_sh";
public const string KillAsSudoLinuxShellFileName = NamespaceSample + "kill_as_sudo_linux_sh";
public const string SingboxFakeIPFilterFileName = NamespaceSample + "singbox_fakeip_filter";
public const string DefaultSecurity = "auto";
public const string DefaultNetwork = "tcp";
public const string TcpHeaderHttp = "http";
public const string DefaultNetwork = "raw";
public const string RawHeaderHttp = "http";
public const string None = "none";
public const string RawNetworkAlias = "tcp";
public const string DefaultXhttpMode = "auto";
public const string ProxyTag = "proxy";
public const string DirectTag = "direct";
public const string BlockTag = "block";
public const string DnsOutboundTag = "dns";
public const string DnsTag = "dns-module";
public const string DirectDnsTag = "direct-dns";
public const string BalancerTagSuffix = "-round";
public const string StreamSecurity = "tls";
public const string StreamSecurityReality = "reality";
public const string Loopback = "127.0.0.1";
@@ -57,9 +64,12 @@ public class Global
public const string HttpsProtocol = "https://";
public const string SocksProtocol = "socks://";
public const string Socks5Protocol = "socks5://";
public const string InnerUriProtocol = "v2rayn://";
public const string AsIs = "AsIs";
public const string IPIfNonMatch = "IPIfNonMatch";
public const string IPOnDemand = "IPOnDemand";
public const string GeoSitePrefix = "geosite:";
public const string GeoIPPrefix = "geoip:";
public const string UserEMail = "t@t.tt";
public const string AutoRunRegPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
@@ -71,6 +81,7 @@ public class Global
public const string GrpcMultiMode = "multi";
public const int MaxPort = 65536;
public const int MinFontSize = 8;
public const int MinFontSizeCount = 13;
public const string RebootAs = "rebootas";
public const string AvaAssets = "avares://v2rayN/Assets/";
public const string LocalAppData = "V2RAYN_LOCAL_APPLICATION_DATA_V2";
@@ -82,238 +93,277 @@ public class Global
public const string SingboxDirectDNSTag = "direct_dns";
public const string SingboxRemoteDNSTag = "remote_dns";
public const string SingboxOutboundResolverTag = "outbound_resolver";
public const string SingboxFinalResolverTag = "final_resolver";
public const string SingboxLocalDNSTag = "local_local";
public const string SingboxHostsDNSTag = "hosts_dns";
public const string SingboxFakeDNSTag = "fake_dns";
public const int Hysteria2DefaultHopInt = 30;
public const string PolicyGroupExcludeKeywords = @"剩余|过期|到期|重置|[Rr]emaining|[Ee]xpir|[Rr]eset";
public const string PolicyGroupDefaultAllFilter = $"^(?!.*(?:{PolicyGroupExcludeKeywords})).*$";
public static readonly List<string> PolicyGroupDefaultFilterList =
[
// All nodes (exclude traffic/expiry info)
PolicyGroupDefaultAllFilter,
// Low multiplier nodes, e.g. ×0.1, 0.5x, 0.1倍
@"^.*(?:[×xX✕*]\s*0\.[0-9]+|0\.[0-9]+\s*[×xX*]).*$",
// Dedicated line nodes, e.g. IPLC, IEPL
$@"^(?!.*(?:{PolicyGroupExcludeKeywords})).*(?:线|IPLC|IEPL|).*$",
// Japan nodes
$@"^(?!.*(?:{PolicyGroupExcludeKeywords})).*(?:|\\b[Jj][Pp]\\b|🇯🇵|[Jj]apan).*$",
];
public static readonly List<string> IEProxyProtocols =
[
"{ip}:{http_port}",
"socks={ip}:{socks_port}",
"http={ip}:{http_port};https={ip}:{http_port};ftp={ip}:{http_port};socks={ip}:{socks_port}",
"http=http://{ip}:{http_port};https=http://{ip}:{http_port}",
""
"socks={ip}:{socks_port}",
"http={ip}:{http_port};https={ip}:{http_port};ftp={ip}:{http_port};socks={ip}:{socks_port}",
"http=http://{ip}:{http_port};https=http://{ip}:{http_port}",
""
];
public static readonly List<string> SubConvertUrls =
[
@"https://sub.xeton.dev/sub?url={0}",
@"https://api.dler.io/sub?url={0}",
@"http://127.0.0.1:25500/sub?url={0}",
""
@"https://api.dler.io/sub?url={0}",
@"http://127.0.0.1:25500/sub?url={0}",
""
];
public static readonly List<string> SubConvertConfig =
[@"https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online.ini"];
[
@"https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online.ini"
];
public static readonly List<string> SubConvertTargets =
[
"",
"mixed",
"v2ray",
"clash",
"ss"
"mixed",
"v2ray",
"clash",
"ss"
];
public static readonly List<string> SpeedTestUrls =
[
@"https://cachefly.cachefly.net/50mb.test",
@"https://speed.cloudflare.com/__down?bytes=10000000",
@"https://speed.cloudflare.com/__down?bytes=50000000",
@"https://speed.cloudflare.com/__down?bytes=100000000",
];
@"https://speed.cloudflare.com/__down?bytes=10000000",
@"https://speed.cloudflare.com/__down?bytes=50000000",
@"https://speed.cloudflare.com/__down?bytes=99999999",
];
public static readonly List<string> SpeedPingTestUrls =
[
@"https://www.google.com/generate_204",
@"https://www.gstatic.com/generate_204",
@"https://www.apple.com/library/test/success.html",
@"http://www.msftconnecttest.com/connecttest.txt"
@"https://www.gstatic.com/generate_204",
@"https://www.apple.com/library/test/success.html",
@"http://www.msftconnecttest.com/connecttest.txt"
];
public static readonly List<string> GeoFilesSources =
[
"",
@"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/{0}.dat",
@"https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/{0}.dat"
@"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/{0}.dat",
@"https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/{0}.dat"
];
public static readonly List<string> SingboxRulesetSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/sing-box/rule-set-{0}/{1}.srs",
@"https://raw.githubusercontent.com/chocolate4u/Iran-sing-box-rules/rule-set/{1}.srs"
];
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/sing-box/rule-set-{0}/{1}.srs",
@"https://raw.githubusercontent.com/chocolate4u/Iran-sing-box-rules/rule-set/{1}.srs"
];
public static readonly List<string> RoutingRulesSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/template.json",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/template.json"
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/template.json",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/template.json"
];
public static readonly List<string> DNSTemplateSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/"
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/"
];
public static readonly Dictionary<string, string> UserAgentTexts = new()
{
{"chrome","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36" },
{"firefox","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" },
{"safari","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" },
{"edge","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70" },
{"none",""}
};
public static readonly Dictionary<string, string> RawHttpUserAgentTexts = new()
{
{"chrome","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36" },
{"firefox","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" },
{"safari","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" },
{"edge","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70" },
{"none",""},
{"golang","Go-http-client/1.1"},
{"curl","curl/7.68.0"},
};
public const string Hysteria2ProtocolShare = "hy2://";
public const string NaiveHttpsProtocolShare = "naive+https://";
public const string NaiveQuicProtocolShare = "naive+quic://";
public static readonly Dictionary<EConfigType, string> ProtocolShares = new()
{
{ EConfigType.VMess, "vmess://" },
{ EConfigType.Shadowsocks, "ss://" },
{ EConfigType.SOCKS, "socks://" },
{ EConfigType.VLESS, "vless://" },
{ EConfigType.Trojan, "trojan://" },
{ EConfigType.Hysteria2, "hysteria2://" },
{ EConfigType.TUIC, "tuic://" },
{ EConfigType.WireGuard, "wireguard://" },
{ EConfigType.Anytls, "anytls://" }
};
{
{ EConfigType.VMess, "vmess://" },
{ EConfigType.Shadowsocks, "ss://" },
{ EConfigType.SOCKS, "socks://" },
{ EConfigType.VLESS, "vless://" },
{ EConfigType.Trojan, "trojan://" },
{ EConfigType.Hysteria2, "hysteria2://" },
{ EConfigType.TUIC, "tuic://" },
{ EConfigType.WireGuard, "wireguard://" },
{ EConfigType.Anytls, "anytls://" },
{ EConfigType.Naive, "naive://" }
};
public static readonly Dictionary<EConfigType, string> ProtocolTypes = new()
{
{ EConfigType.VMess, "vmess" },
{ EConfigType.Shadowsocks, "shadowsocks" },
{ EConfigType.SOCKS, "socks" },
{ EConfigType.HTTP, "http" },
{ EConfigType.VLESS, "vless" },
{ EConfigType.Trojan, "trojan" },
{ EConfigType.Hysteria2, "hysteria2" },
{ EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" }
};
{
{ EConfigType.VMess, "vmess" },
{ EConfigType.Shadowsocks, "shadowsocks" },
{ EConfigType.SOCKS, "socks" },
{ EConfigType.HTTP, "http" },
{ EConfigType.VLESS, "vless" },
{ EConfigType.Trojan, "trojan" },
{ EConfigType.Hysteria2, "hysteria2" },
{ EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" },
{ EConfigType.Naive, "naive" }
};
public static readonly List<string> VmessSecurities =
[
"aes-128-gcm",
"chacha20-poly1305",
"auto",
"none",
"zero"
"chacha20-poly1305",
"auto",
"none",
"zero"
];
public static readonly List<string> SsSecurities =
[
"aes-256-gcm",
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"none",
"plain"
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"none",
"plain"
];
public static readonly List<string> SsSecuritiesInXray =
[
"aes-256-gcm",
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"xchacha20-poly1305",
"xchacha20-ietf-poly1305",
"none",
"plain",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"xchacha20-poly1305",
"xchacha20-ietf-poly1305",
"none",
"plain",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
];
public static readonly List<string> SsSecuritiesInSingbox =
[
"aes-256-gcm",
"aes-192-gcm",
"aes-128-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"none",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-192-cfb",
"aes-256-cfb",
"rc4-md5",
"chacha20-ietf",
"xchacha20"
"aes-192-gcm",
"aes-128-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"none",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-192-cfb",
"aes-256-cfb",
"rc4-md5",
"chacha20-ietf",
"xchacha20"
];
public static readonly List<string> Flows =
[
"",
"xtls-rprx-vision",
"xtls-rprx-vision-udp443"
"xtls-rprx-vision",
"xtls-rprx-vision-udp443"
];
public static readonly List<string> Networks =
[
"tcp",
"kcp",
"ws",
"httpupgrade",
"xhttp",
"h2",
"quic",
"grpc"
"raw",
"xhttp",
"kcp",
"grpc",
"ws",
"httpupgrade"
];
public static readonly List<string> KcpHeaderTypes =
[
"srtp",
"utp",
"wechat-video",
"dtls",
"wireguard",
"dns"
"utp",
"wechat-video",
"dtls",
"wireguard",
"dns"
];
public static readonly Dictionary<string, string> KcpHeaderMaskMap = new()
{
{ "srtp", "header-srtp" },
{ "utp", "header-utp" },
{ "wechat-video", "header-wechat" },
{ "dtls", "header-dtls" },
{ "wireguard", "header-wireguard" },
{ "dns", "header-dns" }
};
public static readonly List<string> CoreTypes =
[
"Xray",
"sing_box"
"sing_box"
];
public static readonly HashSet<EConfigType> XraySupportConfigType =
[
EConfigType.VMess,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
];
public static readonly HashSet<EConfigType> SingboxSupportConfigType =
[
EConfigType.VMess,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.TUIC,
EConfigType.Anytls,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.TUIC,
EConfigType.Anytls,
EConfigType.Naive,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
];
public static readonly HashSet<EConfigType> SingboxOnlyConfigType = SingboxSupportConfigType.Except(XraySupportConfigType).ToHashSet();
public static readonly List<string> DomainStrategies =
[
AsIs,
@@ -321,162 +371,167 @@ public class Global
IPOnDemand
];
public static readonly List<string> DomainStrategies4Singbox =
public static readonly List<string> DomainStrategies4Sbox =
[
"",
"prefer_ipv4",
"prefer_ipv6",
"ipv4_only",
"ipv6_only",
"prefer_ipv4",
"prefer_ipv6",
""
"ipv6_only"
];
public static readonly List<string> Fingerprints =
[
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"randomized",
""
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"randomized",
""
];
public static readonly List<string> UserAgent =
[
"chrome",
"firefox",
"safari",
"edge",
"none"
"firefox",
"edge",
"curl",
"golang",
];
public static readonly List<string> XhttpMode =
[
"auto",
"packet-up",
"stream-up",
"stream-one"
"packet-up",
"stream-up",
"stream-one"
];
public static readonly List<string> AllowInsecure =
[
"true",
"false",
""
"false",
""
];
public static readonly List<string> DomainStrategy4Freedoms =
public static readonly List<string> DomainStrategy =
[
"AsIs",
"UseIP",
"UseIPv4",
"UseIPv6",
""
];
public static readonly List<string> SingboxDomainStrategy4Out =
[
"",
"ipv4_only",
"prefer_ipv4",
"prefer_ipv6",
"ipv6_only"
"UseIP",
"UseIPv4v6",
"UseIPv6v4",
"UseIPv4",
"UseIPv6",
""
];
public static readonly List<string> DomainDirectDNSAddress =
[
"119.29.29.29",
"223.5.5.5",
"119.29.29.29,223.5.5.5,https://doh.pub/dns-query",
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
"https://doh.pub/dns-query",
"223.5.5.5",
"119.29.29.29",
"localhost"
"https://doh.pub/dns-query,https://dns.alidns.com/dns-query",
"localhost"
];
public static readonly List<string> DomainRemoteDNSAddress =
[
"https://cloudflare-dns.com/dns-query",
"https://dns.cloudflare.com/dns-query",
"https://dns.google/dns-query",
"https://doh.dns.sb/dns-query",
"https://doh.opendns.com/dns-query",
"https://common.dot.dns.yandex.net",
"8.8.8.8",
"1.1.1.1",
"185.222.222.222",
"208.67.222.222",
"77.88.8.8"
"https://dns.google/dns-query",
"https://cloudflare-dns.com/dns-query,https://dns.google/dns-query,8.8.8.8",
"https://dns.cloudflare.com/dns-query",
"https://doh.dns.sb/dns-query",
"https://doh.opendns.com/dns-query",
"https://common.dot.dns.yandex.net/dns-query",
"8.8.8.8",
"1.1.1.1",
"185.222.222.222",
"208.67.222.222",
"77.88.8.8"
];
public static readonly List<string> DomainPureIPDNSAddress =
[
"119.29.29.29",
"223.5.5.5",
"119.29.29.29",
"localhost"
"localhost"
];
public static readonly List<string> Languages =
[
"zh-Hans",
"zh-Hant",
"en",
"fa-Ir",
"ru",
"hu"
"zh-Hant",
"en",
"fa-Ir",
"fr",
"ru",
"hu"
];
public static readonly List<string> Alpns =
[
"h3",
"h2",
"http/1.1",
"h3,h2",
"h2,http/1.1",
"h3,h2,http/1.1",
""
"h2",
"http/1.1",
"h3,h2",
"h2,http/1.1",
"h3,h2,http/1.1",
""
];
public static readonly List<string> LogLevels =
[
"debug",
"info",
"warning",
"error",
"none"
"info",
"warning",
"error",
"none"
];
public static readonly Dictionary<string, string> LogLevelColors = new()
{
{ "debug", "#6C757D" },
{ "info", "#2ECC71" },
{ "warning", "#FFA500" },
{ "error", "#E74C3C" },
};
public static readonly List<string> InboundTags =
[
"socks",
"socks2",
"socks3"
"socks2",
"socks3"
];
public static readonly List<string> RuleProtocols =
[
"http",
"tls",
"bittorrent"
"tls",
"quic",
"bittorrent"
];
public static readonly List<string> RuleNetworks =
[
"",
"tcp",
"udp",
"tcp,udp"
"tcp",
"udp",
"tcp,udp"
];
public static readonly List<string> destOverrideProtocols =
[
"http",
"tls",
"quic",
"fakedns",
"fakedns+others"
"tls",
"quic",
"fakedns",
];
public static readonly List<int> TunMtus =
@@ -492,88 +547,95 @@ public class Global
public static readonly List<string> TunStacks =
[
"gvisor",
"system",
"mixed"
"system",
"mixed"
];
public static readonly List<string> PresetMsgFilters =
[
"proxy",
"direct",
"block",
""
"direct",
"block",
""
];
public static readonly List<string> SingboxMuxs =
[
"h2mux",
"smux",
"yamux",
""
"smux",
"yamux",
""
];
public static readonly List<string> TuicCongestionControls =
[
"cubic",
"new_reno",
"bbr"
"new_reno",
"bbr"
];
public static readonly List<string> NaiveCongestionControls =
[
"bbr",
"bbr2",
"cubic",
"reno"
];
public static readonly List<string> allowSelectType =
[
"selector",
"urltest",
"loadbalance",
"fallback"
"urltest",
"loadbalance",
"fallback"
];
public static readonly List<string> notAllowTestType =
[
"selector",
"urltest",
"direct",
"reject",
"compatible",
"pass",
"loadbalance",
"fallback"
"urltest",
"direct",
"reject",
"compatible",
"pass",
"loadbalance",
"fallback"
];
public static readonly List<string> proxyVehicleType =
[
"file",
"http"
"http"
];
public static readonly Dictionary<ECoreType, string> CoreUrls = new()
{
{ ECoreType.v2fly, "v2fly/v2ray-core" },
{ ECoreType.v2fly_v5, "v2fly/v2ray-core" },
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
{ ECoreType.mieru, "enfein/mieru" },
{ ECoreType.v2rayN, "2dust/v2rayN" },
};
{
{ ECoreType.v2fly, "v2fly/v2ray-core" },
{ ECoreType.v2fly_v5, "v2fly/v2ray-core" },
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
{ ECoreType.mieru, "enfein/mieru" },
{ ECoreType.v2rayN, "2dust/v2rayN" },
};
public static readonly List<string> OtherGeoUrls =
[
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat",
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb",
@"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb"
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb",
@"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb"
];
public static readonly List<string> IPAPIUrls =
[
@"https://speed.cloudflare.com/meta",
@"https://api.ip.sb/geoip",
@"https://api-ipv4.ip.sb/geoip",
@"https://api-ipv6.ip.sb/geoip",
@@ -581,6 +643,24 @@ public class Global
@""
];
public static readonly List<string> UdpTestTargets =
[
"ntp:pool.ntp.org",
"ntp:time.google.com",
"dns:1.1.1.1",
"dns:8.8.8.8",
"dns:dns.google",
"stun:stun.voztovoice.org",
"stun:stun.cloudflare.com",
"stun:stun.l.google.com:19302",
"mcbe:pms.mc-complex.com",
"mcbe:bedrock.opblocks.com",
"mcbe:opsucht.net",
"mcbe:play.craftersmc.net",
"mcbe:mps.lemoncloud.net",
"mcbe:bedrock.talonmc.net",
];
public static readonly List<string> OutboundTags =
[
ProxyTag,
@@ -589,28 +669,38 @@ public class Global
];
public static readonly Dictionary<string, List<string>> PredefinedHosts = new()
{
{ "dns.google", new List<string> { "8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844" } },
{ "dns.alidns.com", new List<string> { "223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1" } },
{ "one.one.one.one", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "1dot1dot1dot1.cloudflare-dns.com", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "cloudflare-dns.com", new List<string> { "104.16.249.249", "104.16.248.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9" } },
{ "dns.cloudflare.com", new List<string> { "104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5" } },
{ "dot.pub", new List<string> { "1.12.12.12", "120.53.53.53" } },
{ "dns.quad9.net", new List<string> { "9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9" } },
{ "dns.yandex.net", new List<string> { "77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff" } },
{ "dns.sb", new List<string> { "185.222.222.222", "2a09::" } },
{ "dns.umbrella.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "dns.sse.cisco.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "engage.cloudflareclient.com", new List<string> { "162.159.192.1", "2606:4700:d0::a29f:c001" } }
};
{
{ "dns.google", ["8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844"] },
{ "dns.alidns.com", ["223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1"] },
{ "one.one.one.one", ["1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"] },
{ "1dot1dot1dot1.cloudflare-dns.com", ["1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"] },
{ "cloudflare-dns.com", ["104.16.249.249", "104.16.248.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9"] },
{ "dns.cloudflare.com", ["104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5"] },
{ "dot.pub", ["1.12.12.12", "120.53.53.53"] },
{ "doh.pub", ["1.12.12.12", "120.53.53.53"] },
{ "dns.quad9.net", ["9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9"] },
{ "dns.yandex.net", ["77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff"] },
{ "dns.sb", ["185.222.222.222", "2a09::"] },
{ "dns.umbrella.com", ["208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53"] },
{ "dns.sse.cisco.com", ["208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53"] },
{ "engage.cloudflareclient.com", ["162.159.192.1"] }
};
public static readonly List<string> ExpectedIPs =
[
"geoip:cn",
"geoip:ir",
"geoip:ru",
""
"geoip:ir",
"geoip:ru",
""
];
public static readonly List<string> TunIcmpRoutingPolicies =
[
"rule",
"direct",
"unreachable",
"drop",
"reply",
];
#endregion const
+29 -5
View File
@@ -1,13 +1,37 @@
global using System.Collections.Concurrent;
global using System.Diagnostics;
global using System.Net;
global using System.Net.NetworkInformation;
global using System.Net.Sockets;
global using System.Reactive;
global using System.Reactive.Disposables;
global using System.Reactive.Linq;
global using System.Reflection;
global using System.Runtime.InteropServices;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Encodings.Web;
global using System.Text.Json;
global using System.Text.Json.Nodes;
global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using DynamicData;
global using DynamicData.Binding;
global using ReactiveUI;
global using ReactiveUI.Fody.Helpers;
global using ServiceLib.Base;
global using ServiceLib.Common;
global using ServiceLib.Enums;
global using ServiceLib.Events;
global using ServiceLib.Handler;
global using ServiceLib.Handler.Builder;
global using ServiceLib.Handler.Fmt;
global using ServiceLib.Handler.SysProxy;
global using ServiceLib.Helper;
global using ServiceLib.Manager;
global using ServiceLib.Handler.Fmt;
global using ServiceLib.Services;
global using ServiceLib.Services.Statistics;
global using ServiceLib.Services.CoreConfig;
global using ServiceLib.Models;
global using ServiceLib.Resx;
global using ServiceLib.Handler.SysProxy;
global using ServiceLib.Services;
global using ServiceLib.Services.CoreConfig;
global using ServiceLib.Services.Statistics;
global using SQLite;
-21
View File
@@ -1,21 +0,0 @@
using System.Reactive;
using System.Reactive.Subjects;
namespace ServiceLib.Handler;
public static class AppEvents
{
public static readonly Subject<Unit> ProfilesRefreshRequested = new();
public static readonly Subject<string> SendSnackMsgRequested = new();
public static readonly Subject<string> SendMsgViewRequested = new();
public static readonly Subject<Unit> AppExitRequested = new();
public static readonly Subject<bool> ShutdownRequested = new();
public static readonly Subject<Unit> AdjustMainLvColWidthRequested = new();
public static readonly Subject<ServerSpeedItem> DispatcherStatisticsRequested = new();
}
@@ -1,5 +1,4 @@
using System.Security.Principal;
using System.Text.RegularExpressions;
namespace ServiceLib.Handler;
@@ -27,7 +26,7 @@ public static class AutoStartupHandler
await SetTaskLinux();
}
}
else if (Utils.IsOSX())
else if (Utils.IsMacOS())
{
await ClearTaskOSX();
@@ -0,0 +1,429 @@
namespace ServiceLib.Handler.Builder;
public record CoreConfigContextBuilderResult(CoreConfigContext Context, NodeValidatorResult ValidatorResult)
{
public bool Success => ValidatorResult.Success;
}
/// <summary>
/// Holds the results of a full context build, including the main context and an optional
/// pre-socks context (e.g. for TUN protection or pre-socks chaining).
/// </summary>
public record CoreConfigContextBuilderAllResult(
CoreConfigContextBuilderResult MainResult,
CoreConfigContextBuilderResult? PreSocksResult)
{
/// <summary>True only when both the main result and (if present) the pre-socks result succeeded.</summary>
public bool Success => MainResult.Success && (PreSocksResult?.Success ?? true);
/// <summary>
/// Merges all errors and warnings from the main result and the optional pre-socks result
/// into a single <see cref="NodeValidatorResult"/> for unified notification.
/// </summary>
public NodeValidatorResult CombinedValidatorResult => new(
[.. MainResult.ValidatorResult.Errors, .. PreSocksResult?.ValidatorResult.Errors ?? []],
[.. MainResult.ValidatorResult.Warnings, .. PreSocksResult?.ValidatorResult.Warnings ?? []]);
}
public class CoreConfigContextBuilder
{
/// <summary>
/// Builds a <see cref="CoreConfigContext"/> for the given node, resolves its proxy map,
/// and processes outbound nodes referenced by routing rules.
/// </summary>
public static async Task<CoreConfigContextBuilderResult> Build(Config config, ProfileItem node)
{
var runCoreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var coreType = runCoreType == ECoreType.sing_box ? ECoreType.sing_box : ECoreType.Xray;
var context = new CoreConfigContext()
{
Node = node,
RunCoreType = runCoreType,
AllProxiesMap = [],
AppConfig = config,
FullConfigTemplate = await AppManager.Instance.GetFullConfigTemplateItem(coreType),
IsTunEnabled = config.TunModeItem.EnableTun,
SimpleDnsItem = config.SimpleDNSItem,
ProtectDomainList = [],
RawDnsItem = await AppManager.Instance.GetDNSItem(coreType),
RoutingItem = await ConfigHandler.GetDefaultRouting(config),
IsWindows = Utils.IsWindows(),
IsMacOS = Utils.IsMacOS(),
};
var validatorResult = NodeValidatorResult.Empty();
var (actNode, nodeValidatorResult) = await ResolveNodeAsync(context, node);
if (!nodeValidatorResult.Success)
{
return new CoreConfigContextBuilderResult(context, nodeValidatorResult);
}
context = context with { Node = actNode };
validatorResult.Warnings.AddRange(nodeValidatorResult.Warnings);
if (!(context.RoutingItem?.RuleSet.IsNullOrEmpty() ?? true))
{
var rules = JsonUtils.Deserialize<List<RulesItem>>(context.RoutingItem?.RuleSet) ?? [];
foreach (var ruleItem in rules.Where(ruleItem => ruleItem.Enabled && !Global.OutboundTags.Contains(ruleItem.OutboundTag)))
{
if (ruleItem.OutboundTag.IsNullOrEmpty())
{
validatorResult.Warnings.Add(string.Format(ResUI.MsgRoutingRuleEmptyOutboundTag, ruleItem.Remarks));
ruleItem.OutboundTag = Global.ProxyTag;
continue;
}
var ruleOutboundNode = await AppManager.Instance.GetProfileItemViaRemarks(ruleItem.OutboundTag);
if (ruleOutboundNode == null)
{
validatorResult.Warnings.Add(string.Format(ResUI.MsgRoutingRuleOutboundNodeNotFound, ruleItem.Remarks, ruleItem.OutboundTag));
ruleItem.OutboundTag = Global.ProxyTag;
continue;
}
var (actRuleNode, ruleNodeValidatorResult) = await ResolveNodeAsync(context, ruleOutboundNode, false);
validatorResult.Warnings.AddRange(ruleNodeValidatorResult.Warnings.Select(w =>
string.Format(ResUI.MsgRoutingRuleOutboundNodeWarning, ruleItem.Remarks, ruleItem.OutboundTag, w)));
if (!ruleNodeValidatorResult.Success)
{
validatorResult.Warnings.AddRange(ruleNodeValidatorResult.Errors.Select(e =>
string.Format(ResUI.MsgRoutingRuleOutboundNodeError, ruleItem.Remarks, ruleItem.OutboundTag, e)));
ruleItem.OutboundTag = Global.ProxyTag;
continue;
}
context.AllProxiesMap[$"remark:{ruleItem.OutboundTag}"] = actRuleNode;
}
}
return new CoreConfigContextBuilderResult(context, validatorResult);
}
/// <summary>
/// Builds the main <see cref="CoreConfigContext"/> for <paramref name="node"/> and, when
/// the main build succeeds, also builds the optional pre-socks context required for TUN
/// protection or pre-socks proxy chaining.
/// </summary>
public static async Task<CoreConfigContextBuilderAllResult> BuildAll(Config config, ProfileItem node)
{
var mainResult = await Build(config, node);
if (!mainResult.Success)
{
return new CoreConfigContextBuilderAllResult(mainResult, null);
}
var preResult = await BuildPreSocksIfNeeded(mainResult.Context);
if (preResult is null)
{
return new CoreConfigContextBuilderAllResult(mainResult, null);
}
var resolvedMainResult = mainResult with
{
Context = mainResult.Context with
{
IsTunEnabled = false, // main core doesn't handle tun directly when pre-socks is used
ProtectDomainList = [.. mainResult.Context.ProtectDomainList, .. preResult.Context.ProtectDomainList],
}
};
return new CoreConfigContextBuilderAllResult(resolvedMainResult, preResult);
}
/// <summary>
/// Determines whether a pre-socks context is required for <paramref name="nodeContext"/>
/// and, if so, builds and returns it. Returns <c>null</c> when no pre-socks core is needed.
/// </summary>
private static async Task<CoreConfigContextBuilderResult?> BuildPreSocksIfNeeded(CoreConfigContext nodeContext)
{
var config = nodeContext.AppConfig;
var node = nodeContext.Node;
var coreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var preSocksItem = ConfigHandler.GetPreSocksItem(config, node, coreType);
if (preSocksItem != null)
{
var preSocksResult = await Build(nodeContext.AppConfig, preSocksItem);
return preSocksResult with
{
Context = preSocksResult.Context with
{
ProtectDomainList = [.. nodeContext.ProtectDomainList ?? [], .. preSocksResult.Context.ProtectDomainList ?? []],
}
};
}
return null;
}
/// <summary>
/// Resolves a node into the context, optionally wrapping it in a subscription-level proxy chain.
/// Returns the effective (possibly replaced) node and the validation result.
/// </summary>
public static async Task<(ProfileItem, NodeValidatorResult)> ResolveNodeAsync(CoreConfigContext context,
ProfileItem node,
bool includeSubChain = true)
{
if (node.IndexId.IsNullOrEmpty())
{
return (node, NodeValidatorResult.Empty());
}
if (includeSubChain)
{
var (virtualChainNode, chainValidatorResult) = await BuildSubscriptionChainNodeAsync(node);
if (virtualChainNode != null)
{
context.AllProxiesMap[virtualChainNode.IndexId] = virtualChainNode;
var (resolvedNode, resolvedResult) = await ResolveNodeAsync(context, virtualChainNode, false);
resolvedResult.Warnings.InsertRange(0, chainValidatorResult.Warnings);
return (resolvedNode, resolvedResult);
}
// Chain not built but warnings may still exist (e.g. missing profiles)
if (chainValidatorResult.Warnings.Count > 0)
{
var fillResult = await RegisterNodeAsync(context, node);
fillResult.Warnings.InsertRange(0, chainValidatorResult.Warnings);
return (node, fillResult);
}
}
var registerResult = await RegisterNodeAsync(context, node);
return (node, registerResult);
}
/// <summary>
/// If the node's subscription defines prev/next profiles, creates a virtual
/// <see cref="EConfigType.ProxyChain"/> node that wraps them together.
/// Returns <c>null</c> as the chain item when no chain is needed.
/// Any warnings (e.g. missing prev/next profile) are returned in the validator result.
/// </summary>
private static async Task<(ProfileItem? ChainNode, NodeValidatorResult ValidatorResult)> BuildSubscriptionChainNodeAsync(ProfileItem node)
{
var result = NodeValidatorResult.Empty();
if (node.Subid.IsNullOrEmpty() || node.ConfigType == EConfigType.Custom)
{
return (null, result);
}
var subItem = await AppManager.Instance.GetSubItem(node.Subid);
if (subItem == null)
{
return (null, result);
}
ProfileItem? prevNode = null;
ProfileItem? nextNode = null;
if (!subItem.PrevProfile.IsNullOrEmpty())
{
prevNode = await AppManager.Instance.GetProfileItemViaRemarks(subItem.PrevProfile);
if (prevNode == null)
{
result.Warnings.Add(string.Format(ResUI.MsgSubscriptionPrevProfileNotFound, subItem.PrevProfile));
}
}
if (!subItem.NextProfile.IsNullOrEmpty())
{
nextNode = await AppManager.Instance.GetProfileItemViaRemarks(subItem.NextProfile);
if (nextNode == null)
{
result.Warnings.Add(string.Format(ResUI.MsgSubscriptionNextProfileNotFound, subItem.NextProfile));
}
}
if (prevNode is null && nextNode is null)
{
return (null, result);
}
// Build new proxy chain node
var chainNode = new ProfileItem()
{
IndexId = $"inner-{Utils.GetGuid(false)}",
ConfigType = EConfigType.ProxyChain,
CoreType = AppManager.Instance.GetCoreType(node, node.ConfigType),
Remarks = node.Remarks,
};
List<string?> childItems = [prevNode?.IndexId, node.IndexId, nextNode?.IndexId];
var chainExtraItem = chainNode.GetProtocolExtra() with
{
GroupType = chainNode.ConfigType.ToString(),
ChildItems = string.Join(",", childItems.Where(x => !x.IsNullOrEmpty())),
};
chainNode.SetProtocolExtra(chainExtraItem);
return (chainNode, result);
}
/// <summary>
/// Dispatches registration to either <see cref="RegisterGroupNodeAsync"/> or
/// <see cref="RegisterSingleNodeAsync"/> based on the node's config type.
/// </summary>
private static async Task<NodeValidatorResult> RegisterNodeAsync(CoreConfigContext context, ProfileItem node)
{
if (node.ConfigType.IsGroupType())
{
return await RegisterGroupNodeAsync(context, node);
}
else
{
return RegisterSingleNodeAsync(context, node);
}
}
/// <summary>
/// Validates a single (non-group) node and, on success, adds it to the proxy map
/// and records any domain addresses that should bypass the proxy.
/// </summary>
private static NodeValidatorResult RegisterSingleNodeAsync(CoreConfigContext context, ProfileItem node)
{
if (node.ConfigType.IsGroupType())
{
return NodeValidatorResult.Empty();
}
var nodeValidatorResult = NodeValidator.Validate(node, context.RunCoreType);
var msgs = new List<string>([.. nodeValidatorResult.Errors, .. nodeValidatorResult.Warnings]);
if (msgs.Count > 0)
{
Logging.SaveLog($"{node.Remarks}: {string.Join("; ", msgs)}");
}
if (!nodeValidatorResult.Success)
{
return nodeValidatorResult;
}
context.AllProxiesMap[node.IndexId] = node;
var address = node.Address;
if (Utils.IsDomain(address))
{
context.ProtectDomainList.Add(address);
}
// ech query server name protect
if (!node.EchConfigList.IsNullOrEmpty())
{
var echQuerySni = node.Sni;
if (node.StreamSecurity == Global.StreamSecurity
&& node.EchConfigList?.Contains("://") == true)
{
var idx = node.EchConfigList.IndexOf('+');
echQuerySni = idx > 0 ? node.EchConfigList[..idx] : node.Sni;
}
if (Utils.IsDomain(echQuerySni))
{
context.ProtectDomainList.Add(echQuerySni);
}
}
// xhttp downloadSettings address protect
var xhttpExtra = node.GetTransportExtra().XhttpExtra;
if (!string.IsNullOrEmpty(xhttpExtra)
&& JsonUtils.ParseJson(xhttpExtra) is JsonObject extra
&& extra.TryGetPropertyValue("downloadSettings", out var dsNode)
&& dsNode is JsonObject downloadSettings
&& downloadSettings.TryGetPropertyValue("address", out var dAddrNode)
&& dAddrNode is JsonValue dAddrValue
&& dAddrValue.TryGetValue(out string? dAddr)
&& !string.IsNullOrEmpty(dAddr)
&& Utils.IsDomain(dAddr))
{
context.ProtectDomainList.Add(dAddr);
}
return nodeValidatorResult;
}
/// <summary>
/// Entry point for registering a group node. Initialises the visited/ancestor sets
/// and delegates to <see cref="TraverseGroupNodeAsync"/>.
/// </summary>
private static async Task<NodeValidatorResult> RegisterGroupNodeAsync(CoreConfigContext context,
ProfileItem node)
{
if (!node.ConfigType.IsGroupType())
{
return NodeValidatorResult.Empty();
}
HashSet<string> ancestors = [node.IndexId];
HashSet<string> globalVisited = [node.IndexId];
return await TraverseGroupNodeAsync(context, node, globalVisited, ancestors);
}
/// <summary>
/// Recursively walks the children of a group node, registering valid leaf nodes
/// and nested groups. Detects cycles via <paramref name="ancestorsGroup"/> and
/// deduplicates shared nodes via <paramref name="globalVisitedGroup"/>.
/// </summary>
private static async Task<NodeValidatorResult> TraverseGroupNodeAsync(
CoreConfigContext context,
ProfileItem node,
HashSet<string> globalVisitedGroup,
HashSet<string> ancestorsGroup)
{
var (groupChildList, _) = await GroupProfileManager.GetChildProfileItems(node);
List<string> childIndexIdList = [];
var childNodeValidatorResult = NodeValidatorResult.Empty();
foreach (var childNode in groupChildList)
{
if (ancestorsGroup.Contains(childNode.IndexId))
{
childNodeValidatorResult.Errors.Add(
string.Format(ResUI.MsgGroupCycleDependency, node.Remarks, childNode.Remarks));
continue;
}
if (globalVisitedGroup.Contains(childNode.IndexId))
{
childIndexIdList.Add(childNode.IndexId);
continue;
}
if (!childNode.ConfigType.IsGroupType())
{
var childNodeResult = RegisterSingleNodeAsync(context, childNode);
childNodeValidatorResult.Warnings.AddRange(childNodeResult.Warnings.Select(w =>
string.Format(ResUI.MsgGroupChildNodeWarning, node.Remarks, childNode.Remarks, w)));
childNodeValidatorResult.Errors.AddRange(childNodeResult.Errors.Select(e =>
string.Format(ResUI.MsgGroupChildNodeError, node.Remarks, childNode.Remarks, e)));
if (!childNodeResult.Success)
{
continue;
}
globalVisitedGroup.Add(childNode.IndexId);
childIndexIdList.Add(childNode.IndexId);
continue;
}
var newAncestorsGroup = new HashSet<string>(ancestorsGroup) { childNode.IndexId };
var childGroupResult =
await TraverseGroupNodeAsync(context, childNode, globalVisitedGroup, newAncestorsGroup);
childNodeValidatorResult.Warnings.AddRange(childGroupResult.Warnings.Select(w =>
string.Format(ResUI.MsgGroupChildGroupNodeWarning, node.Remarks, childNode.Remarks, w)));
childNodeValidatorResult.Errors.AddRange(childGroupResult.Errors.Select(e =>
string.Format(ResUI.MsgGroupChildGroupNodeError, node.Remarks, childNode.Remarks, e)));
if (!childGroupResult.Success)
{
continue;
}
globalVisitedGroup.Add(childNode.IndexId);
childIndexIdList.Add(childNode.IndexId);
}
if (childIndexIdList.Count == 0)
{
childNodeValidatorResult.Errors.Add(string.Format(ResUI.MsgGroupNoValidChildNode, node.Remarks));
return childNodeValidatorResult;
}
else
{
childNodeValidatorResult.Warnings.AddRange(childNodeValidatorResult.Errors);
childNodeValidatorResult.Errors.Clear();
}
node.SetProtocolExtra(node.GetProtocolExtra() with { ChildItems = Utils.List2String(childIndexIdList), });
context.AllProxiesMap[node.IndexId] = node;
return childNodeValidatorResult;
}
}
@@ -0,0 +1,186 @@
namespace ServiceLib.Handler.Builder;
public record NodeValidatorResult(List<string> Errors, List<string> Warnings)
{
public bool Success => Errors.Count == 0;
public static NodeValidatorResult Empty()
{
return new NodeValidatorResult([], []);
}
}
public class NodeValidator
{
// Static validator rules
private static readonly HashSet<string> SingboxUnsupportedTransports =
[nameof(ETransport.kcp), nameof(ETransport.xhttp)];
private static readonly HashSet<EConfigType> SingboxTransportSupportedProtocols =
[EConfigType.VMess, EConfigType.VLESS, EConfigType.Trojan, EConfigType.Shadowsocks];
private static readonly HashSet<string> SingboxShadowsocksAllowedTransports =
[nameof(ETransport.raw), nameof(ETransport.ws)];
public static NodeValidatorResult Validate(ProfileItem item, ECoreType coreType)
{
var v = new ValidationContext();
ValidateNodeAndCoreSupport(item, coreType, v);
return v.ToResult();
}
private class ValidationContext
{
public List<string> Errors { get; } = [];
public List<string> Warnings { get; } = [];
public void Error(string message)
{
Errors.Add(message);
}
public void Warning(string message)
{
Warnings.Add(message);
}
public void Assert(bool condition, string errorMsg)
{
if (!condition)
{
Error(errorMsg);
}
}
public NodeValidatorResult ToResult()
{
return new NodeValidatorResult(Errors, Warnings);
}
}
private static void ValidateNodeAndCoreSupport(ProfileItem item, ECoreType coreType, ValidationContext v)
{
if (item.ConfigType is EConfigType.Custom)
{
return;
}
if (item.ConfigType.IsGroupType())
{
// Group logic is handled in ValidateGroupNode
return;
}
// Basic Property Validation
v.Assert(!item.Address.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, "Address"));
v.Assert(item.Port is > 0 and <= 65535, string.Format(ResUI.MsgInvalidProperty, "Port"));
// Network & Core Logic
var net = item.GetNetwork();
if (coreType == ECoreType.sing_box)
{
var transportError = ValidateSingboxTransport(item.ConfigType, net);
if (transportError != null)
{
v.Error(transportError);
}
if (!Global.SingboxSupportConfigType.Contains(item.ConfigType))
{
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, nameof(ECoreType.sing_box), item.ConfigType));
}
}
else if (coreType is ECoreType.Xray)
{
if (!Global.XraySupportConfigType.Contains(item.ConfigType))
{
v.Error(string.Format(ResUI.MsgCoreNotSupportProtocol, nameof(ECoreType.Xray), item.ConfigType));
}
}
// Protocol Specifics
var protocolExtra = item.GetProtocolExtra();
switch (item.ConfigType)
{
case EConfigType.VMess:
v.Assert(!item.Password.IsNullOrEmpty() && Utils.IsGuidByParse(item.Password),
string.Format(ResUI.MsgInvalidProperty, "Password"));
break;
case EConfigType.VLESS:
v.Assert(
!item.Password.IsNullOrEmpty()
&& (Utils.IsGuidByParse(item.Password) || item.Password.Length <= 30),
string.Format(ResUI.MsgInvalidProperty, "Password")
);
v.Assert(Global.Flows.Contains(protocolExtra.Flow ?? string.Empty),
string.Format(ResUI.MsgInvalidProperty, "Flow"));
break;
case EConfigType.Shadowsocks:
v.Assert(!item.Password.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, "Password"));
v.Assert(
!string.IsNullOrEmpty(protocolExtra.SsMethod) &&
Global.SsSecuritiesInSingbox.Contains(protocolExtra.SsMethod),
string.Format(ResUI.MsgInvalidProperty, "SsMethod"));
break;
}
// TLS & Security
if (item.StreamSecurity == Global.StreamSecurity)
{
if (!item.Cert.IsNullOrEmpty() && CertPemManager.ParsePemChain(item.Cert).Count == 0 &&
!item.CertSha.IsNullOrEmpty())
{
v.Error(string.Format(ResUI.MsgInvalidProperty, "TLS Certificate"));
}
}
if (item.StreamSecurity == Global.StreamSecurityReality)
{
v.Assert(!item.PublicKey.IsNullOrEmpty(), string.Format(ResUI.MsgInvalidProperty, "PublicKey"));
}
var transport = item.GetTransportExtra();
if (item.Network == nameof(ETransport.xhttp) && !transport.XhttpExtra.IsNullOrEmpty())
{
if (JsonUtils.ParseJson(transport.XhttpExtra) is not JsonObject)
{
v.Error(string.Format(ResUI.MsgInvalidProperty, "XHTTP Extra"));
}
}
if (!item.Finalmask.IsNullOrEmpty())
{
if (JsonUtils.ParseJson(item.Finalmask) is not JsonObject)
{
v.Error(string.Format(ResUI.MsgInvalidProperty, "Finalmask"));
}
}
}
private static string? ValidateSingboxTransport(EConfigType configType, string net)
{
// sing-box does not support xhttp / kcp transports
if (SingboxUnsupportedTransports.Contains(net))
{
return string.Format(ResUI.MsgCoreNotSupportNetwork, nameof(ECoreType.sing_box), net);
}
// sing-box does not support non-tcp transports for protocols other than vmess/trojan/vless/shadowsocks
if (!SingboxTransportSupportedProtocols.Contains(configType) && net != nameof(ETransport.raw))
{
return string.Format(ResUI.MsgCoreNotSupportProtocolTransport,
nameof(ECoreType.sing_box), configType.ToString(), net);
}
// sing-box shadowsocks only supports tcp/ws/quic transports
if (configType == EConfigType.Shadowsocks && !SingboxShadowsocksAllowedTransports.Contains(net))
{
return string.Format(ResUI.MsgCoreNotSupportProtocolTransport,
nameof(ECoreType.sing_box), configType.ToString(), net);
}
return null;
}
}
File diff suppressed because it is too large Load Diff
+33 -5
View File
@@ -1,5 +1,3 @@
using System.Net;
namespace ServiceLib.Handler;
public static class ConnectionHandler
@@ -8,7 +6,7 @@ public static class ConnectionHandler
public static async Task<string> RunAvailabilityCheck()
{
var time = await GetRealPingTime();
var time = await GetRealPingTimeInfo();
var ip = time > 0 ? await GetIPInfo() ?? Global.None : Global.None;
return string.Format(ResUI.TestMeOutput, time, ip);
@@ -41,7 +39,7 @@ public static class ConnectionHandler
return $"({country ?? "unknown"}) {ip}";
}
private static async Task<int> GetRealPingTime()
private static async Task<int> GetRealPingTimeInfo()
{
var responseTime = -1;
try
@@ -52,7 +50,7 @@ public static class ConnectionHandler
for (var i = 0; i < 2; i++)
{
responseTime = await HttpClientHelper.Instance.GetRealPingTime(url, webProxy, 10);
responseTime = await GetRealPingTime(url, webProxy, 10);
if (responseTime > 0)
{
break;
@@ -67,4 +65,34 @@ public static class ConnectionHandler
}
return responseTime;
}
public static async Task<int> GetRealPingTime(string url, IWebProxy? webProxy, int downloadTimeout)
{
var responseTime = -1;
try
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(downloadTimeout));
using var client = new HttpClient(new SocketsHttpHandler()
{
Proxy = webProxy,
UseProxy = webProxy != null
});
List<int> oneTime = new();
for (var i = 0; i < 2; i++)
{
var timer = Stopwatch.StartNew();
await client.GetAsync(url, cts.Token).ConfigureAwait(false);
timer.Stop();
oneTime.Add((int)timer.Elapsed.TotalMilliseconds);
await Task.Delay(100);
}
responseTime = oneTime.Where(x => x > 0).OrderBy(x => x).FirstOrDefault();
}
catch
{
}
return responseTime;
}
}
+28 -32
View File
@@ -7,27 +7,27 @@ public static class CoreConfigHandler
{
private static readonly string _tag = "CoreConfigHandler";
public static async Task<RetResult> GenerateClientConfig(ProfileItem node, string? fileName)
public static async Task<RetResult> GenerateClientConfig(CoreConfigContext context, string? fileName)
{
var config = AppManager.Instance.Config;
var result = new RetResult();
var node = context.Node;
if (node.ConfigType == EConfigType.Custom)
{
result = node.CoreType switch
{
ECoreType.mihomo => await new CoreConfigClashService(config).GenerateClientCustomConfig(node, fileName),
ECoreType.sing_box => await new CoreConfigSingboxService(config).GenerateClientCustomConfig(node, fileName),
_ => await GenerateClientCustomConfig(node, fileName)
};
}
else if (AppManager.Instance.GetCoreType(node, node.ConfigType) == ECoreType.sing_box)
else if (context.RunCoreType == ECoreType.sing_box)
{
result = await new CoreConfigSingboxService(config).GenerateClientConfigContent(node);
result = new CoreConfigSingboxService(context).GenerateClientConfigContent();
}
else
{
result = await new CoreConfigV2rayService(config).GenerateClientConfigContent(node);
result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
}
if (result.Success != true)
{
@@ -58,7 +58,7 @@ public static class CoreConfigHandler
File.Delete(fileName);
}
string addressFileName = node.Address;
var addressFileName = node.Address;
if (!File.Exists(addressFileName))
{
addressFileName = Utils.GetConfigPath(addressFileName);
@@ -93,13 +93,29 @@ public static class CoreConfigHandler
public static async Task<RetResult> GenerateClientSpeedtestConfig(Config config, string fileName, List<ServerTestItem> selecteds, ECoreType coreType)
{
var result = new RetResult();
var dummyNode = new ProfileItem
{
CoreType = coreType
};
var builderResult = await CoreConfigContextBuilder.Build(config, dummyNode);
var context = builderResult.Context;
foreach (var testItem in selecteds)
{
var node = testItem.Profile;
var (actNode, _) = await CoreConfigContextBuilder.ResolveNodeAsync(context, node, true);
if (node.IndexId == actNode.IndexId)
{
continue;
}
context.ServerTestItemMap[node.IndexId] = actNode.IndexId;
}
if (coreType == ECoreType.sing_box)
{
result = await new CoreConfigSingboxService(config).GenerateClientSpeedtestConfig(selecteds);
result = new CoreConfigSingboxService(context).GenerateClientSpeedtestConfig(selecteds);
}
else if (coreType == ECoreType.Xray)
{
result = await new CoreConfigV2rayService(config).GenerateClientSpeedtestConfig(selecteds);
result = new CoreConfigV2rayService(context).GenerateClientSpeedtestConfig(selecteds);
}
if (result.Success != true)
{
@@ -109,20 +125,20 @@ public static class CoreConfigHandler
return result;
}
public static async Task<RetResult> GenerateClientSpeedtestConfig(Config config, ProfileItem node, ServerTestItem testItem, string fileName)
public static async Task<RetResult> GenerateClientSpeedtestConfig(Config config, CoreConfigContext context, ServerTestItem testItem, string fileName)
{
var result = new RetResult();
var initPort = AppManager.Instance.GetLocalPort(EInboundProtocol.speedtest);
var port = Utils.GetFreePort(initPort + testItem.QueueNum);
testItem.Port = port;
if (AppManager.Instance.GetCoreType(node, node.ConfigType) == ECoreType.sing_box)
if (context.RunCoreType == ECoreType.sing_box)
{
result = await new CoreConfigSingboxService(config).GenerateClientSpeedtestConfig(node, port);
result = new CoreConfigSingboxService(context).GenerateClientSpeedtestConfig(port);
}
else
{
result = await new CoreConfigV2rayService(config).GenerateClientSpeedtestConfig(node, port);
result = new CoreConfigV2rayService(context).GenerateClientSpeedtestConfig(port);
}
if (result.Success != true)
{
@@ -132,24 +148,4 @@ public static class CoreConfigHandler
await File.WriteAllTextAsync(fileName, result.Data.ToString());
return result;
}
public static async Task<RetResult> GenerateClientMultipleLoadConfig(Config config, string fileName, List<ProfileItem> selecteds, ECoreType coreType, EMultipleLoad multipleLoad)
{
var result = new RetResult();
if (coreType == ECoreType.sing_box)
{
result = await new CoreConfigSingboxService(config).GenerateClientMultipleLoadConfig(selecteds);
}
else
{
result = await new CoreConfigV2rayService(config).GenerateClientMultipleLoadConfig(selecteds, multipleLoad);
}
if (result.Success != true)
{
return result;
}
await File.WriteAllTextAsync(fileName, result.Data.ToString());
return result;
}
}
+4 -4
View File
@@ -20,10 +20,10 @@ public class AnytlsFmt : BaseFmt
Port = parsedUrl.Port,
};
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
item.Id = rawUserInfo;
item.Password = rawUserInfo;
var query = Utils.ParseQueryString(parsedUrl.Query);
_ = ResolveStdTransport(query, ref item);
ResolveUriQuery(query, ref item);
return item;
}
@@ -39,9 +39,9 @@ public class AnytlsFmt : BaseFmt
{
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var pw = item.Id;
var pw = item.Password;
var dicQuery = new Dictionary<string, string>();
_ = GetStdTransport(item, Global.None, ref dicQuery);
ToUriQuery(item, Global.None, ref dicQuery);
return ToUri(EConfigType.Anytls, item.Address, item.Port, pw, dicQuery, remark);
}
+236 -97
View File
@@ -4,6 +4,10 @@ namespace ServiceLib.Handler.Fmt;
public class BaseFmt
{
private static readonly string[] _allowInsecureArray = new[] { "insecure", "allowInsecure", "allow_insecure" };
private static string UrlEncodeSafe(string? value) => Utils.UrlEncode(value ?? string.Empty);
protected static string GetIpv6(string address)
{
if (Utils.IsIpv6(address))
@@ -17,12 +21,9 @@ public class BaseFmt
}
}
protected static int GetStdTransport(ProfileItem item, string? securityDef, ref Dictionary<string, string> dicQuery)
protected static int ToUriQuery(ProfileItem item, string? securityDef, ref Dictionary<string, string> dicQuery)
{
if (item.Flow.IsNotEmpty())
{
dicQuery.Add("flow", item.Flow);
}
var transport = item.GetTransportExtra();
if (item.StreamSecurity.IsNotEmpty())
{
@@ -37,11 +38,7 @@ public class BaseFmt
}
if (item.Sni.IsNotEmpty())
{
dicQuery.Add("sni", item.Sni);
}
if (item.Alpn.IsNotEmpty())
{
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
dicQuery.Add("sni", Utils.UrlEncode(item.Sni));
}
if (item.Fingerprint.IsNotEmpty())
{
@@ -63,89 +60,120 @@ public class BaseFmt
{
dicQuery.Add("pqv", Utils.UrlEncode(item.Mldsa65Verify));
}
if (item.AllowInsecure.Equals("true"))
if (item.StreamSecurity.Equals(Global.StreamSecurity))
{
dicQuery.Add("allowInsecure", "1");
if (item.Alpn.IsNotEmpty())
{
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
}
ToUriQueryAllowInsecure(item, ref dicQuery);
}
if (item.EchConfigList.IsNotEmpty())
{
dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList));
}
if (item.CertSha.IsNotEmpty())
{
dicQuery.Add("pcs", Utils.UrlEncode(item.CertSha));
}
if (item.Finalmask.IsNotEmpty())
{
var node = JsonUtils.ParseJson(item.Finalmask);
var finalmask = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
: item.Finalmask;
dicQuery.Add("fm", Utils.UrlEncode(finalmask));
}
dicQuery.Add("type", item.Network.IsNotEmpty() ? item.Network : nameof(ETransport.tcp));
switch (item.Network)
var network = item.GetNetwork();
if (!Global.Networks.Contains(network))
{
case nameof(ETransport.tcp):
dicQuery.Add("headerType", item.HeaderType.IsNotEmpty() ? item.HeaderType : Global.None);
if (item.RequestHost.IsNotEmpty())
network = nameof(ETransport.raw);
}
//dicQuery.Add("type", network);
dicQuery.Add("type", network == nameof(ETransport.raw) ? Global.RawNetworkAlias : network);
switch (network)
{
case nameof(ETransport.raw):
dicQuery.Add("headerType", transport.RawHeaderType.IsNotEmpty() ? transport.RawHeaderType : Global.None);
if (transport.Host.IsNotEmpty())
{
dicQuery.Add("host", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("host", UrlEncodeSafe(transport.Host));
}
if (transport.Path.IsNotEmpty())
{
dicQuery.Add("path", UrlEncodeSafe(transport.Path));
}
break;
case nameof(ETransport.kcp):
dicQuery.Add("headerType", item.HeaderType.IsNotEmpty() ? item.HeaderType : Global.None);
if (item.Path.IsNotEmpty())
dicQuery.Add("headerType", transport.KcpHeaderType.IsNotEmpty() ? transport.KcpHeaderType : Global.None);
if (transport.KcpSeed.IsNotEmpty())
{
dicQuery.Add("seed", Utils.UrlEncode(item.Path));
dicQuery.Add("seed", UrlEncodeSafe(transport.KcpSeed));
}
if (transport.KcpMtu > 0)
{
dicQuery.Add("mtu", transport.KcpMtu.ToString());
}
break;
case nameof(ETransport.ws):
case nameof(ETransport.httpupgrade):
if (item.RequestHost.IsNotEmpty())
if (transport.Host.IsNotEmpty())
{
dicQuery.Add("host", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("host", UrlEncodeSafe(transport.Host));
}
if (item.Path.IsNotEmpty())
if (transport.Path.IsNotEmpty())
{
dicQuery.Add("path", Utils.UrlEncode(item.Path));
dicQuery.Add("path", UrlEncodeSafe(transport.Path));
}
break;
case nameof(ETransport.xhttp):
if (item.RequestHost.IsNotEmpty())
if (transport.Host.IsNotEmpty())
{
dicQuery.Add("host", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("host", UrlEncodeSafe(transport.Host));
}
if (item.Path.IsNotEmpty())
if (transport.Path.IsNotEmpty())
{
dicQuery.Add("path", Utils.UrlEncode(item.Path));
dicQuery.Add("path", UrlEncodeSafe(transport.Path));
}
if (item.HeaderType.IsNotEmpty() && Global.XhttpMode.Contains(item.HeaderType))
if (transport.XhttpMode.IsNotEmpty() && Global.XhttpMode.Contains(transport.XhttpMode))
{
dicQuery.Add("mode", Utils.UrlEncode(item.HeaderType));
dicQuery.Add("mode", UrlEncodeSafe(transport.XhttpMode));
}
if (item.Extra.IsNotEmpty())
if (transport.XhttpExtra.IsNotEmpty())
{
dicQuery.Add("extra", Utils.UrlEncode(item.Extra));
var node = JsonUtils.ParseJson(transport.XhttpExtra);
var extra = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
: transport.XhttpExtra;
dicQuery.Add("extra", UrlEncodeSafe(extra));
}
break;
case nameof(ETransport.http):
case nameof(ETransport.h2):
dicQuery["type"] = nameof(ETransport.http);
if (item.RequestHost.IsNotEmpty())
{
dicQuery.Add("host", Utils.UrlEncode(item.RequestHost));
}
if (item.Path.IsNotEmpty())
{
dicQuery.Add("path", Utils.UrlEncode(item.Path));
}
break;
case nameof(ETransport.quic):
dicQuery.Add("headerType", item.HeaderType.IsNotEmpty() ? item.HeaderType : Global.None);
dicQuery.Add("quicSecurity", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("key", Utils.UrlEncode(item.Path));
break;
case nameof(ETransport.grpc):
if (item.Path.IsNotEmpty())
if (transport.GrpcServiceName.IsNotEmpty())
{
dicQuery.Add("authority", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("serviceName", Utils.UrlEncode(item.Path));
if (item.HeaderType is Global.GrpcGunMode or Global.GrpcMultiMode)
dicQuery.Add("authority", UrlEncodeSafe(transport.GrpcAuthority));
dicQuery.Add("serviceName", UrlEncodeSafe(transport.GrpcServiceName));
if (transport.GrpcMode is Global.GrpcGunMode or Global.GrpcMultiMode)
{
dicQuery.Add("mode", Utils.UrlEncode(item.HeaderType));
dicQuery.Add("mode", UrlEncodeSafe(transport.GrpcMode));
}
}
break;
@@ -153,68 +181,169 @@ public class BaseFmt
return 0;
}
protected static int ResolveStdTransport(NameValueCollection query, ref ProfileItem item)
protected static int ToUriQueryLite(ProfileItem item, ref Dictionary<string, string> dicQuery)
{
item.Flow = query["flow"] ?? "";
item.StreamSecurity = query["security"] ?? "";
item.Sni = query["sni"] ?? "";
item.Alpn = Utils.UrlDecode(query["alpn"] ?? "");
item.Fingerprint = Utils.UrlDecode(query["fp"] ?? "");
item.PublicKey = Utils.UrlDecode(query["pbk"] ?? "");
item.ShortId = Utils.UrlDecode(query["sid"] ?? "");
item.SpiderX = Utils.UrlDecode(query["spx"] ?? "");
item.Mldsa65Verify = Utils.UrlDecode(query["pqv"] ?? "");
item.AllowInsecure = (query["allowInsecure"] ?? "") == "1" ? "true" : "";
if (item.Sni.IsNotEmpty())
{
dicQuery.Add("sni", Utils.UrlEncode(item.Sni));
}
if (item.Alpn.IsNotEmpty())
{
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
}
item.Network = query["type"] ?? nameof(ETransport.tcp);
ToUriQueryAllowInsecure(item, ref dicQuery);
return 0;
}
private static int ToUriQueryAllowInsecure(ProfileItem item, ref Dictionary<string, string> dicQuery)
{
if (item.AllowInsecure.Equals(Global.AllowInsecure.First()))
{
// Add two for compatibility
dicQuery.Add("insecure", "1");
dicQuery.Add("allowInsecure", "1");
}
else
{
dicQuery.Add("insecure", "0");
dicQuery.Add("allowInsecure", "0");
}
return 0;
}
protected static int ResolveUriQuery(NameValueCollection query, ref ProfileItem item)
{
var transport = item.GetTransportExtra();
item.StreamSecurity = GetQueryValue(query, "security");
item.Sni = GetQueryValue(query, "sni");
item.Alpn = GetQueryDecoded(query, "alpn");
item.Fingerprint = GetQueryDecoded(query, "fp");
item.PublicKey = GetQueryDecoded(query, "pbk");
item.ShortId = GetQueryDecoded(query, "sid");
item.SpiderX = GetQueryDecoded(query, "spx");
item.Mldsa65Verify = GetQueryDecoded(query, "pqv");
item.EchConfigList = GetQueryDecoded(query, "ech");
item.CertSha = GetQueryDecoded(query, "pcs");
var finalmaskDecoded = GetQueryDecoded(query, "fm");
if (finalmaskDecoded.IsNotEmpty())
{
var node = JsonUtils.ParseJson(finalmaskDecoded);
item.Finalmask = node != null
? JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
})
: finalmaskDecoded;
}
else
{
item.Finalmask = string.Empty;
}
if (_allowInsecureArray.Any(k => GetQueryDecoded(query, k) == "1"))
{
item.AllowInsecure = Global.AllowInsecure.First();
}
else if (_allowInsecureArray.Any(k => GetQueryDecoded(query, k) == "0"))
{
item.AllowInsecure = Global.AllowInsecure.Skip(1).First();
}
else
{
item.AllowInsecure = string.Empty;
}
var net = GetQueryValue(query, "type", nameof(ETransport.raw));
if (net == Global.RawNetworkAlias)
{
net = nameof(ETransport.raw);
}
if (!Global.Networks.Contains(net))
{
net = nameof(ETransport.raw);
}
item.Network = net;
switch (item.Network)
{
case nameof(ETransport.tcp):
item.HeaderType = query["headerType"] ?? Global.None;
item.RequestHost = Utils.UrlDecode(query["host"] ?? "");
case nameof(ETransport.raw):
transport = transport with
{
RawHeaderType = GetQueryValue(query, "headerType", Global.None),
Host = GetQueryDecoded(query, "host"),
Path = GetQueryDecoded(query, "path"),
};
break;
case nameof(ETransport.kcp):
item.HeaderType = query["headerType"] ?? Global.None;
item.Path = Utils.UrlDecode(query["seed"] ?? "");
var kcpSeed = GetQueryDecoded(query, "seed");
var kcpMtuStr = GetQueryValue(query, "mtu");
var kcpMtu = int.TryParse(kcpMtuStr, out var mtu) ? mtu : 0;
transport = transport with
{
KcpHeaderType = GetQueryValue(query, "headerType", Global.None),
KcpSeed = kcpSeed,
KcpMtu = kcpMtu > 0 ? mtu : null,
};
break;
case nameof(ETransport.ws):
case nameof(ETransport.httpupgrade):
item.RequestHost = Utils.UrlDecode(query["host"] ?? "");
item.Path = Utils.UrlDecode(query["path"] ?? "/");
transport = transport with
{
Host = GetQueryDecoded(query, "host"),
Path = GetQueryDecoded(query, "path", "/"),
};
break;
case nameof(ETransport.xhttp):
item.RequestHost = Utils.UrlDecode(query["host"] ?? "");
item.Path = Utils.UrlDecode(query["path"] ?? "/");
item.HeaderType = Utils.UrlDecode(query["mode"] ?? "");
item.Extra = Utils.UrlDecode(query["extra"] ?? "");
break;
var xhttpExtra = GetQueryDecoded(query, "extra");
if (xhttpExtra.IsNotEmpty())
{
var node = JsonUtils.ParseJson(xhttpExtra);
if (node != null)
{
xhttpExtra = JsonUtils.Serialize(node, new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
}
case nameof(ETransport.http):
case nameof(ETransport.h2):
item.Network = nameof(ETransport.h2);
item.RequestHost = Utils.UrlDecode(query["host"] ?? "");
item.Path = Utils.UrlDecode(query["path"] ?? "/");
break;
case nameof(ETransport.quic):
item.HeaderType = query["headerType"] ?? Global.None;
item.RequestHost = query["quicSecurity"] ?? Global.None;
item.Path = Utils.UrlDecode(query["key"] ?? "");
transport = transport with
{
Host = GetQueryDecoded(query, "host"),
Path = GetQueryDecoded(query, "path", "/"),
XhttpMode = GetQueryDecoded(query, "mode"),
XhttpExtra = xhttpExtra,
};
break;
case nameof(ETransport.grpc):
item.RequestHost = Utils.UrlDecode(query["authority"] ?? "");
item.Path = Utils.UrlDecode(query["serviceName"] ?? "");
item.HeaderType = Utils.UrlDecode(query["mode"] ?? Global.GrpcGunMode);
transport = transport with
{
GrpcAuthority = GetQueryDecoded(query, "authority"),
GrpcServiceName = GetQueryDecoded(query, "serviceName"),
GrpcMode = GetQueryDecoded(query, "mode", Global.GrpcGunMode),
};
break;
default:
item.Network = nameof(ETransport.raw);
break;
}
item.SetTransportExtra(transport);
return 0;
}
@@ -239,4 +368,14 @@ public class BaseFmt
var url = $"{Utils.UrlEncode(userInfo)}@{GetIpv6(address)}:{port}";
return $"{Global.ProtocolShares[eConfigType]}{url}{query}{remark}";
}
protected static string GetQueryValue(NameValueCollection query, string key, string defaultValue = "")
{
return query[key] ?? defaultValue;
}
protected static string GetQueryDecoded(NameValueCollection query, string key, string defaultValue = "")
{
return Utils.UrlDecode(GetQueryValue(query, key, defaultValue));
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ public class ClashFmt : BaseFmt
{
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
{
if (Contains(strData, "port", "socks-port", "proxies"))
if (Contains(strData, "rules", "-port", "proxies"))
{
var fileName = WriteAllText(strData, "yaml");
+9 -2
View File
@@ -19,6 +19,7 @@ public class FmtHandler
EConfigType.TUIC => TuicFmt.ToUri(item),
EConfigType.WireGuard => WireguardFmt.ToUri(item),
EConfigType.Anytls => AnytlsFmt.ToUri(item),
EConfigType.Naive => NaiveFmt.ToUri(item),
_ => null,
};
@@ -27,7 +28,7 @@ public class FmtHandler
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return "";
return string.Empty;
}
}
@@ -37,7 +38,7 @@ public class FmtHandler
try
{
string str = config.TrimEx();
var str = config.TrimEx();
if (str.IsNullOrEmpty())
{
msg = ResUI.FailedReadConfiguration;
@@ -80,6 +81,12 @@ public class FmtHandler
{
return AnytlsFmt.Resolve(str, out msg);
}
else if (str.StartsWith(Global.ProtocolShares[EConfigType.Naive])
|| str.StartsWith(Global.NaiveHttpsProtocolShare)
|| str.StartsWith(Global.NaiveQuicProtocolShare))
{
return NaiveFmt.Resolve(str, out msg);
}
else
{
msg = ResUI.NonvmessOrssProtocol;
+33 -37
View File
@@ -12,19 +12,26 @@ public class Hysteria2Fmt : BaseFmt
var url = Utils.TryUri(str);
if (url == null)
{
return null;
}
item.Address = url.IdnHost;
item.Port = url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Id = Utils.UrlDecode(url.UserInfo);
item.Password = Utils.UrlDecode(url.UserInfo);
var query = Utils.ParseQueryString(url.Query);
ResolveStdTransport(query, ref item);
item.Path = Utils.UrlDecode(query["obfs-password"] ?? "");
item.AllowInsecure = (query["insecure"] ?? "") == "1" ? "true" : "false";
item.Ports = Utils.UrlDecode(query["mport"] ?? "");
ResolveUriQuery(query, ref item);
if (item.CertSha.IsNullOrEmpty())
{
item.CertSha = GetQueryDecoded(query, "pinSHA256");
}
item.SetProtocolExtra(item.GetProtocolExtra() with
{
Ports = GetQueryDecoded(query, "mport"),
SalamanderPass = GetQueryDecoded(query, "obfs-password"),
});
return item;
}
@@ -32,53 +39,42 @@ public class Hysteria2Fmt : BaseFmt
public static string? ToUri(ProfileItem? item)
{
if (item == null)
{
return null;
string url = string.Empty;
}
string remark = string.Empty;
var url = string.Empty;
var remark = string.Empty;
if (item.Remarks.IsNotEmpty())
{
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var dicQuery = new Dictionary<string, string>();
if (item.Sni.IsNotEmpty())
{
dicQuery.Add("sni", item.Sni);
}
if (item.Alpn.IsNotEmpty())
{
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
}
if (item.Path.IsNotEmpty())
ToUriQueryLite(item, ref dicQuery);
var protocolExtraItem = item.GetProtocolExtra();
if (!protocolExtraItem.SalamanderPass.IsNullOrEmpty())
{
dicQuery.Add("obfs", "salamander");
dicQuery.Add("obfs-password", Utils.UrlEncode(item.Path));
dicQuery.Add("obfs-password", Utils.UrlEncode(protocolExtraItem.SalamanderPass));
}
dicQuery.Add("insecure", item.AllowInsecure.ToLower() == "true" ? "1" : "0");
if (item.Ports.IsNotEmpty())
if (!protocolExtraItem.Ports.IsNullOrEmpty())
{
dicQuery.Add("mport", Utils.UrlEncode(item.Ports.Replace(':', '-')));
dicQuery.Add("mport", Utils.UrlEncode(protocolExtraItem.Ports.Replace(':', '-')));
}
return ToUri(EConfigType.Hysteria2, item.Address, item.Port, item.Id, dicQuery, remark);
}
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
{
if (Contains(strData, "server", "up", "down", "listen", "<html>", "<body>"))
if (!item.CertSha.IsNullOrEmpty())
{
var fileName = WriteAllText(strData);
var profileItem = new ProfileItem
var sha = item.CertSha;
var idx = sha.IndexOf(',');
if (idx > 0)
{
CoreType = ECoreType.hysteria,
Address = fileName,
Remarks = subRemarks ?? "hysteria_custom"
};
return profileItem;
sha = sha[..idx];
}
dicQuery.Add("pinSHA256", Utils.UrlEncode(sha));
}
return null;
return ToUri(EConfigType.Hysteria2, item.Address, item.Port, item.Password, dicQuery, remark);
}
public static ProfileItem? ResolveFull2(string strData, string? subRemarks)
+302
View File
@@ -0,0 +1,302 @@
namespace ServiceLib.Handler.Fmt;
public class InnerFmt
{
private static readonly Lazy<string> SessionSalt = new(() => Utils.GetGuid(false));
public static List<ProfileItem>? Resolve(string strData, string subid)
{
var list = new List<ProfileItem>();
// Overwrite externally imported indexIds to avoid possible sources of attacks
var indexIdMap = new Dictionary<string, string>();
using (var reader = new StringReader(strData))
{
while (reader.ReadLine() is { } line)
{
if (line.IsNullOrEmpty())
{
continue;
}
var trimmedLine = line.Trim();
if (!trimmedLine.StartsWith(Global.InnerUriProtocol, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var profileItem = ResolveSingle(trimmedLine);
if (profileItem is null)
{
continue;
}
if (profileItem.ConfigType == EConfigType.Custom)
{
// Unsupported, also to avoid possible sources of attacks, skip it
continue;
}
// overwrite indexId
var newIndexId = Utils.GetGuid(false);
if (!profileItem.IndexId.IsNullOrEmpty())
{
// Ignore duplicated indexId
indexIdMap[profileItem.IndexId] = newIndexId;
}
profileItem.IndexId = newIndexId;
list.Add(profileItem);
}
}
// For group-type profile items, also overwrite the ChildItems and ChildSubId
var emptyGroupProfileList = new List<ProfileItem>();
foreach (var item in list.Where(i => i.ConfigType.IsGroupType()))
{
var protocolExtra = item.GetProtocolExtra();
// Only allow "self" as a special value for SubChildItems to avoid possible sources of attacks,
// which means it will be replaced with the subid, otherwise set it to null
//if (!protocolExtra.SubChildItems.IsNullOrEmpty())
if (protocolExtra.SubChildItems == "self")
{
protocolExtra = protocolExtra with
{
SubChildItems = subid
};
}
else
{
protocolExtra = protocolExtra with
{
SubChildItems = null
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
{
var newChildIndexIds = childIndexIds
.Select(id => indexIdMap.GetValueOrDefault(id, null))
.Where(id => !id.IsNullOrEmpty())
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
};
}
else
{
protocolExtra = protocolExtra with
{
ChildItems = null
};
}
item.SetProtocolExtra(protocolExtra);
if (protocolExtra.SubChildItems.IsNullOrEmpty()
&& protocolExtra.ChildItems.IsNullOrEmpty())
{
emptyGroupProfileList.Add(item);
}
}
// Remove empty group profile items
list.RemoveAll(emptyGroupProfileList.Contains);
return list;
}
public static string? ToUri(List<ProfileItem> items)
{
var sb = new StringBuilder();
foreach (var item in items)
{
if (item.ConfigType == EConfigType.Custom)
{
continue;
}
var itemClone = JsonUtils.DeepCopy(item);
if (itemClone is null)
{
continue;
}
// overwrite indexId
var originalIndexId = itemClone.IndexId;
var newIndexId = GetReproducibleExportId(originalIndexId);
itemClone.IndexId = newIndexId;
if (itemClone.ConfigType.IsGroupType())
{
var protocolExtra = itemClone.GetProtocolExtra();
if (!protocolExtra.SubChildItems.IsNullOrEmpty())
{
protocolExtra = protocolExtra with
{
SubChildItems = "self"
};
}
if (Utils.String2List(protocolExtra.ChildItems) is { Count: > 0 } childIndexIds)
{
var newChildIndexIds = childIndexIds
.Select(GetReproducibleExportId)
.Where(id => !id.IsNullOrEmpty())
.ToList();
protocolExtra = protocolExtra with
{
ChildItems = Utils.List2String(newChildIndexIds)
};
}
itemClone.SetProtocolExtra(protocolExtra);
}
var uri = ToUriSingle(itemClone);
if (!uri.IsNullOrEmpty())
{
sb.AppendLine(uri);
}
}
return sb.Length > 0 ? sb.ToString() : null;
}
private static ProfileItem? ResolveSingle(string str)
{
// format: v2rayn://vless/{url-safe base64 encoded_string}
var parsedUri = Utils.TryUri(str);
if (parsedUri is null)
{
return null;
}
var segment = parsedUri.AbsolutePath.TrimStart('/');
var decodedResult = Utils.Base64Decode(segment);
var jsonNode = JsonUtils.ParseJson(decodedResult);
if (jsonNode is not JsonObject jsonObj)
{
return null;
}
// flatten
// move jsonObj.ProtoExtraObj to jsonObj.ProtoExtra (string)
// move jsonObj.TransportExtraObj to jsonObj.TransportExtra (string)
if (jsonObj.TryGetPropertyValue("ProtoExtraObj", out var protoExtraNode)
&& protoExtraNode is JsonObject protoExtraObj)
{
jsonObj["ProtoExtra"] = JsonUtils.Serialize(protoExtraObj, false);
jsonObj.Remove("ProtoExtraObj");
}
if (jsonObj.TryGetPropertyValue("TransportExtraObj", out var transportExtraNode)
&& transportExtraNode is JsonObject transportExtraObj)
{
jsonObj["TransportExtra"] = JsonUtils.Serialize(transportExtraObj, false);
jsonObj.Remove("TransportExtraObj");
}
var profileItem = JsonUtils.Deserialize<ProfileItem>(JsonUtils.Serialize(jsonObj, false));
if (profileItem is null)
{
return null;
}
if (profileItem.ConfigVersion != 4)
{
return null;
}
// Check Enum.IsDefined
if (!Enum.IsDefined(typeof(EConfigType), profileItem.ConfigType))
{
return null;
}
if (profileItem.CoreType is not (null or ECoreType.Xray or ECoreType.sing_box))
{
return null;
}
var protocolExtra = profileItem.GetProtocolExtra();
var multipleLoad = protocolExtra.MultipleLoad;
if (multipleLoad is not null && !Enum.IsDefined(typeof(EMultipleLoad), multipleLoad))
{
return null;
}
return profileItem;
}
private static string? ToUriSingle(ProfileItem item)
{
var jsonNode = JsonUtils.ParseJson(JsonUtils.Serialize(item, false));
if (jsonNode is not JsonObject jsonObj)
{
return null;
}
// unflatten
// move jsonObj.ProtoExtra (string) to jsonObj.ProtoExtraObj
// move jsonObj.TransportExtra (string) to jsonObj.TransportExtraObj
if (jsonObj.TryGetPropertyValue("ProtoExtra", out var protoExtraNode)
&& protoExtraNode is JsonValue protoExtraValue
&& protoExtraValue.TryGetValue<string>(out var protoExtraStr)
&& !protoExtraStr.IsNullOrEmpty()
&& JsonUtils.ParseJson(protoExtraStr) is JsonObject protoExtraObj)
{
jsonObj["ProtoExtraObj"] = protoExtraObj;
jsonObj.Remove("ProtoExtra");
}
if (jsonObj.TryGetPropertyValue("TransportExtra", out var transportExtraNode)
&& transportExtraNode is JsonValue transportExtraValue
&& transportExtraValue.TryGetValue<string>(out var transportExtraStr)
&& !transportExtraStr.IsNullOrEmpty()
&& JsonUtils.ParseJson(transportExtraStr) is JsonObject transportExtraObj)
{
jsonObj["TransportExtraObj"] = transportExtraObj;
jsonObj.Remove("TransportExtra");
}
// remove subid and isSub
jsonObj.Remove("Subid");
jsonObj.Remove("IsSub");
// Remove empty properties to reduce the length of the exported string
RemoveEmptyJson(jsonObj);
var jsonStr = JsonUtils.Serialize(jsonObj, false);
var encodedStr = Utils.Base64Encode(jsonStr).Replace('+', '-').Replace('/', '_').Replace("=", "");
return $"{Global.InnerUriProtocol}{item.ConfigType.ToString().ToLower()}/{encodedStr}";
}
private static string GetReproducibleExportId(string originalIndexId)
{
if (originalIndexId.IsNullOrEmpty())
{
return originalIndexId;
}
var hash = HashCode.Combine(SessionSalt.Value, originalIndexId) & 0x7FFFFFFF;
var bytes = BitConverter.GetBytes(hash);
return Convert.ToBase64String(bytes).Replace("=", "");
}
private static void RemoveEmptyJson(JsonNode? node)
{
// ReSharper disable once ConvertIfStatementToSwitchStatement
if (node is JsonObject jsonObject)
{
var propertiesToRemove = new List<string>();
foreach (var property in jsonObject)
{
RemoveEmptyJson(property.Value);
if (IsEmpty(property.Value))
{
propertiesToRemove.Add(property.Key);
}
}
foreach (var key in propertiesToRemove)
{
jsonObject.Remove(key);
}
}
else if (node is JsonArray jsonArray)
{
for (var i = jsonArray.Count - 1; i >= 0; i--)
{
RemoveEmptyJson(jsonArray[i]);
if (IsEmpty(jsonArray[i]))
{
jsonArray.RemoveAt(i);
}
}
}
}
private static bool IsEmpty(JsonNode? node)
{
return node switch
{
null => true,
JsonValue value when value.TryGetValue<string>(out var str) => string.IsNullOrEmpty(str),
JsonObject obj => obj.Count == 0,
JsonArray arr => arr.Count == 0,
_ => false
};
}
}
+91
View File
@@ -0,0 +1,91 @@
namespace ServiceLib.Handler.Fmt;
public class NaiveFmt : BaseFmt
{
public static ProfileItem? Resolve(string str, out string msg)
{
msg = ResUI.ConfigurationFormatIncorrect;
var parsedUrl = Utils.TryUri(str);
if (parsedUrl == null)
{
return null;
}
ProfileItem item = new()
{
ConfigType = EConfigType.Naive,
Remarks = parsedUrl.GetComponents(UriComponents.Fragment, UriFormat.Unescaped),
Address = parsedUrl.IdnHost,
Port = parsedUrl.Port,
};
var protocolExtra = item.GetProtocolExtra();
if (parsedUrl.Scheme.Contains("quic"))
{
protocolExtra = protocolExtra with
{
NaiveQuic = true,
};
}
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
if (rawUserInfo.Contains(':'))
{
var split = rawUserInfo.Split(':', 2);
item.Username = split[0];
item.Password = split[1];
}
else
{
item.Password = rawUserInfo;
}
var query = Utils.ParseQueryString(parsedUrl.Query);
ResolveUriQuery(query, ref item);
var insecureConcurrency = int.TryParse(GetQueryValue(query, "insecure-concurrency"), out var ic) ? ic : 0;
if (insecureConcurrency > 0)
{
protocolExtra = protocolExtra with
{
InsecureConcurrency = insecureConcurrency,
};
}
item.SetProtocolExtra(protocolExtra);
return item;
}
public static string? ToUri(ProfileItem? item)
{
if (item == null)
{
return null;
}
var remark = string.Empty;
if (item.Remarks.IsNotEmpty())
{
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var userInfo = item.Username.IsNotEmpty() ? $"{Utils.UrlEncode(item.Username)}:{Utils.UrlEncode(item.Password)}" : Utils.UrlEncode(item.Password);
var dicQuery = new Dictionary<string, string>();
ToUriQuery(item, Global.None, ref dicQuery);
var protocolExtra = item.GetProtocolExtra();
if (protocolExtra.InsecureConcurrency > 0)
{
dicQuery.Add("insecure-concurrency", protocolExtra?.InsecureConcurrency.ToString());
}
var query = dicQuery.Count > 0
? ("?" + string.Join("&", dicQuery.Select(x => x.Key + "=" + x.Value).ToArray()))
: string.Empty;
var url = $"{userInfo}@{GetIpv6(item.Address)}:{item.Port}";
if (protocolExtra.NaiveQuic == true)
{
return $"{Global.NaiveQuicProtocolShare}{url}{query}{remark}";
}
else
{
return $"{Global.NaiveHttpsProtocolShare}{url}{query}{remark}";
}
}
}
@@ -1,22 +0,0 @@
namespace ServiceLib.Handler.Fmt;
public class NaiveproxyFmt : BaseFmt
{
public static ProfileItem? ResolveFull(string strData, string? subRemarks)
{
if (Contains(strData, "listen", "proxy", "<html>", "<body>"))
{
var fileName = WriteAllText(strData);
var profileItem = new ProfileItem
{
CoreType = ECoreType.naiveproxy,
Address = fileName,
Remarks = subRemarks ?? "naiveproxy_custom"
};
return profileItem;
}
return null;
}
}
+158 -24
View File
@@ -1,5 +1,3 @@
using System.Text.RegularExpressions;
namespace ServiceLib.Handler.Fmt;
public class ShadowsocksFmt : BaseFmt
@@ -14,7 +12,8 @@ public class ShadowsocksFmt : BaseFmt
{
return null;
}
if (item.Address.Length == 0 || item.Port == 0 || item.Security.Length == 0 || item.Id.Length == 0)
if (item.Address.Length == 0 || item.Port == 0 || item.GetProtocolExtra().SsMethod.IsNullOrEmpty() || item.Password.Length == 0)
{
return null;
}
@@ -42,8 +41,66 @@ public class ShadowsocksFmt : BaseFmt
// item.port);
//url = Utile.Base64Encode(url);
//new Sip002
var pw = Utils.Base64Encode($"{item.Security}:{item.Id}");
return ToUri(EConfigType.Shadowsocks, item.Address, item.Port, pw, null, remark);
var pw = Utils.Base64Encode($"{item.GetProtocolExtra().SsMethod}:{item.Password}", true);
var transport = item.GetTransportExtra();
// plugin
var plugin = string.Empty;
var pluginArgs = string.Empty;
if (item.Network == nameof(ETransport.raw) && transport.RawHeaderType == Global.RawHeaderHttp)
{
plugin = "obfs-local";
pluginArgs = $"obfs=http;obfs-host={transport.Host};";
}
else
{
if (item.Network == nameof(ETransport.ws))
{
pluginArgs += "mode=websocket;";
pluginArgs += $"host={transport.Host};";
// https://github.com/shadowsocks/v2ray-plugin/blob/e9af1cdd2549d528deb20a4ab8d61c5fbe51f306/args.go#L172
// Equal signs and commas [and backslashes] must be escaped with a backslash.
var path = (transport.Path ?? string.Empty).Replace("\\", "\\\\").Replace("=", "\\=").Replace(",", "\\,");
pluginArgs += $"path={path};";
}
if (item.StreamSecurity == Global.StreamSecurity)
{
pluginArgs += "tls;";
var certs = CertPemManager.ParsePemChain(item.Cert);
if (certs.Count > 0)
{
var cert = certs.First();
const string beginMarker = "-----BEGIN CERTIFICATE-----\n";
const string endMarker = "\n-----END CERTIFICATE-----";
var base64Content = cert.Replace(beginMarker, "").Replace(endMarker, "").Trim();
base64Content = base64Content.Replace("=", "\\=");
pluginArgs += $"certRaw={base64Content};";
}
}
if (pluginArgs.Length > 0)
{
plugin = "v2ray-plugin";
pluginArgs += "mux=0;";
}
}
var dicQuery = new Dictionary<string, string>();
if (plugin.IsNotEmpty())
{
var pluginStr = plugin + ";" + pluginArgs;
// pluginStr remove last ';' and url encode
if (pluginStr.EndsWith(';'))
{
pluginStr = pluginStr[..^1];
}
dicQuery["plugin"] = Utils.UrlEncode(pluginStr);
}
return ToUri(EConfigType.Shadowsocks, item.Address, item.Port, pw, dicQuery, remark);
}
private static readonly Regex UrlFinder = new(@"ss://(?<base64>[A-Za-z0-9+-/=_]+)(?:#(?<tag>\S+))?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
@@ -77,8 +134,8 @@ public class ShadowsocksFmt : BaseFmt
{
return null;
}
item.Security = details.Groups["method"].Value;
item.Id = details.Groups["password"].Value;
item.SetProtocolExtra(item.GetProtocolExtra() with { SsMethod = details.Groups["method"].Value });
item.Password = details.Groups["password"].Value;
item.Address = details.Groups["hostname"].Value;
item.Port = details.Groups["port"].Value.ToInt();
return item;
@@ -107,8 +164,8 @@ public class ShadowsocksFmt : BaseFmt
{
return null;
}
item.Security = userInfoParts.First();
item.Id = Utils.UrlDecode(userInfoParts.Last());
item.SetProtocolExtra(item.GetProtocolExtra() with { SsMethod = userInfoParts.First() });
item.Password = Utils.UrlDecode(userInfoParts.Last());
}
else
{
@@ -119,28 +176,105 @@ public class ShadowsocksFmt : BaseFmt
{
return null;
}
item.Security = userInfoParts.First();
item.Id = userInfoParts.Last();
item.SetProtocolExtra(item.GetProtocolExtra() with { SsMethod = userInfoParts.First() });
item.Password = userInfoParts.Last();
}
var queryParameters = Utils.ParseQueryString(parsedUrl.Query);
if (queryParameters["plugin"] != null)
{
//obfs-host exists
var obfsHost = queryParameters["plugin"]?.Split(';').FirstOrDefault(t => t.Contains("obfs-host"));
if (queryParameters["plugin"].Contains("obfs=http") && obfsHost.IsNotEmpty())
{
obfsHost = obfsHost?.Replace("obfs-host=", "");
item.Network = Global.DefaultNetwork;
item.HeaderType = Global.TcpHeaderHttp;
item.RequestHost = obfsHost ?? "";
}
else
var pluginStr = queryParameters["plugin"];
var pluginParts = pluginStr.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
if (pluginParts.Length == 0)
{
return null;
}
}
var pluginName = pluginParts[0];
// A typo in https://github.com/shadowsocks/shadowsocks-org/blob/6b1c064db4129de99c516294960e731934841c94/docs/doc/sip002.md?plain=1#L15
// "simple-obfs" should be "obfs-local"
if (pluginName == "simple-obfs")
{
pluginName = "obfs-local";
}
// Parse obfs-local plugin
if (pluginName == "obfs-local")
{
var obfsMode = pluginParts.FirstOrDefault(t => t.StartsWith("obfs="));
var obfsHost = pluginParts.FirstOrDefault(t => t.StartsWith("obfs-host="));
if ((!obfsMode.IsNullOrEmpty()) && obfsMode.Contains("obfs=http") && obfsHost.IsNotEmpty())
{
obfsHost = obfsHost.Replace("obfs-host=", "");
item.Network = Global.DefaultNetwork;
item.SetTransportExtra(item.GetTransportExtra() with
{
RawHeaderType = Global.RawHeaderHttp,
Host = obfsHost,
});
}
}
// Parse v2ray-plugin
else if (pluginName == "v2ray-plugin")
{
var mode = pluginParts.FirstOrDefault(t => t.StartsWith("mode="), "websocket");
var host = pluginParts.FirstOrDefault(t => t.StartsWith("host="));
var path = pluginParts.FirstOrDefault(t => t.StartsWith("path="));
var hasTls = pluginParts.Any(t => t == "tls");
var certRaw = pluginParts.FirstOrDefault(t => t.StartsWith("certRaw="));
var mux = pluginParts.FirstOrDefault(t => t.StartsWith("mux="));
var modeValue = mode.Replace("mode=", "");
if (modeValue == "websocket")
{
item.Network = nameof(ETransport.ws);
var t = item.GetTransportExtra();
if (!host.IsNullOrEmpty())
{
var wsHost = host.Replace("host=", "");
t = t with { Host = wsHost };
item.Sni = wsHost;
}
if (!path.IsNullOrEmpty())
{
var pathValue = path.Replace("path=", "");
pathValue = pathValue.Replace("\\=", "=").Replace("\\,", ",").Replace("\\\\", "\\");
t = t with { Path = pathValue };
}
item.SetTransportExtra(t);
}
if (hasTls)
{
item.StreamSecurity = Global.StreamSecurity;
if (!certRaw.IsNullOrEmpty())
{
var certBase64 = certRaw.Replace("certRaw=", "");
certBase64 = certBase64.Replace("\\=", "=");
const string beginMarker = "-----BEGIN CERTIFICATE-----\n";
const string endMarker = "\n-----END CERTIFICATE-----";
var certPem = beginMarker + certBase64 + endMarker;
item.Cert = certPem;
}
}
if (!mux.IsNullOrEmpty())
{
var muxValue = mux.Replace("mux=", "");
var muxCount = muxValue.ToInt();
if (muxCount > 0)
{
return null;
}
}
}
}
return item;
}
@@ -165,11 +299,11 @@ public class ShadowsocksFmt : BaseFmt
var ssItem = new ProfileItem()
{
Remarks = it.remarks,
Security = it.method,
Id = it.password,
Password = it.password,
Address = it.server,
Port = it.server_port.ToInt()
};
ssItem.SetProtocolExtra(new ProtocolExtraItem() { SsMethod = it.method });
lst.Add(ssItem);
}
return lst;
+8 -10
View File
@@ -33,7 +33,7 @@ public class SocksFmt : BaseFmt
remark = "#" + Utils.UrlEncode(item.Remarks);
}
//new
var pw = Utils.Base64Encode($"{item.Security}:{item.Id}");
var pw = Utils.Base64Encode($"{item.Username}:{item.Password}", true);
return ToUri(EConfigType.SOCKS, item.Address, item.Port, pw, null, remark);
}
@@ -45,18 +45,18 @@ public class SocksFmt : BaseFmt
};
result = result[Global.ProtocolShares[EConfigType.SOCKS].Length..];
//remark
var indexRemark = result.IndexOf("#");
var indexRemark = result.IndexOf('#');
if (indexRemark > 0)
{
try
{
item.Remarks = Utils.UrlDecode(result.Substring(indexRemark + 1, result.Length - indexRemark - 1));
item.Remarks = Utils.UrlDecode(result.Substring(indexRemark + 1));
}
catch { }
result = result[..indexRemark];
}
//part decode
var indexS = result.IndexOf("@");
var indexS = result.IndexOf('@');
if (indexS > 0)
{
}
@@ -78,9 +78,8 @@ public class SocksFmt : BaseFmt
}
item.Address = arr1[1][..indexPort];
item.Port = arr1[1][(indexPort + 1)..].ToInt();
item.Security = arr21.First();
item.Id = arr21[1];
item.Username = arr21.First();
item.Password = arr21[1];
return item;
}
@@ -98,15 +97,14 @@ public class SocksFmt : BaseFmt
Address = parsedUrl.IdnHost,
Port = parsedUrl.Port,
};
// parse base64 UserInfo
var rawUserInfo = Utils.UrlDecode(parsedUrl.UserInfo);
var userInfo = Utils.Base64Decode(rawUserInfo);
var userInfoParts = userInfo.Split([':'], 2);
if (userInfoParts.Length == 2)
{
item.Security = userInfoParts.First();
item.Id = userInfoParts[1];
item.Username = userInfoParts.First();
item.Password = userInfoParts[1];
}
return item;
+9 -4
View File
@@ -20,10 +20,11 @@ public class TrojanFmt : BaseFmt
item.Address = url.IdnHost;
item.Port = url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Id = Utils.UrlDecode(url.UserInfo);
item.Password = Utils.UrlDecode(url.UserInfo);
var query = Utils.ParseQueryString(url.Query);
_ = ResolveStdTransport(query, ref item);
item.SetProtocolExtra(item.GetProtocolExtra() with { Flow = GetQueryValue(query, "flow") });
ResolveUriQuery(query, ref item);
return item;
}
@@ -40,8 +41,12 @@ public class TrojanFmt : BaseFmt
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var dicQuery = new Dictionary<string, string>();
_ = GetStdTransport(item, null, ref dicQuery);
if (!item.GetProtocolExtra().Flow.IsNullOrEmpty())
{
dicQuery.Add("flow", item.GetProtocolExtra().Flow);
}
ToUriQuery(item, null, ref dicQuery);
return ToUri(EConfigType.Trojan, item.Address, item.Port, item.Id, dicQuery, remark);
return ToUri(EConfigType.Trojan, item.Address, item.Port, item.Password, dicQuery, remark);
}
}
+16 -15
View File
@@ -24,13 +24,16 @@ public class TuicFmt : BaseFmt
var userInfoParts = rawUserInfo.Split(new[] { ':' }, 2);
if (userInfoParts.Length == 2)
{
item.Id = userInfoParts.First();
item.Security = userInfoParts.Last();
item.Username = userInfoParts.First();
item.Password = userInfoParts.Last();
}
var query = Utils.ParseQueryString(url.Query);
ResolveStdTransport(query, ref item);
item.HeaderType = query["congestion_control"] ?? "";
ResolveUriQuery(query, ref item);
item.SetProtocolExtra(item.GetProtocolExtra() with
{
CongestionControl = GetQueryValue(query, "congestion_control")
});
return item;
}
@@ -47,17 +50,15 @@ public class TuicFmt : BaseFmt
{
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var dicQuery = new Dictionary<string, string>();
if (item.Sni.IsNotEmpty())
{
dicQuery.Add("sni", item.Sni);
}
if (item.Alpn.IsNotEmpty())
{
dicQuery.Add("alpn", Utils.UrlEncode(item.Alpn));
}
dicQuery.Add("congestion_control", item.HeaderType);
return ToUri(EConfigType.TUIC, item.Address, item.Port, $"{item.Id}:{item.Security}", dicQuery, remark);
var dicQuery = new Dictionary<string, string>();
ToUriQueryLite(item, ref dicQuery);
if (!item.GetProtocolExtra().CongestionControl.IsNullOrEmpty())
{
dicQuery.Add("congestion_control", item.GetProtocolExtra().CongestionControl);
}
return ToUri(EConfigType.TUIC, item.Address, item.Port, $"{item.Username ?? ""}:{item.Password}", dicQuery, remark);
}
}
+14 -13
View File
@@ -9,7 +9,6 @@ public class VLESSFmt : BaseFmt
ProfileItem item = new()
{
ConfigType = EConfigType.VLESS,
Security = Global.None
};
var url = Utils.TryUri(str);
@@ -21,12 +20,16 @@ public class VLESSFmt : BaseFmt
item.Address = url.IdnHost;
item.Port = url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Id = Utils.UrlDecode(url.UserInfo);
item.Password = Utils.UrlDecode(url.UserInfo);
var query = Utils.ParseQueryString(url.Query);
item.Security = query["encryption"] ?? Global.None;
item.StreamSecurity = query["security"] ?? "";
_ = ResolveStdTransport(query, ref item);
item.SetProtocolExtra(item.GetProtocolExtra() with
{
VlessEncryption = GetQueryValue(query, "encryption", Global.None),
Flow = GetQueryValue(query, "flow")
});
item.StreamSecurity = GetQueryValue(query, "security");
ResolveUriQuery(query, ref item);
return item;
}
@@ -44,16 +47,14 @@ public class VLESSFmt : BaseFmt
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var dicQuery = new Dictionary<string, string>();
if (item.Security.IsNotEmpty())
dicQuery.Add("encryption",
!item.GetProtocolExtra().VlessEncryption.IsNullOrEmpty() ? item.GetProtocolExtra().VlessEncryption : Global.None);
if (!item.GetProtocolExtra().Flow.IsNullOrEmpty())
{
dicQuery.Add("encryption", item.Security);
dicQuery.Add("flow", item.GetProtocolExtra().Flow);
}
else
{
dicQuery.Add("encryption", Global.None);
}
_ = GetStdTransport(item, Global.None, ref dicQuery);
ToUriQuery(item, Global.None, ref dicQuery);
return ToUri(EConfigType.VLESS, item.Address, item.Port, item.Id, dicQuery, remark);
return ToUri(EConfigType.VLESS, item.Address, item.Port, item.Password, dicQuery, remark);
}
}
+75 -24
View File
@@ -23,23 +23,50 @@ public class VmessFmt : BaseFmt
{
return null;
}
var vmessQRCode = new VmessQRCode
{
v = item.ConfigVersion,
// vmess link keeps shared transport keys; map from new transport model on export.
v = 2,
ps = item.Remarks.TrimEx(),
add = item.Address,
port = item.Port,
id = item.Id,
aid = item.AlterId,
scy = item.Security,
net = item.Network,
type = item.HeaderType,
host = item.RequestHost,
path = item.Path,
id = item.Password,
aid = int.TryParse(item.GetProtocolExtra()?.AlterId, out var result) ? result : 0,
scy = item.GetProtocolExtra().VmessSecurity ?? "",
net = item.GetNetwork() == nameof(ETransport.raw) ? Global.RawNetworkAlias : item.Network,
type = item.GetNetwork() switch
{
nameof(ETransport.raw) => item.GetTransportExtra().RawHeaderType,
nameof(ETransport.kcp) => item.GetTransportExtra().KcpHeaderType,
nameof(ETransport.xhttp) => item.GetTransportExtra().XhttpMode,
nameof(ETransport.grpc) => item.GetTransportExtra().GrpcMode,
_ => Global.None,
},
host = item.GetNetwork() switch
{
nameof(ETransport.raw) => item.GetTransportExtra().Host,
nameof(ETransport.ws) => item.GetTransportExtra().Host,
nameof(ETransport.httpupgrade) => item.GetTransportExtra().Host,
nameof(ETransport.xhttp) => item.GetTransportExtra().Host,
nameof(ETransport.grpc) => item.GetTransportExtra().GrpcAuthority,
_ => null,
},
path = item.GetNetwork() switch
{
nameof(ETransport.raw) => item.GetTransportExtra().Path,
nameof(ETransport.kcp) => item.GetTransportExtra().KcpSeed,
nameof(ETransport.ws) => item.GetTransportExtra().Path,
nameof(ETransport.httpupgrade) => item.GetTransportExtra().Path,
nameof(ETransport.xhttp) => item.GetTransportExtra().Path,
nameof(ETransport.grpc) => item.GetTransportExtra().GrpcServiceName,
_ => null,
},
tls = item.StreamSecurity,
sni = item.Sni,
alpn = item.Alpn,
fp = item.Fingerprint
fp = item.Fingerprint,
insecure = item.AllowInsecure.Equals(Global.AllowInsecure.First()) ? "1" : "0"
};
var url = JsonUtils.Serialize(vmessQRCode);
@@ -68,32 +95,52 @@ public class VmessFmt : BaseFmt
}
item.Network = Global.DefaultNetwork;
item.HeaderType = Global.None;
var transport = new TransportExtraItem
{
RawHeaderType = Global.None,
};
item.ConfigVersion = vmessQRCode.v;
//item.ConfigVersion = vmessQRCode.v;
item.Remarks = Utils.ToString(vmessQRCode.ps);
item.Address = Utils.ToString(vmessQRCode.add);
item.Port = vmessQRCode.port;
item.Id = Utils.ToString(vmessQRCode.id);
item.AlterId = vmessQRCode.aid;
item.Security = Utils.ToString(vmessQRCode.scy);
item.Security = vmessQRCode.scy.IsNotEmpty() ? vmessQRCode.scy : Global.DefaultSecurity;
item.Password = Utils.ToString(vmessQRCode.id);
item.SetProtocolExtra(new ProtocolExtraItem
{
AlterId = vmessQRCode.aid.ToString(),
VmessSecurity = vmessQRCode.scy.IsNullOrEmpty() ? Global.DefaultSecurity : vmessQRCode.scy,
});
if (vmessQRCode.net.IsNotEmpty())
{
item.Network = vmessQRCode.net;
item.Network = vmessQRCode.net == Global.RawNetworkAlias ? nameof(ETransport.raw) : vmessQRCode.net;
}
if (vmessQRCode.type.IsNotEmpty())
{
item.HeaderType = vmessQRCode.type;
transport = item.GetNetwork() switch
{
nameof(ETransport.raw) => transport with { RawHeaderType = vmessQRCode.type },
nameof(ETransport.kcp) => transport with { KcpHeaderType = vmessQRCode.type },
nameof(ETransport.xhttp) => transport with { XhttpMode = vmessQRCode.type },
nameof(ETransport.grpc) => transport with { GrpcMode = vmessQRCode.type },
_ => transport,
};
}
item.RequestHost = Utils.ToString(vmessQRCode.host);
item.Path = Utils.ToString(vmessQRCode.path);
transport = item.GetNetwork() switch
{
nameof(ETransport.raw) => transport with { Host = Utils.ToString(vmessQRCode.host), Path = Utils.ToString(vmessQRCode.path) },
nameof(ETransport.kcp) => transport with { KcpSeed = Utils.ToString(vmessQRCode.path) },
nameof(ETransport.ws) => transport with { Host = Utils.ToString(vmessQRCode.host), Path = Utils.ToString(vmessQRCode.path) },
nameof(ETransport.httpupgrade) => transport with { Host = Utils.ToString(vmessQRCode.host), Path = Utils.ToString(vmessQRCode.path) },
nameof(ETransport.xhttp) => transport with { Host = Utils.ToString(vmessQRCode.host), Path = Utils.ToString(vmessQRCode.path) },
nameof(ETransport.grpc) => transport with { GrpcAuthority = Utils.ToString(vmessQRCode.host), GrpcServiceName = Utils.ToString(vmessQRCode.path) },
_ => transport,
};
item.SetTransportExtra(transport);
item.StreamSecurity = Utils.ToString(vmessQRCode.tls);
item.Sni = Utils.ToString(vmessQRCode.sni);
item.Alpn = Utils.ToString(vmessQRCode.alpn);
item.Fingerprint = Utils.ToString(vmessQRCode.fp);
item.AllowInsecure = vmessQRCode.insecure == "1" ? Global.AllowInsecure.First() : string.Empty;
return item;
}
@@ -103,7 +150,6 @@ public class VmessFmt : BaseFmt
var item = new ProfileItem
{
ConfigType = EConfigType.VMess,
Security = "auto"
};
var url = Utils.TryUri(str);
@@ -115,10 +161,15 @@ public class VmessFmt : BaseFmt
item.Address = url.IdnHost;
item.Port = url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Id = Utils.UrlDecode(url.UserInfo);
item.Password = Utils.UrlDecode(url.UserInfo);
item.SetProtocolExtra(new ProtocolExtraItem
{
VmessSecurity = Global.DefaultSecurity,
});
var query = Utils.ParseQueryString(url.Query);
ResolveStdTransport(query, ref item);
ResolveUriQuery(query, ref item);
return item;
}
+178 -14
View File
@@ -20,14 +20,18 @@ public class WireguardFmt : BaseFmt
item.Address = url.IdnHost;
item.Port = url.Port;
item.Remarks = url.GetComponents(UriComponents.Fragment, UriFormat.Unescaped);
item.Id = Utils.UrlDecode(url.UserInfo);
item.Password = Utils.UrlDecode(url.UserInfo);
var query = Utils.ParseQueryString(url.Query);
item.PublicKey = Utils.UrlDecode(query["publickey"] ?? "");
item.Path = Utils.UrlDecode(query["reserved"] ?? "");
item.RequestHost = Utils.UrlDecode(query["address"] ?? "");
item.ShortId = Utils.UrlDecode(query["mtu"] ?? "");
item.SetProtocolExtra(item.GetProtocolExtra() with
{
WgPublicKey = GetQueryDecoded(query, "publickey"),
WgPresharedKey = GetQueryDecoded(query, "presharedkey"),
WgReserved = GetQueryDecoded(query, "reserved"),
WgInterfaceAddress = GetQueryDecoded(query, "address"),
WgMtu = int.TryParse(GetQueryDecoded(query, "mtu"), out var mtuVal) ? mtuVal : null,
});
return item;
}
@@ -45,23 +49,183 @@ public class WireguardFmt : BaseFmt
remark = "#" + Utils.UrlEncode(item.Remarks);
}
var protoExtra = item.GetProtocolExtra();
var dicQuery = new Dictionary<string, string>();
if (item.PublicKey.IsNotEmpty())
if (!protoExtra.WgPublicKey.IsNullOrEmpty())
{
dicQuery.Add("publickey", Utils.UrlEncode(item.PublicKey));
dicQuery.Add("publickey", Utils.UrlEncode(protoExtra.WgPublicKey));
}
if (item.Path.IsNotEmpty())
if (!protoExtra.WgPresharedKey.IsNullOrEmpty())
{
dicQuery.Add("reserved", Utils.UrlEncode(item.Path));
dicQuery.Add("presharedkey", Utils.UrlEncode(protoExtra.WgPresharedKey));
}
if (item.RequestHost.IsNotEmpty())
if (!protoExtra.WgReserved.IsNullOrEmpty())
{
dicQuery.Add("address", Utils.UrlEncode(item.RequestHost));
dicQuery.Add("reserved", Utils.UrlEncode(protoExtra.WgReserved));
}
if (item.ShortId.IsNotEmpty())
if (!protoExtra.WgInterfaceAddress.IsNullOrEmpty())
{
dicQuery.Add("mtu", Utils.UrlEncode(item.ShortId));
dicQuery.Add("address", Utils.UrlEncode(protoExtra.WgInterfaceAddress));
}
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Id, dicQuery, remark);
if (protoExtra.WgMtu > 0)
{
dicQuery.Add("mtu", protoExtra.WgMtu.ToString());
}
return ToUri(EConfigType.WireGuard, item.Address, item.Port, item.Password, dicQuery, remark);
}
public static List<ProfileItem>? ResolveConfig(string strData)
{
var interfaceDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var peerDicList = new List<Dictionary<string, string>>();
var currentDicRef = interfaceDic;
using (var reader = new StringReader(strData))
{
while (reader.ReadLine() is { } line)
{
if (line.IsNullOrEmpty())
{
continue;
}
var trimmedLine = line.Trim();
if (trimmedLine.Equals("[Interface]", StringComparison.OrdinalIgnoreCase))
{
currentDicRef = interfaceDic;
continue;
}
if (trimmedLine.Equals("[Peer]", StringComparison.OrdinalIgnoreCase))
{
var peerDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
peerDicList.Add(peerDic);
currentDicRef = peerDic;
continue;
}
if (trimmedLine.StartsWith('[') || trimmedLine.StartsWith('#') || trimmedLine.StartsWith(';'))
{
continue;
}
var idx = line.IndexOf('=');
if (idx <= 0)
{
continue;
}
var key = line[..idx].Trim();
var value = line[(idx + 1)..].Trim();
var commentPos = value.IndexOfAny(['#', ';']);
if (commentPos >= 0)
{
value = value[..commentPos].TrimEnd();
}
currentDicRef[key] = value;
}
}
if (!interfaceDic.TryGetValue("PrivateKey", out var privateKey) || privateKey.IsNullOrEmpty())
{
return null;
}
var wgMtu = interfaceDic.TryGetValue("MTU", out var mtuStr) && int.TryParse(mtuStr, out var mtuVal) ? mtuVal : 0;
var wgInterfaceAddress = interfaceDic.TryGetValue("Address", out var interfaceAddress) ? interfaceAddress : string.Empty;
var index = 0;
var resultList = new List<ProfileItem>();
foreach (var peerDic in peerDicList)
{
if (!peerDic.TryGetValue("Endpoint", out var endpoint) || endpoint.IsNullOrEmpty())
{
continue;
}
if (!TryParseEndpoint(endpoint, out var peerAddress, out var peerPort))
{
continue;
}
var protoExtra = new ProtocolExtraItem
{
WgPublicKey = (peerDic.TryGetValue("PublicKey", out var publicKey) ? publicKey : string.Empty).NullIfEmpty(),
WgPresharedKey = (peerDic.TryGetValue("PresharedKey", out var presharedKey) ? presharedKey : string.Empty).NullIfEmpty(),
WgInterfaceAddress = wgInterfaceAddress,
WgReserved = (peerDic.TryGetValue("Reserved", out var reserved) ? reserved : string.Empty).NullIfEmpty(),
WgMtu = wgMtu > 0 ? wgMtu : null,
};
var item = new ProfileItem
{
Remarks = $"{nameof(EConfigType.WireGuard)} Peer {index + 1}",
ConfigType = EConfigType.WireGuard,
Address = peerAddress,
Port = peerPort,
Password = privateKey,
};
item.SetProtocolExtra(protoExtra);
resultList.Add(item);
index += 1;
}
return resultList;
}
private static bool TryParseEndpoint(string endpoint, out string address, out int port)
{
address = string.Empty;
port = 2408;
var trimmedEndpoint = endpoint.Trim();
if (trimmedEndpoint.IsNullOrEmpty())
{
return false;
}
if (trimmedEndpoint[0] == '[')
{
var closeIndex = trimmedEndpoint.IndexOf(']');
if (closeIndex <= 1)
{
return false;
}
address = trimmedEndpoint[1..closeIndex].Trim();
var portIndex = closeIndex + 1;
if (portIndex < trimmedEndpoint.Length && trimmedEndpoint[portIndex] == ':' &&
int.TryParse(trimmedEndpoint[(portIndex + 1)..].Trim(), out var bracketedPort) && bracketedPort is > 0 and <= 65535)
{
port = bracketedPort;
}
return address.IsNotEmpty();
}
var lastColonIndex = trimmedEndpoint.LastIndexOf(':');
if (lastColonIndex <= 0)
{
address = trimmedEndpoint;
return true;
}
address = trimmedEndpoint[..lastColonIndex].Trim();
var portText = trimmedEndpoint[(lastColonIndex + 1)..].Trim();
if (address.IsNullOrEmpty())
{
return false;
}
if (int.TryParse(portText, out var parsedPortValue) && parsedPortValue is > 0 and <= 65535)
{
port = parsedPortValue;
return true;
}
address = trimmedEndpoint;
return true;
}
}
@@ -18,7 +18,13 @@ public static class ProxySettingLinux
private static async Task ExecCmd(List<string> args)
{
var fileName = await FileManager.CreateLinuxShellFile(_proxySetFileName, EmbedUtils.GetEmbedText(Global.ProxySetLinuxShellFileName), false);
var customSystemProxyScriptPath = AppManager.Instance.Config.SystemProxyItem?.CustomSystemProxyScriptPath;
var fileName = (customSystemProxyScriptPath.IsNotEmpty() && File.Exists(customSystemProxyScriptPath))
? customSystemProxyScriptPath
: await FileUtils.CreateLinuxShellFile(_proxySetFileName, EmbedUtils.GetEmbedText(Global.ProxySetLinuxShellFileName), false);
// TODO: temporarily notify which script is being used
NoticeManager.Instance.SendMessage(fileName);
await Utils.GetCliWrapOutput(fileName, args);
}
@@ -23,7 +23,13 @@ public static class ProxySettingOSX
private static async Task ExecCmd(List<string> args)
{
var fileName = await FileManager.CreateLinuxShellFile(_proxySetFileName, EmbedUtils.GetEmbedText(Global.ProxySetOSXShellFileName), false);
var customSystemProxyScriptPath = AppManager.Instance.Config.SystemProxyItem?.CustomSystemProxyScriptPath;
var fileName = (customSystemProxyScriptPath.IsNotEmpty() && File.Exists(customSystemProxyScriptPath))
? customSystemProxyScriptPath
: await FileUtils.CreateLinuxShellFile(_proxySetFileName, EmbedUtils.GetEmbedText(Global.ProxySetOSXShellFileName), false);
// TODO: temporarily notify which script is being used
NoticeManager.Instance.SendMessage(fileName);
await Utils.GetCliWrapOutput(fileName, args);
}
@@ -1,4 +1,3 @@
using System.Runtime.InteropServices;
using static ServiceLib.Handler.SysProxy.ProxySettingWindows.InternetConnectionOption;
namespace ServiceLib.Handler.SysProxy;
@@ -33,7 +33,7 @@ public static class SysProxyHandler
await ProxySettingLinux.SetProxy(Global.Loopback, port, exceptions);
break;
case ESysProxyType.ForcedChange when Utils.IsOSX():
case ESysProxyType.ForcedChange when Utils.IsMacOS():
await ProxySettingOSX.SetProxy(Global.Loopback, port, exceptions);
break;
@@ -45,7 +45,7 @@ public static class SysProxyHandler
await ProxySettingLinux.UnsetProxy();
break;
case ESysProxyType.ForcedClear when Utils.IsOSX():
case ESysProxyType.ForcedClear when Utils.IsMacOS():
await ProxySettingOSX.UnsetProxy();
break;
@@ -91,7 +91,7 @@ public static class SysProxyHandler
private static async Task SetWindowsProxyPac(int port)
{
var portPac = AppManager.Instance.GetLocalPort(EInboundProtocol.pac);
await PacManager.Instance.StartAsync(Utils.GetConfigPath(), port, portPac);
await PacManager.Instance.StartAsync(port, portPac);
var strProxy = $"{Global.HttpProtocol}{Global.Loopback}:{portPac}/pac?t={DateTime.Now.Ticks}";
ProxySettingWindows.SetProxy(strProxy, "", 4);
}
+25 -20
View File
@@ -1,4 +1,3 @@
using System.Net;
using Downloader;
namespace ServiceLib.Helper;
@@ -25,13 +24,13 @@ public class DownloaderHelper
var downloadOpt = new DownloadConfiguration()
{
Timeout = timeout * 1000,
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration =
{
Headers = headers,
UserAgent = userAgent,
Timeout = timeout * 1000,
ConnectTimeout = timeout * 1000,
Proxy = webProxy
}
};
@@ -63,37 +62,34 @@ public class DownloaderHelper
var downloadOpt = new DownloadConfiguration()
{
Timeout = timeout * 1000,
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration =
{
Timeout= timeout * 1000,
ConnectTimeout= timeout * 1000,
Proxy = webProxy
}
};
var totalDatetime = DateTime.Now;
var totalSecond = 0;
var lastUpdateTime = DateTime.Now;
var hasValue = false;
double maxSpeed = 0;
await using var downloader = new Downloader.DownloadService(downloadOpt);
//downloader.DownloadStarted += (sender, value) =>
//{
// if (progress != null)
// {
// progress.Report("Start download data...");
// }
//};
downloader.DownloadProgressChanged += (sender, value) =>
{
var ts = DateTime.Now - totalDatetime;
if (progress != null && ts.Seconds > totalSecond)
if (progress != null && value.BytesPerSecondSpeed > 0)
{
hasValue = true;
totalSecond = ts.Seconds;
if (value.BytesPerSecondSpeed > maxSpeed)
{
maxSpeed = value.BytesPerSecondSpeed;
}
var ts = DateTime.Now - lastUpdateTime;
if (ts.TotalMilliseconds >= 1000)
{
lastUpdateTime = DateTime.Now;
var speed = (maxSpeed / 1000 / 1000).ToString("#0.0");
progress.Report(speed);
}
@@ -103,10 +99,19 @@ public class DownloaderHelper
{
if (progress != null)
{
if (!hasValue && value.Error != null)
if (hasValue && maxSpeed > 0)
{
var finalSpeed = (maxSpeed / 1000 / 1000).ToString("#0.0");
progress.Report(finalSpeed);
}
else if (value.Error != null)
{
progress.Report(value.Error?.Message);
}
else
{
progress.Report("0");
}
}
};
//progress.Report("......");
@@ -134,11 +139,11 @@ public class DownloaderHelper
var downloadOpt = new DownloadConfiguration()
{
Timeout = timeout * 1000,
BlockTimeout = timeout * 1000,
MaxTryAgainOnFailure = 2,
RequestConfiguration =
{
Timeout= timeout * 1000,
ConnectTimeout= timeout * 1000,
Proxy = webProxy
}
};
@@ -1,8 +1,5 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
namespace ServiceLib.Helper;
@@ -52,15 +49,6 @@ public class HttpClientHelper
return await httpClient.GetStringAsync(url);
}
public async Task<string?> GetAsync(HttpClient client, string url, CancellationToken token = default)
{
if (url.IsNullOrEmpty())
{
return null;
}
return await client.GetStringAsync(url, token);
}
public async Task PutAsync(string url, Dictionary<string, string> headers)
{
var jsonContent = JsonUtils.Serialize(headers);
@@ -83,156 +71,4 @@ public class HttpClientHelper
{
await httpClient.DeleteAsync(url);
}
public static async Task DownloadFileAsync(HttpClient client, string url, string fileName, IProgress<double>? progress, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(url);
ArgumentNullException.ThrowIfNull(fileName);
if (File.Exists(fileName))
{
File.Delete(fileName);
}
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode)
{
throw new Exception(response.StatusCode.ToString());
}
var total = response.Content.Headers.ContentLength ?? -1L;
var canReportProgress = total != -1 && progress != null;
await using var stream = await response.Content.ReadAsStreamAsync(token);
await using var file = File.Create(fileName);
var totalRead = 0L;
var buffer = new byte[1024 * 1024];
var progressPercentage = 0;
while (true)
{
token.ThrowIfCancellationRequested();
var read = await stream.ReadAsync(buffer, token);
totalRead += read;
if (read == 0)
{
break;
}
await file.WriteAsync(buffer.AsMemory(0, read), token);
if (canReportProgress)
{
var percent = (int)(100.0 * totalRead / total);
//if (progressPercentage != percent && percent % 10 == 0)
{
progressPercentage = percent;
progress?.Report(percent);
}
}
}
if (canReportProgress)
{
progress?.Report(101);
}
}
public async Task DownloadDataAsync4Speed(HttpClient client, string url, IProgress<string> progress, CancellationToken token = default)
{
if (url.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(url));
}
var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
if (!response.IsSuccessStatusCode)
{
throw new Exception(response.StatusCode.ToString());
}
//var total = response.Content.Headers.ContentLength.HasValue ? response.Content.Headers.ContentLength.Value : -1L;
//var canReportProgress = total != -1 && progress != null;
await using var stream = await response.Content.ReadAsStreamAsync(token);
var totalRead = 0L;
var buffer = new byte[1024 * 64];
var isMoreToRead = true;
var progressSpeed = string.Empty;
var totalDatetime = DateTime.Now;
var totalSecond = 0;
do
{
if (token.IsCancellationRequested)
{
if (totalRead > 0)
{
return;
}
else
{
token.ThrowIfCancellationRequested();
}
}
var read = await stream.ReadAsync(buffer, token);
if (read == 0)
{
isMoreToRead = false;
}
else
{
var data = new byte[read];
buffer.ToList().CopyTo(0, data, 0, read);
totalRead += read;
var ts = DateTime.Now - totalDatetime;
if (progress != null && ts.Seconds > totalSecond)
{
totalSecond = ts.Seconds;
var speed = (totalRead * 1d / ts.TotalMilliseconds / 1000).ToString("#0.0");
if (progressSpeed != speed)
{
progressSpeed = speed;
progress.Report(speed);
}
}
}
} while (isMoreToRead);
}
public async Task<int> GetRealPingTime(string url, IWebProxy? webProxy, int downloadTimeout)
{
var responseTime = -1;
try
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(downloadTimeout));
using var client = new HttpClient(new SocketsHttpHandler()
{
Proxy = webProxy,
UseProxy = webProxy != null
});
List<int> oneTime = new();
for (var i = 0; i < 2; i++)
{
var timer = Stopwatch.StartNew();
await client.GetAsync(url, cts.Token).ConfigureAwait(false);
timer.Stop();
oneTime.Add((int)timer.Elapsed.TotalMilliseconds);
await Task.Delay(100);
}
responseTime = oneTime.Where(x => x > 0).OrderBy(x => x).FirstOrDefault();
}
catch //(Exception ex)
{
//Utile.SaveLog(ex.Message, ex);
}
return responseTime;
}
}
-1
View File
@@ -1,5 +1,4 @@
using System.Collections;
using SQLite;
namespace ServiceLib.Helper;
+398 -23
View File
@@ -1,5 +1,3 @@
using System.Reactive;
namespace ServiceLib.Manager;
public sealed class AppManager
@@ -10,7 +8,6 @@ public sealed class AppManager
private Config _config;
private int? _statePort;
private int? _statePort2;
private Job? _processJob;
public static AppManager Instance => _instance.Value;
public Config Config => _config;
@@ -34,6 +31,23 @@ public sealed class AppManager
public string LinuxSudoPwd { get; set; }
public bool ShowInTaskbar { get; set; }
public ECoreType RunningCoreType { get; set; }
public bool IsRunningCore(ECoreType type)
{
switch (type)
{
case ECoreType.Xray when RunningCoreType is ECoreType.Xray or ECoreType.v2fly or ECoreType.v2fly_v5:
case ECoreType.sing_box when RunningCoreType is ECoreType.sing_box or ECoreType.mihomo:
return true;
default:
return false;
}
}
#endregion Property
#region App
@@ -67,6 +81,9 @@ public sealed class AppManager
SQLiteHelper.Instance.CreateTable<ProfileExItem>();
SQLiteHelper.Instance.CreateTable<DNSItem>();
SQLiteHelper.Instance.CreateTable<FullConfigTemplateItem>();
#pragma warning disable CS0618
SQLiteHelper.Instance.CreateTable<ProfileGroupItem>();
#pragma warning restore CS0618
return true;
}
@@ -79,6 +96,11 @@ public sealed class AppManager
_ = StatePort;
_ = StatePort2;
Task.Run(async () =>
{
await MigrateProfileExtra();
}).Wait();
return true;
}
@@ -96,7 +118,7 @@ public sealed class AppManager
Logging.SaveLog("AppExitAsync Begin");
await SysProxyHandler.UpdateSysProxy(_config, true);
AppEvents.AppExitRequested.OnNext(Unit.Default);
AppEvents.AppExitRequested.Publish();
await Task.Delay(50); //Wait for AppExitRequested to be processed
await ConfigHandler.SaveConfig(_config);
@@ -119,7 +141,13 @@ public sealed class AppManager
public void Shutdown(bool byUser)
{
AppEvents.ShutdownRequested.OnNext(byUser);
AppEvents.ShutdownRequested.Publish(byUser);
}
public async Task RebootAsAdmin()
{
ProcUtils.RebootAsAdmin();
await AppManager.Instance.AppExitAsync(true);
}
#endregion App
@@ -132,21 +160,6 @@ public sealed class AppManager
return localPort + (int)protocol;
}
public void AddProcess(nint processHandle)
{
if (Utils.IsWindows())
{
_processJob ??= new();
try
{
_processJob?.AddProcess(processHandle);
}
catch
{
}
}
}
#endregion Config
#region SqliteHelper
@@ -178,10 +191,17 @@ public sealed class AppManager
return (await ProfileItems(subid))?.Select(t => t.IndexId)?.ToList();
}
public async Task<List<ProfileItemModel>?> ProfileItems(string subid, string filter)
public async Task<List<ProfileItemModel>?> ProfileModels(string subid, string filter)
{
var sql = @$"select a.*
,b.remarks subRemarks
var sql = @$"select a.IndexId
,a.ConfigType
,a.Remarks
,a.Address
,a.Port
,a.Network
,a.StreamSecurity
,a.Subid
,b.remarks as subRemarks
from ProfileItem a
left join SubItem b on a.subid = b.id
where 1=1 ";
@@ -210,6 +230,42 @@ public sealed class AppManager
return await SQLiteHelper.Instance.TableAsync<ProfileItem>().FirstOrDefaultAsync(it => it.IndexId == indexId);
}
public async Task<List<ProfileItem>> GetProfileItemsByIndexIds(IEnumerable<string> indexIds)
{
var ids = indexIds.Where(id => !id.IsNullOrEmpty()).Distinct().ToList();
if (ids.Count == 0)
{
return [];
}
return await SQLiteHelper.Instance.TableAsync<ProfileItem>()
.Where(it => ids.Contains(it.IndexId))
.ToListAsync();
}
public async Task<Dictionary<string, ProfileItem>> GetProfileItemsByIndexIdsAsMap(IEnumerable<string> indexIds)
{
var items = await GetProfileItemsByIndexIds(indexIds);
return items.ToDictionary(it => it.IndexId);
}
public async Task<List<ProfileItem>> GetProfileItemsOrderedByIndexIds(IEnumerable<string> indexIds)
{
var idList = indexIds.Where(id => !id.IsNullOrEmpty()).Distinct().ToList();
if (idList.Count == 0)
{
return [];
}
var items = await SQLiteHelper.Instance.TableAsync<ProfileItem>()
.Where(it => idList.Contains(it.IndexId))
.ToListAsync();
var itemMap = items.ToDictionary(it => it.IndexId);
return idList.Select(id => itemMap.GetValueOrDefault(id))
.Where(item => item != null)
.ToList();
}
public async Task<ProfileItem?> GetProfileItemViaRemarks(string? remarks)
{
if (remarks.IsNullOrEmpty())
@@ -249,6 +305,325 @@ public sealed class AppManager
return await SQLiteHelper.Instance.TableAsync<FullConfigTemplateItem>().FirstOrDefaultAsync(it => it.CoreType == eCoreType);
}
#pragma warning disable CS0618
public async Task MigrateProfileExtra()
{
await MigrateProfileExtraGroupV2ToV3();
await MigrateProfileExtraV2ToV3();
await MigrateProfileTransportV3ToV4();
}
private async Task MigrateProfileExtraV2ToV3()
{
const int pageSize = 100;
var offset = 0;
while (true)
{
var sql = $"SELECT * FROM ProfileItem " +
$"WHERE ConfigVersion < 3 " +
$"AND ConfigType NOT IN ({(int)EConfigType.PolicyGroup}, {(int)EConfigType.ProxyChain}) " +
$"LIMIT {pageSize} OFFSET {offset}";
var batch = await SQLiteHelper.Instance.QueryAsync<ProfileItem>(sql);
if (batch is null || batch.Count == 0)
{
break;
}
var batchSuccessCount = await MigrateProfileExtraV2ToV3Sub(batch);
// Only increment offset by the number of failed items that remain in the result set
// Successfully updated items are automatically excluded from future queries due to ConfigVersion = 3
offset += batch.Count - batchSuccessCount;
}
//await ProfileGroupItemManager.Instance.ClearAll();
}
private async Task MigrateProfileTransportV3ToV4()
{
const int pageSize = 100;
var offset = 0;
while (true)
{
var sql = $"SELECT * FROM ProfileItem WHERE ConfigVersion = 3 LIMIT {pageSize} OFFSET {offset}";
var batch = await SQLiteHelper.Instance.QueryAsync<ProfileItem>(sql);
if (batch is null || batch.Count == 0)
{
break;
}
var updateProfileItems = new List<ProfileItem>();
foreach (var item in batch)
{
try
{
if (item.Network == Global.RawNetworkAlias)
{
item.Network = nameof(ETransport.raw);
}
var transport = item.GetTransportExtra();
var network = item.GetNetwork();
switch (network)
{
case nameof(ETransport.raw):
transport = transport with
{
RawHeaderType = item.HeaderType.NullIfEmpty(),
Host = item.RequestHost.NullIfEmpty(),
Path = item.Path.NullIfEmpty(),
};
break;
case nameof(ETransport.ws):
case nameof(ETransport.httpupgrade):
transport = transport with
{
Host = item.RequestHost.NullIfEmpty(),
Path = item.Path.NullIfEmpty(),
};
break;
case nameof(ETransport.xhttp):
transport = transport with
{
Host = item.RequestHost.NullIfEmpty(),
Path = item.Path.NullIfEmpty(),
XhttpMode = item.HeaderType.NullIfEmpty(),
XhttpExtra = item.Extra.NullIfEmpty(),
};
break;
case nameof(ETransport.grpc):
transport = transport with
{
GrpcAuthority = item.RequestHost.NullIfEmpty(),
GrpcServiceName = item.Path.NullIfEmpty(),
GrpcMode = item.HeaderType.NullIfEmpty(),
};
break;
case nameof(ETransport.kcp):
transport = transport with
{
KcpHeaderType = item.HeaderType.NullIfEmpty(),
KcpSeed = item.Path.NullIfEmpty(),
};
break;
default:
item.Network = Global.DefaultNetwork;
transport = transport with
{
RawHeaderType = item.HeaderType.NullIfEmpty(),
Host = item.RequestHost.NullIfEmpty(),
};
break;
}
item.SetTransportExtra(transport);
item.ConfigVersion = 4;
updateProfileItems.Add(item);
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileTransportV3ToV4 Error: {ex}");
}
}
if (updateProfileItems.Count > 0)
{
try
{
var count = await SQLiteHelper.Instance.UpdateAllAsync(updateProfileItems);
offset += batch.Count - count;
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileTransportV3ToV4 update error: {ex}");
offset += batch.Count;
}
}
else
{
offset += batch.Count;
}
}
}
private async Task<int> MigrateProfileExtraV2ToV3Sub(List<ProfileItem> batch)
{
var updateProfileItems = new List<ProfileItem>();
foreach (var item in batch)
{
try
{
var extra = item.GetProtocolExtra();
switch (item.ConfigType)
{
case EConfigType.Shadowsocks:
extra = extra with { SsMethod = item.Security.NullIfEmpty() };
break;
case EConfigType.VMess:
extra = extra with
{
AlterId = item.AlterId.ToString(),
VmessSecurity = item.Security.NullIfEmpty(),
};
break;
case EConfigType.VLESS:
extra = extra with
{
Flow = item.Flow.NullIfEmpty(),
VlessEncryption = item.Security,
};
break;
case EConfigType.Hysteria2:
extra = extra with
{
SalamanderPass = item.Path.NullIfEmpty(),
Ports = item.Ports.NullIfEmpty(),
UpMbps = _config.HysteriaItem.UpMbps,
DownMbps = _config.HysteriaItem.DownMbps,
HopInterval = _config.HysteriaItem.HopInterval.ToString(),
};
break;
case EConfigType.TUIC:
extra = extra with { CongestionControl = item.HeaderType.NullIfEmpty(), };
item.Username = item.Id;
item.Id = item.Security;
item.Password = item.Security;
break;
case EConfigType.HTTP:
case EConfigType.SOCKS:
item.Username = item.Security;
break;
case EConfigType.WireGuard:
extra = extra with
{
WgPublicKey = item.PublicKey.NullIfEmpty(),
WgInterfaceAddress = item.RequestHost.NullIfEmpty(),
WgReserved = item.Path.NullIfEmpty(),
WgMtu = int.TryParse(item.ShortId, out var mtu) ? mtu : 1280
};
break;
}
item.SetProtocolExtra(extra);
item.Password = item.Id;
item.ConfigVersion = 3;
updateProfileItems.Add(item);
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileExtra Error: {ex}");
}
}
if (updateProfileItems.Count > 0)
{
try
{
var count = await SQLiteHelper.Instance.UpdateAllAsync(updateProfileItems);
return count;
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileExtraGroup update error: {ex}");
return 0;
}
}
else
{
return 0;
}
}
private async Task<bool> MigrateProfileExtraGroupV2ToV3()
{
var list = await SQLiteHelper.Instance.TableAsync<ProfileGroupItem>().ToListAsync();
var groupItems = new ConcurrentDictionary<string, ProfileGroupItem>(list.Where(t => !string.IsNullOrEmpty(t.IndexId)).ToDictionary(t => t.IndexId!));
var sql = $"SELECT * FROM ProfileItem WHERE ConfigVersion < 3 AND ConfigType IN ({(int)EConfigType.PolicyGroup}, {(int)EConfigType.ProxyChain})";
var items = await SQLiteHelper.Instance.QueryAsync<ProfileItem>(sql);
if (items is null || items.Count == 0)
{
Logging.SaveLog("MigrateProfileExtraGroup: No items to migrate.");
return true;
}
Logging.SaveLog($"MigrateProfileExtraGroup: Found {items.Count} group items to migrate.");
var updateProfileItems = new List<ProfileItem>();
foreach (var item in items)
{
try
{
var extra = item.GetProtocolExtra();
extra = extra with { GroupType = nameof(item.ConfigType) };
groupItems.TryGetValue(item.IndexId, out var groupItem);
if (groupItem != null && !groupItem.NotHasChild())
{
extra = extra with
{
ChildItems = groupItem.ChildItems,
SubChildItems = groupItem.SubChildItems,
Filter = groupItem.Filter,
MultipleLoad = groupItem.MultipleLoad,
};
}
item.SetProtocolExtra(extra);
item.ConfigVersion = 3;
updateProfileItems.Add(item);
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileExtraGroup item error [{item.IndexId}]: {ex}");
}
}
if (updateProfileItems.Count > 0)
{
try
{
var count = await SQLiteHelper.Instance.UpdateAllAsync(updateProfileItems);
Logging.SaveLog($"MigrateProfileExtraGroup: Successfully updated {updateProfileItems.Count} items.");
return updateProfileItems.Count == count;
}
catch (Exception ex)
{
Logging.SaveLog($"MigrateProfileExtraGroup update error: {ex}");
return false;
}
}
return true;
//await ProfileGroupItemManager.Instance.ClearAll();
}
#pragma warning restore CS0618
#endregion SqliteHelper
#region Core Type
+445
View File
@@ -0,0 +1,445 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace ServiceLib.Manager;
/// <summary>
/// Manager for certificate operations with CA pinning to prevent MITM attacks
/// </summary>
public class CertPemManager
{
private static readonly string _tag = "CertPemManager";
private static readonly Lazy<CertPemManager> _instance = new(() => new());
public static CertPemManager Instance => _instance.Value;
/// <summary>
/// Trusted CA certificate thumbprints (SHA256) to prevent MITM attacks
/// </summary>
private static readonly HashSet<string> TrustedCaThumbprints = new(StringComparer.OrdinalIgnoreCase)
{
"EBD41040E4BB3EC742C9E381D31EF2A41A48B6685C96E7CEF3C1DF6CD4331C99", // GlobalSign Root CA
"6DC47172E01CBCB0BF62580D895FE2B8AC9AD4F873801E0C10B9C837D21EB177", // Entrust.net Premium 2048 Secure Server CA
"73C176434F1BC6D5ADF45B0E76E727287C8DE57616C1E6E6141A2B2CBC7D8E4C", // Entrust Root Certification Authority
"D8E0FEBC1DB2E38D00940F37D27D41344D993E734B99D5656D9778D4D8143624", // Certum Root CA
"D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4", // Comodo AAA Services root
"85A0DD7DD720ADB7FF05F83D542B209DC7FF4528F7D677B18389FEA5E5C49E86", // QuoVadis Root CA 2
"18F1FC7F205DF8ADDDEB7FE007DD57E3AF375A9C4D8D73546BF4F1FED1E18D35", // QuoVadis Root CA 3
"CECDDC905099D8DADFC5B1D209B737CBE2C18CFB2C10C0FF0BCF0D3286FC1AA2", // XRamp Global CA Root
"C3846BF24B9E93CA64274C0EC67C1ECC5E024FFCACD2D74019350E81FE546AE4", // Go Daddy Class 2 CA
"1465FA205397B876FAA6F0A9958E5590E40FCC7FAA4FB7C2C8677521FB5FB658", // Starfield Class 2 CA
"3E9099B5015E8F486C00BCEA9D111EE721FABA355A89BCF1DF69561E3DC6325C", // DigiCert Assured ID Root CA
"4348A0E9444C78CB265E058D5E8944B4D84F9662BD26DB257F8934A443C70161", // DigiCert Global Root CA
"7431E5F4C3C1CE4690774F0B61E05440883BA9A01ED00BA6ABD7806ED3B118CF", // DigiCert High Assurance EV Root CA
"62DD0BE9B9F50A163EA0F8E75C053B1ECA57EA55C8688F647C6881F2C8357B95", // SwissSign Gold CA - G2
"F1C1B50AE5A20DD8030EC9F6BC24823DD367B5255759B4E71B61FCE9F7375D73", // SecureTrust CA
"4200F5043AC8590EBB527D209ED1503029FBCBD41CA1B506EC27F15ADE7DAC69", // Secure Global CA
"0C2CD63DF7806FA399EDE809116B575BF87989F06518F9808C860503178BAF66", // COMODO Certification Authority
"1793927A0614549789ADCE2F8F34F7F0B66D0F3AE3A3B84D21EC15DBBA4FADC7", // COMODO ECC Certification Authority
"41C923866AB4CAD6B7AD578081582E020797A6CBDF4FFF78CE8396B38937D7F5", // OISTE WISeKey Global Root GA CA
"E3B6A2DB2ED7CE48842F7AC53241C7B71D54144BFB40C11F3F1D0B42F5EEA12D", // Certigna
"C0A6F4DC63A24BFDCF54EF2A6A082A0A72DE35803E2FF5FF527AE5D87206DFD5", // ePKI Root Certification Authority
"EAA962C4FA4A6BAFEBE415196D351CCD888D4F53F3FA8AE6D7C466A94E6042BB", // certSIGN ROOT CA
"6C61DAC3A2DEF031506BE036D2A6FE401994FBD13DF9C8D466599274C446EC98", // NetLock Arany (Class Gold) Főtanúsítvány
"3C5F81FEA5FAB82C64BFA2EAECAFCDE8E077FC8620A7CAE537163DF36EDBF378", // Microsec e-Szigno Root CA 2009
"CBB522D7B7F127AD6A0113865BDF1CD4102E7D0759AF635A7CF4720DC963C53B", // GlobalSign Root CA - R3
"2530CC8E98321502BAD96F9B1FBA1B099E2D299E0F4548BB914F363BC0D4531F", // Izenpe.com
"45140B3247EB9CC8C5B4F0D7B53091F73292089E6E5A63E2749DD3ACA9198EDA", // Go Daddy Root Certificate Authority - G2
"2CE1CB0BF9D2F9E102993FBE215152C3B2DD0CABDE1C68E5319B839154DBB7F5", // Starfield Root Certificate Authority - G2
"568D6905A2C88708A4B3025190EDCFEDB1974A606A13C6E5290FCB2AE63EDAB5", // Starfield Services Root Certificate Authority - G2
"0376AB1D54C5F9803CE4B2E201A0EE7EEF7B57B636E8A93C9B8D4860C96F5FA7", // AffirmTrust Commercial
"0A81EC5A929777F145904AF38D5D509F66B5E2C58FCDB531058B0E17F3F0B41B", // AffirmTrust Networking
"70A73F7F376B60074248904534B11482D5BF0E698ECC498DF52577EBF2E93B9A", // AffirmTrust Premium
"BD71FDF6DA97E4CF62D1647ADD2581B07D79ADF8397EB4ECBA9C5E8488821423", // AffirmTrust Premium ECC
"5C58468D55F58E497E743982D2B50010B6D165374ACF83A7D4A32DB768C4408E", // Certum Trusted Network CA
"BFD88FE1101C41AE3E801BF8BE56350EE9BAD1A6B9BD515EDC5C6D5B8711AC44", // TWCA Root Certification Authority
"513B2CECB810D4CDE5DD85391ADFC6C2DD60D87BB736D2B521484AA47A0EBEF6", // Security Communication RootCA2
"55926084EC963A64B96E2ABE01CE0BA86A64FBFEBCC7AAB5AFC155B37FD76066", // Actalis Authentication Root CA
"9A114025197C5BB95D94E63D55CD43790847B646B23CDF11ADA4A00EFF15FB48", // Buypass Class 2 Root CA
"EDF7EBBCA27A2A384D387B7D4010C666E2EDB4843E4C29B4AE1D5B9332E6B24D", // Buypass Class 3 Root CA
"FD73DAD31C644FF1B43BEF0CCDDA96710B9CD9875ECA7E31707AF3E96D522BBD", // T-TeleSec GlobalRoot Class 3
"49E7A442ACF0EA6287050054B52564B650E4F49E42E348D6AA38E039E957B1C1", // D-TRUST Root Class 3 CA 2 2009
"EEC5496B988CE98625B934092EEC2908BED0B0F316C2D4730C84EAF1F3D34881", // D-TRUST Root Class 3 CA 2 EV 2009
"E23D4A036D7B70E9F595B1422079D2B91EDFBB1FB651A0633EAA8A9DC5F80703", // CA Disig Root R2
"9A6EC012E1A7DA9DBE34194D478AD7C0DB1822FB071DF12981496ED104384113", // ACCVRAIZ1
"59769007F7685D0FCD50872F9F95D5755A5B2B457D81F3692B610A98672F0E1B", // TWCA Global Root CA
"DD6936FE21F8F077C123A1A521C12224F72255B73E03A7260693E8A24B0FA389", // TeliaSonera Root CA v1
"91E2F5788D5810EBA7BA58737DE1548A8ECACD014598BC0B143E041B17052552", // T-TeleSec GlobalRoot Class 2
"F356BEA244B7A91EB35D53CA9AD7864ACE018E2D35D5F8F96DDF68A6F41AA474", // Atos TrustedRoot 2011
"8A866FD1B276B57E578E921C65828A2BED58E9F2F288054134B7F1F4BFC9CC74", // QuoVadis Root CA 1 G3
"8FE4FB0AF93A4D0D67DB0BEBB23E37C71BF325DCBCDD240EA04DAF58B47E1840", // QuoVadis Root CA 2 G3
"88EF81DE202EB018452E43F864725CEA5FBD1FC2D9D205730709C5D8B8690F46", // QuoVadis Root CA 3 G3
"7D05EBB682339F8C9451EE094EEBFEFA7953A114EDB2F44949452FAB7D2FC185", // DigiCert Assured ID Root G2
"7E37CB8B4C47090CAB36551BA6F45DB840680FBA166A952DB100717F43053FC2", // DigiCert Assured ID Root G3
"CB3CCBB76031E5E0138F8DD39A23F9DE47FFC35E43C1144CEA27D46A5AB1CB5F", // DigiCert Global Root G2
"31AD6648F8104138C738F39EA4320133393E3A18CC02296EF97C2AC9EF6731D0", // DigiCert Global Root G3
"552F7BDCF1A7AF9E6CE672017F4F12ABF77240C78E761AC203D1D9D20AC89988", // DigiCert Trusted Root G4
"52F0E1C4E58EC629291B60317F074671B85D7EA80D5B07273463534B32B40234", // COMODO RSA Certification Authority
"E793C9B02FD8AA13E21C31228ACCB08119643B749C898964B1746D46C3D4CBD2", // USERTrust RSA Certification Authority
"4FF460D54B9C86DABFBCFC5712E0400D2BED3FBC4D4FBDAA86E06ADCD2A9AD7A", // USERTrust ECC Certification Authority
"179FBC148A3DD00FD24EA13458CC43BFA7F59C8182D783A513F6EBEC100C8924", // GlobalSign ECC Root CA - R5
"3C4FB0B95AB8B30032F432B86F535FE172C185D0FD39865837CF36187FA6F428", // Staat der Nederlanden Root CA - G3
"5D56499BE4D2E08BCFCAD08A3E38723D50503BDE706948E42F55603019E528AE", // IdenTrust Commercial Root CA 1
"30D0895A9A448A262091635522D1F52010B5867ACAE12C78EF958FD4F4389F2F", // IdenTrust Public Sector Root CA 1
"43DF5774B03E7FEF5FE40D931A7BEDF1BB2E6B42738C4E6D3841103D3AA7F339", // Entrust Root Certification Authority - G2
"02ED0EB28C14DA45165C566791700D6451D7FB56F0B2AB1D3B8EB070E56EDFF5", // Entrust Root Certification Authority - EC1
"5CC3D78E4E1D5E45547A04E6873E64F90CF9536D1CCC2EF800F355C4C5FD70FD", // CFCA EV ROOT
"6B9C08E86EB0F767CFAD65CD98B62149E5494A67F5845E7BD1ED019F27B86BD6", // OISTE WISeKey Global Root GB CA
"A1339D33281A0B56E557D3D32B1CE7F9367EB094BD5FA72A7E5004C8DED7CAFE", // SZAFIR ROOT CA2
"B676F2EDDAE8775CD36CB0F63CD1D4603961F49E6265BA013A2F0307B6D0B804", // Certum Trusted Network CA 2
"A040929A02CE53B4ACF4F2FFC6981CE4496F755E6D45FE0B2A692BCD52523F36", // Hellenic Academic and Research Institutions RootCA 2015
"44B545AA8A25E65A73CA15DC27FC36D24C1CB9953A066539B11582DC487B4833", // Hellenic Academic and Research Institutions ECC RootCA 2015
"96BCEC06264976F37460779ACF28C5A7CFE8A3C0AAE11A8FFCEE05C0BDDF08C6", // ISRG Root X1
"EBC5570C29018C4D67B1AA127BAF12F703B4611EBC17B7DAB5573894179B93FA", // AC RAIZ FNMT-RCM
"8ECDE6884F3D87B1125BA31AC3FCB13D7016DE7F57CC904FE1CB97C6AE98196E", // Amazon Root CA 1
"1BA5B2AA8C65401A82960118F80BEC4F62304D83CEC4713A19C39C011EA46DB4", // Amazon Root CA 2
"18CE6CFE7BF14E60B2E347B8DFE868CB31D02EBB3ADA271569F50343B46DB3A4", // Amazon Root CA 3
"E35D28419ED02025CFA69038CD623962458DA5C695FBDEA3C22B0BFB25897092", // Amazon Root CA 4
"A1A86D04121EB87F027C66F53303C28E5739F943FC84B38AD6AF009035DD9457", // D-TRUST Root CA 3 2013
"46EDC3689046D53A453FB3104AB80DCAEC658B2660EA1629DD7E867990648716", // TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1
"BFFF8FD04433487D6A8AA60C1A29767A9FC2BBB05E420F713A13B992891D3893", // GDCA TrustAUTH R5 ROOT
"85666A562EE0BE5CE925C1D8890A6F76A87EC16D4D7D5F29EA7419CF20123B69", // SSL.com Root Certification Authority RSA
"3417BB06CC6007DA1B961C920B8AB4CE3FAD820E4AA30B9ACBC4A74EBDCEBC65", // SSL.com Root Certification Authority ECC
"2E7BF16CC22485A7BBE2AA8696750761B0AE39BE3B2FE9D0CC6D4EF73491425C", // SSL.com EV Root Certification Authority RSA R2
"22A2C1F7BDED704CC1E701B5F408C310880FE956B5DE2A4A44F99C873A25A7C8", // SSL.com EV Root Certification Authority ECC
"2CABEAFE37D06CA22ABA7391C0033D25982952C453647349763A3AB5AD6CCF69", // GlobalSign Root CA - R6
"8560F91C3624DABA9570B5FEA0DBE36FF11A8323BE9486854FB3F34A5571198D", // OISTE WISeKey Global Root GC CA
"9BEA11C976FE014764C1BE56A6F914B5A560317ABD9988393382E5161AA0493C", // UCA Global G2 Root
"D43AF9B35473755C9684FC06D7D8CB70EE5C28E773FB294EB41EE71722924D24", // UCA Extended Validation Root
"D48D3D23EEDB50A459E55197601C27774B9D7B18C94D5A059511A10250B93168", // Certigna Root CA
"40F6AF0346A99AA1CD1D555A4E9CCE62C7F9634603EE406615833DC8C8D00367", // emSign Root CA - G1
"86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B", // emSign ECC Root CA - G3
"125609AA301DA0A249B97A8239CB6A34216F44DCAC9F3954B14292F2E8C8608F", // emSign Root CA - C1
"BC4D809B15189D78DB3E1D8CF4F9726A795DA1643CA5F1358E1DDB0EDC0D7EB3", // emSign ECC Root CA - C3
"5A2FC03F0C83B090BBFA40604B0988446C7636183DF9846E17101A447FB8EFD6", // Hongkong Post Root CA 3
"DB3517D1F6732A2D5AB97C533EC70779EE3270A62FB4AC4238372460E6F01E88", // Entrust Root Certification Authority - G4
"358DF39D764AF9E1B766E9C972DF352EE15CFAC227AF6AD1D70E8E4A6EDCBA02", // Microsoft ECC Root Certificate Authority 2017
"C741F70F4B2A8D88BF2E71C14122EF53EF10EBA0CFA5E64CFA20F418853073E0", // Microsoft RSA Root Certificate Authority 2017
"BEB00B30839B9BC32C32E4447905950641F26421B15ED089198B518AE2EA1B99", // e-Szigno Root CA 2017
"657CFE2FA73FAA38462571F332A2363A46FCE7020951710702CDFBB6EEDA3305", // certSIGN Root CA G2
"97552015F5DDFC3C8788C006944555408894450084F100867086BC1A2BB58DC8", // Trustwave Global Certification Authority
"945BBC825EA554F489D1FD51A73DDF2EA624AC7019A05205225C22A78CCFA8B4", // Trustwave Global ECC P256 Certification Authority
"55903859C8C0C3EBB8759ECE4E2557225FF5758BBD38EBD48276601E1BD58097", // Trustwave Global ECC P384 Certification Authority
"88F438DCF8FFD1FA8F429115FFE5F82AE1E06E0C70C375FAAD717B34A49E7265", // NAVER Global Root Certification Authority
"554153B13D2CF9DDB753BFBE1A4E0AE08D0AA4187058FE60A2B862B2E4B87BCB", // AC RAIZ FNMT-RCM SERVIDORES SEGUROS
"319AF0A7729E6F89269C131EA6A3A16FCD86389FDCAB3C47A4A675C161A3F974", // GlobalSign Secure Mail Root R45
"5CBF6FB81FD417EA4128CD6F8172A3C9402094F74AB2ED3A06B4405D04F30B19", // GlobalSign Secure Mail Root E45
"4FA3126D8D3A11D1C4855A4F807CBAD6CF919D3A5A88B03BEA2C6372D93C40C9", // GlobalSign Root R46
"CBB9C44D84B8043E1050EA31A69F514955D7BFD2E2C6B49301019AD61D9F5058", // GlobalSign Root E46
"9A296A5182D1D451A2E37F439B74DAAFA267523329F90F9A0D2007C334E23C9A", // GLOBALTRUST 2020
"FB8FEC759169B9106B1E511644C618C51304373F6C0643088D8BEFFD1B997599", // ANF Secure Server Root CA
"6B328085625318AA50D173C98D8BDA09D57E27413D114CF787A0F5D06C030CF6", // Certum EC-384 CA
"FE7696573855773E37A95E7AD4D9CC96C30157C15D31765BA9B15704E1AE78FD", // Certum Trusted Root CA
"2E44102AB58CB85419451C8E19D9ACF3662CAFBC614B6A53960A30F7D0E2EB41", // TunTrust Root CA
"D95D0E8EDA79525BF9BEB11B14D2100D3294985F0C62D9FABD9CD999ECCB7B1D", // HARICA TLS RSA Root CA 2021
"3F99CC474ACFCE4DFED58794665E478D1547739F2E780F1BB4CA9B133097D401", // HARICA TLS ECC Root CA 2021
"1BE7ABE30686B16348AFD1C61B6866A0EA7F4821E67D5E8AF937CF8011BC750D", // HARICA Client RSA Root CA 2021
"8DD4B5373CB0DE36769C12339280D82746B3AA6CD426E797A31BABE4279CF00B", // HARICA Client ECC Root CA 2021
"57DE0583EFD2B26E0361DA99DA9DF4648DEF7EE8441C3B728AFA9BCDE0F9B26A", // Autoridad de Certificacion Firmaprofesional CIF A62634068
"30FBBA2C32238E2A98547AF97931E550428B9B3F1C8EEB6633DCFA86C5B27DD3", // vTrus ECC Root CA
"8A71DE6559336F426C26E53880D00D88A18DA4C6A91F0DCB6194E206C5C96387", // vTrus Root CA
"69729B8E15A86EFC177A57AFB7171DFC64ADD28C2FCA8CF1507E34453CCB1470", // ISRG Root X2
"F015CE3CC239BFEF064BE9F1D2C417E1A0264A0A94BE1F0C8D121864EB6949CC", // HiPKI Root CA - G1
"B085D70B964F191A73E4AF0D54AE7A0E07AAFDAF9B71DD0862138AB7325A24A2", // GlobalSign ECC Root CA - R4
"D947432ABDE7B7FA90FC2E6B59101B1280E0E1C7E4E40FA3C6887FFF57A7F4CF", // GTS Root R1
"8D25CD97229DBF70356BDA4EB3CC734031E24CF00FAFCFD32DC76EB5841C7EA8", // GTS Root R2
"34D8A73EE208D9BCDB0D956520934B4E40E69482596E8B6F73C8426B010A6F48", // GTS Root R3
"349DFA4058C5E263123B398AE795573C4E1313C83FE68F93556CD5E8031B3C7D", // GTS Root R4
"242B69742FCB1E5B2ABF98898B94572187544E5B4D9911786573621F6A74B82C", // Telia Root CA v2
"E59AAA816009C22BFF5B25BAD37DF306F049797C1F81D85AB089E657BD8F0044", // D-TRUST BR Root CA 1 2020
"08170D1AA36453901A2F959245E347DB0C8D37ABAABC56B81AA100DC958970DB", // D-TRUST EV Root CA 1 2020
"018E13F0772532CF809BD1B17281867283FC48C6E13BE9C69812854A490C1B05", // DigiCert TLS ECC P384 Root G5
"371A00DC0533B3721A7EEB40E8419E70799D2B0A0F2C1D80693165F7CEC4AD75", // DigiCert TLS RSA4096 Root G5
"E8E8176536A60CC2C4E10187C3BEFCA20EF263497018F566D5BEA0F94D0C111B", // DigiCert SMIME ECC P384 Root G5
"90370D3EFA88BF58C30105BA25104A358460A7FA52DFC2011DF233A0F417912A", // DigiCert SMIME RSA4096 Root G5
"77B82CD8644C4305F7ACC5CB156B45675004033D51C60C6202A8E0C33467D3A0", // Certainly Root R1
"B4585F22E4AC756A4E8612A1361C5D9D031A93FD84FEBB778FA3068B0FC42DC2", // Certainly Root E1
"82BD5D851ACF7F6E1BA7BFCBC53030D0E7BC3C21DF772D858CAB41D199BDF595", // DIGITALSIGN GLOBAL ROOT RSA CA
"261D7114AE5F8FF2D8C7209A9DE4289E6AFC9D717023D85450909199F1857CFE", // DIGITALSIGN GLOBAL ROOT ECDSA CA
"E74FBDA55BD564C473A36B441AA799C8A68E077440E8288B9FA1E50E4BBACA11", // Security Communication ECC RootCA1
"F3896F88FE7C0A882766A7FA6AD2749FB57A7F3E98FB769C1FA7B09C2C44D5AE", // BJCA Global Root CA1
"574DF6931E278039667B720AFDC1600FC27EB66DD3092979FB73856487212882", // BJCA Global Root CA2
"48E1CF9E43B688A51044160F46D773B8277FE45BEAAD0E4DF90D1974382FEA99", // LAWtrust Root CA2 (4096)
"22D9599234D60F1D4BC7C7E96F43FA555B07301FD475175089DAFB8C25E477B3", // Sectigo Public Email Protection Root E46
"D5917A7791EB7CF20A2E57EB98284A67B28A57E89182DA53D546678C9FDE2B4F", // Sectigo Public Email Protection Root R46
"C90F26F0FB1B4018B22227519B5CA2B53E2CA5B3BE5CF18EFE1BEF47380C5383", // Sectigo Public Server Authentication Root E46
"7BB647A62AEEAC88BF257AA522D01FFEA395E0AB45C73F93F65654EC38F25A06", // Sectigo Public Server Authentication Root R46
"8FAF7D2E2CB4709BB8E0B33666BF75A5DD45B5DE480F8EA8D4BFE6BEBC17F2ED", // SSL.com TLS RSA Root CA 2022
"C32FFD9F46F936D16C3673990959434B9AD60AAFBB9E7CF33654F144CC1BA143", // SSL.com TLS ECC Root CA 2022
"AD7DD58D03AEDB22A30B5084394920CE12230C2D8017AD9B81AB04079BDD026B", // SSL.com Client ECC Root CA 2022
"1D4CA4A2AB21D0093659804FC0EB2175A617279B56A2475245C9517AFEB59153", // SSL.com Client RSA Root CA 2022
"E38655F4B0190C84D3B3893D840A687E190A256D98052F159E6D4A39F589A6EB", // Atos TrustedRoot Root CA ECC G2 2020
"78833A783BB2986C254B9370D3C20E5EBA8FA7840CBF63FE17297A0B0119685E", // Atos TrustedRoot Root CA RSA G2 2020
"B2FAE53E14CCD7AB9212064701AE279C1D8988FACB775FA8A008914E663988A8", // Atos TrustedRoot Root CA ECC TLS 2021
"81A9088EA59FB364C548A6F85559099B6F0405EFBF18E5324EC9F457BA00112F", // Atos TrustedRoot Root CA RSA TLS 2021
"E0D3226AEB1163C2E48FF9BE3B50B4C6431BE7BB1EACC5C36B5D5EC509039A08", // TrustAsia Global Root CA G3
"BE4B56CB5056C0136A526DF444508DAA36A0B54F42E4AC38F72AF470E479654C", // TrustAsia Global Root CA G4
"D92C171F5CF890BA428019292927FE22F3207FD2B54449CB6F675AF4922146E2", // D-Trust SBR Root CA 1 2022
"DBA84DD7EF622D485463A90137EA4D574DF8550928F6AFA03B4D8B1141E636CC", // D-Trust SBR Root CA 2 2022
"3AE6DF7E0D637A65A8C81612EC6F9A142F85A16834C10280D88E707028518755", // Telekom Security SMIME ECC Root 2021
"578AF4DED0853F4E5998DB4AEAF9CBEA8D945F60B620A38D1A3C13B2BC7BA8E1", // Telekom Security TLS ECC Root 2020
"78A656344F947E9CC0F734D9053D32F6742086B6B9CD2CAE4FAE1A2E4EFDE048", // Telekom Security SMIME RSA Root 2023
"EFC65CADBB59ADB6EFE84DA22311B35624B71B3B1EA0DA8B6655174EC8978646", // Telekom Security TLS RSA Root 2023
"BEF256DAF26E9C69BDEC1602359798F3CAF71821A03E018257C53C65617F3D4A", // FIRMAPROFESIONAL CA ROOT-A WEB
"3F63BB2814BE174EC8B6439CF08D6D56F0B7C405883A5648A334424D6B3EC558", // TWCA CYBER Root CA
"3A0072D49FFC04E996C59AEB75991D3C340F3615D6FD4DCE90AC0B3D88EAD4F4", // TWCA Global Root CA G2
"3F034BB5704D44B2D08545A02057DE93EBF3905FCE721ACBC730C06DDAEE904E", // SecureSign Root CA12
"4B009C1034494F9AB56BBA3BA1D62731FC4D20D8955ADCEC10A925607261E338", // SecureSign Root CA14
"E778F0F095FE843729CD1A0082179E5314A9C291442805E1FB1D8FB6B8886C3A", // SecureSign Root CA15
"0552E6F83FDF65E8FA9670E666DF28A4E21340B510CBE52566F97C4FB94B2BD1", // D-TRUST BR Root CA 2 2023
"436472C1009A325C54F1A5BBB5468A7BAEECCBE05DE5F099CB70D3FE41E13C16", // TrustAsia SMIME ECC Root CA
"C7796BEB62C101BB143D262A7C96A0C6168183223EF50D699632D86E03B8CC9B", // TrustAsia SMIME RSA Root CA
"C0076B9EF0531FB1A656D67C4EBE97CD5DBAA41EF44598ACC2489878C92D8711", // TrustAsia TLS ECC Root CA
"06C08D7DAFD876971EB1124FE67F847EC0C7A158D3EA53CBE940E2EA9791F4C3", // TrustAsia TLS RSA Root CA
"8E8221B2E7D4007836A1672F0DCC299C33BC07D316F132FA1A206D587150F1CE", // D-TRUST EV Root CA 2 2023
"9A12C392BFE57891A0C545309D4D9FD567E480CB613D6342278B195C79A7931F", // SwissSign RSA SMIME Root CA 2022 - 1
"193144F431E0FDDB740717D4DE926A571133884B4360D30E272913CBE660CE41", // SwissSign RSA TLS Root CA 2022 - 1
"D9A32485A8CCA85539CEF12FFFFF711378A17851D73DA2732AB4302D763BD62B", // OISTE Client Root ECC G1
"D02A0F994A868C66395F2E7A880DF509BD0C29C96DE16015A0FD501EDA4F96A9", // OISTE Client Root RSA G1
"EEC997C0C30F216F7E3B8B307D2BAE42412D753FC8219DAFD1520B2572850F49", // OISTE Server Root ECC G1
"9AE36232A5189FFDDB353DFD26520C015395D22777DAC59DB57B98C089A651E6", // OISTE Server Root RSA G1
"B49141502D00663D740F2E7EC340C52800962666121A36D09CF7DD2B90384FB4", // e-Szigno TLS Root CA 2023
};
/// <summary>
/// Get certificate in PEM format from a server with CA pinning validation
/// </summary>
public async Task<(string?, string?)> GetCertPemAsync(string target, string serverName, int timeout = 4, bool allowInsecure = false)
{
try
{
var (domain, _, port, _) = Utils.ParseUrl(target);
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var client = new TcpClient();
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
var callback = new RemoteCertificateValidationCallback((sender, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificate(sender, certificate, chain, sslPolicyErrors, allowInsecure));
await using var ssl = new SslStream(client.GetStream(), false, callback);
var sslOptions = new SslClientAuthenticationOptions
{
TargetHost = serverName,
RemoteCertificateValidationCallback = callback
};
await ssl.AuthenticateAsClientAsync(sslOptions, cts.Token);
var remote = ssl.RemoteCertificate;
if (remote == null)
{
return (null, null);
}
var leaf = new X509Certificate2(remote);
return (ExportCertToPem(leaf), null);
}
catch (OperationCanceledException)
{
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
return (null, $"Connection timeout after {timeout} seconds");
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return (null, ex.Message);
}
}
/// <summary>
/// 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, int timeout = 4, bool allowInsecure = false)
{
var pemList = new List<string>();
try
{
var (domain, _, port, _) = Utils.ParseUrl(target);
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var client = new TcpClient();
await client.ConnectAsync(domain, port > 0 ? port : 443, cts.Token);
var callback = new RemoteCertificateValidationCallback((sender, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificate(sender, certificate, chain, sslPolicyErrors, allowInsecure));
await using var ssl = new SslStream(client.GetStream(), false, callback);
var sslOptions = new SslClientAuthenticationOptions
{
TargetHost = serverName,
RemoteCertificateValidationCallback = callback
};
await ssl.AuthenticateAsClientAsync(sslOptions, cts.Token);
if (ssl.RemoteCertificate is not X509Certificate2 certChain)
{
return (pemList, null);
}
var chain = new X509Chain();
chain.Build(certChain);
pemList.AddRange(chain.ChainElements.Select(element => ExportCertToPem(element.Certificate)));
return (pemList, null);
}
catch (OperationCanceledException)
{
Logging.SaveLog(_tag, new TimeoutException($"Connection timeout after {timeout} seconds"));
return (pemList, $"Connection timeout after {timeout} seconds");
}
catch (Exception ex)
{
Logging.SaveLog(_tag, ex);
return (pemList, ex.Message);
}
}
/// <summary>
/// Validate server certificate with CA pinning
/// </summary>
private bool ValidateServerCertificate(
object _,
X509Certificate? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors,
bool allowInsecure)
{
if (certificate == null)
{
return false;
}
// In insecure mode, accept any certificate so self-signed certs can be fetched.
if (allowInsecure)
{
return true;
}
// Check certificate name mismatch
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch))
{
return false;
}
// Build certificate chain
var cert2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate);
var certChain = chain ?? new X509Chain();
certChain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
certChain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
certChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
certChain.ChainPolicy.VerificationTime = DateTime.Now;
certChain.Build(cert2);
// Find root CA
if (certChain.ChainElements.Count == 0)
{
return false;
}
var rootCert = certChain.ChainElements[certChain.ChainElements.Count - 1].Certificate;
var rootThumbprint = rootCert.GetCertHashString(HashAlgorithmName.SHA256);
return TrustedCaThumbprints.Contains(rootThumbprint);
}
public static string ExportCertToPem(X509Certificate2 cert)
{
var der = cert.Export(X509ContentType.Cert);
var b64 = Convert.ToBase64String(der);
return $"-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n";
}
/// <summary>
/// Parse concatenated PEM certificates string into a list of individual certificates
/// Normalizes format: removes line breaks from base64 content for better compatibility
/// </summary>
/// <param name="pemChain">Concatenated PEM certificates string (supports both \r\n and \n line endings)</param>
/// <returns>List of individual PEM certificate strings with normalized format</returns>
public static List<string> ParsePemChain(string pemChain)
{
var certs = new List<string>();
if (string.IsNullOrWhiteSpace(pemChain))
{
return certs;
}
// Normalize line endings (CRLF -> LF) at the beginning
pemChain = pemChain.Replace("\r\n", "\n").Replace("\r", "\n");
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
var index = 0;
while (index < pemChain.Length)
{
var beginIndex = pemChain.IndexOf(beginMarker, index, StringComparison.Ordinal);
if (beginIndex == -1)
{
break;
}
var endIndex = pemChain.IndexOf(endMarker, beginIndex, StringComparison.Ordinal);
if (endIndex == -1)
{
break;
}
// Extract certificate content
var base64Start = beginIndex + beginMarker.Length;
var base64Content = pemChain.Substring(base64Start, endIndex - base64Start);
// Remove all whitespace from base64 content
base64Content = new string(base64Content.Where(c => !char.IsWhiteSpace(c)).ToArray());
// Reconstruct with clean format: BEGIN marker + base64 (no line breaks) + END marker
var normalizedCert = $"{beginMarker}\n{base64Content}\n{endMarker}\n";
certs.Add(normalizedCert);
// Move to next certificate
index = endIndex + endMarker.Length;
}
return certs;
}
/// <summary>
/// Concatenate a list of PEM certificates into a single string
/// </summary>
/// <param name="pemList">List of individual PEM certificate strings</param>
/// <returns>Concatenated PEM certificates string</returns>
public static string ConcatenatePemChain(IEnumerable<string> pemList)
{
if (pemList == null)
{
return string.Empty;
}
return string.Concat(pemList);
}
public static string GetCertSha256Thumbprint(string pemCert, bool includeColon = false)
{
try
{
var cert = X509Certificate2.CreateFromPem(pemCert);
var thumbprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
if (includeColon)
{
return string.Join(":", thumbprint.Chunk(2).Select(c => new string(c)));
}
return thumbprint;
}
catch
{
return string.Empty;
}
}
}
+18 -45
View File
@@ -1,5 +1,3 @@
using System.Diagnostics;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
@@ -31,58 +29,33 @@ public class CoreAdminManager
await _updateFunc?.Invoke(notify, msg);
}
public async Task<Process?> RunProcessAsLinuxSudo(string fileName, CoreInfo coreInfo, string configPath)
public async Task<ProcessService?> RunProcessAsLinuxSudo(string fileName, CoreInfo coreInfo, string configPath)
{
StringBuilder sb = new();
sb.AppendLine("#!/bin/bash");
var cmdLine = $"{fileName.AppendQuotes()} {string.Format(coreInfo.Arguments, Utils.GetBinConfigPath(configPath).AppendQuotes())}";
sb.AppendLine($"sudo -S {cmdLine}");
var shFilePath = await FileManager.CreateLinuxShellFile("run_as_sudo.sh", sb.ToString(), true);
sb.AppendLine($"exec sudo -S -- {cmdLine}");
var shFilePath = await FileUtils.CreateLinuxShellFile("run_as_sudo.sh", sb.ToString(), true);
Process proc = new()
{
StartInfo = new()
{
FileName = shFilePath,
Arguments = "",
WorkingDirectory = Utils.GetBinConfigPath(),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
}
};
var procService = new ProcessService(
fileName: shFilePath,
arguments: "",
workingDirectory: Utils.GetBinConfigPath(),
displayLog: true,
redirectInput: true,
environmentVars: null,
updateFunc: _updateFunc
);
void dataHandler(object sender, DataReceivedEventArgs e)
{
if (e.Data.IsNotEmpty())
{
_ = UpdateFunc(false, e.Data + Environment.NewLine);
}
}
await procService.StartAsync(AppManager.Instance.LinuxSudoPwd);
proc.OutputDataReceived += dataHandler;
proc.ErrorDataReceived += dataHandler;
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await Task.Delay(10);
await proc.StandardInput.WriteLineAsync(AppManager.Instance.LinuxSudoPwd);
await Task.Delay(100);
if (proc is null or { HasExited: true })
if (procService is null or { HasExited: true })
{
throw new Exception(ResUI.FailedToRunCore);
}
_linuxSudoPid = procService.Id;
_linuxSudoPid = proc.Id;
return proc;
return procService;
}
public async Task KillProcessAsLinuxSudo()
@@ -94,8 +67,8 @@ public class CoreAdminManager
try
{
var shellFileName = Utils.IsOSX() ? Global.KillAsSudoOSXShellFileName : Global.KillAsSudoLinuxShellFileName;
var shFilePath = await FileManager.CreateLinuxShellFile("kill_as_sudo.sh", EmbedUtils.GetEmbedText(shellFileName), true);
var shellFileName = Utils.IsMacOS() ? Global.KillAsSudoOSXShellFileName : Global.KillAsSudoLinuxShellFileName;
var shFilePath = await FileUtils.CreateLinuxShellFile("kill_as_sudo.sh", EmbedUtils.GetEmbedText(shellFileName), true);
if (shFilePath.Contains(' '))
{
shFilePath = shFilePath.AppendQuotes();
+36 -1
View File
@@ -68,6 +68,7 @@ public sealed class CoreInfoManager
DownloadUrlWinArm64 = urlN + "/download/{0}/v2rayN-windows-arm64.zip",
DownloadUrlLinux64 = urlN + "/download/{0}/v2rayN-linux-64.zip",
DownloadUrlLinuxArm64 = urlN + "/download/{0}/v2rayN-linux-arm64.zip",
DownloadUrlLinuxRiscV64 = urlN + "/download/{0}/v2rayN-linux-riscv64.zip",
DownloadUrlOSX64 = urlN + "/download/{0}/v2rayN-macos-64.zip",
DownloadUrlOSXArm64 = urlN + "/download/{0}/v2rayN-macos-arm64.zip",
},
@@ -111,6 +112,7 @@ public sealed class CoreInfoManager
DownloadUrlWinArm64 = urlXray + "/download/{0}/Xray-windows-arm64-v8a.zip",
DownloadUrlLinux64 = urlXray + "/download/{0}/Xray-linux-64.zip",
DownloadUrlLinuxArm64 = urlXray + "/download/{0}/Xray-linux-arm64-v8a.zip",
DownloadUrlLinuxRiscV64 = urlXray + "/download/{0}/Xray-linux-riscv64.zip",
DownloadUrlOSX64 = urlXray + "/download/{0}/Xray-macos-64.zip",
DownloadUrlOSXArm64 = urlXray + "/download/{0}/Xray-macos-arm64-v8a.zip",
Match = "Xray",
@@ -125,7 +127,7 @@ public sealed class CoreInfoManager
new CoreInfo
{
CoreType = ECoreType.mihomo,
CoreExes = ["mihomo-windows-amd64-v1", "mihomo-windows-amd64-compatible", "mihomo-windows-amd64", "mihomo-linux-amd64", "clash", "mihomo"],
CoreExes = GetMihomoCoreExes(),
Arguments = "-f {0}" + PortableMode(),
Url = GetCoreUrl(ECoreType.mihomo),
ReleaseApiUrl = urlMihomo.Replace(Global.GithubUrl, Global.GithubApiUrl),
@@ -133,6 +135,7 @@ public sealed class CoreInfoManager
DownloadUrlWinArm64 = urlMihomo + "/download/{0}/mihomo-windows-arm64-{0}.zip",
DownloadUrlLinux64 = urlMihomo + "/download/{0}/mihomo-linux-amd64-v1-{0}.gz",
DownloadUrlLinuxArm64 = urlMihomo + "/download/{0}/mihomo-linux-arm64-{0}.gz",
DownloadUrlLinuxRiscV64 = urlMihomo + "/download/{0}/mihomo-linux-riscv64-{0}.gz",
DownloadUrlOSX64 = urlMihomo + "/download/{0}/mihomo-darwin-amd64-v1-{0}.gz",
DownloadUrlOSXArm64 = urlMihomo + "/download/{0}/mihomo-darwin-arm64-{0}.gz",
Match = "Mihomo",
@@ -175,6 +178,7 @@ public sealed class CoreInfoManager
DownloadUrlWinArm64 = urlSingbox + "/download/{0}/sing-box-{1}-windows-arm64.zip",
DownloadUrlLinux64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-amd64.tar.gz",
DownloadUrlLinuxArm64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-arm64.tar.gz",
DownloadUrlLinuxRiscV64 = urlSingbox + "/download/{0}/sing-box-{1}-linux-riscv64.tar.gz",
DownloadUrlOSX64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-amd64.tar.gz",
DownloadUrlOSXArm64 = urlSingbox + "/download/{0}/sing-box-{1}-darwin-arm64.tar.gz",
Match = "sing-box",
@@ -248,4 +252,35 @@ public sealed class CoreInfoManager
{
return $"{Global.GithubUrl}/{Global.CoreUrls[eCoreType]}/releases";
}
private static List<string>? GetMihomoCoreExes()
{
var names = new List<string>();
if (Utils.IsWindows())
{
names.Add("mihomo-windows-amd64-v1");
names.Add("mihomo-windows-amd64-compatible");
names.Add("mihomo-windows-amd64");
names.Add("mihomo-windows-arm64");
}
else if (Utils.IsLinux())
{
names.Add("mihomo-linux-amd64-v1");
names.Add("mihomo-linux-amd64");
names.Add("mihomo-linux-arm64");
names.Add("mihomo-linux-riscv64");
}
else if (Utils.IsMacOS())
{
names.Add("mihomo-darwin-amd64-v1");
names.Add("mihomo-darwin-amd64");
names.Add("mihomo-darwin-arm64");
}
names.Add("clash");
names.Add("mihomo");
return names;
}
}
+83 -99
View File
@@ -1,6 +1,3 @@
using System.Diagnostics;
using System.Text;
namespace ServiceLib.Manager;
/// <summary>
@@ -11,8 +8,9 @@ public class CoreManager
private static readonly Lazy<CoreManager> _instance = new(() => new());
public static CoreManager Instance => _instance.Value;
private Config _config;
private Process? _process;
private Process? _processPre;
private WindowsJobService? _processJob;
private ProcessService? _processService;
private ProcessService? _processPreService;
private bool _linuxSudo = false;
private Func<bool, string, Task>? _updateFunc;
private const string _tag = "CoreHandler";
@@ -29,7 +27,7 @@ public class CoreManager
var toPath = Utils.GetBinPath("");
if (fromPath != toPath)
{
FileManager.CopyDirectory(fromPath, toPath, true, false);
FileUtils.CopyDirectory(fromPath, toPath, true, false);
}
}
@@ -59,16 +57,19 @@ public class CoreManager
}
}
public async Task LoadCore(ProfileItem? node)
/// <param name="mainContext">Resolved main context (with pre-socks ports already merged if applicable).</param>
/// <param name="preContext">Optional pre-socks context passed to <see cref="CoreStartPreService"/>.</param>
public async Task LoadCore(CoreConfigContext? mainContext, CoreConfigContext? preContext)
{
if (node == null)
if (mainContext == null)
{
await UpdateFunc(false, ResUI.CheckServerSettings);
return;
}
var node = mainContext.Node;
var fileName = Utils.GetBinConfigPath(Global.CoreConfigFileName);
var result = await CoreConfigHandler.GenerateClientConfig(node, fileName);
var result = await CoreConfigHandler.GenerateClientConfig(mainContext, fileName);
if (result.Success != true)
{
await UpdateFunc(true, result.Msg);
@@ -87,64 +88,56 @@ public class CoreManager
await WindowsUtils.RemoveTunDevice();
}
await CoreStart(node);
await CoreStartPreService(node);
if (_process != null)
await CoreStart(mainContext);
await CoreStartPreService(preContext);
AppManager.Instance.RunningCoreType = preContext?.RunCoreType ?? mainContext.RunCoreType;
if (_processService != null)
{
await UpdateFunc(true, $"{node.GetSummary()}");
}
}
public async Task<int> LoadCoreConfigSpeedtest(List<ServerTestItem> selecteds)
public async Task<ProcessService?> LoadCoreConfigSpeedtest(List<ServerTestItem> selecteds)
{
var coreType = selecteds.Exists(t => t.ConfigType is EConfigType.Hysteria2 or EConfigType.TUIC or EConfigType.Anytls) ? ECoreType.sing_box : ECoreType.Xray;
var coreType = selecteds.FirstOrDefault()?.CoreType == ECoreType.sing_box ? ECoreType.sing_box : ECoreType.Xray;
var fileName = string.Format(Global.CoreSpeedtestConfigFileName, Utils.GetGuid(false));
var configPath = Utils.GetBinConfigPath(fileName);
var result = await CoreConfigHandler.GenerateClientSpeedtestConfig(_config, configPath, selecteds, coreType);
await UpdateFunc(false, result.Msg);
if (result.Success != true)
{
return -1;
return null;
}
await UpdateFunc(false, string.Format(ResUI.StartService, DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss")));
await UpdateFunc(false, configPath);
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType);
var proc = await RunProcess(coreInfo, fileName, true, false);
if (proc is null)
{
return -1;
}
return proc.Id;
return await RunProcess(coreInfo, fileName, true, false);
}
public async Task<int> LoadCoreConfigSpeedtest(ServerTestItem testItem)
public async Task<ProcessService?> LoadCoreConfigSpeedtest(ServerTestItem testItem)
{
var node = await AppManager.Instance.GetProfileItem(testItem.IndexId);
if (node is null)
{
return -1;
return null;
}
var fileName = string.Format(Global.CoreSpeedtestConfigFileName, Utils.GetGuid(false));
var configPath = Utils.GetBinConfigPath(fileName);
var result = await CoreConfigHandler.GenerateClientSpeedtestConfig(_config, node, testItem, configPath);
var (context, _) = await CoreConfigContextBuilder.Build(_config, node);
var result = await CoreConfigHandler.GenerateClientSpeedtestConfig(_config, context, testItem, configPath);
if (result.Success != true)
{
return -1;
return null;
}
var coreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var coreType = context.RunCoreType;
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType);
var proc = await RunProcess(coreInfo, fileName, true, false);
if (proc is null)
{
return -1;
}
return proc.Id;
return await RunProcess(coreInfo, fileName, true, false);
}
public async Task CoreStop()
@@ -157,16 +150,18 @@ public class CoreManager
_linuxSudo = false;
}
if (_process != null)
if (_processService != null)
{
await ProcUtils.ProcessKill(_process, Utils.IsWindows());
_process = null;
await _processService.StopAsync();
_processService.Dispose();
_processService = null;
}
if (_processPre != null)
if (_processPreService != null)
{
await ProcUtils.ProcessKill(_processPre, Utils.IsWindows());
_processPre = null;
await _processPreService.StopAsync();
_processPreService.Dispose();
_processPreService = null;
}
}
catch (Exception ex)
@@ -177,9 +172,10 @@ public class CoreManager
#region Private
private async Task CoreStart(ProfileItem node)
private async Task CoreStart(CoreConfigContext context)
{
var coreType = _config.RunningCoreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var node = context.Node;
var coreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(coreType);
var displayLog = node.ConfigType != EConfigType.Custom || node.DisplayLog;
@@ -188,30 +184,25 @@ public class CoreManager
{
return;
}
_process = proc;
_processService = proc;
}
private async Task CoreStartPreService(ProfileItem node)
private async Task CoreStartPreService(CoreConfigContext? preContext)
{
if (_process != null && !_process.HasExited)
if (_processService is { HasExited: false } && preContext != null)
{
var coreType = AppManager.Instance.GetCoreType(node, node.ConfigType);
var itemSocks = await ConfigHandler.GetPreSocksItem(_config, node, coreType);
if (itemSocks != null)
var preCoreType = preContext?.Node?.CoreType ?? ECoreType.sing_box;
var fileName = Utils.GetBinConfigPath(Global.CorePreConfigFileName);
var result = await CoreConfigHandler.GenerateClientConfig(preContext, fileName);
if (result.Success)
{
var preCoreType = itemSocks.CoreType ?? ECoreType.sing_box;
var fileName = Utils.GetBinConfigPath(Global.CorePreConfigFileName);
var result = await CoreConfigHandler.GenerateClientConfig(itemSocks, fileName);
if (result.Success)
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(preCoreType);
var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true);
if (proc is null)
{
var coreInfo = CoreInfoManager.Instance.GetCoreInfo(preCoreType);
var proc = await RunProcess(coreInfo, Global.CorePreConfigFileName, true, true);
if (proc is null)
{
return;
}
_processPre = proc;
return;
}
_processPreService = proc;
}
}
}
@@ -225,7 +216,7 @@ public class CoreManager
#region Process
private async Task<Process?> RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo)
private async Task<ProcessService?> RunProcess(CoreInfo? coreInfo, string configPath, bool displayLog, bool mayNeedSudo)
{
var fileName = CoreInfoManager.Instance.GetCoreExecFile(coreInfo, out var msg);
if (fileName.IsNullOrEmpty())
@@ -238,7 +229,7 @@ public class CoreManager
{
if (mayNeedSudo
&& _config.TunModeItem.EnableTun
&& coreInfo.CoreType == ECoreType.sing_box
&& (coreInfo.CoreType is ECoreType.sing_box or ECoreType.mihomo)
&& Utils.IsNonWindows())
{
_linuxSudo = true;
@@ -256,55 +247,48 @@ public class CoreManager
}
}
private async Task<Process?> RunProcessNormal(string fileName, CoreInfo? coreInfo, string configPath, bool displayLog)
private async Task<ProcessService?> RunProcessNormal(string fileName, CoreInfo? coreInfo, string configPath, bool displayLog)
{
Process proc = new()
{
StartInfo = new()
{
FileName = fileName,
Arguments = string.Format(coreInfo.Arguments, coreInfo.AbsolutePath ? Utils.GetBinConfigPath(configPath).AppendQuotes() : configPath),
WorkingDirectory = Utils.GetBinConfigPath(),
UseShellExecute = false,
RedirectStandardOutput = displayLog,
RedirectStandardError = displayLog,
CreateNoWindow = true,
StandardOutputEncoding = displayLog ? Encoding.UTF8 : null,
StandardErrorEncoding = displayLog ? Encoding.UTF8 : null,
}
};
var environmentVars = new Dictionary<string, string>();
foreach (var kv in coreInfo.Environment)
{
proc.StartInfo.Environment[kv.Key] = string.Format(kv.Value, coreInfo.AbsolutePath ? Utils.GetBinConfigPath(configPath).AppendQuotes() : configPath);
environmentVars[kv.Key] = string.Format(kv.Value, coreInfo.AbsolutePath ? Utils.GetBinConfigPath(configPath).AppendQuotes() : configPath);
}
if (displayLog)
{
void dataHandler(object sender, DataReceivedEventArgs e)
{
if (e.Data.IsNotEmpty())
{
_ = UpdateFunc(false, e.Data + Environment.NewLine);
}
}
proc.OutputDataReceived += dataHandler;
proc.ErrorDataReceived += dataHandler;
}
proc.Start();
var procService = new ProcessService(
fileName: fileName,
arguments: string.Format(coreInfo.Arguments, coreInfo.AbsolutePath ? Utils.GetBinConfigPath(configPath).AppendQuotes() : configPath),
workingDirectory: Utils.GetBinConfigPath(),
displayLog: displayLog,
redirectInput: false,
environmentVars: environmentVars,
updateFunc: _updateFunc
);
if (displayLog)
{
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
}
await procService.StartAsync();
await Task.Delay(100);
AppManager.Instance.AddProcess(proc.Handle);
if (proc is null or { HasExited: true })
if (procService is null or { HasExited: true })
{
throw new Exception(ResUI.FailedToRunCore);
}
return proc;
AddProcessJob(procService.Handle);
return procService;
}
private void AddProcessJob(nint processHandle)
{
if (Utils.IsWindows())
{
_processJob ??= new();
try
{
_processJob?.AddProcess(processHandle);
}
catch { }
}
}
#endregion Process
@@ -0,0 +1,151 @@
namespace ServiceLib.Manager;
public class GroupProfileManager
{
public static async Task<bool> HasCycle(ProfileItem item)
{
return await HasCycle(item.IndexId, item.GetProtocolExtra());
}
public static async Task<bool> HasCycle(string? indexId, ProtocolExtraItem? extraInfo)
{
return await HasCycle(indexId, extraInfo, new HashSet<string>(), new HashSet<string>());
}
private static async Task<bool> HasCycle(string? indexId, ProtocolExtraItem? extraInfo, HashSet<string> visited, HashSet<string> stack)
{
if (indexId.IsNullOrEmpty() || extraInfo == null)
{
return false;
}
if (stack.Contains(indexId))
{
return true;
}
if (visited.Contains(indexId))
{
return false;
}
visited.Add(indexId);
stack.Add(indexId);
try
{
if (extraInfo.GroupType.IsNullOrEmpty())
{
return false;
}
if (extraInfo.ChildItems.IsNullOrEmpty())
{
return false;
}
var childIds = Utils.String2List(extraInfo.ChildItems)
?.Where(p => !string.IsNullOrEmpty(p))
.ToList();
if (childIds == null)
{
return false;
}
var childItems = await AppManager.Instance.GetProfileItemsByIndexIds(childIds);
foreach (var childItem in childItems)
{
if (await HasCycle(childItem.IndexId, childItem?.GetProtocolExtra(), visited, stack))
{
return true;
}
}
return false;
}
finally
{
stack.Remove(indexId);
}
}
public static async Task<(List<ProfileItem> Items, ProtocolExtraItem? Extra)> GetChildProfileItems(ProfileItem profileItem)
{
var protocolExtra = profileItem?.GetProtocolExtra();
return (await GetChildProfileItemsByProtocolExtra(protocolExtra), protocolExtra);
}
public static async Task<List<ProfileItem>> GetChildProfileItemsByProtocolExtra(ProtocolExtraItem? protocolExtra)
{
if (protocolExtra == null)
{
return [];
}
var items = new List<ProfileItem>();
items.AddRange(await GetSubChildProfileItems(protocolExtra));
items.AddRange(await GetSelectedChildProfileItems(protocolExtra));
return items;
}
private static async Task<List<ProfileItem>> GetSelectedChildProfileItems(ProtocolExtraItem? extra)
{
if (extra == null || extra.ChildItems.IsNullOrEmpty())
{
return [];
}
var childProfileIds = Utils.String2List(extra.ChildItems)
?.Where(p => !string.IsNullOrEmpty(p))
.ToList() ?? [];
if (childProfileIds.Count == 0)
{
return [];
}
var ordered = await AppManager.Instance.GetProfileItemsOrderedByIndexIds(childProfileIds);
return ordered;
}
private static async Task<List<ProfileItem>> GetSubChildProfileItems(ProtocolExtraItem? extra)
{
if (extra == null || extra.SubChildItems.IsNullOrEmpty())
{
return [];
}
var childProfiles = await AppManager.Instance.ProfileItems(extra.SubChildItems ?? string.Empty);
return childProfiles?.Where(p =>
p != null &&
p.IsValid() &&
!p.ConfigType.IsComplexType() &&
(extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter))
)
.ToList() ?? [];
}
public static async Task<Dictionary<string, ProfileItem>> GetAllChildProfileItems(ProfileItem profileItem)
{
var itemMap = new Dictionary<string, ProfileItem>();
var visited = new HashSet<string>();
await CollectChildItems(profileItem, itemMap, visited);
return itemMap;
}
private static async Task CollectChildItems(ProfileItem profileItem, Dictionary<string, ProfileItem> itemMap,
HashSet<string> visited)
{
var (childItems, _) = await GetChildProfileItems(profileItem);
foreach (var child in childItems.Where(child => visited.Add(child.IndexId)))
{
itemMap[child.IndexId] = child;
if (child.ConfigType.IsGroupType())
{
await CollectChildItems(child, itemMap, visited);
}
}
}
}
+23 -2
View File
@@ -11,7 +11,7 @@ public class NoticeManager
{
return;
}
AppEvents.SendSnackMsgRequested.OnNext(content);
AppEvents.SendSnackMsgRequested.Publish(content);
}
public void SendMessage(string? content)
@@ -20,7 +20,7 @@ public class NoticeManager
{
return;
}
AppEvents.SendMsgViewRequested.OnNext(content);
AppEvents.SendMsgViewRequested.Publish(content);
}
public void SendMessageEx(string? content)
@@ -38,4 +38,25 @@ public class NoticeManager
Enqueue(msg);
SendMessage(msg);
}
/// <summary>
/// Sends each error and warning in <paramref name="validatorResult"/> to the message panel
/// and enqueues a summary snack notification (capped at 10 messages).
/// Returns <c>true</c> when there were any messages so the caller can decide on early-return
/// based on <see cref="NodeValidatorResult.Success"/>.
/// </summary>
public bool NotifyValidatorResult(NodeValidatorResult validatorResult)
{
var msgs = new List<string>([.. validatorResult.Errors, .. validatorResult.Warnings]);
if (msgs.Count == 0)
{
return false;
}
foreach (var msg in msgs)
{
SendMessage(msg);
}
Enqueue(Utils.List2String(msgs.Take(10).ToList(), true));
return true;
}
}
+12 -17
View File
@@ -1,6 +1,3 @@
using System.Net.Sockets;
using System.Text;
namespace ServiceLib.Manager;
public class PacManager
@@ -8,7 +5,6 @@ public class PacManager
private static readonly Lazy<PacManager> _instance = new(() => new PacManager());
public static PacManager Instance => _instance.Value;
private string _configPath;
private int _httpPort;
private int _pacPort;
private TcpListener? _tcpListener;
@@ -16,11 +12,10 @@ public class PacManager
private bool _isRunning;
private bool _needRestart = true;
public async Task StartAsync(string configPath, int httpPort, int pacPort)
public async Task StartAsync(int httpPort, int pacPort)
{
_needRestart = configPath != _configPath || httpPort != _httpPort || pacPort != _pacPort || !_isRunning;
_needRestart = httpPort != _httpPort || pacPort != _pacPort || !_isRunning;
_configPath = configPath;
_httpPort = httpPort;
_pacPort = pacPort;
@@ -35,22 +30,22 @@ public class PacManager
private async Task InitText()
{
var path = Path.Combine(_configPath, "pac.txt");
var customSystemProxyPacPath = AppManager.Instance.Config.SystemProxyItem?.CustomSystemProxyPacPath;
var fileName = (customSystemProxyPacPath.IsNotEmpty() && File.Exists(customSystemProxyPacPath))
? customSystemProxyPacPath
: Path.Combine(Utils.GetConfigPath(), "pac.txt");
// Delete the old pac file
if (File.Exists(path) && Utils.GetFileHash(path).Equals("b590c07280f058ef05d5394aa2f927fe"))
{
File.Delete(path);
}
// TODO: temporarily notify which script is being used
NoticeManager.Instance.SendMessage(fileName);
if (!File.Exists(path))
if (!File.Exists(fileName))
{
var pac = EmbedUtils.GetEmbedText(Global.PacFileName);
await File.AppendAllTextAsync(path, pac);
await File.AppendAllTextAsync(fileName, pac);
}
var pacText =
(await File.ReadAllTextAsync(path)).Replace("__PROXY__", $"PROXY 127.0.0.1:{_httpPort};DIRECT;");
var pacText = await File.ReadAllTextAsync(fileName);
pacText = pacText.Replace("__PROXY__", $"PROXY 127.0.0.1:{_httpPort};DIRECT;");
var sb = new StringBuilder();
sb.AppendLine("HTTP/1.0 200 OK");
@@ -1,5 +1,3 @@
using System.Collections.Concurrent;
//using System.Reactive.Linq;
namespace ServiceLib.Manager;
+30 -11
View File
@@ -26,15 +26,29 @@ public class TaskManager
await Task.Delay(1000 * 60);
//Execute once 1 minute
await UpdateTaskRunSubscription();
try
{
await UpdateTaskRunSubscription();
}
catch (Exception ex)
{
Logging.SaveLog("ScheduledTasks - UpdateTaskRunSubscription", ex);
}
//Execute once 20 minute
if (numOfExecuted % 20 == 0)
{
//Logging.SaveLog("Execute save config");
await ConfigHandler.SaveConfig(_config);
await ProfileExManager.Instance.SaveTo();
try
{
await ConfigHandler.SaveConfig(_config);
await ProfileExManager.Instance.SaveTo();
}
catch (Exception ex)
{
Logging.SaveLog("ScheduledTasks - SaveConfig", ex);
}
}
//Execute once 1 hour
@@ -42,12 +56,18 @@ public class TaskManager
{
//Logging.SaveLog("Execute delete expired files");
FileManager.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1));
FileManager.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1));
FileManager.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1));
FileUtils.DeleteExpiredFiles(Utils.GetBinConfigPath(), DateTime.Now.AddHours(-1));
FileUtils.DeleteExpiredFiles(Utils.GetLogPath(), DateTime.Now.AddMonths(-1));
FileUtils.DeleteExpiredFiles(Utils.GetTempPath(), DateTime.Now.AddMonths(-1));
//Check once 1 hour
await UpdateTaskRunGeo(numOfExecuted / 60);
try
{
await UpdateTaskRunGeo(numOfExecuted / 60);
}
catch (Exception ex)
{
Logging.SaveLog("ScheduledTasks - UpdateTaskRunGeo", ex);
}
}
numOfExecuted++;
@@ -91,11 +111,10 @@ public class TaskManager
{
Logging.SaveLog("Execute update geo files");
var updateHandle = new UpdateService();
await updateHandle.UpdateGeoFileAll(_config, async (success, msg) =>
await new UpdateService(_config, async (success, msg) =>
{
await _updateFunc?.Invoke(false, msg);
});
}).UpdateGeoFileAll();
}
}
}
+4 -2
View File
@@ -1,4 +1,3 @@
using System.Net;
using WebDav;
namespace ServiceLib.Manager;
@@ -44,9 +43,12 @@ public sealed class WebDavManager
_webDir = _config.WebDavItem.DirName.TrimEx();
}
// Ensure BaseAddress URL ends with a trailing slash
var baseUrl = _config.WebDavItem.Url.Trim().TrimEnd('/') + "/";
var clientParams = new WebDavClientParams
{
BaseAddress = new Uri(_config.WebDavItem.Url),
BaseAddress = new Uri(baseUrl),
Credentials = new NetworkCredential(_config.WebDavItem.UserName, _config.WebDavItem.Password)
};
_client = new WebDavClient(clientParams);
+2 -2
View File
@@ -1,10 +1,10 @@
namespace ServiceLib.Models;
public class CheckUpdateModel
public class CheckUpdateModel : ReactiveObject
{
public bool? IsSelected { get; set; }
public string? CoreType { get; set; }
public string? Remarks { get; set; }
[Reactive] public string? Remarks { get; set; }
public string? FileName { get; set; }
public bool? IsFinished { get; set; }
}
+3 -3
View File
@@ -1,7 +1,7 @@
namespace ServiceLib.Models;
[Serializable]
public class ClashProxyModel
public class ClashProxyModel : ReactiveObject
{
public string? Name { get; set; }
@@ -9,9 +9,9 @@ public class ClashProxyModel
public string? Now { get; set; }
public int Delay { get; set; }
[Reactive] public int Delay { get; set; }
public string? DelayName { get; set; }
[Reactive] public string? DelayName { get; set; }
public bool IsActive { get; set; }
}
-15
View File
@@ -8,21 +8,6 @@ public class Config
public string IndexId { get; set; }
public string SubIndexId { get; set; }
public ECoreType RunningCoreType { get; set; }
public bool IsRunningCore(ECoreType type)
{
switch (type)
{
case ECoreType.Xray when RunningCoreType is ECoreType.Xray or ECoreType.v2fly or ECoreType.v2fly_v5:
case ECoreType.sing_box when RunningCoreType is ECoreType.sing_box or ECoreType.mihomo:
return true;
default:
return false;
}
}
#endregion property
#region other entities

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