Compare commits

...
Author SHA1 Message Date
Shtorm 6a6a1e0a31 Merge branch 'extended' of https://github.com/shtorm-7/sing-box into extended 2026-09-05 12:57:15 +03:00
Shtorm 217f77643f provider: Rename StoreProviders to StoreSubscriptions 2026-09-05 10:12:55 +03:00
Shtorm 7afda58818 provider: Add override_dialer_options option 2026-09-05 09:23:55 +03:00
Shtorm 64605fc47a protocol: Forward lifecycle Start stage to nested outbounds in vpn, failover and limiters 2026-09-05 04:30:03 +03:00
Shtorm 08703c9035 Update wireguard-go 2026-09-05 04:26:58 +03:00
ShtormandGitHub 28f6f4289e Update Amnezia version from 3.0 to 3.1
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-09-04 09:24:56 +03:00
Shtorm 8208af72ac build: add desktop release targets and trim go version suffix 2026-09-04 09:20:40 +03:00
Shtorm bcb83578d9 vless: fix nil pointer panic on encryption handshake failure 2026-09-04 06:01:33 +03:00
Shtorm 102330f968 provider: add store_providers cache_file option to gate subscription caching 2026-09-04 05:19:05 +03:00
Shtorm de3c7ff37e naive: wait for handshake before returning outbound conn 2026-09-04 04:42:43 +03:00
Shtorm 7fc9e5e773 examples: use ranges for amnezia timing options 2026-09-04 04:25:22 +03:00
Shtorm 2eeb120091 update rmux 2026-09-04 04:10:18 +03:00
Shtorm 301df12dee limiter: revert traffic limiter to Can/Add interface 2026-09-04 01:48:30 +03:00
Shtorm 673a408dca warp: add address/port option to override peer endpoint 2026-09-04 01:42:45 +03:00
Shtorm 04922b1898 parser/link: support gecko obfs in hysteria2 links 2026-09-04 01:41:33 +03:00
Shtorm 3540c09b93 masque: add address/port option to override endpoint 2026-09-04 01:40:28 +03:00
Shtorm 25618424e6 limiter: use named routers, keep original inbound metadata 2026-09-04 01:30:11 +03:00
Shtorm eac79f2080 mieru: add mtu and handshake_mode options 2026-09-04 01:24:10 +03:00
Shtorm b0ec582349 Regenerate started_service_grpc.pb.go 2026-09-04 01:08:46 +03:00
Shtorm c0bfc731ec Merge tag 'v1.14.0' 2026-09-04 01:08:30 +03:00
Shtorm 99bbd49406 Remove Snell and OpenVPN 2026-09-02 21:01:21 +03:00
世界 0b8995879f Bump version 2026-08-31 11:28:31 +08:00
世界 65a50758e1 Update naiveproxy to v150.0.7871.63-2 2026-08-31 11:28:31 +08:00
世界 138ba19b25 Add desktop client dev branch support 2026-08-31 11:28:31 +08:00
世界 336cde087e Run tests on oldstable branch 2026-08-31 08:46:29 +08:00
世界 a48b4cbd89 release: Fetch client main branch explicitly in update_clients.sh 2026-08-31 08:46:29 +08:00
世界 05b6802cfa Fix QUIC congestion control application limited reporting
Report application limited to injected congestion controllers when the packer stays empty for longer than the pacer burst window, instead of suppressing the report within one RTT of a pacing or congestion limit. The suppression let low bandwidth samples from small bursts after idle periods enter the BBR bandwidth filter, so long-lived TUIC and naive QUIC connections lost throughput after light traffic.
2026-08-30 17:46:30 +08:00
世界 a9713335fd boxdd: Fix log output 2026-08-30 17:41:47 +08:00
世界 66beaf541e Test nested groups recursively in URLTest 2026-08-30 17:41:47 +08:00
世界 dd9cdbc712 tailscale: Fix endpoint start failure when no route is available 2026-08-30 17:41:47 +08:00
世界 384344ead3 Bump version 2026-08-30 17:41:47 +08:00
世界 6aecbe2750 snell: Fuse client handshake with the first payload 2026-08-30 17:41:47 +08:00
世界 2a38f30125 Add Tailscale certificate API commands 2026-08-30 17:41:47 +08:00
世界 4658dfc6c6 Make badlinkname without bad linkname 2026-08-30 17:41:47 +08:00
世界 fb0063221d Remove tlsunsafeekm GODEBUG default 2026-08-30 17:41:47 +08:00
世界 f50ca2244b Fix HTTP/2 transport reset with Go 1.27 2026-08-30 17:41:47 +08:00
世界 de7460ec7c Update Go to 1.26.7 2026-08-30 17:41:47 +08:00
世界 c53c2375c4 Improve power report 2026-08-30 17:41:47 +08:00
世界 3f986d584e Discard OOM drafts written by previous app versions 2026-08-30 17:41:47 +08:00
世界 103e266ebc Fix QUIC congestion control
- Remove `bbr_standard`, `bbr2` and `bbr2_variant` from the naive inbound
- Report injected congestion controllers with connection-wide packet numbers in quic-go
2026-08-30 17:41:47 +08:00
世界 55b18b2271 Migrate Apple developer account 2026-08-30 17:41:47 +08:00
世界 3439be1bbb Do not hide groups containing only a single item 2026-08-30 17:41:47 +08:00
世界 a349a0d769 Fix naive quic congestion control 2026-08-30 17:41:47 +08:00
世界 310206a889 Fix naive performance 2026-08-30 17:41:47 +08:00
世界 be54f9afef endpoint: Drop packets to non-IP destinations 2026-08-30 17:41:47 +08:00
世界 673485755c Fix fakeip UDP reply address mapping 2026-08-30 17:41:47 +08:00
世界 33e63afb0f snell: Fix UDP requests to domain destinations 2026-08-30 17:41:46 +08:00
世界 37da1712e0 naive: Fix receive window options not published 2026-08-30 17:41:46 +08:00
世界 080f1c56aa Fix bbolt recover 2026-08-30 17:41:46 +08:00
世界 82098a8760 Add power report service 2026-08-30 17:41:46 +08:00
世界 6d431a2c43 Improve traffic totals 2026-08-30 17:41:46 +08:00
世界 08986ad405 Fix address filter matching for inverted legacy DNS rules
Inverted rules whose address filter conditions come from rule sets or
logical rules were rejected during pre-lookup matching, so the lookup was
never sent and the rules never matched; for plain ip rule sets this was a
regression from the rule-set invert fix in 1.12.22. Pre-lookup matching
now tracks whether the result depends on deferred address filter
conditions and defers such rules to the response phase, while results
already determined by evaluated conditions are still decided during
pre-lookup.
2026-08-30 17:41:46 +08:00
世界 aea28ca6dc Cache tproxy UDP write-back connection when netns is configured 2026-08-30 17:41:46 +08:00
世界 e6b7c6ca75 Invalidate remote rule-set cache when URL changes 2026-08-30 17:41:46 +08:00
世界 7d84f7d3e0 Fix lint errors 2026-08-30 17:41:46 +08:00
世界 ba4146ea31 Reformat code 2026-08-30 17:41:46 +08:00
世界 e4ec3b0345 Fix bbolt flock 2026-08-30 17:41:46 +08:00
世界 6720a27109 Handle network update callbacks in background 2026-08-30 17:41:46 +08:00
世界 cfddedb3da daemon: Fix start or reload race 2026-08-30 17:41:46 +08:00
世界 2e238abe01 Fix network environment log 2026-08-30 17:41:46 +08:00
世界 ec497c7dda Fix tailssh pixels 2026-08-30 17:41:46 +08:00
世界 0a5078a848 Package usbip drivers with the daemon 2026-08-30 17:41:46 +08:00
世界 f7e6a85166 Fix duplicate interface binding for UDP connections 2026-08-30 17:41:46 +08:00
世界 680f37182f Install Apple provisioning profiles from a variable number of secrets 2026-08-30 17:41:46 +08:00
世界 495446cf4f Update Go to 1.26.6 2026-08-30 17:41:46 +08:00
世界 4b30a03adf Add Taildrop support 2026-08-30 17:41:46 +08:00
世界 c86164bd1b Fix file descriptor double close when netlink socket bind fails 2026-08-30 17:41:46 +08:00
世界 bd059fc8aa Add api command 2026-08-30 17:41:45 +08:00
世界 fcee5656b9 daemon: Send complete initial snapshots in status subscriptions 2026-08-30 17:41:45 +08:00
世界 24d1f271bf Add listen_port option to Tailscale endpoint 2026-08-30 17:41:45 +08:00
世界 b3a45e0839 Add query_client_subnet and query_dnssec DNS rule items and remove_client_subnet DNS route action option 2026-08-30 17:41:45 +08:00
世界 4e91d92c5f Refactor local DNS cache partitioning 2026-08-30 17:41:45 +08:00
世界 96fe4f7317 documentation: Rename Root Helper to Helper Service 2026-08-30 17:41:45 +08:00
世界 20b4b8e17d daemon: Throttle group and outbound subscription pushes
- Limit SubscribeGroups and SubscribeOutbounds to one push per 250ms, coalescing bursts of URLTest updates
2026-08-30 17:41:45 +08:00
世界 87c4f84a89 Fix search domain handling in DNS transports 2026-08-30 17:41:45 +08:00
世界 0e75f5f45c Partition local DNS caches by interface signature 2026-08-30 17:41:45 +08:00
世界 28b25598ed Refactor local DNS transport 2026-08-30 17:41:45 +08:00
世界 1e2846a1a5 Resolve and race all WireGuard addresses on handshake 2026-08-30 17:41:45 +08:00
世界 ce25e7791d openconnect: Improve PPP carrier tolerance 2026-08-30 17:41:45 +08:00
世界 0b850d6b56 Fix Android process finder 2026-08-30 17:41:45 +08:00
世界 a8091761d7 Fix missing process info in hijacked DNS queries 2026-08-30 17:41:45 +08:00
世界 097f784e7a daemon: Fix endpoint subscription 2026-08-30 17:41:45 +08:00
世界 06a82b6601 Enable stateless reset for hysteria2 server when obfs disabled 2026-08-30 17:41:45 +08:00
世界 baa8580efd Update Go to 1.26.5 2026-08-30 17:41:45 +08:00
世界 059508ed7a Avoid clearing DNS caches during network resets 2026-08-30 17:41:45 +08:00
世界 5572b3c329 Fix QUIC connection closed by ENOBUFS
Batched darwin packet I/O now also covers connected sockets on iOS.
2026-08-30 17:41:45 +08:00
世界 0dd17d78d8 Fix DNS response buffer headroom 2026-08-30 17:41:45 +08:00
世界 baa99c6f8f Package WinDivert drivers instead of embedding it 2026-08-30 17:41:45 +08:00
世界 9ea88e556a Fix hysteria2 chrome parrot 2026-08-30 17:41:45 +08:00
世界 2f766be0be Improve UDP packet usage for darwin 2026-08-30 17:41:45 +08:00
世界 5c50f7a084 Use connected sockets for QUIC clients 2026-08-30 17:41:45 +08:00
世界 93a4bbacbe Fix security check for executables 2026-08-30 17:41:45 +08:00
NagaseMinatoand世界 eb6db316cc dns: Fix preferred_by local support for platform local transport 2026-08-30 17:41:45 +08:00
世界 ec3e95935f Add hysteria2 chrome parrot support 2026-08-30 17:41:45 +08:00
世界 10d37384f0 Update quic-go to v0.61.0 2026-08-30 17:41:45 +08:00
世界 e797c2d0ba Update tailscale to v1.102.1 2026-08-30 17:41:45 +08:00
世界 9bf39f85f9 Remove client metadata from AnyTLS requests by default 2026-08-30 17:41:45 +08:00
世界 475eb7c845 Fix TLS fragment ACK wait on Windows without TCP estats
Recent Windows 11 builds remove TCP estats, which made writeAndWaitAck
return without waiting for acknowledgment. sing now queries
SIO_TCP_INFO on the socket instead, and falls back to estats on
systems predating it (Windows 10 1703).
2026-08-30 17:41:45 +08:00
世界 b3a6dcda5f wireguard: Fix system device configures DNS for interface 2026-08-30 17:41:44 +08:00
世界 cdbfceafd1 dns: Fix completed race rule blocked by earlier armed rule 2026-08-30 17:41:44 +08:00
世界 34397d5dc8 release: Fix rpm package format for desktop client 2026-08-30 17:41:44 +08:00
世界 9220eb136c Fix openvpn 2026-08-30 17:41:44 +08:00
世界 525e98b05b Fix string type 2026-08-30 17:41:44 +08:00
世界 251a508fae Fix inconsistent URLTest results 2026-08-30 17:41:44 +08:00
世界 c90f3564e8 daemon: Improve URLTest 2026-08-30 17:41:44 +08:00
世界 1698e6cf66 Add initial_path option to remote rule-sets 2026-08-30 17:41:44 +08:00
世界 ec884dd508 release: Fix linux sign 2026-08-30 17:41:44 +08:00
世界 6e09f2892a Add JSON schema support 2026-08-30 17:41:44 +08:00
世界 e09e3b49f9 dns: Add namespace and parallel support for evaluate 2026-08-30 17:41:44 +08:00
世界 510586c4f8 route: simplify rule_set matching semantics
Since b0c6762bc, every rule inside a referenced rule-set was evaluated
as if merged into the outer rule, which required tracking per-branch
group states and let outer rules and rule-set rules satisfy each
other's grouped conditions in both directions.

Restrict merging to the only designed case: a rule-set containing
exactly one non-inverted default rule is merged into the outer rule as
before. Any other rule-set now matches as an ordinary condition of the
outer rule: it matches when any of its rules matches on its own, and
its rules no longer exchange grouped match state with the outer rule
in either direction. Multiple referenced rule-sets keep OR semantics.

Flat address rule-sets such as generated geosite/geoip sets contain a
single default rule, so their behavior is unchanged. The group-state
set machinery is replaced by a single required/satisfied mask pair.

Also update the route and DNS rule docs.
2026-08-30 17:41:44 +08:00
世界 442177de59 platform: Fix WIFI state on reload 2026-08-30 17:41:44 +08:00
世界 7a20afb289 Fix Fortinet SSO again 2026-08-30 17:41:44 +08:00
世界 88ab140cf9 dns: Probe connection reuse support for TCP transports 2026-08-30 17:41:44 +08:00
世界 df0946c2a4 dns: Cache responses with client subnet 2026-08-30 17:41:44 +08:00
世界 b84fb4952e Fix Tailscale 4via6 subnet routing 2026-08-30 17:41:44 +08:00
世界 80044d655f release: Improve release workflow 2026-08-30 17:41:44 +08:00
世界 b491cac8f7 Add Fortinet hostcheck support 2026-08-30 17:41:44 +08:00
世界 866f93fa2d Update issue template for new clients 2026-08-30 17:41:44 +08:00
flyzstuand世界 bc5f34343f Update uTLS to v1.8.7 2026-08-30 17:41:44 +08:00
世界 33e42bf423 Fix TCP DNS retry 2026-08-30 17:41:44 +08:00
世界 71aedcac87 boxdd: Fix windows update 2026-08-30 17:41:44 +08:00
世界 30d40d09de build: Refactor workflow 2026-08-30 17:41:44 +08:00
世界 b9c7d52582 Improve OpenVPN & OpenConnect interoperability 2026-08-30 17:41:44 +08:00
世界 b2f1f630d9 Fix security check message 2026-08-30 17:41:44 +08:00
世界 086e0b87cf openvpn: Fix certificate verification compatibility 2026-08-30 17:41:44 +08:00
世界 18398cd3f2 boxdd: Fix unsafe permission check 2026-08-30 17:41:44 +08:00
世界 61f950bf61 Fix wg detour 2026-08-30 17:41:44 +08:00
世界 fc57a1b8ac openconnect: Fix Fortinet SSO 2026-08-30 17:41:44 +08:00
世界 abf7fe1b5b Add linux desktop build 2026-08-30 17:41:44 +08:00
世界 71c2388bed desktop: Pin dependencies 2026-08-30 17:41:44 +08:00
世界 f61d14b3ae boxdd: Implement linux http proxy 2026-08-30 17:41:43 +08:00
世界 e4c8b49eaf Improve network reset 2026-08-30 17:41:43 +08:00
世界 3afd290278 boxdd: Fix linux permission 2026-08-30 17:41:43 +08:00
世界 ee0ff1c4a2 Fix DNS route suffix matching 2026-08-30 17:41:43 +08:00
世界 5d424ea2ac refactor: Async DNS 2026-08-30 17:41:43 +08:00
世界 90d75b9673 Refactor UDP DNS hijack 2026-08-30 17:41:43 +08:00
世界 013dab8737 Fix SSO support for anyconnect 2026-08-30 17:41:43 +08:00
世界 6269e1c2b3 Refactor OpenVPN and OpenConnect endpoints 2026-08-30 17:41:43 +08:00
世界 b02ec478c5 Refactor endpoint in started interface 2026-08-30 17:41:43 +08:00
世界 719676519d Fix bind control for auto redirect 2026-08-30 17:41:43 +08:00
世界 2df7a9bac7 Add openvpn and openconnect 2026-08-30 17:41:43 +08:00
世界 23ca3b415f Update Go to 1.25.12 2026-08-30 17:41:43 +08:00
世界 668ced35f8 Refactor endpoint listen 2026-08-30 17:41:43 +08:00
世界 1f7d571017 refactor: New tun udpnat 2026-08-30 17:41:43 +08:00
世界 a072f21978 boxx: Fix unsafe installation 2026-08-30 17:41:43 +08:00
世界 9dfe842794 Add multiple tags support to rule-sets 2026-08-30 17:41:43 +08:00
世界 f65065fdf3 Fix iOS brdige 2026-08-30 17:41:43 +08:00
世界 3af1325a93 Fix oomkiller 2026-08-30 17:41:43 +08:00
世界 5ee69759e1 platform: Fix version compare 2026-08-30 17:41:43 +08:00
世界 b9572e5812 Fix start lifecycle 2026-08-30 17:41:43 +08:00
世界 0a4e4c8061 boxdd: Add insecure mode 2026-08-30 17:41:43 +08:00
世界 b4600dd4f7 Update windows build 2026-08-30 17:41:43 +08:00
世界 24d2626b52 tailscale: support Windows SSH user sessions 2026-08-30 17:41:43 +08:00
世界 5ba8abd0fd boxdd: Add custom working directory support 2026-08-30 17:41:43 +08:00
世界 c66dd4c8ad boxxx: Add update API 2026-08-30 17:41:43 +08:00
世界 ae2c5c84ef boxdd: Fix log format 2026-08-30 17:41:43 +08:00
世界 58d2ed88aa Add windows build 2026-08-30 17:41:43 +08:00
世界 680f37e9a6 Fix windows bridge 2026-08-30 17:41:43 +08:00
世界 c7c9feef95 Fix tun gso check 2026-08-30 17:41:43 +08:00
世界 9a96e63643 Fix SetGroupExpand race 2026-08-30 17:41:43 +08:00
世界 7f1a2bd2be windivert: stabilize Windows driver lifecycle 2026-08-30 17:41:43 +08:00
世界 2c65ed1ea1 Fix nfqueue options 2026-08-30 17:41:43 +08:00
世界 73e9ff81de documentation: Update icon svg 2026-08-30 17:41:42 +08:00
世界 4f330d73ec platform: Add http proxy support for Windows 2026-08-30 17:41:42 +08:00
世界 9eb60234fc platform: Fix daemon data 2026-08-30 17:41:42 +08:00
世界 9a543cfe42 platform: Add boxdd 2026-08-30 17:41:42 +08:00
世界 2e0466da94 Fix daemon groups 2026-08-30 17:41:42 +08:00
世界 13ca2511b1 bridge: Raise accept_ra to 2 when enabling IPv6 forwarding 2026-08-30 17:41:42 +08:00
世界 9bb5bf421d Fix windivert open 2026-08-30 17:41:42 +08:00
世界 4b79c5bdf1 platform: Export tester and reporter 2026-08-30 17:41:42 +08:00
世界 678b3d1703 windivert: Verify cached driver against embedded asset before load 2026-08-30 17:41:42 +08:00
世界 16c8393569 Fix netns manager 2026-08-30 17:41:42 +08:00
世界 9531a6a9cd Add netns and unshare support 2026-08-30 17:41:42 +08:00
世界 7b3993db12 Fix tailscale version 2026-08-30 17:41:42 +08:00
世界 b0eaf05225 Add connections to OOM report 2026-08-30 17:41:42 +08:00
世界 970d93d428 Add hysteria2 realm ip_version and port_mapping options 2026-08-30 17:41:42 +08:00
世界 b35a747dab Fix process and neighbor rules not matched in pre-match 2026-08-30 17:41:42 +08:00
世界 fa743743e1 Fix macOS bridge kernel panic 2026-08-30 17:41:42 +08:00
世界 17b12494de Fix macOS bridge with includeAllNetworks 2026-08-30 17:41:42 +08:00
世界 7004fc6fd4 Fix windows bridge forwarding to connected subnets 2026-08-30 17:41:42 +08:00
世界 edb7e1da15 Improve windivert syscall 2026-08-30 17:41:42 +08:00
世界 9df9269421 Fix bridge MTU 2026-08-30 17:41:42 +08:00
世界 017e2c53de Improve darwin bridge 2026-08-30 17:41:42 +08:00
世界 ec544485b9 Add windows bridge 2026-08-30 17:41:42 +08:00
世界 97bbf2a9a4 Improve bridge 2026-08-30 17:41:42 +08:00
世界 1e2f1d04da Add bridge outbound 2026-08-30 17:41:42 +08:00
世界 01e5196c12 daemon: Release memory to OS on service reload 2026-08-30 17:41:42 +08:00
世界 fa479edc12 Imrpove flow tracking & sniff action 2026-08-30 17:41:42 +08:00
世界 bcae189da3 Fix local DNS transport on darwin 2026-08-30 17:41:42 +08:00
世界 5f2054bf90 Add L3 forwarding support 2026-08-30 17:41:42 +08:00
世界 14fd94e9d4 Add snell protocol 2026-08-30 17:41:42 +08:00
世界 d1d19fcca3 dialer: Dial directly when only one interface is available 2026-08-30 17:41:41 +08:00
世界 154d74b7a0 tls: Fix read wait buffer sizing 2026-08-30 17:41:41 +08:00
世界 2eacabec1d Fix linux ping 2026-08-30 17:41:41 +08:00
世界 485761cbe8 Improve DHCP DNS server initialize
Avoid querying DHCP DNS servers on cellular networks
2026-08-30 17:41:41 +08:00
世界 31922cb2ba Add age support for report export 2026-08-30 17:41:41 +08:00
世界 98512fc8f6 Write logs to oom report 2026-08-30 17:41:41 +08:00
世界 2d581b35f3 Refactor darwin local DNS to raw mDNSResponder call 2026-08-30 17:41:41 +08:00
世界 99242f4113 release: Fix upload ios deb 2026-08-30 17:41:41 +08:00
世界 20846b2183 Fix oom draft flood 2026-08-30 17:41:41 +08:00
世界 7fc5657e8f platform: Fix missing api version check for usb/ip 2026-08-30 17:41:41 +08:00
世界 70c8549f02 certificate: Replace platform bridge with CGO JNI 2026-08-30 17:41:41 +08:00
世界 12431fe93c Add iOS jailbreak release 2026-08-30 17:41:41 +08:00
世界 fbc260b430 Fix Cloudflared edge discovery ignoring configured resolver 2026-08-30 17:41:41 +08:00
世界 d43170b00a documentation: Add USB/IP server and client 2026-08-30 17:41:41 +08:00
世界 16f2a39298 Add USB/IP support for macOS 2026-08-30 17:41:41 +08:00
世界 38b447c6b4 Add USB/IP service 2026-08-30 17:41:41 +08:00
世界 a123c041f7 Fix remote control when Clash server is unavailable 2026-08-30 17:41:41 +08:00
世界 2a7240ae27 Add dashboard support for API service 2026-08-30 17:41:41 +08:00
世界 d8cd38d31b Improve remote rule-set update 2026-08-30 17:41:41 +08:00
世界 7e1187a48f dns: Remove unused files 2026-08-30 17:41:41 +08:00
世界 9ef9a411b2 release: Fix apple release 2026-08-30 17:41:41 +08:00
世界 a89cac300d Fix group status updates broken by API service
The URL test history update hook and the Clash mode update hook were
single-slot: the API service's attached service overwrote the hook set
by the daemon, so clients stopped receiving group updates. Replace both
with multicast hook lists.

Also share a single URL test history storage via context: Clash API
looked it up under a key nobody registered and fell back to its own
empty storage, so dashboards showed no delay once an API service was
configured. Selector changes now notify through the shared storage,
covering selections made from any API surface.
2026-08-30 17:41:41 +08:00
世界 1f4e9e75ed tailscale: Fix auth URL not refreshed after logout 2026-08-30 17:41:41 +08:00
世界 c12367a013 Add sing-box API service 2026-08-30 17:41:41 +08:00
世界 2bd6a233dc daemon: Split host operations into ManagedService 2026-08-30 17:41:41 +08:00
世界 5d7a0d92c6 platform: Add shell support for iOS 2026-08-30 17:41:41 +08:00
世界 3d237fed39 platform: Add tailscale device name and logout 2026-08-30 17:41:41 +08:00
世界 4b853215c6 tailssh: fix platform SFTP session teardown 2026-08-30 17:41:41 +08:00
世界 b151c9e88d tailscale: Add tailssh server 2026-08-30 17:41:41 +08:00
世界 07e83f5981 tools: Fix mising cleanup 2026-08-30 17:41:40 +08:00
世界 3b9f5a0193 hysteria2: Add gecko obfs 2026-08-30 17:41:40 +08:00
世界 c03353ad0e daemon: Add Tailssh 2026-08-30 17:41:40 +08:00
世界 0d2fb35733 Fix tailscale dns 2026-08-30 17:41:40 +08:00
世界 fe34a79277 tailscale: Expose more peer info fields 2026-08-30 17:41:40 +08:00
世界 87dc0f797d tailscale: Add runtime exit node API 2026-08-30 17:41:40 +08:00
世界 27fe18d715 tailscale: Fix handle peer DNS query 2026-08-30 17:41:40 +08:00
世界 18fd5d7a7a tailscale: Revert dialer deprecation and remove control_http_client 2026-08-30 17:41:40 +08:00
世界 39a0167cbb oom-killer: Remove log "OOM draft discarded" 2026-08-30 17:41:40 +08:00
世界 704853b3be Fix lint errors 2026-08-30 17:41:40 +08:00
世界 f720383adc Fix hysteria2 realm server 2026-08-30 17:41:40 +08:00
世界 9895d60484 Fix TLS server close 2026-08-30 17:41:40 +08:00
世界 97b7c5f91f Update hysteria2 realm 2026-08-30 17:41:40 +08:00
世界 6da670ac45 Add hysteria2 realm service and support 2026-08-30 17:41:40 +08:00
世界 ae0c62aae8 Fix reset network 2026-08-30 17:41:40 +08:00
macronutand世界 d9290bc940 Add more spoof method
Signed-off-by: macronut <4027187+macronut@users.noreply.github.com>
2026-08-30 17:41:40 +08:00
世界 3c016c978b Allow customizing TUN DNS mode and hijack interface DNS by default 2026-08-30 17:41:40 +08:00
世界 caf940c805 dns: Add mDNS server 2026-08-30 17:41:40 +08:00
世界 14b8f1bd4d dns: Add preferred_by rule item 2026-08-30 17:41:40 +08:00
世界 f8a623e0c6 dns: Add neighbor-based hostname resolution to local server 2026-08-30 17:41:40 +08:00
世界 35540b4ff3 Fix tailscale start dependencies 2026-08-30 17:41:40 +08:00
世界 87c2b89120 dns: Add timeout configuration 2026-08-30 17:41:40 +08:00
世界 a0fcede368 ssh: Add cipher, MAC, and key exchange configuration 2026-08-30 17:41:40 +08:00
世界 c17407ec94 Improve oom-killer 2026-08-30 17:41:40 +08:00
世界 c9fb076dd7 Preserve comments between formatting 2026-08-30 17:41:40 +08:00
nekohasekai 9471dde765 Add Windows TLS engine 2026-08-30 17:41:40 +08:00
世界 2bbff23640 Improve UDP batch support 2026-08-30 17:41:40 +08:00
世界 9ab4c0f5cb Fix darwin cgo DNS again 2026-08-30 17:41:40 +08:00
世界 1322c0671b platform: Improve oom-killer 2026-08-30 17:41:40 +08:00
世界 7963713314 Add ACME profile support for IP address certificates 2026-08-30 17:41:40 +08:00
世界 0e7f176a16 Fix goroutine leak in networkquality tool
Serialize probe rounds in startProber to eliminate unbounded fan-out of
fire-and-forget probe goroutines (up to 100/sec per direction), and close
HTTP/3 transports via transport.Close() in addition to CloseIdleConnections.
2026-08-30 17:41:40 +08:00
世界 2496c846f4 Fix Tailscale search domain response name mismatch 2026-08-30 17:41:40 +08:00
世界 b4e670c36f Log DNS optimistic background refresh outcomes 2026-08-30 17:41:40 +08:00
世界 63373385f5 Add search domain support for Tailscale DNS 2026-08-30 17:41:40 +08:00
世界 a33858523f Fix tls-spoof 2026-08-30 17:41:40 +08:00
世界 9bcc0f1d37 Fix Apple TLS metadata capture 2026-08-30 17:41:40 +08:00
世界 836fa8ce58 Strip EDNS padding from upstream DNS responses 2026-08-30 17:41:40 +08:00
世界 7de1c4b4cf Defer implicit default HTTP client fallback to first use 2026-08-30 17:41:39 +08:00
世界 6877fba346 Scope HTTP/2 fallback and HTTP/3 broken state per authority 2026-08-30 17:41:39 +08:00
世界 c56d0f2462 Fix macOS tlsspoof 2026-08-30 17:41:39 +08:00
世界 2ddfecb47a Reject IP literal server name with TLS spoof 2026-08-30 17:41:39 +08:00
世界 3e8ea4e590 Fix use-after-free of pooled value buffers in bbolt Batch writes 2026-08-30 17:41:39 +08:00
世界 a04daaff75 Reject pure-IP rule-set references without match_response
DNS rules referencing rule-sets that contain only ip_cidr predicates
silently stopped matching when legacy DNS mode was disabled, because the
IP-CIDR branch cannot match against an in-flight DNS query. The existing
validation intentionally let every rule_set through on the premise that
mixed sets still work via their non-IP branches, which is only true when
such a branch exists. Track whether a rule-set carries any non-IP-CIDR
predicate and reject pure-IP references the same way bare ip_cidr fields
are already rejected.
2026-08-30 17:41:39 +08:00
世界 8e8d1dcca8 Fix legacy rule-set download_detour blocked by empty direct check 2026-08-30 17:41:39 +08:00
世界 40e2b5fd4b Add TLS spoof support 2026-08-30 17:41:39 +08:00
世界 c82163f287 Standardize hosts path 2026-08-30 17:41:39 +08:00
世界 2515811356 Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines 2026-08-30 17:41:39 +08:00
世界 3f242a7114 oom-killer: Record report before reset network 2026-08-30 17:41:39 +08:00
世界 c322ad5d33 Add optimistic DNS cache 2026-08-30 17:41:39 +08:00
世界 5cce5454e1 Fix tailscale error 2026-08-30 17:41:39 +08:00
世界 fe88004eaf Fix darwin cgo DNS again 2026-08-30 17:41:39 +08:00
世界 435d7e0cdf Fix stun test 2026-08-30 17:41:39 +08:00
世界 42ca206037 documentation: Fix missing update for ip_version and query_type 2026-08-30 17:41:39 +08:00
世界 ac7f1ca3aa Add cloudflared inbound 2026-08-30 17:41:39 +08:00
世界 2b691a94a4 Fix lint errors 2026-08-30 17:41:39 +08:00
世界 3b4f7c8ab7 platform: Wrap command RPC error returns with E.Cause 2026-08-30 17:41:39 +08:00
世界 bc6f9703e3 Add package_name_regex route, DNS and headless rule item 2026-08-30 17:41:39 +08:00
世界 cdf596da19 documentation: Fixes 2026-08-30 17:41:39 +08:00
世界 d72c1d9be7 Un-deprecate ip_accept_any DNS rule item 2026-08-30 17:41:39 +08:00
世界 ba059b0b2a tools: Tailscale status 2026-08-30 17:41:39 +08:00
世界 22e852cf36 Fix darwin local DNS transport 2026-08-30 17:41:39 +08:00
世界 496043dd9c Fix rules lock 2026-08-30 17:41:39 +08:00
世界 d2b10d3b09 Revert "Also enable certificate store by default on Apple platforms"
This reverts commit 62cb06c02fc569beb7b2ffa3f0a10ef23e136748.
2026-08-30 17:41:39 +08:00
世界 bd9a10d349 tools: Tailscale status 2026-08-30 17:41:39 +08:00
世界 f24691cd60 platform: Fix darwin signal handler 2026-08-30 17:41:39 +08:00
世界 07cdca0a2b tools: Network Quality & STUN 2026-08-30 17:41:39 +08:00
世界 7f86dffa06 oom-killer: Free memory on pressure notification and use gradual interval backoff 2026-08-30 17:41:39 +08:00
世界 1735da6c17 Fix deprecated warning double-formatting on localized clients 2026-08-30 17:41:39 +08:00
世界 37ac4d6b16 platform: Fix set local 2026-08-30 17:41:39 +08:00
nekohasekai cd77e78f0a Add evaluate DNS rule action and related rule items 2026-08-30 17:41:39 +08:00
世界 86170406af Also enable certificate store by default on Apple platforms
`SecTrustEvaluateWithError` is serial
2026-08-30 17:41:38 +08:00
世界 03412b12d0 platform: Add OOM Report & Crash Report 2026-08-30 17:41:38 +08:00
世界 da18cb70fa Add BBR profile and hop interval randomization for Hysteria2 2026-08-30 17:41:38 +08:00
nekohasekai 01b3d19d6d Refactor ACME support to certificate provider 2026-08-30 17:41:38 +08:00
世界 564cb451ac documentation: Update descriptions for neighbor rules 2026-08-30 17:41:38 +08:00
世界 1cde511d6d Add macOS support for MAC and hostname rule items 2026-08-30 17:41:38 +08:00
世界 39592518b0 Add Android support for MAC and hostname rule items 2026-08-30 17:41:38 +08:00
世界 57c62b5330 Add MAC and hostname rule items 2026-08-30 17:41:38 +08:00
世界 0c7b333743 Use sing-tun v0.8.15 tag 2026-08-30 17:41:22 +08:00
世界 6766b17131 Fix panic on TCP packets of an unconfigured address family in system stack 2026-08-30 17:32:28 +08:00
世界 bd602b6d11 Fix panic on websocket handshake failure 2026-08-30 17:23:03 +08:00
世界 56f91dfeab Bump version 2026-08-29 18:25:15 +08:00
世界 849ac908c8 Use sing-quic v0.6.5 tag 2026-08-29 17:06:50 +08:00
世界 0b5391447e Fix websocket early data with smux and yamux multiplex 2026-08-29 15:59:41 +08:00
世界 d34fbe99e8 Fix tailscale build with Go 1.27 2026-08-29 14:43:16 +08:00
世界 0aefee74c6 Fix TCP NAT port reuse across address families in system stack 2026-08-29 11:33:11 +08:00
世界 1c99c4ceac Use legacy Apple credentials 2026-08-27 18:48:04 +08:00
世界 4f15700db3 direct: Fix TUN loopback protection 2026-08-27 12:45:59 +08:00
世界 1389e2fd8e documentation: Fix matching logic note for DNS rules
The address filter fields ip_cidr, ip_is_private and ip_accept_any share
the destination address group with the domain fields, matching with OR
semantics like in route rules.
2026-08-22 16:14:54 +08:00
世界 4444f297c2 Fix address filter matching for inverted DNS rules
Inverted rules whose address filter conditions come from rule sets or
logical rules were rejected during pre-lookup matching, so the lookup was
never sent and the rules never matched; for plain ip rule sets this was a
regression from the rule-set invert fix in 1.12.22. Pre-lookup matching
now tracks whether the result depends on deferred address filter
conditions and defers such rules to the response phase, while results
already determined by evaluated conditions are still decided during
pre-lookup.
2026-08-22 16:14:54 +08:00
世界 c375fb0b7f Fix su lookup for auto_redirect on Android 2026-08-21 23:09:45 +08:00
世界 f7634d5846 Fix excessive TCP keepalive traffic in gVisor stack 2026-08-21 19:53:51 +08:00
世界 e4a4e8c790 Fix URLTest group hang on unresponsive outbound 2026-08-21 18:21:51 +08:00
世界 9d8d4b8796 Fix log output before service started 2026-08-21 17:42:42 +08:00
世界 b5ebaa1fc0 Bump version 2026-08-17 17:07:24 +08:00
世界 bf41bb4ced Install Apple provisioning profiles from a variable number of secrets 2026-08-17 17:07:23 +08:00
世界 cdb88a33ac Fix version quoting in Apple project update script 2026-08-17 16:03:58 +08:00
世界 077a4a0b54 Fix DHCP DNS server search domain handling 2026-08-12 13:29:01 +08:00
Shtorm e8f6936480 Add license_key support for masque and warp Cloudflare profiles 2026-08-11 23:36:15 +03:00
Shtorm 6126cc2158 Update wireguard 2026-08-11 23:33:38 +03:00
Shtorm 4926a0cb91 Fix unified delay 2026-08-11 23:31:13 +03:00
Shtorm f74146648e Fix xhttp link parsing 2026-08-11 23:30:42 +03:00
世界 b0491faeee Fix default interface monitor stuck on system boot 2026-08-10 23:25:56 +08:00
世界 9fa673d105 Fix oomkiller service stub build 2026-08-10 23:16:04 +08:00
Gavin LuoandGitHub 7ac8e5c0ab Fix reset network 2026-08-10 23:10:24 +08:00
Shtorm b900e3513c Update wireguard 2026-08-10 14:28:56 +03:00
NagaseMinatoandGitHub 65380457b8 Fix tailscale preferred MagicDNS domain matching 2026-08-10 19:11:10 +08:00
efbd84276c Fix FakeIP metadata async save
Co-authored-by: Sway-Chan <nz8r488bbc@privaterelay.appleid.com>
2026-08-10 18:42:27 +08:00
世界 f260e771ea Fix unbounded allocations when reading untrusted binary data 2026-08-10 13:26:42 +08:00
Shtorm dec3f0713f Update sing-box core 2026-08-09 16:32:34 +03:00
Shtorm 21a23d7488 Fix OpenStream dropping connection errors 2026-08-09 15:03:14 +03:00
Shtorm cb664d1a4b Fix DNS Fallback 2026-08-09 15:01:36 +03:00
世界 45ca32dcb9 Bump version 2026-08-09 14:57:01 +08:00
世界 93df3ef612 Fix slow open connection declaring lazy headroom 2026-08-09 13:13:24 +08:00
Shtorm f682ceb8e2 Update packages 2026-08-08 22:56:39 +03:00
Shtorm 545424b86b Fix wireguard 2026-08-08 20:49:55 +03:00
Shtorm 1d10636a5f Fix mieru outbound with fqdn server address 2026-08-08 20:46:52 +03:00
Shtorm 9f34cdb2f8 Fix DefaultGateway 2026-08-08 20:43:13 +03:00
Shtorm da4c532efb Merge branch 'extended' of https://github.com/shtorm-7/sing-box-extended into extended 2026-08-08 10:33:36 +03:00
Shtorm 97c981764d Add DefaultGateway 2026-08-08 10:33:31 +03:00
Shtorm 3310474137 Fix limiters 2026-08-08 10:11:10 +03:00
Shtorm 6c81ddd00f Fix Wireguard options 2026-08-08 10:11:01 +03:00
Shtorm da50e2969d Fix XHTTP dialing 2026-08-08 10:10:05 +03:00
世界 7afc0aa2ba Update naiveproxy to v150.0.7871.63-1 2026-08-08 11:27:55 +08:00
ShtormandGitHub 1e35987658 Fix typo in Rmux description in README.md
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-08-07 06:33:09 +03:00
ShtormandGitHub 44c9c36c65 Fix typo
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-08-06 17:14:08 +03:00
Shtorm 00d3ed46bf Resolve conflicts 2026-08-06 14:20:09 +03:00
Shtorm d6b9f693c4 Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements 2026-08-06 14:19:34 +03:00
Shtorm 051e928b01 Update sing-box core 2026-08-06 14:09:46 +03:00
ShtormandGitHub b644e671c2 Fix typo in VPN description in README.md
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-08-05 09:31:26 +03:00
Ofek LevandGitHub 4c02326989 Fix route_address_set duplicated IP sets causing route creation failure 2026-08-05 14:13:43 +08:00
世界 af40956e58 Update sing 2026-08-04 22:50:40 +08:00
世界 3b22615aec tailscale: Match bare hostnames in preferred_by when search domains exist
MagicDNS resolves single-label names via tailnet search domains, so
route preferred_by should treat them as tailscale domains, matching
official client behavior.
2026-08-04 22:29:14 +08:00
世界 cc1c891400 tailscale: Fix preferred_by matching all addresses with exit node
Exit node peers carry default routes (0.0.0.0/0, ::/0) in their allowed
IPs, so including them in routePrefixes made PreferredAddress report
every address as preferred.
2026-08-04 22:17:04 +08:00
世界 3d85cbd7f0 tailscale: Fix handle peer DNS query 2026-08-04 21:08:00 +08:00
世界 17ec3c71af Bump version 2026-08-03 19:19:51 +08:00
世界 6849e26ee7 tailscale: Fix system interface network category on Windows 2026-08-03 18:59:23 +08:00
世界 fd7cd31d21 Fix tailscale DNS transport missing DNS-only netmap updates 2026-08-03 18:59:23 +08:00
世界 d763884442 Fix tailscale panic with IP control URL 2026-08-03 18:59:23 +08:00
a29b4ea1e8 Fix AnyTLS URLTest results
Co-authored-by: makt28 <makt28@users.noreply.github.com>
2026-08-03 18:59:23 +08:00
ShtormandGitHub 4d0eedd474 Merge pull request #124 from anzix/extended-anytls-parser
feat(parser): Adding support parsing AnyTLS outbound
2026-08-03 07:47:44 +03:00
世界 256d42ad18 Update sing-quic 2026-08-03 12:33:48 +08:00
世界 cb88d94d6d Remove client metadata from AnyTLS requests by default 2026-08-03 12:33:41 +08:00
anzix 2979fe6e7f feat(parser): update README.md to mention AnyTLS 2026-08-01 12:42:14 +05:00
anzix a22671d7c4 feat(parser): update the parsing documentation for provider 2026-08-01 12:14:50 +05:00
anzix 1dd63d4dcb feat(parser): update the parsing documentation 2026-08-01 11:47:12 +05:00
anzix 9d1745d29d feat(parser): adding support AnyTLS 2026-08-01 10:57:04 +05:00
世界 7da573f187 direct: Fix routing loop to exact TUN address on darwin 2026-07-31 11:04:35 +08:00
世界 9d103249e2 release: Fix rpm package format with rpm 6 2026-07-29 21:43:54 +08:00
世界 3708fa1876 Bump version 2026-07-29 19:17:31 +08:00
世界 dba80e614e Fix local DNS transport blocking on canceled queries 2026-07-29 19:17:00 +08:00
世界 c8f76760be Update sing-tun 2026-07-27 23:13:10 +08:00
世界 c9e81856e5 Update sing 2026-07-27 17:32:54 +08:00
世界 9791fa52fb release: Backport workflow refactor 2026-07-27 16:18:06 +08:00
世界 62cc3959b9 Update Go to 1.25.12 2026-07-27 16:18:06 +08:00
世界 1533244d34 Fix UDP message ownership 2026-07-27 15:51:12 +08:00
世界 890544e88b Fix rule-set file descriptor leak 2026-07-27 15:51:12 +08:00
世界 51531186d0 Strip EDNS padding from upstream DNS responses 2026-07-27 15:51:12 +08:00
世界 513910462e Fix DNS route suffix matching 2026-07-27 15:51:12 +08:00
世界 1f1d6d9a87 Fix string type 2026-07-27 15:51:12 +08:00
世界 cb0051d64d Fix inconsistent URLTest results 2026-07-27 15:51:12 +08:00
世界 397fb4d20a dns: Fix search domain expansion
DHCP option 119/15 and resolved link domains are not rooted, so appending
them to the query name produced a non-FQDN name, failing to pack with
"domain must be fully qualified".
2026-07-27 15:51:12 +08:00
世界 67a600dbfb Fix derp stun options 2026-07-27 15:51:12 +08:00
世界 9d7e9b9562 Fix Tailscale 4via6 subnet routing 2026-07-27 15:51:12 +08:00
H-TTTTTand世界 3c4a5a6ed3 Fix naive HTTP/2 connections 2026-07-27 15:51:12 +08:00
世界 d72f0ec1c7 Fix tailscale version 2026-07-27 15:51:12 +08:00
世界 79bc79eeef Fix missing tun logger 2026-07-27 15:51:12 +08:00
世界 c8c41c2038 dialer: Fix auto_detect_interface for UDP connections 2026-07-27 15:51:12 +08:00
世界 4056231e5c Fix tailscale dns 2026-07-27 15:51:12 +08:00
世界 ee808c46be process: Fix panic when package manager is unavailable on Android 2026-07-27 15:51:12 +08:00
世界 7b9439f761 route: Refetch rule-set when cache restore fails 2026-07-27 15:51:12 +08:00
世界 dcfdd8a184 dns: Fix DHCP reset 2026-07-27 15:51:12 +08:00
世界 dc4ca85cbb Fix tailscale start dependencies 2026-07-27 15:51:12 +08:00
世界 61ede421b4 Fix stderr deprecated manager 2026-07-27 15:51:12 +08:00
世界 cb74ad78de Fix naive slow open 2026-07-27 15:51:12 +08:00
世界 b6e554e4c0 Fix ACME HTTP-01 challenge for IPv6 literal addresses 2026-07-27 15:51:12 +08:00
世界 95f2d6302f Update sing-shadowtls to v0.2.1 2026-07-27 15:50:40 +08:00
世界 0df408d807 Update sing-tun 2026-07-27 15:50:40 +08:00
世界 abdaa55e17 Update sing 2026-07-27 15:50:40 +08:00
Shtorm c243a5b6e5 Merge branch 'extended' of https://github.com/shtorm-7/sing-box-extended into extended 2026-07-25 21:18:16 +03:00
Shtorm b4ec688f1a Fix MASQUE endpoint parsing 2026-07-25 21:17:48 +03:00
Shtorm 2e083b2d8f Update sing-vmess 2026-07-25 21:16:01 +03:00
ShtormandGitHub 909f19640c Add Dial Fields comment to client.json
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-07-24 15:59:49 +03:00
ShtormandGitHub a79e16c03c Merge pull request #118 from embogomolov/fix/interrupt-group-deadlock-extended
common/interrupt: close connections outside group lock
2026-07-22 14:22:08 +03:00
embogomolov d2805ffcc9 common/interrupt: close connections outside group lock 2026-07-22 14:09:56 +03:00
ShtormandGitHub 7c19b660b4 Merge pull request #116 from embogomolov/fix/xhttp-h3-udp-socket-leak
transport/v2rayxhttp: close UDP connection when HTTP/3 dial fails
2026-07-22 13:35:15 +03:00
embogomolov 33907d2e23 transport/v2rayxhttp: close UDP connection when HTTP/3 dial fails 2026-07-22 11:41:41 +03:00
Shtorm ea48501b1d Merge branch 'extended' of https://github.com/shtorm-7/sing-box-extended into extended 2026-07-18 16:40:41 +03:00
ShtormandGitHub 425bfaf779 Update Telegram badge to include logo
Signed-off-by: Shtorm <108103062+shtorm-7@users.noreply.github.com>
2026-07-17 16:58:08 +03:00
世界 7067276170 Fix quic-go write leak 2026-07-09 11:07:27 +08:00
世界 0f1763877c Fix v2rayhttp upgrade leak 2026-07-03 12:28:39 +08:00
1155 changed files with 159002 additions and 17756 deletions
+1 -1
View File
@@ -1 +1 @@
98d539ce67568fb911654e66a14cf4247ed833ec
45832ab074849607406baa3e3a2c4660274602ed
+2
View File
@@ -31,6 +31,8 @@ body:
- sing-box for macOS Graphical Client
- sing-box for Apple tvOS Graphical Client
- sing-box for Android Graphical Client
- sing-box for Windows Graphical Client
- sing-box for Linux Graphical Client
- Third-party graphical clients that advertise themselves as using sing-box (Windows)
- Third-party graphical clients that advertise themselves as using sing-box (Android)
- Others
+2
View File
@@ -31,6 +31,8 @@ body:
- sing-box for macOS 图形客户端程序
- sing-box for Apple tvOS 图形客户端程序
- sing-box for Android 图形客户端程序
- sing-box for Windows 图形客户端程序
- sing-box for Linux 图形客户端程序
- 宣传使用 sing-box 的第三方图形客户端程序 (Windows)
- 宣传使用 sing-box 的第三方图形客户端程序 (Android)
- 其他
+8 -6
View File
@@ -2,10 +2,11 @@
set -euo pipefail
VERSION="1.25.10"
VERSION="1.26.7"
PATCH_COMMITS=(
"afe69d3cec1c6dcf0f1797b20546795730850070"
"1ed289b0cf87dc5aae9c6fe1aa5f200a83412938"
"f080b0c6346eb690c0dc82497b35925f385b35ac"
"2d9c12887c342fb9051d231aa5388743cb7e9cb6"
"367663849c656612fc3c8875ef6e7f6b0c93dbd0"
)
CURL_ARGS=(
-fL
@@ -25,12 +26,13 @@ tar -xzf "go${VERSION}.darwin-arm64.tar.gz"
mv go go_osx
cd go_osx
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.25/
# these patch URLs only work on golang1.26.x
# that means after golang1.27 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.26/
# revert:
# 33d3f603c1: "cmd/link/internal/ld: use 12.0.0 OS/SDK versions for macOS linking"
# 937368f84e: "crypto/x509: change how we retrieve chains on darwin"
# d90a57ffe8: "cmd/link/internal/ld: unify OS/SDK versions for macOS linking"
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1
+10 -10
View File
@@ -2,14 +2,14 @@
set -euo pipefail
VERSION="1.25.10"
VERSION="1.26.7"
PATCH_COMMITS=(
"466f6c7a29bc098b0d4c987b803c779222894a11"
"1bdabae205052afe1dadb2ad6f1ba612cdbc532a"
"a90777dcf692dd2168577853ba743b4338721b06"
"f6bddda4e8ff58a957462a1a09562924d5f3d05c"
"bed309eff415bcb3c77dd4bc3277b682b89a388d"
"34b899c2fb39b092db4fa67c4417e41dc046be4b"
"a4ae550aa148b04c9d4890e98bee63aede5c4b53"
"95b851f661584711faa8115b3234a461044f4510"
"b0bf0a863cf218b9bb0d6c013903ab160ae0c39b"
"7c7a2a0d68920f8764d9a3aeae7ad1067a17b3fa"
"ba34356e82f3c12a7303d2231e6ea0e42bbbb3bf"
"472a88edbc2a42feb53fa31d29ab54d33840dca6"
)
CURL_ARGS=(
-fL
@@ -29,9 +29,9 @@ mv go go_win7
cd go_win7
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
# these patch URLs only work on golang1.26.x
# that means after golang1.27 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.26/
# revert:
# 693def151adff1af707d82d28f55dba81ceb08e1: "crypto/rand,runtime: switch RtlGenRandom for ProcessPrng"
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
+2 -1
View File
@@ -4,7 +4,7 @@ PROJECTS=$(dirname "$0")/../..
function updateClient() {
pushd clients/$1
git fetch
git fetch origin main
git reset FETCH_HEAD --hard
popd
git add clients/$1
@@ -12,3 +12,4 @@ function updateClient() {
updateClient "apple"
updateClient "android"
updateClient "desktop"
+1 -1
View File
@@ -10,4 +10,4 @@ git -C $PROJECTS/cronet-go fetch origin go_dev
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go get -x github.com/sagernet/cronet-go@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go mod tidy
git -C $PROJECTS/cronet-go rev-parse origin/dev > "$SCRIPT_DIR/CRONET_GO_VERSION"
git -C $PROJECTS/cronet-go rev-parse origin/go_dev > "$SCRIPT_DIR/CRONET_GO_VERSION"
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -55,7 +55,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.10
go-version: 1.26.7
- name: Clone cronet-go
if: matrix.naive
run: |
@@ -63,7 +63,8 @@ jobs:
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go sparse-checkout set --no-cone '/*' '!/lib'
git -C ~/cronet-go fetch --depth=1 --filter=blob:none origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25
go-version: ^1.26
- name: Cache go module
uses: actions/cache@v4
with:
+6 -6
View File
@@ -29,7 +29,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.10
go-version: 1.26.7
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -45,7 +45,7 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
name: Build binary
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs:
- calculate_version
strategy:
@@ -72,7 +72,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.10
go-version: 1.26.7
- name: Clone cronet-go
if: matrix.naive
run: |
@@ -80,7 +80,8 @@ jobs:
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go sparse-checkout set --no-cone '/*' '!/lib'
git -C ~/cronet-go fetch --depth=1 --filter=blob:none origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
@@ -182,8 +183,6 @@ jobs:
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \
--architecture ${{ matrix.debian }} \
dist/sing-box=/usr/bin/sing-box
curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff'
sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff'
rm -rf $HOME/.gnupg
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
@@ -194,6 +193,7 @@ jobs:
run: |-
set -xeuo pipefail
sudo gem install fpm
echo '%_rpmformat 4' > "$HOME/.rpmmacros"
cp .fpm_systemd .fpm
fpm -t rpm \
--name "${NAME}" \
+57
View File
@@ -0,0 +1,57 @@
name: Test
on:
push:
branches:
- oldstable
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- oldstable
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
go:
- ~1.25
- ~1.26
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Set build tags and ldflags
shell: bash
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
- name: Test (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
+1 -1
View File
@@ -18,7 +18,6 @@ linters:
default: none
enable:
- ineffassign
- paralleltest
- staticcheck
- unused
- modernize
@@ -32,6 +31,7 @@ linters:
- -QF1008 # could remove embedded field "<interface>" from selector
- -ST1003 # should not use ALL_CAPS in Go names; use CamelCase instead
- -QF1001 # could apply De Morgan's law
- -SA4023 # impossible nil comparison, false positive on platform-specific implementations
exclusions:
generated: lax
presets:
+10 -20
View File
@@ -23,10 +23,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -62,10 +61,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_profiler
- badlinkname
- tfogo_checklinkname0
@@ -122,10 +120,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -156,10 +153,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -190,10 +186,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -224,10 +219,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -258,10 +252,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -308,10 +301,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_manager
- with_admin_panel
- with_profiler
@@ -366,10 +358,9 @@ builds:
- with_tailscale
- with_masque
- with_mtproxy
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_profiler
- badlinkname
- tfogo_checklinkname0
@@ -439,10 +430,9 @@ builds:
- with_mtproxy
- with_ccm
- with_ocm
- with_openvpn
- with_trusttunnel
- with_call
- with_sudoku
- with_snell
- with_profiler
- badlinkname
- tfogo_checklinkname0
+36 -17
View File
@@ -24,7 +24,7 @@ DOCKER_PLATFORMS ?= linux/amd64,linux/arm64
CRONET_GO_PATH ?= $(shell pwd)/cronet-go
.PHONY: test release docs build
.PHONY: test release docs build schema
build:
go build $(MAIN_PARAMS) $(MAIN)
@@ -57,6 +57,9 @@ ci_build:
generate_completions:
go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN)
schema:
go run -ldflags "$(LDFLAGS_SHARED)" --tags "$(TAGS)" $(MAIN) schema -o docs/schema.json
install:
go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN)
@@ -78,8 +81,7 @@ lint_install:
proto:
@go run ./cmd/internal/protogen
@gofumpt -l -w .
@gofumpt -l -w .
@golangci-lint fmt
proto_install:
go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest
@@ -118,6 +120,9 @@ release_docker:
update_android_version:
go run ./cmd/internal/update_android_version
update_desktop_version:
go run ./cmd/internal/update_desktop_version
build_android:
cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease && ./gradlew --stop
@@ -136,34 +141,48 @@ release_android: lib_android update_android_version build_android upload_android
publish_android:
cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop
build_desktop_windows:
cd ../sing-box-for-desktop && pnpm install --frozen-lockfile && pnpm run package:win
build_desktop_linux:
cd ../sing-box-for-desktop && pnpm install --frozen-lockfile && pnpm run package:linux
upload_desktop:
mkdir -p dist/release_desktop
cp ../sing-box-for-desktop/release/SFW-*.exe dist/release_desktop 2>/dev/null || true
cp ../sing-box-for-desktop/release/SFL-*.deb ../sing-box-for-desktop/release/SFL-*.rpm ../sing-box-for-desktop/release/SFL-*.pkg.tar.zst dist/release_desktop 2>/dev/null || true
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_desktop
./codeberg-release.sh --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_desktop
rm -rf dist/release_desktop
release_desktop: update_desktop_version build_desktop_windows build_desktop_linux upload_desktop
# TODO: find why and remove `-destination 'generic/platform=iOS'`
# TODO: remove xcode clean when fix control widget fixed
build_ios:
cd ../sing-box-for-apple && \
rm -rf build/SFI.xcarchive && \
xcodebuild clean -scheme SFI && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild clean -scheme SFI -derivedDataPath build/SFI.dd && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -derivedDataPath build/SFI.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_ios_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
export_ios_ipa:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFI && \
cp build/SFI/sing-box.ipa dist/SFI.ipa
build_ios_deb:
$(MAKE) -C ../sing-box-for-apple build_ios_deb
upload_ios_ipa:
cd dist && \
cp SFI.ipa "SFI-${VERSION}.ipa" && \
ghr --replace --draft --prerelease "v${VERSION}" "SFI-${VERSION}.ipa"
upload_ios_deb:
ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64.deb"
release_ios: build_ios upload_ios_app_store
release_ios_deb: build_ios_deb upload_ios_deb
build_macos:
cd ../sing-box-for-apple && \
rm -rf build/SFM.xcarchive && \
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -derivedDataPath build/SFM.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_macos_app_store:
cd ../sing-box-for-apple && \
@@ -232,7 +251,7 @@ replace_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg up
build_tvos:
cd ../sing-box-for-apple && \
rm -rf build/SFT.xcarchive && \
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -derivedDataPath build/SFT.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_tvos_app_store:
cd ../sing-box-for-apple && \
@@ -297,8 +316,8 @@ lib_apple_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple
lib_install:
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.13
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.13
docs:
venv/bin/mkdocs serve
+6 -6
View File
@@ -3,7 +3,7 @@
[![license](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE)
[![go](https://img.shields.io/badge/go-1.26-00ADD8.svg)](go.mod)
[![codeberg](https://img.shields.io/badge/mirror-codeberg-2185D0.svg)](https://codeberg.org/shtorm-7/sing-box-extended)
[![telegram](https://img.shields.io/badge/telegram-chat-26A5E4.svg)](https://t.me/sing_box_extended)
[![telegram](https://img.shields.io/badge/telegram-chat-26A5E4.svg?logo=telegram)](https://t.me/sing_box_extended)
Sing-box with extended features.
@@ -14,12 +14,11 @@ Sing-box with extended features.
- **MASQUE** — Cloudflare MASQUE proxy over QUIC / HTTP-2
- **MTProxy** — Telegram MTProxy server with FakeTLS and domain fronting
- **Mieru** — Secure, hard to classify, hard to probe network protocol
- **OpenVPN** — OpenVPN client with tls-auth, tls-crypt and tls-crypt-v2 support
- **TrustTunnel** — AdGuard's obfuscated VPN protocol, indistinguishable from HTTPS traffic
- **Sudoku** — Traffic obfuscation protocol based on 4×4 Sudoku puzzles with low-entropy fingerprints
- **Snell** — Lightweight encrypted proxy (v1v5) with TLS / HTTP obfuscation
- **SSH** — SSH client and server with certificate authentication and upstream fallback
- **VPN** — Routed tunnel over any TCP sing-box protocol
- **Call** — Traffic tunneling through video-call platforms (VK, Dion, Telemost, WBStream)
- **VPN** — Routed tunnel over any sing-box protocol
- **Bond** — Link aggregation for increasing throughput
- **Fallback** — Outbound group with priority-based switching
- **Failover** — Automatic outbound switching with session recovery for high availability
@@ -35,12 +34,13 @@ Sing-box with extended features.
- **Rate Limiter** — Request rate limiting
### Encryption & Obfuscation
- **Amnezia 2.0** — WireGuard traffic obfuscation
- **Amnezia 3.1** — WireGuard traffic obfuscation
- **VLESS encryption** — XRAY encryption for VLESS protocol
### Transports
- **mKCP** — Reliable UDP-based transport
- **XHTTP** — Modern XRAY transport
- **Rmux** — Improved smux multiplexer
### Services
- **Admin Panel** — Web-based management interface
@@ -50,7 +50,7 @@ Sing-box with extended features.
### Miscellaneous
- **Providers** — Outbound subscriptions from local files, inline lists, or remote URLs (sing-box JSON, Clash YAML, SIP008, share links)
- **Link Parser** — Outbound configured from a share link (VLESS, VMess, Shadowsocks, Trojan, Hysteria, Hysteria2, TUIC)
- **Link Parser** — Outbound configured from a share link (VLESS, VMess, Shadowsocks, Trojan, Hysteria, Hysteria2, TUIC, AnyTLS)
- **Extended WireGuard options** — Advanced configuration capabilities
- **Unified Delay** — Unified latency measurement
+1
View File
@@ -10,6 +10,7 @@ import (
type CertificateStore interface {
LifecycleService
Pool() *x509.CertPool
ExclusiveAnchors() bool
}
func RootPoolFromContext(ctx context.Context) *x509.CertPool {
+21
View File
@@ -0,0 +1,21 @@
package certificate
type Adapter struct {
providerType string
providerTag string
}
func NewAdapter(providerType string, providerTag string) Adapter {
return Adapter{
providerType: providerType,
providerTag: providerTag,
}
}
func (a *Adapter) Type() string {
return a.providerType
}
func (a *Adapter) Tag() string {
return a.providerTag
}
+158
View File
@@ -0,0 +1,158 @@
package certificate
import (
"context"
"os"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/taskmonitor"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
)
var _ adapter.CertificateProviderManager = (*Manager)(nil)
type Manager struct {
logger log.ContextLogger
registry adapter.CertificateProviderRegistry
access sync.Mutex
started bool
stage adapter.StartStage
providers []adapter.CertificateProviderService
providerByTag map[string]adapter.CertificateProviderService
}
func NewManager(logger log.ContextLogger, registry adapter.CertificateProviderRegistry) *Manager {
return &Manager{
logger: logger,
registry: registry,
providerByTag: make(map[string]adapter.CertificateProviderService),
}
}
func (m *Manager) Start(stage adapter.StartStage) error {
m.access.Lock()
if m.started && m.stage >= stage {
panic("already started")
}
m.started = true
m.stage = stage
providers := m.providers
m.access.Unlock()
for _, provider := range providers {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
m.logger.Trace(stage, " ", name)
startTime := time.Now()
err := adapter.LegacyStart(provider, stage)
if err != nil {
return E.Cause(err, stage, " ", name)
}
m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
return nil
}
func (m *Manager) Close() error {
m.access.Lock()
defer m.access.Unlock()
if !m.started {
return nil
}
m.started = false
providers := m.providers
m.providers = nil
monitor := taskmonitor.New(m.logger, C.StopTimeout)
var err error
for _, provider := range providers {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
m.logger.Trace("close ", name)
startTime := time.Now()
monitor.Start("close ", name)
err = E.Append(err, provider.Close(), func(err error) error {
return E.Cause(err, "close ", name)
})
monitor.Finish()
m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
return err
}
func (m *Manager) CertificateProviders() []adapter.CertificateProviderService {
m.access.Lock()
defer m.access.Unlock()
return m.providers
}
func (m *Manager) Get(tag string) (adapter.CertificateProviderService, bool) {
m.access.Lock()
provider, found := m.providerByTag[tag]
m.access.Unlock()
return provider, found
}
func (m *Manager) Remove(tag string) error {
m.access.Lock()
provider, found := m.providerByTag[tag]
if !found {
m.access.Unlock()
return os.ErrInvalid
}
delete(m.providerByTag, tag)
index := common.Index(m.providers, func(it adapter.CertificateProviderService) bool {
return it == provider
})
if index == -1 {
panic("invalid certificate provider index")
}
m.providers = append(m.providers[:index], m.providers[index+1:]...)
started := m.started
m.access.Unlock()
if started {
return provider.Close()
}
return nil
}
func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error {
provider, err := m.registry.Create(ctx, logger, tag, providerType, options)
if err != nil {
return err
}
m.access.Lock()
defer m.access.Unlock()
if m.started {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
for _, stage := range adapter.ListStartStages {
m.logger.Trace(stage, " ", name)
startTime := time.Now()
err = adapter.LegacyStart(provider, stage)
if err != nil {
return E.Cause(err, stage, " ", name)
}
m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
}
if existsProvider, loaded := m.providerByTag[tag]; loaded {
if m.started {
err = existsProvider.Close()
if err != nil {
return E.Cause(err, "close certificate-provider/", existsProvider.Type(), "[", existsProvider.Tag(), "]")
}
}
existsIndex := common.Index(m.providers, func(it adapter.CertificateProviderService) bool {
return it == existsProvider
})
if existsIndex == -1 {
panic("invalid certificate provider index")
}
m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...)
}
m.providers = append(m.providers, provider)
m.providerByTag[tag] = provider
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package certificate
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
)
type ConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.CertificateProviderService, error)
func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) {
registry.register(providerType, func() any {
return new(Options)
}, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.CertificateProviderService, error) {
var options *Options
if rawOptions != nil {
options = rawOptions.(*Options)
}
return constructor(ctx, logger, tag, common.PtrValueOrDefault(options))
})
}
var _ adapter.CertificateProviderRegistry = (*Registry)(nil)
type (
optionsConstructorFunc func() any
constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.CertificateProviderService, error)
)
type Registry struct {
access sync.Mutex
optionsType map[string]optionsConstructorFunc
constructor map[string]constructorFunc
}
func NewRegistry() *Registry {
return &Registry{
optionsType: make(map[string]optionsConstructorFunc),
constructor: make(map[string]constructorFunc),
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(providerType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
optionsConstructor, loaded := m.optionsType[providerType]
if !loaded {
return nil, false
}
return optionsConstructor(), true
}
func (m *Registry) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (adapter.CertificateProviderService, error) {
m.access.Lock()
defer m.access.Unlock()
constructor, loaded := m.constructor[providerType]
if !loaded {
return nil, E.New("certificate provider type not found: " + providerType)
}
return constructor(ctx, logger, tag, options)
}
func (m *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) {
m.access.Lock()
defer m.access.Unlock()
m.optionsType[providerType] = optionsConstructor
m.constructor[providerType] = constructor
}
+17
View File
@@ -0,0 +1,17 @@
//go:build darwin && cgo
package adapter
import "unsafe"
type AppleAnchors interface {
Retain() AppleAnchors
Release()
// Ref returns the underlying CFArrayRef, or nil if the anchor set is empty.
Ref() unsafe.Pointer
}
type AppleCertificateStore interface {
CertificateStore
AppleAnchors() AppleAnchors
}
+38
View File
@@ -0,0 +1,38 @@
package adapter
import (
"context"
"crypto/tls"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
)
type CertificateProvider interface {
GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
}
type ACMECertificateProvider interface {
CertificateProvider
GetACMENextProtos() []string
}
type CertificateProviderService interface {
Lifecycle
Type() string
Tag() string
CertificateProvider
}
type CertificateProviderRegistry interface {
option.CertificateProviderOptionsRegistry
Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (CertificateProviderService, error)
}
type CertificateProviderManager interface {
Lifecycle
CertificateProviders() []CertificateProviderService
Get(tag string) (CertificateProviderService, bool)
Remove(tag string) error
Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error
}
+42 -21
View File
@@ -3,6 +3,7 @@ package adapter
import (
"context"
"net/netip"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
@@ -17,6 +18,7 @@ import (
type DNSRouter interface {
Lifecycle
Exchange(ctx context.Context, message *dns.Msg, options DNSQueryOptions) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, message *dns.Msg, options DNSQueryOptions, callback func(response *dns.Msg, err error))
Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error)
ClearCache()
LookupReverseMapping(ip netip.Addr) (string, bool)
@@ -25,35 +27,41 @@ type DNSRouter interface {
type DNSClient interface {
Start()
Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error)
Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error)
Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error))
Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error)
ClearCache()
}
type DNSQueryOptions struct {
Transport DNSTransport
Strategy C.DomainStrategy
LookupStrategy C.DomainStrategy
DisableCache bool
RewriteTTL *uint32
ClientSubnet netip.Prefix
Transport DNSTransport
Strategy C.DomainStrategy
LookupStrategy C.DomainStrategy
DisableCache bool
DisableOptimisticCache bool
RewriteTTL *uint32
Timeout time.Duration
ClientSubnet netip.Prefix
RemoveClientSubnet bool
}
func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) {
if options == nil {
return &DNSQueryOptions{}, nil
func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (DNSQueryOptions, error) {
if options == nil || options.Server == "" {
return DNSQueryOptions{}, nil
}
transportManager := service.FromContext[DNSTransportManager](ctx)
transport, loaded := transportManager.Transport(options.Server)
if !loaded {
return nil, E.New("domain resolver not found: " + options.Server)
return DNSQueryOptions{}, E.New("domain resolver not found: " + options.Server)
}
return &DNSQueryOptions{
Transport: transport,
Strategy: C.DomainStrategy(options.Strategy),
DisableCache: options.DisableCache,
RewriteTTL: options.RewriteTTL,
ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}),
return DNSQueryOptions{
Transport: transport,
Strategy: C.DomainStrategy(options.Strategy),
DisableCache: options.DisableCache,
DisableOptimisticCache: options.DisableOptimisticCache,
RewriteTTL: options.RewriteTTL,
Timeout: time.Duration(options.Timeout),
ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}),
}, nil
}
@@ -63,6 +71,13 @@ type RDRCStore interface {
SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger)
}
type DNSCacheStore interface {
LoadDNSCache(transportName string, qName string, qType uint16) (rawMessage []byte, expireAt time.Time, loaded bool)
SaveDNSCache(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time) error
SaveDNSCacheAsync(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time, logger logger.Logger)
ClearDNSCache() error
}
type DNSTransport interface {
Lifecycle
Type() string
@@ -72,11 +87,17 @@ type DNSTransport interface {
// Exchanges that are currently using those connections may fail.
Reset()
Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, message *dns.Msg, callback func(response *dns.Msg, err error))
}
type LegacyDNSTransport interface {
LegacyStrategy() C.DomainStrategy
LegacyClientSubnet() netip.Prefix
type DNSTransportWithPreferredDomain interface {
DNSTransport
PreferredDomain(domain string) bool
}
type DNSTransportWithEnvironment interface {
DNSTransport
Environment() []string
}
type DNSTransportRegistry interface {
+8
View File
@@ -2,6 +2,8 @@ package endpoint
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+44 -19
View File
@@ -7,17 +7,17 @@ import (
"io"
"time"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/observable"
"github.com/sagernet/sing/common/varbin"
)
type ClashServer interface {
LifecycleService
ConnectionTracker
Mode() string
ModeList() []string
SetModeUpdateHook(hook *observable.Subscriber[struct{}])
HistoryStorage() URLTestHistoryStorage
SetMode(mode string)
AddModeUpdateHook(hook *observable.Subscriber[struct{}])
}
type URLTestHistory struct {
@@ -25,14 +25,6 @@ type URLTestHistory struct {
Delay uint16 `json:"delay"`
}
type URLTestHistoryStorage interface {
SetHook(hook *observable.Subscriber[struct{}])
LoadURLTestHistory(tag string) *URLTestHistory
DeleteURLTestHistory(tag string)
StoreURLTestHistory(tag string, history *URLTestHistory)
Close() error
}
type V2RayServer interface {
LifecycleService
StatsService() ConnectionTracker
@@ -41,6 +33,8 @@ type V2RayServer interface {
type CacheFile interface {
LifecycleService
CacheID() string
StoreFakeIP() bool
FakeIPStorage
@@ -49,6 +43,13 @@ type CacheFile interface {
StoreWARPConfig() bool
StoreMASQUEConfig() bool
StoreSubscriptions() bool
StoreDNS() bool
DNSCacheStore
SetDisableExpire(disableExpire bool)
SetOptimisticTimeout(timeout time.Duration)
LoadMode() string
StoreMode(mode string) error
@@ -70,11 +71,12 @@ type SavedBinary struct {
Content []byte
LastUpdated time.Time
LastEtag string
URLHash []byte
}
func (s *SavedBinary) MarshalBinary() ([]byte, error) {
var buffer bytes.Buffer
err := binary.Write(&buffer, binary.BigEndian, uint8(1))
err := binary.Write(&buffer, binary.BigEndian, uint8(2))
if err != nil {
return nil, err
}
@@ -98,6 +100,14 @@ func (s *SavedBinary) MarshalBinary() ([]byte, error) {
if err != nil {
return nil, err
}
_, err = varbin.WriteUvarint(&buffer, uint64(len(s.URLHash)))
if err != nil {
return nil, err
}
_, err = buffer.Write(s.URLHash)
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -112,6 +122,9 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error {
if err != nil {
return err
}
if contentLength > uint64(reader.Len()) {
return E.New("invalid content length: ", contentLength)
}
s.Content = make([]byte, contentLength)
_, err = io.ReadFull(reader, s.Content)
if err != nil {
@@ -127,12 +140,30 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error {
if err != nil {
return err
}
if etagLength > uint64(reader.Len()) {
return E.New("invalid etag length: ", etagLength)
}
etagBytes := make([]byte, etagLength)
_, err = io.ReadFull(reader, etagBytes)
if err != nil {
return err
}
s.LastEtag = string(etagBytes)
if version < 2 {
return nil
}
urlHashLength, err := binary.ReadUvarint(reader)
if err != nil {
return err
}
if urlHashLength > uint64(reader.Len()) {
return E.New("invalid url hash length: ", urlHashLength)
}
s.URLHash = make([]byte, urlHashLength)
_, err = io.ReadFull(reader, s.URLHash)
if err != nil {
return err
}
return nil
}
@@ -145,11 +176,5 @@ type OutboundGroup interface {
type URLTestGroup interface {
OutboundGroup
URLTest(ctx context.Context) (map[string]uint16, error)
}
func OutboundTag(detour Outbound) string {
if group, isGroup := detour.(OutboundGroup); isGroup {
return group.Now()
}
return detour.Tag()
PerformUpdateCheck()
}
+6 -32
View File
@@ -5,57 +5,31 @@ import (
"net"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
// Deprecated
type ConnectionHandler interface {
NewConnection(ctx context.Context, conn net.Conn, metadata InboundContext) error
NewConnection(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
type ConnectionHandlerEx interface {
NewConnectionEx(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
// Deprecated: use PacketHandlerEx instead
type PacketHandler interface {
NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata InboundContext) error
NewPacket(buffer *buf.Buffer, source M.Socksaddr)
}
type PacketHandlerEx interface {
NewPacketEx(buffer *buf.Buffer, source M.Socksaddr)
type PacketBatchHandler interface {
NewPacketBatch(buffers []*buf.Buffer, sources []M.Socksaddr)
}
// Deprecated: use OOBPacketHandlerEx instead
type OOBPacketHandler interface {
NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, oob []byte, metadata InboundContext) error
NewPacket(buffer *buf.Buffer, oob []byte, source M.Socksaddr)
}
type OOBPacketHandlerEx interface {
NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr)
}
// Deprecated
type PacketConnectionHandler interface {
NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
type PacketConnectionHandlerEx interface {
NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
// Deprecated: use TCPConnectionHandlerEx instead
//
//nolint:staticcheck
type UpstreamHandlerAdapter interface {
N.TCPConnectionHandler
N.UDPConnectionHandler
E.Handler
}
type UpstreamHandlerAdapterEx interface {
N.TCPConnectionHandlerEx
N.UDPConnectionHandlerEx
}
+43
View File
@@ -0,0 +1,43 @@
package adapter
import (
"context"
"net/http"
"sync"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/logger"
)
type HTTPTransport interface {
http.RoundTripper
CloseIdleConnections()
Reset()
}
type HTTPClientManager interface {
ResolveTransport(ctx context.Context, logger logger.ContextLogger, options option.HTTPClientOptions) (HTTPTransport, error)
DefaultTransport() HTTPTransport
ResetNetwork()
}
type HTTPStartContext struct {
access sync.Mutex
transports []HTTPTransport
}
func NewHTTPStartContext() *HTTPStartContext {
return &HTTPStartContext{}
}
func (c *HTTPStartContext) Register(transport HTTPTransport) {
c.access.Lock()
defer c.access.Unlock()
c.transports = append(c.transports, transport)
}
func (c *HTTPStartContext) Close() {
for _, transport := range c.transports {
transport.CloseIdleConnections()
}
}
+85 -10
View File
@@ -2,13 +2,17 @@ package adapter
import (
"context"
"net"
"net/netip"
"time"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
"github.com/miekg/dns"
)
type Inbound interface {
@@ -19,12 +23,12 @@ type Inbound interface {
type TCPInjectableInbound interface {
Inbound
ConnectionHandlerEx
ConnectionHandler
}
type UDPInjectableInbound interface {
Inbound
PacketConnectionHandlerEx
PacketConnectionHandler
}
type InboundRegistry interface {
@@ -52,6 +56,11 @@ type InboundContext struct {
User string
Outbound string
// power report
RouteRule string
RouteOutbound string
// sniffer
Protocol string
@@ -74,18 +83,28 @@ type InboundContext struct {
TLSFragment bool
TLSFragmentFallbackDelay time.Duration
TLSRecordFragment bool
TLSSpoof string
TLSSpoofMethod tlsspoof.Method
NetworkStrategy *C.NetworkStrategy
NetworkType []C.InterfaceType
FallbackNetworkType []C.InterfaceType
FallbackDelay time.Duration
DestinationAddresses []netip.Addr
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
QueryType uint16
FakeIP bool
DestinationAddresses []netip.Addr
DNSResponse *dns.Msg
NamedDNSResponses map[string]*dns.Msg
DestinationAddressMatchFromResponse bool
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
SourceMACAddress net.HardwareAddr
SourceHostname string
QueryType uint16
QueryClientSubnet netip.Prefix
QueryDNSSEC bool
FakeIP bool
PreMatch bool
// rule cache
@@ -96,7 +115,7 @@ type InboundContext struct {
SourcePortMatch bool
DestinationAddressMatch bool
DestinationPortMatch bool
DidMatch bool
DeferredIPCIDRMatchGroups uint8
IgnoreDestinationIPCIDRMatch bool
}
@@ -111,11 +130,67 @@ func (c *InboundContext) ResetRuleMatchCache() {
c.SourcePortMatch = false
c.DestinationAddressMatch = false
c.DestinationPortMatch = false
c.DidMatch = false
c.DeferredIPCIDRMatchGroups = 0
}
func (c *InboundContext) DNSResponseAddressesForMatch() []netip.Addr {
return DNSResponseAddresses(c.DNSResponse)
}
func DNSResponseAddresses(response *dns.Msg) []netip.Addr {
if response == nil || response.Rcode != dns.RcodeSuccess {
return nil
}
addresses := make([]netip.Addr, 0, len(response.Answer))
for _, rawRecord := range response.Answer {
switch record := rawRecord.(type) {
case *dns.A:
addr := M.AddrFromIP(record.A)
if addr.IsValid() {
addresses = append(addresses, addr)
}
case *dns.AAAA:
addr := M.AddrFromIP(record.AAAA)
if addr.IsValid() {
addresses = append(addresses, addr)
}
case *dns.HTTPS:
for _, value := range record.SVCB.Value {
switch hint := value.(type) {
case *dns.SVCBIPv4Hint:
for _, ip := range hint.Hint {
addr := M.AddrFromIP(ip).Unmap()
if addr.IsValid() {
addresses = append(addresses, addr)
}
}
case *dns.SVCBIPv6Hint:
for _, ip := range hint.Hint {
addr := M.AddrFromIP(ip)
if addr.IsValid() {
addresses = append(addresses, addr)
}
}
}
}
}
}
return addresses
}
type inboundContextKey struct{}
type dnsTransportTagKey struct{}
func ContextWithDNSTransportTag(ctx context.Context, transportTag string) context.Context {
return context.WithValue(ctx, (*dnsTransportTagKey)(nil), transportTag)
}
func DNSTransportTagFromContext(ctx context.Context) (string, bool) {
transportTag, loaded := ctx.Value((*dnsTransportTagKey)(nil)).(string)
return transportTag, loaded
}
func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context {
return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext)
}
+8
View File
@@ -2,6 +2,8 @@ package inbound
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+45
View File
@@ -0,0 +1,45 @@
package adapter
import (
"net"
"net/netip"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
func TestDNSResponseAddressesUnmapsHTTPSIPv4Hints(t *testing.T) {
t.Parallel()
ipv4Hint := net.ParseIP("1.1.1.1")
require.NotNil(t, ipv4Hint)
response := &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
Rcode: dns.RcodeSuccess,
},
Answer: []dns.RR{
&dns.HTTPS{
SVCB: dns.SVCB{
Hdr: dns.RR_Header{
Name: dns.Fqdn("example.com"),
Rrtype: dns.TypeHTTPS,
Class: dns.ClassINET,
Ttl: 60,
},
Priority: 1,
Target: ".",
Value: []dns.SVCBKeyValue{
&dns.SVCBIPv4Hint{Hint: []net.IP{ipv4Hint}},
},
},
},
},
}
addresses := DNSResponseAddresses(response)
require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses)
require.True(t, addresses[0].Is4())
}
+13 -4
View File
@@ -1,6 +1,7 @@
package adapter
import (
"context"
"reflect"
"strings"
"time"
@@ -74,11 +75,15 @@ func getServiceName(service any) string {
return strings.ToLower(t.Name())
}
func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) error {
func Start(ctx context.Context, logger log.ContextLogger, stage StartStage, services ...Lifecycle) error {
for _, service := range services {
err := ctx.Err()
if err != nil {
return err
}
name := getServiceName(service)
done := LogElapsed(logger, stage, " ", name)
err := service.Start(stage)
err = service.Start(stage)
done()
if err != nil {
return err
@@ -87,10 +92,14 @@ func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) er
return nil
}
func StartNamed(logger log.ContextLogger, stage StartStage, services []LifecycleService) error {
func StartNamed(ctx context.Context, logger log.ContextLogger, stage StartStage, services []LifecycleService) error {
for _, service := range services {
err := ctx.Err()
if err != nil {
return err
}
done := LogElapsed(logger, stage, " ", service.Name())
err := service.Start(stage)
err = service.Start(stage)
done()
if err != nil {
return E.Cause(err, stage.String(), " ", service.Name())
+24
View File
@@ -0,0 +1,24 @@
package adapter
import (
"net"
"net/netip"
)
type NeighborEntry struct {
Address netip.Addr
MACAddress net.HardwareAddr
Hostname string
}
type NeighborResolver interface {
LookupMAC(address netip.Addr) (net.HardwareAddr, bool)
LookupHostname(address netip.Addr) (string, bool)
LookupAddresses(hostname string) []netip.Addr
Start() error
Close() error
}
type NeighborUpdateListener interface {
UpdateNeighborTable(entries []NeighborEntry)
}
+5
View File
@@ -0,0 +1,5 @@
package adapter
type NetworkNamespaceManager interface {
ResolvePath(nameOrPath string) string
}
+7 -3
View File
@@ -1,8 +1,10 @@
package adapter
import (
"context"
"encoding/hex"
"net"
"net/netip"
"strings"
"time"
@@ -18,6 +20,7 @@ type NetworkManager interface {
UpdateInterfaces() error
DefaultNetworkInterface() *NetworkInterface
NetworkInterfaces() []NetworkInterface
NetworkEnvironment() uint64
AutoDetectInterface() bool
AutoDetectInterfaceFunc() control.Func
ProtectFunc() control.Func
@@ -30,8 +33,8 @@ type NetworkManager interface {
PackageManager() tun.PackageManager
NeedWIFIState() bool
WIFIState() WIFIState
UpdateWIFIState()
ResetNetwork()
UpdateWIFIState(ctx context.Context)
ResetNetwork(ctx context.Context)
}
type NetworkOptions struct {
@@ -46,7 +49,7 @@ type NetworkOptions struct {
}
type InterfaceUpdateListener interface {
InterfaceUpdated()
InterfaceUpdated(ctx context.Context)
}
type WIFIState struct {
@@ -76,6 +79,7 @@ type NetworkInterface struct {
control.Interface
Type C.InterfaceType
DNSServers []string
Gateways []netip.Addr
Expensive bool
Constrained bool
}
+101
View File
@@ -0,0 +1,101 @@
package adapter
import (
"net/netip"
"time"
)
const (
OpenConnectStateConnecting = "connecting"
OpenConnectStateAuthPending = "auth-pending"
OpenConnectStateConnected = "connected"
OpenConnectStateError = "error"
)
type OpenConnectEndpoint interface {
Endpoint
OpenConnectStatus() OpenConnectStatus
StatusUpdated() <-chan struct{}
CompleteAuthChallenge(challengeID string, response OpenConnectAuthResponse) error
CancelAuthChallenge(challengeID string) error
}
type OpenConnectStatus struct {
State string
AuthChallenge *OpenConnectAuthChallenge
Error string
TunnelInfo *OpenConnectTunnelInfo
}
type OpenConnectTunnelInfo struct {
Server string
Flavor string
Transport string
IPv4 []netip.Prefix
IPv6 []netip.Prefix
DNS []netip.Addr
MTU uint32
ConnectedSince time.Time
}
type OpenConnectAuthChallenge struct {
ID string
Banner string
Message string
Error string
Form *OpenConnectAuthForm
Browser *OpenConnectBrowserRequest
}
type OpenConnectAuthForm struct {
Fields []OpenConnectAuthFormField
}
type OpenConnectBrowserRequest struct {
URL string
FinalURL string
CookieNames []string
EarlyCookieNames []string
HeaderNames []string
CallbackURLPrefixes []string
CacheID string
}
type OpenConnectBrowserCookie struct {
Name string
Value string
}
type OpenConnectBrowserHeader struct {
Name string
Values []string
}
type OpenConnectAuthResponse struct {
Form *OpenConnectAuthFormResponse
Browser *OpenConnectBrowserResult
}
type OpenConnectAuthFormResponse struct {
Values map[string]string
}
type OpenConnectBrowserResult struct {
FinalURL string
Cookies []OpenConnectBrowserCookie
Headers []OpenConnectBrowserHeader
}
type OpenConnectAuthFormField struct {
SubmissionKey string
Name string
Label string
Kind string
Value string
Options []OpenConnectAuthFormChoice
}
type OpenConnectAuthFormChoice struct {
Value string
Label string
}
+57
View File
@@ -0,0 +1,57 @@
package adapter
import (
"net/netip"
"time"
)
const (
OpenVPNStateConnecting = "connecting"
OpenVPNStateAuthPending = "auth-pending"
OpenVPNStateConnected = "connected"
OpenVPNStateError = "error"
)
type OpenVPNEndpoint interface {
Endpoint
OpenVPNStatus() OpenVPNStatus
StatusUpdated() <-chan struct{}
CompleteChallenge(challengeID string, response OpenVPNChallengeResponse) error
CancelChallenge(challengeID string) error
}
type OpenVPNStatus struct {
State string
Challenge *OpenVPNChallenge
Error string
TunnelInfo *OpenVPNTunnelInfo
}
type OpenVPNTunnelInfo struct {
Server string
Network string
Cipher string
IPv4 []netip.Prefix
IPv6 []netip.Prefix
DNS []netip.Addr
MTU uint32
ConnectedSince time.Time
}
type OpenVPNChallenge struct {
ID string
Kind string
Username string
Message string
URL string
SecretMessage string
Echo bool
PreviousError string
Deadline time.Time
}
type OpenVPNChallengeResponse struct {
Username string
Password string
Secret string
}
+10 -5
View File
@@ -3,7 +3,6 @@ package adapter
import (
"context"
"net/netip"
"time"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
@@ -23,13 +22,19 @@ type Outbound interface {
type OutboundWithPreferredRoutes interface {
Outbound
PreferredDomain(domain string) bool
PreferredAddress(address netip.Addr) bool
PreferredDomain(metadata *InboundContext, domain string) bool
PreferredAddress(metadata *InboundContext, address netip.Addr) bool
}
type DirectRouteOutbound interface {
type OutboundWithMultiplex interface {
Outbound
NewDirectRouteConnection(metadata InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error)
MultiplexEnabled() bool
}
type FlowOutbound interface {
Outbound
tun.Port
PreMatchFlow(network string, destination netip.Addr) PreMatchAction
}
type OutboundRegistry interface {
+8
View File
@@ -2,6 +2,8 @@ package outbound
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (r *Registry) OptionTypes() []string {
r.access.Lock()
defer r.access.Unlock()
return slices.Sorted(maps.Keys(r.optionsType))
}
func (r *Registry) CreateOptions(outboundType string) (any, bool) {
r.access.Lock()
defer r.access.Unlock()
+46 -2
View File
@@ -1,6 +1,7 @@
package adapter
import (
"context"
"net/netip"
"github.com/sagernet/sing-box/option"
@@ -17,6 +18,7 @@ type PlatformInterface interface {
UsePlatformInterface() bool
OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error)
ProcessPlatformOptions(options option.TunPlatformOptions) error
UsePlatformDefaultInterfaceMonitor() bool
CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor
@@ -29,8 +31,7 @@ type PlatformInterface interface {
ClearDNSCache()
RequestPermissionForWIFIState() error
ReadWIFIState() WIFIState
SystemCertificates() []string
ReadWIFIState(ctx context.Context) WIFIState
UsePlatformConnectionOwnerFinder() bool
FindConnectionOwner(request *FindConnectionOwnerRequest) (*ConnectionOwner, error)
@@ -39,8 +40,51 @@ type PlatformInterface interface {
UsePlatformNotification() bool
SendNotification(notification *Notification) error
CancelNotification(identifier string, typeID int32) error
MyInterfaceAddress() []netip.Addr
UsePlatformNeighborResolver() bool
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
UsePlatformShell() bool
CheckPlatformShell() error
OpenShellSession(user *PlatformUser, command string, env []string, term string, rows int32, cols int32) (ShellSession, error)
LookupUser(username string) (*PlatformUser, error)
LookupSFTPServer() (string, error)
ReadSystemSSHHostKey() ([]byte, error)
TailscaleHostname() string
UsePlatformBridge() bool
CreateBridge(options BridgeOptions) (BridgeSession, error)
}
type BridgeOptions struct {
BridgeName string
MTU uint32
Inet4Port netip.Addr
Inet6Port netip.Addr
Interface string
RuleIndex int
RouteTable int
}
type BridgeSession interface {
FileDescriptor() int
Name() string
Inet6Active() bool
SetEgress(interfaceName string) error
Close() error
}
type PlatformUser struct {
Username string
Uid int
Gid int
HomeDir string
Shell string
Groups []int
}
type FindConnectionOwnerRequest struct {
+18 -12
View File
@@ -32,15 +32,16 @@ type Adapter struct {
outboundsByTag map[string]adapter.Outbound
ticker *time.Ticker
checking atomic.Bool
history adapter.URLTestHistoryStorage
history *urltest.HistoryStorage
callbackAccess sync.Mutex
callbacks list.List[adapter.ProviderUpdateCallback]
link string
enabled bool
removeEmojis bool
timeout time.Duration
interval time.Duration
link string
enabled bool
removeEmojis bool
overrideDialerOptions *option.DialerOptions
timeout time.Duration
interval time.Duration
}
func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter {
@@ -75,14 +76,14 @@ func (a *Adapter) SetRemoveEmojis(remove bool) {
a.removeEmojis = remove
}
func (a *Adapter) SetOverrideDialerOptions(options *option.DialerOptions) {
a.overrideDialerOptions = options
}
func (a *Adapter) Start() error {
a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx)
a.history = service.PtrFromContext[urltest.HistoryStorage](a.ctx)
if a.history == nil {
if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil {
a.history = clashServer.HistoryStorage()
} else {
a.history = urltest.NewHistoryStorage()
}
a.history = urltest.NewHistoryStorage()
}
go a.loopCheck()
return nil
@@ -123,6 +124,11 @@ func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Ou
oldOptByTag[opt.Tag] = opt
}
for i, opt := range newOpts {
if a.overrideDialerOptions != nil {
if wrapper, ok := opt.Options.(option.DialerOptionsWrapper); ok {
wrapper.ReplaceDialerOptions(*a.overrideDialerOptions)
}
}
var tag string
if opt.Tag != "" {
tag = F.ToString(a.providerTag, "/", opt.Tag)
+93 -53
View File
@@ -2,17 +2,14 @@ package adapter
import (
"context"
"crypto/tls"
"net"
"net/http"
"sync"
"net/netip"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/common/x/list"
"go4.org/netipx"
@@ -21,18 +18,93 @@ import (
type Router interface {
Lifecycle
ConnectionRouter
PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error)
PreMatch(metadata InboundContext, firstPacket []byte) PreMatchResult
HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata InboundContext)
ConnectionRouterEx
RuleSet(tag string) (RuleSet, bool)
Rules() []Rule
NeedFindProcess() bool
NeedFindNeighbor() bool
NeighborResolver() NeighborResolver
AppendTracker(tracker ConnectionTracker)
ResetNetwork()
}
type PreMatchAction uint8
const (
PreMatchContinue PreMatchAction = iota
PreMatchFlow
PreMatchReject
PreMatchDrop
PreMatchBypass
PreMatchHijackDNS
)
type PreMatchResult struct {
Action PreMatchAction
Outbound Outbound
Destination netip.AddrPort
UDPTimeout time.Duration
NewTracker func() tun.FlowTracker
}
func JudgeFlow(router Router, inbound string, inboundType string, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
var networkName string
switch network {
case uint8(header.TCPProtocolNumber):
networkName = N.NetworkTCP
case uint8(header.UDPProtocolNumber):
networkName = N.NetworkUDP
case uint8(header.ICMPv4ProtocolNumber), uint8(header.ICMPv6ProtocolNumber):
networkName = N.NetworkICMP
default:
return tun.FlowVerdict{Action: tun.ActionAccept}
}
metadata := InboundContext{
Inbound: inbound,
InboundType: inboundType,
Network: networkName,
Source: M.SocksaddrFromNetIP(source),
Destination: M.SocksaddrFromNetIP(destination),
}
if networkName == N.NetworkICMP {
metadata.Source.Port = 0
metadata.Destination.Port = 0
}
result := router.PreMatch(metadata, firstPacket)
switch result.Action {
case PreMatchFlow:
port, isPort := result.Outbound.(tun.Port)
if !isPort {
return tun.FlowVerdict{Action: tun.ActionAccept}
}
verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker}
if result.Destination.IsValid() {
destinationPort := result.Destination.Port()
if networkName == N.NetworkICMP {
destinationPort = destination.Port()
}
verdict.Destination = netip.AddrPortFrom(result.Destination.Addr(), destinationPort)
}
return verdict
case PreMatchReject:
return tun.FlowVerdict{Action: tun.ActionReject}
case PreMatchDrop:
return tun.FlowVerdict{Action: tun.ActionDrop}
case PreMatchBypass:
return tun.FlowVerdict{Action: tun.ActionBypass}
case PreMatchHijackDNS:
return tun.FlowVerdict{Action: tun.ActionHijackDNS}
default:
return tun.FlowVerdict{Action: tun.ActionAccept}
}
}
type ConnectionTracker interface {
RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) net.Conn
RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) N.PacketConn
RoutedFlow(ctx context.Context, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) tun.FlowTracker
}
// Deprecated: Use ConnectionRouterEx instead.
@@ -50,7 +122,6 @@ type ConnectionRouterEx interface {
type RuleSet interface {
Name() string
StartContext(ctx context.Context, startContext *HTTPStartContext) error
PostStart() error
Metadata() RuleSetMetadata
ExtractIPSet() []*netipx.IPSet
IncRef()
@@ -64,51 +135,20 @@ type RuleSet interface {
type RuleSetUpdateCallback func(it RuleSet)
type DNSRuleSetUpdateValidator interface {
ValidateRuleSetMetadataUpdate(tag string, metadata RuleSetMetadata) error
}
// ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent.
type RuleSetMetadata struct {
ContainsProcessRule bool
ContainsWIFIRule bool
ContainsIPCIDRRule bool
}
type HTTPStartContext struct {
ctx context.Context
access sync.Mutex
httpClientCache map[string]*http.Client
}
func NewHTTPStartContext(ctx context.Context) *HTTPStartContext {
return &HTTPStartContext{
ctx: ctx,
httpClientCache: make(map[string]*http.Client),
}
}
func (c *HTTPStartContext) HTTPClient(detour string, dialer N.Dialer) *http.Client {
c.access.Lock()
defer c.access.Unlock()
if httpClient, loaded := c.httpClientCache[detour]; loaded {
return httpClient
}
httpClient := &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: C.TCPTimeout,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
TLSClientConfig: &tls.Config{
Time: ntp.TimeFuncFromContext(c.ctx),
RootCAs: RootPoolFromContext(c.ctx),
},
},
}
c.httpClientCache[detour] = httpClient
return httpClient
}
func (c *HTTPStartContext) Close() {
c.access.Lock()
defer c.access.Unlock()
for _, client := range c.httpClientCache {
client.CloseIdleConnections()
}
ContainsProcessRule bool
ContainsWIFIRule bool
ContainsIPCIDRRule bool
ContainsDNSQueryTypeRule bool
// ContainsNonIPCIDRRule signals that the rule-set carries at least one sub-rule
// with a predicate other than destination ip_cidr / ip_set, so it can contribute
// to DNS pre-response matching. A rule-set where this is false and
// ContainsIPCIDRRule is true is "pure-IP" and matches nothing before a DNS
// response is available.
ContainsNonIPCIDRRule bool
}
+9 -2
View File
@@ -2,6 +2,8 @@ package adapter
import (
C "github.com/sagernet/sing-box/constant"
"github.com/miekg/dns"
)
type HeadlessRule interface {
@@ -18,8 +20,13 @@ type Rule interface {
type DNSRule interface {
Rule
LegacyPreMatch(metadata *InboundContext) bool
WithAddressLimit() bool
MatchAddressLimit(metadata *InboundContext) bool
MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool
MatchResponseTag() string
MatchResponseTags() []string
MatchResponseAnonymous() bool
Race() bool
}
type RuleAction interface {
@@ -29,7 +36,7 @@ type RuleAction interface {
func IsFinalAction(action RuleAction) bool {
switch action.Type() {
case C.RuleActionTypeSniff, C.RuleActionTypeResolve:
case C.RuleActionTypeSniff, C.RuleActionTypeResolve, C.RuleActionTypeEvaluate:
return false
default:
return true
+19
View File
@@ -0,0 +1,19 @@
package adapter
import (
"context"
"github.com/sagernet/sing/service"
)
type SecurityPolicy interface {
CheckFeature(ctx context.Context, feature string) error
}
func CheckSecurityFeature(ctx context.Context, feature string) error {
policy := service.FromContext[SecurityPolicy](ctx)
if policy == nil {
return nil
}
return policy.CheckFeature(ctx, feature)
}
+8
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+104
View File
@@ -0,0 +1,104 @@
package adapter
import (
"context"
"io"
"time"
)
type TailscaleEndpoint interface {
SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error
StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error
SetTailscaleExitNode(ctx context.Context, stableID string) error
Logout(ctx context.Context) error
GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) (certificatePEM []byte, privateKeyPEM []byte, err error)
SubscribeTaildropInbox(ctx context.Context, fn func(*TaildropInbox)) error
MarkTaildropInboxRead() error
SendTaildropFile(ctx context.Context, peerStableID string, fileName string, size int64, content io.Reader, progress func(sentBytes int64)) error
OpenTaildropFile(fileName string) (io.ReadCloser, int64, error)
DeleteTaildropFile(fileName string) error
CancelTaildropReceiving(senderID string, fileName string) error
}
type TaildropInbox struct {
Files []*TaildropFile
Receiving []*TaildropReceivingFile
}
type TaildropFile struct {
Name string
Size int64
SenderName string
ModifiedAt int64
}
type TaildropReceivingFile struct {
Name string
Size int64
ReceivedBytes int64
SenderID string
SenderName string
}
type TailscalePingResult struct {
LatencyMs float64
IsDirect bool
Endpoint string
PeerRelay string
DERPRegionID int32
DERPRegionCode string
Error string
}
type TailscaleEndpointStatus struct {
BackendState string
AuthURL string
NetworkName string
MagicDNSSuffix string
Self *TailscalePeer
ExitNode *TailscalePeer
UserGroups []*TailscaleUserGroup
KeyAuth bool
CanShareFiles bool
WaitingFileCount int32
ReceivingFileCount int32
UnreadFileCount int32
CertDomains []string
}
type TailscaleUserGroup struct {
UserID int64
LoginName string
DisplayName string
ProfilePicURL string
Peers []*TailscalePeer
}
type TailscalePeer struct {
StableID string
HostName string
DNSName string
OS string
TailscaleIPs []string
SSHHostKeys []string
Online bool
ExitNode bool
ExitNodeOption bool
ShareeNode bool
Expired bool
Active bool
CanReceiveFiles bool
RxBytes int64
TxBytes int64
UserID int64
KeyExpiry int64
LastSeen int64
}
type ShellSession interface {
MasterFD() int32
Resize(rows int32, cols int32) error
Signal(signal int32) error
WaitExit() (int32, error)
Close() error
}
+38 -38
View File
@@ -9,31 +9,31 @@ import (
)
type (
ConnectionHandlerFuncEx = func(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
PacketConnectionHandlerFuncEx = func(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
ConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
PacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
)
func NewUpstreamHandlerEx(
func NewUpstreamHandler(
metadata InboundContext,
connectionHandler ConnectionHandlerFuncEx,
packetHandler PacketConnectionHandlerFuncEx,
) UpstreamHandlerAdapterEx {
return &myUpstreamHandlerWrapperEx{
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
) UpstreamHandlerAdapter {
return &myUpstreamHandlerWrapper{
metadata: metadata,
connectionHandler: connectionHandler,
packetHandler: packetHandler,
}
}
var _ UpstreamHandlerAdapterEx = (*myUpstreamHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*myUpstreamHandlerWrapper)(nil)
type myUpstreamHandlerWrapperEx struct {
type myUpstreamHandlerWrapper struct {
metadata InboundContext
connectionHandler ConnectionHandlerFuncEx
packetHandler PacketConnectionHandlerFuncEx
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
}
func (w *myUpstreamHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := w.metadata
if source.IsValid() {
myMetadata.Source = source
@@ -44,7 +44,7 @@ func (w *myUpstreamHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn n
w.connectionHandler(ctx, conn, myMetadata, onClose)
}
func (w *myUpstreamHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := w.metadata
if source.IsValid() {
myMetadata.Source = source
@@ -55,24 +55,24 @@ func (w *myUpstreamHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context,
w.packetHandler(ctx, conn, myMetadata, onClose)
}
var _ UpstreamHandlerAdapterEx = (*myUpstreamContextHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*myUpstreamContextHandlerWrapper)(nil)
type myUpstreamContextHandlerWrapperEx struct {
connectionHandler ConnectionHandlerFuncEx
packetHandler PacketConnectionHandlerFuncEx
type myUpstreamContextHandlerWrapper struct {
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
}
func NewUpstreamContextHandlerEx(
connectionHandler ConnectionHandlerFuncEx,
packetHandler PacketConnectionHandlerFuncEx,
) UpstreamHandlerAdapterEx {
return &myUpstreamContextHandlerWrapperEx{
func NewUpstreamContextHandler(
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
) UpstreamHandlerAdapter {
return &myUpstreamContextHandlerWrapper{
connectionHandler: connectionHandler,
packetHandler: packetHandler,
}
}
func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamContextHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
@@ -83,7 +83,7 @@ func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context,
w.connectionHandler(ctx, conn, *myMetadata, onClose)
}
func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamContextHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
@@ -94,24 +94,24 @@ func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Co
w.packetHandler(ctx, conn, *myMetadata, onClose)
}
func NewRouteHandlerEx(
func NewRouteHandler(
metadata InboundContext,
router ConnectionRouterEx,
) UpstreamHandlerAdapterEx {
return &routeHandlerWrapperEx{
) UpstreamHandlerAdapter {
return &routeHandlerWrapper{
metadata: metadata,
router: router,
}
}
var _ UpstreamHandlerAdapterEx = (*routeHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*routeHandlerWrapper)(nil)
type routeHandlerWrapperEx struct {
type routeHandlerWrapper struct {
metadata InboundContext
router ConnectionRouterEx
}
func (r *routeHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
if source.IsValid() {
r.metadata.Source = source
}
@@ -121,7 +121,7 @@ func (r *routeHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Co
r.router.RouteConnectionEx(ctx, conn, r.metadata, onClose)
}
func (r *routeHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
if source.IsValid() {
r.metadata.Source = source
}
@@ -131,21 +131,21 @@ func (r *routeHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn
r.router.RoutePacketConnectionEx(ctx, conn, r.metadata, onClose)
}
func NewRouteContextHandlerEx(
func NewRouteContextHandler(
router ConnectionRouterEx,
) UpstreamHandlerAdapterEx {
return &routeContextHandlerWrapperEx{
) UpstreamHandlerAdapter {
return &routeContextHandlerWrapper{
router: router,
}
}
var _ UpstreamHandlerAdapterEx = (*routeContextHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*routeContextHandlerWrapper)(nil)
type routeContextHandlerWrapperEx struct {
type routeContextHandlerWrapper struct {
router ConnectionRouterEx
}
func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeContextHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
@@ -156,7 +156,7 @@ func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn
r.router.RouteConnectionEx(ctx, conn, *metadata, onClose)
}
func (r *routeContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeContextHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
+59 -50
View File
@@ -12,21 +12,30 @@ import (
type (
// Deprecated
ConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext) error
LegacyConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext) error
// Deprecated
PacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
LegacyPacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
)
// Deprecated
//
//nolint:staticcheck
func NewUpstreamHandler(
type LegacyUpstreamHandlerAdapter interface {
N.TCPConnectionHandler
N.UDPConnectionHandler
E.Handler
}
// Deprecated
//
//nolint:staticcheck
func NewLegacyUpstreamHandler(
metadata InboundContext,
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
connectionHandler LegacyConnectionHandlerFunc,
packetHandler LegacyPacketConnectionHandlerFunc,
errorHandler E.Handler,
) UpstreamHandlerAdapter {
return &myUpstreamHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyUpstreamHandlerWrapper{
metadata: metadata,
connectionHandler: connectionHandler,
packetHandler: packetHandler,
@@ -34,20 +43,20 @@ func NewUpstreamHandler(
}
}
var _ UpstreamHandlerAdapter = (*myUpstreamHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyUpstreamHandlerWrapper)(nil)
// Deprecated: use myUpstreamHandlerWrapperEx instead.
// Deprecated: use NewUpstreamHandler instead.
//
//nolint:staticcheck
type myUpstreamHandlerWrapper struct {
type legacyUpstreamHandlerWrapper struct {
metadata InboundContext
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
connectionHandler LegacyConnectionHandlerFunc
packetHandler LegacyPacketConnectionHandlerFunc
errorHandler E.Handler
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -58,8 +67,8 @@ func (w *myUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.C
return w.connectionHandler(ctx, conn, myMetadata)
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -70,8 +79,8 @@ func (w *myUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn
return w.packetHandler(ctx, conn, myMetadata)
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewError(ctx context.Context, err error) {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewError(ctx context.Context, err error) {
w.errorHandler.NewError(ctx, err)
}
@@ -83,28 +92,28 @@ func UpstreamMetadata(metadata InboundContext) M.Metadata {
}
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
type myUpstreamContextHandlerWrapper struct {
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
// Deprecated: Use NewUpstreamContextHandler instead.
type legacyUpstreamContextHandlerWrapper struct {
connectionHandler LegacyConnectionHandlerFunc
packetHandler LegacyPacketConnectionHandlerFunc
errorHandler E.Handler
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func NewUpstreamContextHandler(
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
// Deprecated: Use NewUpstreamContextHandler instead.
func NewLegacyUpstreamContextHandler(
connectionHandler LegacyConnectionHandlerFunc,
packetHandler LegacyPacketConnectionHandlerFunc,
errorHandler E.Handler,
) UpstreamHandlerAdapter {
return &myUpstreamContextHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyUpstreamContextHandlerWrapper{
connectionHandler: connectionHandler,
packetHandler: packetHandler,
errorHandler: errorHandler,
}
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -115,8 +124,8 @@ func (w *myUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, con
return w.connectionHandler(ctx, conn, *myMetadata)
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -127,18 +136,18 @@ func (w *myUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Contex
return w.packetHandler(ctx, conn, *myMetadata)
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewError(ctx context.Context, err error) {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewError(ctx context.Context, err error) {
w.errorHandler.NewError(ctx, err)
}
// Deprecated: Use ConnectionRouterEx instead.
func NewRouteHandler(
func NewLegacyRouteHandler(
metadata InboundContext,
router ConnectionRouter,
logger logger.ContextLogger,
) UpstreamHandlerAdapter {
return &routeHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyRouteHandlerWrapper{
metadata: metadata,
router: router,
logger: logger,
@@ -146,29 +155,29 @@ func NewRouteHandler(
}
// Deprecated: Use ConnectionRouterEx instead.
func NewRouteContextHandler(
func NewLegacyRouteContextHandler(
router ConnectionRouter,
logger logger.ContextLogger,
) UpstreamHandlerAdapter {
return &routeContextHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyRouteContextHandlerWrapper{
router: router,
logger: logger,
}
}
var _ UpstreamHandlerAdapter = (*routeHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyRouteHandlerWrapper)(nil)
// Deprecated: Use ConnectionRouterEx instead.
//
//nolint:staticcheck
type routeHandlerWrapper struct {
type legacyRouteHandlerWrapper struct {
metadata InboundContext
router ConnectionRouter
logger logger.ContextLogger
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
func (w *legacyRouteHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -180,7 +189,7 @@ func (w *routeHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn,
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
func (w *legacyRouteHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -192,20 +201,20 @@ func (w *routeHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.Pa
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewError(ctx context.Context, err error) {
func (w *legacyRouteHandlerWrapper) NewError(ctx context.Context, err error) {
w.logger.ErrorContext(ctx, err)
}
var _ UpstreamHandlerAdapter = (*routeContextHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyRouteContextHandlerWrapper)(nil)
// Deprecated: Use ConnectionRouterEx instead.
type routeContextHandlerWrapper struct {
type legacyRouteContextHandlerWrapper struct {
router ConnectionRouter
logger logger.ContextLogger
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
func (w *legacyRouteContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -217,7 +226,7 @@ func (w *routeContextHandlerWrapper) NewConnection(ctx context.Context, conn net
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
func (w *legacyRouteContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -229,6 +238,6 @@ func (w *routeContextHandlerWrapper) NewPacketConnection(ctx context.Context, co
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewError(ctx context.Context, err error) {
func (w *legacyRouteContextHandlerWrapper) NewError(ctx context.Context, err error) {
w.logger.ErrorContext(ctx, err)
}
+15
View File
@@ -0,0 +1,15 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package adapter
import (
"context"
"github.com/sagernet/sing-usbip"
)
type USBIPDynamicServer interface {
AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error)
RemoveDevice(busID string)
SubscribeDevices(ctx context.Context, listener func([]usbip.ControlDeviceInfo))
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package adapter
type USBIPDynamicServer interface {
usbipNotIncluded()
}
+176 -58
View File
@@ -4,11 +4,13 @@ import (
"context"
"fmt"
"io"
"net/http"
"os"
"runtime/debug"
"time"
"github.com/sagernet/sing-box/adapter"
boxCertificate "github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/adapter/outbound"
@@ -16,13 +18,17 @@ import (
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/certificate"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/httpclient"
"github.com/sagernet/sing-box/common/netns"
"github.com/sagernet/sing-box/common/taskmonitor"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/common/trafficcontrol"
"github.com/sagernet/sing-box/common/urltest"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/experimental"
"github.com/sagernet/sing-box/experimental/cachefile"
"github.com/sagernet/sing-box/experimental/deprecated"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/direct"
@@ -38,27 +44,33 @@ import (
var _ adapter.SimpleLifecycle = (*Box)(nil)
type Box struct {
createdAt time.Time
logFactory log.Factory
logger log.ContextLogger
network *route.NetworkManager
endpoint *endpoint.Manager
inbound *inbound.Manager
outbound *outbound.Manager
provider *provider.Manager
service *boxService.Manager
dnsTransport *dns.TransportManager
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
internalService []adapter.LifecycleService
done chan struct{}
ctx context.Context
createdAt time.Time
debugOptions option.DebugOptions
debugHTTPServer *http.Server
logFactory log.Factory
logger log.ContextLogger
network *route.NetworkManager
endpoint *endpoint.Manager
inbound *inbound.Manager
outbound *outbound.Manager
provider *provider.Manager
service *boxService.Manager
certificateProvider *boxCertificate.Manager
dnsTransport *dns.TransportManager
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
httpClientService adapter.LifecycleService
internalService []adapter.LifecycleService
done chan struct{}
}
type Options struct {
option.Options
Context context.Context
PlatformLogWriter log.PlatformWriter
Context context.Context
PlatformLogWriter log.PlatformWriter
NetworkNamespaceHolderArgs []string
}
func Context(
@@ -69,6 +81,7 @@ func Context(
providerRegistry adapter.ProviderRegistry,
dnsTransportRegistry adapter.DNSTransportRegistry,
serviceRegistry adapter.ServiceRegistry,
certificateProviderRegistry adapter.CertificateProviderRegistry,
) context.Context {
if service.FromContext[option.InboundOptionsRegistry](ctx) == nil ||
service.FromContext[adapter.InboundRegistry](ctx) == nil {
@@ -98,6 +111,10 @@ func Context(
ctx = service.ContextWith[option.ServiceOptionsRegistry](ctx, serviceRegistry)
ctx = service.ContextWith[adapter.ServiceRegistry](ctx, serviceRegistry)
}
if service.FromContext[adapter.CertificateProviderRegistry](ctx) == nil {
ctx = service.ContextWith[option.CertificateProviderOptionsRegistry](ctx, certificateProviderRegistry)
ctx = service.ContextWith[adapter.CertificateProviderRegistry](ctx, certificateProviderRegistry)
}
return ctx
}
@@ -115,6 +132,7 @@ func New(options Options) (*Box, error) {
providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx)
dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx)
serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx)
certificateProviderRegistry := service.FromContext[adapter.CertificateProviderRegistry](ctx)
if endpointRegistry == nil {
return nil, E.New("missing endpoint registry in context")
@@ -134,10 +152,14 @@ func New(options Options) (*Box, error) {
if serviceRegistry == nil {
return nil, E.New("missing service registry in context")
}
if certificateProviderRegistry == nil {
return nil, E.New("missing certificate provider registry in context")
}
ctx = pause.WithDefaultManager(ctx)
experimentalOptions := common.PtrValueOrDefault(options.Experimental)
err := applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug))
debugOptions := common.PtrValueOrDefault(experimentalOptions.Debug)
err := checkDebugOptions(debugOptions)
if err != nil {
return nil, err
}
@@ -156,6 +178,12 @@ func New(options Options) (*Box, error) {
if experimentalOptions.UnifiedDelay != nil && experimentalOptions.UnifiedDelay.Enabled {
ctx = urltest.ContextWithIsUnifiedDelay(ctx)
}
needAPIService := common.Any(options.Services, func(it option.Service) bool {
return it.Type == C.TypeAPI
})
if service.PtrFromContext[urltest.HistoryStorage](ctx) == nil {
ctx = service.ContextWithPtr(ctx, urltest.NewHistoryStorage())
}
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
var defaultLogWriter io.Writer
if platformInterface != nil {
@@ -164,7 +192,7 @@ func New(options Options) (*Box, error) {
logFactory, err := log.New(log.Options{
Context: ctx,
Options: common.PtrValueOrDefault(options.Log),
Observable: needClashAPI,
Observable: needClashAPI || needAPIService,
DefaultWriter: defaultLogWriter,
BaseTime: createdAt,
PlatformWriter: options.PlatformLogWriter,
@@ -175,6 +203,7 @@ func New(options Options) (*Box, error) {
service.MustRegister[log.Factory](ctx, logFactory)
var internalServices []adapter.LifecycleService
routeOptions := common.PtrValueOrDefault(options.Route)
certificateOptions := common.PtrValueOrDefault(options.Certificate)
if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem ||
len(certificateOptions.Certificate) > 0 ||
@@ -187,8 +216,12 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.CertificateStore](ctx, certificateStore)
internalServices = append(internalServices, certificateStore)
}
routeOptions := common.PtrValueOrDefault(options.Route)
netnsManager, err := netns.NewManager(logFactory.NewLogger("netns"), options.NetworkNamespaces, options.NetworkNamespaceHolderArgs)
if err != nil {
return nil, err
}
service.MustRegister[adapter.NetworkNamespaceManager](ctx, netnsManager)
internalServices = append(internalServices, netnsManager)
dnsOptions := common.PtrValueOrDefault(options.DNS)
endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry)
inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager)
@@ -196,14 +229,20 @@ func New(options Options) (*Box, error) {
providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry)
dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final)
serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry)
certificateProviderManager := boxCertificate.NewManager(logFactory.NewLogger("certificate-provider"), certificateProviderRegistry)
service.MustRegister[adapter.EndpointManager](ctx, endpointManager)
service.MustRegister[adapter.InboundManager](ctx, inboundManager)
service.MustRegister[adapter.OutboundManager](ctx, outboundManager)
service.MustRegister[adapter.ProviderManager](ctx, providerManager)
service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager)
service.MustRegister[adapter.ServiceManager](ctx, serviceManager)
dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions)
service.MustRegister[adapter.CertificateProviderManager](ctx, certificateProviderManager)
dnsRouter, err := dns.NewRouter(ctx, logFactory, dnsOptions)
if err != nil {
return nil, E.Cause(err, "initialize DNS router")
}
service.MustRegister[adapter.DNSRouter](ctx, dnsRouter)
service.MustRegister[adapter.DNSRuleSetUpdateValidator](ctx, dnsRouter)
networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions)
if err != nil {
return nil, E.Cause(err, "initialize network manager")
@@ -211,12 +250,22 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.NetworkManager](ctx, networkManager)
connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection"))
service.MustRegister[adapter.ConnectionManager](ctx, connectionManager)
router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions)
// Must register after ConnectionManager: the Apple HTTP engine's proxy bridge reads it from the context when Manager.Start resolves the default client.
httpClientManager := httpclient.NewManager(ctx, logFactory.NewLogger("httpclient"), options.HTTPClients, routeOptions.DefaultHTTPClient)
service.MustRegister[adapter.HTTPClientManager](ctx, httpClientManager)
httpClientService := adapter.LifecycleService(httpClientManager)
router := route.NewRouter(ctx, logFactory, "router", routeOptions, dnsOptions)
service.MustRegister[adapter.Router](ctx, router)
err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet)
if err != nil {
return nil, E.Cause(err, "initialize router")
}
if needClashAPI || needAPIService {
trafficManager := trafficcontrol.NewManager(outboundManager)
service.MustRegisterPtr(ctx, trafficManager)
router.AppendTracker(trafficManager)
internalServices = append(internalServices, trafficManager)
}
ntpOptions := common.PtrValueOrDefault(options.NTP)
var timeService *tls.TimeServiceWrapper
if ntpOptions.Enabled {
@@ -294,6 +343,24 @@ func New(options Options) (*Box, error) {
Tag: "Compatible",
Type: C.TypeDirect,
})
for i, serviceOptions := range options.Services {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
}
}
for i, outboundOptions := range options.Outbounds {
var tag string
if outboundOptions.Tag != "" {
@@ -339,22 +406,22 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize provider[", i, "]")
}
}
for i, serviceOptions := range options.Services {
for i, certificateProviderOptions := range options.CertificateProviders {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
if certificateProviderOptions.Tag != "" {
tag = certificateProviderOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
err = certificateProviderManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
logFactory.NewLogger(F.ToString("certificate-provider/", certificateProviderOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
certificateProviderOptions.Type,
certificateProviderOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
return nil, E.Cause(err, "initialize certificate provider[", i, "]")
}
}
outboundManager.Initialize(func() (adapter.Outbound, error) {
@@ -375,6 +442,12 @@ func New(options Options) (*Box, error) {
&option.LocalDNSServerOptions{},
)
})
httpClientManager.Initialize(func() (*httpclient.ManagedTransport, error) {
deprecated.Report(ctx, deprecated.OptionImplicitDefaultHTTPClient)
var httpClientOptions option.HTTPClientOptions
httpClientOptions.DefaultOutbound = true
return httpclient.NewTransport(ctx, logFactory.NewLogger("httpclient"), "", httpClientOptions)
})
if platformInterface != nil {
err = platformInterface.Initialize(networkManager)
if err != nil {
@@ -382,7 +455,7 @@ func New(options Options) (*Box, error) {
}
}
if needCacheFile {
cacheFile := cachefile.New(ctx, common.PtrValueOrDefault(experimentalOptions.CacheFile))
cacheFile := cachefile.New(ctx, logFactory.NewLogger("cache-file"), common.PtrValueOrDefault(experimentalOptions.CacheFile))
service.MustRegister[adapter.CacheFile](ctx, cacheFile)
internalServices = append(internalServices, cacheFile)
}
@@ -393,7 +466,6 @@ func New(options Options) (*Box, error) {
if err != nil {
return nil, E.Cause(err, "create clash-server")
}
router.AppendTracker(clashServer)
service.MustRegister[adapter.ClashServer](ctx, clashServer)
internalServices = append(internalServices, clashServer)
}
@@ -409,6 +481,12 @@ func New(options Options) (*Box, error) {
}
}
if ntpOptions.Enabled {
if ntpOptions.WriteToSystem {
err = adapter.CheckSecurityFeature(ctx, "NTP `write_to_system`")
if err != nil {
return nil, err
}
}
ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain())
if err != nil {
return nil, E.Cause(err, "create NTP service")
@@ -425,21 +503,25 @@ func New(options Options) (*Box, error) {
internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service"))
}
return &Box{
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
provider: providerManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
createdAt: createdAt,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
ctx: ctx,
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
provider: providerManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
certificateProvider: certificateProviderManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
httpClientService: httpClientService,
createdAt: createdAt,
debugOptions: debugOptions,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
}, nil
}
@@ -489,15 +571,28 @@ func (s *Box) preStart() error {
if err != nil {
return E.Cause(err, "start logger")
}
err = adapter.StartNamed(s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api
applyDebugOptions(s.debugOptions)
s.debugHTTPServer, err = startDebugHTTPServer(s.debugOptions)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.inbound, s.endpoint, s.service, s.certificateProvider)
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.network, s.connection)
if err != nil {
return err
}
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, []adapter.LifecycleService{s.httpClientService})
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.router, s.dnsRouter)
if err != nil {
return err
}
@@ -509,27 +604,35 @@ func (s *Box) start() error {
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStateStart, s.internalService)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, s.internalService)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.endpoint)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.certificateProvider)
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStatePostStart, s.internalService)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStatePostStart, s.outbound, s.provider, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStateStarted, s.internalService)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStatePostStart, s.internalService)
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStarted, s.internalService)
if err != nil {
return err
}
@@ -544,14 +647,21 @@ func (s *Box) Close() error {
close(s.done)
}
var err error
if s.debugHTTPServer != nil {
err = E.Append(err, s.debugHTTPServer.Close(), func(err error) error {
return E.Cause(err, "close debug HTTP server")
})
s.debugHTTPServer = nil
}
for _, closeItem := range []struct {
name string
service adapter.Lifecycle
}{
{"service", s.service},
{"endpoint", s.endpoint},
{"inbound", s.inbound},
{"certificate-provider", s.certificateProvider},
{"provider", s.provider},
{"endpoint", s.endpoint},
{"outbound", s.outbound},
{"router", s.router},
{"connection", s.connection},
@@ -565,6 +675,14 @@ func (s *Box) Close() error {
})
done()
}
if s.httpClientService != nil {
s.logger.Trace("close ", s.httpClientService.Name())
startTime := time.Now()
err = E.Append(err, s.httpClientService.Close(), func(err error) error {
return E.Cause(err, "close ", s.httpClientService.Name())
})
s.logger.Trace("close ", s.httpClientService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
for _, lifecycleService := range s.internalService {
done := adapter.LogElapsed(s.logger, "close ", lifecycleService.Name())
err = E.Append(err, lifecycleService.Close(), func(err error) error {
+1
Submodule clients/desktop added at cebee0d527
+45 -29
View File
@@ -19,8 +19,11 @@ import (
func main() {
ctx := context.Background()
switch os.Args[1] {
case "next_macos_project_version":
err := fetchMacOSVersion(ctx)
case "next_project_version":
if len(os.Args) < 3 {
log.Fatal("platform required: ios, macos, or tvos")
}
err := fetchNextProjectVersion(ctx, os.Args[2])
if err != nil {
log.Fatal(err)
}
@@ -50,8 +53,8 @@ func main() {
}
const (
appID = "6673731168"
groupID = "5c5f3b78-b7a0-40c0-bcad-e6ef87bbefda"
appID = "6785326793"
groupID = "39f9ebdc-05d4-421f-9595-dae71df227c4"
)
func createClient(expireDuration time.Duration) *asc.Client {
@@ -66,36 +69,49 @@ func createClient(expireDuration time.Duration) *asc.Client {
return asc.NewClient(tokenConfig.Client())
}
func fetchMacOSVersion(ctx context.Context) error {
func fetchNextProjectVersion(ctx context.Context, platformName string) error {
var platform asc.Platform
switch platformName {
case "ios":
platform = asc.PlatformIOS
case "macos":
platform = asc.PlatformMACOS
case "tvos":
platform = asc.PlatformTVOS
default:
return E.New("unknown platform: ", platformName)
}
query := &asc.ListBuildsQuery{
FilterApp: []string{appID},
FilterPreReleaseVersionPlatform: []string{string(platform)},
Limit: 200,
}
if platform != asc.PlatformMACOS {
tagVersion, err := build_shared.ReadTagVersion()
if err != nil {
return err
}
query.FilterPreReleaseVersionVersion = []string{build_shared.TestFlightVersion(tagVersion)}
}
client := createClient(time.Minute)
versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{
FilterPlatform: []string{"MAC_OS"},
})
builds, _, err := client.Builds.ListBuilds(ctx, query)
if err != nil {
return err
}
var versionID string
findVersion:
for _, version := range versions.Data {
switch *version.Attributes.AppStoreState {
case asc.AppStoreVersionStateReadyForSale,
asc.AppStoreVersionStatePendingDeveloperRelease:
versionID = version.ID
break findVersion
nextProjectVersion := 1
var projectVersion int
for _, build := range builds.Data {
projectVersion, err = strconv.Atoi(*build.Attributes.Version)
if err != nil {
return E.Cause(err, "parse version code")
}
if projectVersion >= nextProjectVersion {
nextProjectVersion = projectVersion + 1
}
}
if versionID == "" {
return E.New("no version found")
}
latestBuild, _, err := client.Builds.GetBuildForAppStoreVersion(ctx, versionID, &asc.GetBuildForAppStoreVersionQuery{})
if err != nil {
return err
}
versionInt, err := strconv.Atoi(*latestBuild.Data.Attributes.Version)
if err != nil {
return E.Cause(err, "parse version code")
}
os.Stdout.WriteString(F.ToString(versionInt+1, "\n"))
os.Stdout.WriteString(F.ToString(nextProjectVersion, "\n"))
return nil
}
@@ -289,7 +305,7 @@ func prepareAppStore(ctx context.Context) error {
return err
}
if len(builds.Data) == 0 {
log.Fatal(platform, " ", tag, " no build found")
log.Fatal(string(platform), " ", tag, " no build found")
}
buildID := common.Ptr(builds.Data[0].ID)
if version.ID == "" {
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"flag"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/common/windivert"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-usbip/driverassets"
E "github.com/sagernet/sing/common/exceptions"
)
var (
debugEnabled bool
outputPath string
target string
)
func init() {
flag.BoolVar(&debugEnabled, "debug", false, "enable debug")
flag.StringVar(&outputPath, "output", "", "output path")
flag.StringVar(&target, "target", runtime.GOOS+"/"+runtime.GOARCH, "target platform")
}
func main() {
flag.Parse()
err := build()
if err != nil {
log.Fatal(err)
}
}
func build() error {
targetParts := strings.Split(target, "/")
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
return E.New("invalid target: ", target)
}
operatingSystem := targetParts[0]
architecture := targetParts[1]
if outputPath == "" {
outputPath = "sing-box-daemon"
if operatingSystem == "windows" {
outputPath += ".exe"
}
}
absoluteOutputPath, err := filepath.Abs(outputPath)
if err != nil {
return E.Cause(err, "resolve output path")
}
err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755)
if err != nil {
return E.Cause(err, "create output directory")
}
version, err := build_shared.ReadTag()
if err != nil {
return E.Cause(err, "read version")
}
cgoEnabled := operatingSystem != "windows" && os.Getenv("CC") != ""
tags, err := buildTags(operatingSystem, architecture, cgoEnabled)
if err != nil {
return err
}
arguments := []string{
"build",
"-v",
"-trimpath",
"-buildvcs=false",
"-tags", strings.Join(tags, ","),
"-ldflags", build_shared.LinkerFlags(version, debugEnabled),
"-o", absoluteOutputPath,
}
if operatingSystem == "windows" && architecture == "386" {
arguments = append(arguments, "-gcflags=net=-l")
}
arguments = append(arguments, "./experimental/boxdd")
command := exec.Command("go", arguments...)
cgoEnabledValue := "0"
if cgoEnabled {
cgoEnabledValue = "1"
}
command.Env = append(os.Environ(),
"CGO_ENABLED="+cgoEnabledValue,
"GOOS="+operatingSystem,
"GOARCH="+architecture,
"GOTOOLCHAIN=local",
)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
err = command.Run()
if err != nil {
return E.Cause(err, "build sing-box daemon")
}
if operatingSystem == "windows" {
err = stageWinDivertDriver(architecture, filepath.Dir(absoluteOutputPath))
if err != nil {
return err
}
err = stageUSBIPDrivers(architecture, filepath.Dir(absoluteOutputPath))
if err != nil {
return err
}
}
return nil
}
func stageUSBIPDrivers(architecture string, outputDirectory string) error {
driverPackages := []struct {
assets map[string]driverassets.Package
assetDir string
}{
{driverassets.VBoxUSB, filepath.Join("internal", "vboxusb", "assets")},
{driverassets.VHCI, filepath.Join("internal", "usbipvhci", "assets")},
}
var moduleDirectory string
for _, driverPackage := range driverPackages {
staged := driverPackage.assets[architecture]
for _, architecturePackage := range driverPackage.assets {
for _, file := range architecturePackage.Files {
if slices.ContainsFunc(staged.Files, func(stagedFile driverassets.File) bool {
return stagedFile.Name == file.Name
}) {
continue
}
err := os.Remove(filepath.Join(outputDirectory, file.Name))
if err != nil && !os.IsNotExist(err) {
return E.Cause(err, "remove stale ", file.Name)
}
}
}
if len(staged.Files) == 0 {
continue
}
if moduleDirectory == "" {
listOutput, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/sagernet/sing-usbip").Output()
if err != nil {
return E.Cause(err, "locate sing-usbip module directory")
}
moduleDirectory = strings.TrimSpace(string(listOutput))
}
for _, file := range staged.Files {
content, err := os.ReadFile(filepath.Join(moduleDirectory, driverPackage.assetDir, architecture, file.Name))
if err != nil {
return E.Cause(err, "read ", file.Name)
}
checksum := sha256.Sum256(content)
if hex.EncodeToString(checksum[:]) != file.SHA256 {
return E.New(file.Name, " does not match the digest declared in sing-usbip/driverassets")
}
targetPath := filepath.Join(outputDirectory, file.Name)
stagedContent, err := os.ReadFile(targetPath)
if err == nil && bytes.Equal(stagedContent, content) {
continue
}
err = os.WriteFile(targetPath, content, 0o644)
if err != nil {
return E.Cause(err, "write ", file.Name)
}
}
}
return nil
}
func stageWinDivertDriver(architecture string, outputDirectory string) error {
var assetName, assetDigest string
switch architecture {
case "amd64":
assetName, assetDigest = windivert.Asset64Name, windivert.Asset64SHA256
case "386":
assetName, assetDigest = windivert.Asset32Name, windivert.Asset32SHA256
}
for _, name := range []string{windivert.Asset64Name, windivert.Asset32Name} {
if name == assetName {
continue
}
err := os.Remove(filepath.Join(outputDirectory, name))
if err != nil && !os.IsNotExist(err) {
return E.Cause(err, "remove stale ", name)
}
}
if assetName == "" {
return nil
}
assetDirectory := filepath.Join("common", "windivert", "assets")
content, err := os.ReadFile(filepath.Join(assetDirectory, assetName))
if err != nil {
return E.Cause(err, "read ", assetName)
}
checksum := sha256.Sum256(content)
if hex.EncodeToString(checksum[:]) != assetDigest {
return E.New(assetName, " does not match the digest declared in common/windivert")
}
targetPath := filepath.Join(outputDirectory, assetName)
staged, err := os.ReadFile(targetPath)
if err == nil && bytes.Equal(staged, content) {
return nil
}
err = os.WriteFile(targetPath, content, 0o644)
if err != nil {
return E.Cause(err, "write ", assetName)
}
return nil
}
func buildTags(operatingSystem string, architecture string, cgoEnabled bool) ([]string, error) {
tagsFile := "release/DEFAULT_BUILD_TAGS"
if operatingSystem == "windows" {
if architecture == "386" {
tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS"
} else {
tagsFile = "release/DEFAULT_BUILD_TAGS_WINDOWS"
}
} else if !cgoEnabled {
tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS"
}
content, err := os.ReadFile(tagsFile)
if err != nil {
return nil, E.Cause(err, "read build tags")
}
tags := strings.Split(strings.TrimSpace(string(content)), ",")
if operatingSystem == "windows" {
tags = append(tags, "with_external_windivert", "with_external_usbip_drivers")
}
if debugEnabled {
tags = append(tags, "debug")
}
return tags, nil
}
+8 -5
View File
@@ -60,10 +60,10 @@ func init() {
if err != nil {
currentTag = "unknown"
}
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
sharedFlags = append(sharedFlags, "-ldflags", build_shared.LinkerFlags(currentTag, false))
debugFlags = append(debugFlags, "-ldflags", build_shared.LinkerFlags(currentTag, true))
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_openvpn", "with_sudoku", "with_snell", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_call", "with_sudoku", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "with_openvpn", "with_openconnect", "badlinkname", "tfogo_checklinkname0")
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
// memcTags = append(memcTags, "with_tailscale")
sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird")
@@ -163,14 +163,14 @@ func buildAndroid() {
bindTarget := getAndroidBindTarget()
// Build main variant (SDK 23)
// Build main variant (SDK 24)
mainTags := append([]string{}, sharedTags...)
// mainTags = append(mainTags, memcTags...)
if debugEnabled {
mainTags = append(mainTags, debugTags...)
}
buildAndroidVariant(AndroidBuildConfig{
AndroidAPI: 23,
AndroidAPI: 24,
OutputName: "libbox.aar",
Tags: mainTags,
}, bindTarget)
@@ -204,6 +204,9 @@ func buildApple() {
"-target", bindTarget,
"-libname=box",
"-tags-not-macos=with_low_memory",
"-iosversion=15.0",
"-macosversion=13.0",
"-tvosversion=17.0",
}
//if !withTailscale {
// args = append(args, "-tags-macos="+strings.Join(memcTags, ","))
+15
View File
@@ -0,0 +1,15 @@
package build_shared
import "strings"
func LinkerFlags(version string, debug bool) string {
flags := []string{
"-X github.com/sagernet/sing-box/constant.Version=" + version,
"-X runtime.godebugDefault=multipathtcp=0,tlssha1=1",
"-checklinkname=0",
}
if !debug {
flags = append(flags, "-s", "-w", "-buildid=")
}
return strings.Join(flags, " ")
}
+5
View File
@@ -3,6 +3,7 @@ package build_shared
import (
"github.com/sagernet/sing-box/common/badversion"
"github.com/sagernet/sing/common"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/shell"
)
@@ -36,3 +37,7 @@ func ReadTagVersion() (badversion.Version, error) {
}
return version, nil
}
func TestFlightVersion(version badversion.Version) string {
return F.ToString(version.Major, ".", version.Minor, ".10")
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"archive/zip"
"crypto/sha256"
"flag"
"io"
"os"
"path/filepath"
"strings"
"github.com/sagernet/sing-box/log"
E "github.com/sagernet/sing/common/exceptions"
)
var outputPath string
func init() {
flag.StringVar(&outputPath, "output", "", "output AAR path")
}
func main() {
flag.Parse()
err := merge()
if err != nil {
log.Fatal(err)
}
}
func merge() error {
inputPaths := flag.Args()
if outputPath == "" {
return E.New("missing output path")
}
if len(inputPaths) == 0 {
return E.New("missing input AAR paths")
}
archiveReaders := make([]*zip.ReadCloser, 0, len(inputPaths))
for _, inputPath := range inputPaths {
archiveReader, err := zip.OpenReader(inputPath)
if err != nil {
return E.Cause(err, "open input AAR: ", inputPath)
}
archiveReaders = append(archiveReaders, archiveReader)
}
defer func() {
for _, archiveReader := range archiveReaders {
archiveReader.Close()
}
}()
referenceEntries := make(map[string][sha256.Size]byte)
selectedEntries := make([]*zip.File, 0)
selectedJNIEntries := make(map[string]bool)
for inputIndex, archiveReader := range archiveReaders {
seenEntries := make(map[string]bool)
for _, archiveFile := range archiveReader.File {
if strings.HasPrefix(archiveFile.Name, "jni/") {
if archiveFile.FileInfo().IsDir() {
continue
}
if selectedJNIEntries[archiveFile.Name] {
return E.New("duplicate AAR JNI entry: ", archiveFile.Name)
}
selectedJNIEntries[archiveFile.Name] = true
selectedEntries = append(selectedEntries, archiveFile)
continue
}
entryDigest, err := digestEntry(archiveFile)
if err != nil {
return E.Cause(err, "read AAR entry: ", archiveFile.Name)
}
if inputIndex == 0 {
referenceEntries[archiveFile.Name] = entryDigest
selectedEntries = append(selectedEntries, archiveFile)
} else {
referenceDigest, loaded := referenceEntries[archiveFile.Name]
if !loaded {
return E.New("unexpected AAR entry: ", archiveFile.Name)
}
if referenceDigest != entryDigest {
return E.New("AAR entry differs between architectures: ", archiveFile.Name)
}
}
seenEntries[archiveFile.Name] = true
}
if inputIndex > 0 {
for referenceName := range referenceEntries {
if !seenEntries[referenceName] {
return E.New("missing AAR entry: ", referenceName)
}
}
}
}
absoluteOutputPath, err := filepath.Abs(outputPath)
if err != nil {
return E.Cause(err, "resolve output AAR path")
}
err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755)
if err != nil {
return E.Cause(err, "create output AAR directory")
}
temporaryFile, err := os.CreateTemp(filepath.Dir(absoluteOutputPath), ".merge-aar-*.aar")
if err != nil {
return E.Cause(err, "create temporary output AAR")
}
temporaryPath := temporaryFile.Name()
defer os.Remove(temporaryPath)
archiveWriter := zip.NewWriter(temporaryFile)
for _, archiveFile := range selectedEntries {
rawReader, openErr := archiveFile.OpenRaw()
if openErr != nil {
archiveWriter.Close()
temporaryFile.Close()
return E.Cause(openErr, "open raw AAR entry: ", archiveFile.Name)
}
header := archiveFile.FileHeader
rawWriter, createErr := archiveWriter.CreateRaw(&header)
if createErr != nil {
archiveWriter.Close()
temporaryFile.Close()
return E.Cause(createErr, "create output AAR entry: ", archiveFile.Name)
}
_, copyErr := io.Copy(rawWriter, rawReader)
if copyErr != nil {
archiveWriter.Close()
temporaryFile.Close()
return E.Cause(copyErr, "copy output AAR entry: ", archiveFile.Name)
}
}
err = archiveWriter.Close()
if err != nil {
temporaryFile.Close()
return E.Cause(err, "finalize output AAR")
}
err = temporaryFile.Close()
if err != nil {
return E.Cause(err, "close output AAR")
}
err = os.Rename(temporaryPath, absoluteOutputPath)
if err != nil {
return E.Cause(err, "replace output AAR")
}
return nil
}
func digestEntry(archiveFile *zip.File) ([sha256.Size]byte, error) {
entryReader, err := archiveFile.Open()
if err != nil {
return [sha256.Size]byte{}, err
}
digest := sha256.New()
_, err = io.Copy(digest, entryReader)
closeErr := entryReader.Close()
if err != nil {
return [sha256.Size]byte{}, err
}
if closeErr != nil {
return [sha256.Size]byte{}, closeErr
}
var result [sha256.Size]byte
copy(result[:], digest.Sum(nil))
return result, nil
}
@@ -0,0 +1,172 @@
package main
import (
"flag"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/sagernet/sing-box/log"
E "github.com/sagernet/sing/common/exceptions"
"howett.net/plist"
)
type xcFrameworkInfo struct {
AvailableLibraries []xcFrameworkLibrary `plist:"AvailableLibraries"`
}
type xcFrameworkLibrary struct {
BinaryPath string `plist:"BinaryPath"`
LibraryIdentifier string `plist:"LibraryIdentifier"`
LibraryPath string `plist:"LibraryPath"`
SupportedArchitectures []string `plist:"SupportedArchitectures"`
SupportedPlatform string `plist:"SupportedPlatform"`
SupportedPlatformVariant string `plist:"SupportedPlatformVariant"`
}
type frameworkSlice struct {
rootPath string
library xcFrameworkLibrary
}
var outputPath string
func init() {
flag.StringVar(&outputPath, "output", "", "output XCFramework path")
}
func main() {
flag.Parse()
err := merge()
if err != nil {
log.Fatal(err)
}
}
func merge() error {
inputPaths := flag.Args()
if outputPath == "" {
return E.New("missing output path")
}
if len(inputPaths) == 0 {
return E.New("missing input XCFramework paths")
}
frameworkGroups := make(map[string][]frameworkSlice)
for _, inputPath := range inputPaths {
infoFile, err := os.Open(filepath.Join(inputPath, "Info.plist"))
if err != nil {
return E.Cause(err, "open XCFramework metadata: ", inputPath)
}
var info xcFrameworkInfo
decoder := plist.NewDecoder(infoFile)
err = decoder.Decode(&info)
closeErr := infoFile.Close()
if err != nil {
return E.Cause(err, "decode XCFramework metadata: ", inputPath)
}
if closeErr != nil {
return E.Cause(closeErr, "close XCFramework metadata: ", inputPath)
}
for _, library := range info.AvailableLibraries {
groupName := library.SupportedPlatform + "|" + library.SupportedPlatformVariant
frameworkGroups[groupName] = append(frameworkGroups[groupName], frameworkSlice{
rootPath: inputPath,
library: library,
})
}
}
groupNames := make([]string, 0, len(frameworkGroups))
for groupName := range frameworkGroups {
groupNames = append(groupNames, groupName)
}
sort.Strings(groupNames)
absoluteOutputPath, err := filepath.Abs(outputPath)
if err != nil {
return E.Cause(err, "resolve output XCFramework path")
}
err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755)
if err != nil {
return E.Cause(err, "create output XCFramework directory")
}
temporaryDirectory, err := os.MkdirTemp(filepath.Dir(absoluteOutputPath), ".merge-xcframework-*")
if err != nil {
return E.Cause(err, "create XCFramework merge directory")
}
defer os.RemoveAll(temporaryDirectory)
frameworkPaths := make([]string, 0, len(groupNames))
for groupIndex, groupName := range groupNames {
frameworkSlices := frameworkGroups[groupName]
firstSlice := frameworkSlices[0]
firstFrameworkPath := filepath.Join(firstSlice.rootPath, firstSlice.library.LibraryIdentifier, firstSlice.library.LibraryPath)
if len(frameworkSlices) == 1 {
frameworkPaths = append(frameworkPaths, firstFrameworkPath)
continue
}
architectures := make(map[string]bool)
binaryPaths := make([]string, 0, len(frameworkSlices))
for _, currentSlice := range frameworkSlices {
if currentSlice.library.LibraryPath != firstSlice.library.LibraryPath || currentSlice.library.BinaryPath != firstSlice.library.BinaryPath {
return E.New("incompatible XCFramework slices for platform: ", currentSlice.library.SupportedPlatform)
}
for _, architecture := range currentSlice.library.SupportedArchitectures {
if architectures[architecture] {
return E.New("duplicate XCFramework architecture: ", architecture)
}
architectures[architecture] = true
}
binaryPaths = append(binaryPaths, filepath.Join(currentSlice.rootPath, currentSlice.library.LibraryIdentifier, currentSlice.library.BinaryPath))
}
mergedFrameworkPath := filepath.Join(temporaryDirectory, "framework-"+strconv.Itoa(groupIndex), filepath.Base(firstSlice.library.LibraryPath))
copyCommand := exec.Command("ditto", firstFrameworkPath, mergedFrameworkPath)
copyCommand.Stdout = os.Stdout
copyCommand.Stderr = os.Stderr
err = copyCommand.Run()
if err != nil {
return E.Cause(err, "copy XCFramework slice")
}
binaryRelativePath, relativeErr := filepath.Rel(firstSlice.library.LibraryPath, firstSlice.library.BinaryPath)
if relativeErr != nil {
return E.Cause(relativeErr, "resolve XCFramework binary path")
}
if binaryRelativePath == "." || strings.HasPrefix(binaryRelativePath, ".."+string(filepath.Separator)) {
return E.New("invalid XCFramework binary path: ", firstSlice.library.BinaryPath)
}
mergedBinaryPath := filepath.Join(mergedFrameworkPath, binaryRelativePath)
temporaryBinaryPath := mergedBinaryPath + ".merged"
lipoArguments := append([]string{"lipo", "-create"}, binaryPaths...)
lipoArguments = append(lipoArguments, "-output", temporaryBinaryPath)
lipoCommand := exec.Command("xcrun", lipoArguments...)
lipoCommand.Stdout = os.Stdout
lipoCommand.Stderr = os.Stderr
err = lipoCommand.Run()
if err != nil {
return E.Cause(err, "merge XCFramework binaries")
}
err = os.Rename(temporaryBinaryPath, mergedBinaryPath)
if err != nil {
return E.Cause(err, "replace merged XCFramework binary")
}
frameworkPaths = append(frameworkPaths, mergedFrameworkPath)
}
err = os.RemoveAll(absoluteOutputPath)
if err != nil {
return E.Cause(err, "remove output XCFramework")
}
xcodebuildArguments := []string{"-create-xcframework"}
for _, frameworkPath := range frameworkPaths {
xcodebuildArguments = append(xcodebuildArguments, "-framework", frameworkPath)
}
xcodebuildArguments = append(xcodebuildArguments, "-output", absoluteOutputPath)
xcodebuildCommand := exec.Command("xcodebuild", xcodebuildArguments...)
xcodebuildCommand.Stdout = os.Stdout
xcodebuildCommand.Stderr = os.Stderr
err = xcodebuildCommand.Run()
if err != nil {
return E.Cause(err, "create XCFramework")
}
return nil
}
+87 -14
View File
@@ -8,16 +8,21 @@ import (
"strings"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/common/badversion"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
"howett.net/plist"
)
var flagRunInCI bool
var (
flagRunInCI bool
flagTestFlight bool
)
func init() {
flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI")
flag.BoolVar(&flagTestFlight, "testflight", false, "Override the App Store marketing version with the version reserved for TestFlight")
}
func main() {
@@ -40,19 +45,39 @@ func main() {
common.Must(decoder.Decode(&project))
objectsMap := project["objects"].(map[string]any)
projectContent := string(common.Must1(os.ReadFile("sing-box.xcodeproj/project.pbxproj")))
newContent, updated0 := findAndReplace(objectsMap, projectContent, []string{"io.nekohasekai.sfavt"}, newVersion.VersionString())
newContent, updated1 := findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfavt.standalone", "io.nekohasekai.sfavt.system"}, newVersion.String())
if updated0 || updated1 {
log.Info("updated version to ", newVersion.VersionString(), " (", newVersion.String(), ")")
}
var updated2 bool
if macProjectVersion := os.Getenv("MACOS_PROJECT_VERSION"); macProjectVersion != "" {
newContent, updated2 = findAndReplaceProjectVersion(objectsMap, newContent, []string{"SFM"}, macProjectVersion)
if updated2 {
log.Info("updated macos project version to ", macProjectVersion)
newContent := projectContent
var marketingVersionUpdated bool
if flagTestFlight {
testFlightVersion := build_shared.TestFlightVersion(newVersion)
newContent, marketingVersionUpdated = findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfamt"}, testFlightVersion)
if marketingVersionUpdated {
log.Info("updated App Store version to ", testFlightVersion)
}
}
if updated0 || updated1 || updated2 {
var standaloneVersionUpdated bool
newContent, standaloneVersionUpdated = findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfamt.standalone", "io.nekohasekai.sfamt.system"}, newVersion.String())
if standaloneVersionUpdated {
marketingVersionUpdated = true
log.Info("updated version to ", newVersion.String())
}
var projectVersionUpdated bool
for environmentName, directory := range map[string]string{
"IOS_PROJECT_VERSION": "SFI",
"MACOS_PROJECT_VERSION": "SFM",
"TVOS_PROJECT_VERSION": "SFT",
} {
projectVersion := os.Getenv(environmentName)
if projectVersion == "" {
continue
}
var updated bool
newContent, updated = findAndReplaceProjectVersion(objectsMap, newContent, []string{directory}, projectVersion)
if updated {
projectVersionUpdated = true
log.Info("updated ", directory, " project version to ", projectVersion)
}
}
if marketingVersionUpdated || projectVersionUpdated {
common.Must(os.WriteFile("sing-box.xcodeproj/project.pbxproj", []byte(newContent), 0o644))
}
}
@@ -76,11 +101,20 @@ func findAndReplace(objectsMap map[string]any, projectContent string, bundleIDLi
continue
}
updated = true
projectContent = projectContent[:versionStart] + "\"" + newVersion + "\"" + projectContent[versionEnd:]
projectContent = projectContent[:versionStart] + formatProjectVersion(newVersion) + projectContent[versionEnd:]
}
return projectContent, updated
}
// Xcode serializes a version without quotes unless it contains a pre-release
// part; always quoting makes Xcode rewrite the value on the next save.
func formatProjectVersion(version string) string {
if badversion.Parse(version).PreReleaseIdentifier == "" {
return version
}
return "\"" + version + "\""
}
func findAndReplaceProjectVersion(objectsMap map[string]any, projectContent string, directoryList []string, newVersion string) (string, bool) {
objectKeyList := findObjectKeyByDirectory(objectsMap, directoryList)
var updated bool
@@ -106,6 +140,7 @@ func findAndReplaceProjectVersion(objectsMap map[string]any, projectContent stri
}
func findObjectKey(objectsMap map[string]any, bundleIDList []string) []string {
globalSettings := collectBuildSettings(objectsMap)
var objectKeyList []string
for objectKey, object := range objectsMap {
buildSettings := object.(map[string]any)["buildSettings"]
@@ -116,13 +151,51 @@ func findObjectKey(objectsMap map[string]any, bundleIDList []string) []string {
if bundleIDObject == nil {
continue
}
if common.Contains(bundleIDList, bundleIDObject.(string)) {
bundleID := expandBuildVariables(bundleIDObject.(string), globalSettings)
if common.Contains(bundleIDList, bundleID) {
objectKeyList = append(objectKeyList, objectKey)
}
}
return objectKeyList
}
func collectBuildSettings(objectsMap map[string]any) map[string]string {
settings := make(map[string]string)
for _, object := range objectsMap {
buildSettings, loaded := object.(map[string]any)["buildSettings"].(map[string]any)
if !loaded {
continue
}
for key, value := range buildSettings {
valueString, isString := value.(string)
if !isString {
continue
}
settings[key] = valueString
}
}
return settings
}
var buildVariableRegexp = regexp.MustCompile(`\$[({]([A-Za-z0-9_]+)[)}]`)
func expandBuildVariables(value string, settings map[string]string) string {
for {
expanded := buildVariableRegexp.ReplaceAllStringFunc(value, func(match string) string {
name := buildVariableRegexp.FindStringSubmatch(match)[1]
replacement, loaded := settings[name]
if !loaded {
return match
}
return replacement
})
if expanded == value {
return expanded
}
value = expanded
}
}
func findObjectKeyByDirectory(objectsMap map[string]any, directoryList []string) []string {
var objectKeyList []string
for objectKey, object := range objectsMap {
+12 -26
View File
@@ -43,11 +43,8 @@ func updateMozillaIncludedRootCAs() error {
package certificate
import "crypto/x509"
func newMozillaIncluded() *x509.CertPool {
pool := x509.NewCertPool()
`)
func mozillaIncludedPEM() string {
return ` + "`")
for {
record, err := reader.Read()
if err == io.EOF {
@@ -58,17 +55,14 @@ func newMozillaIncluded() *x509.CertPool {
if record[geoIndex] == "China" {
continue
}
generated.WriteString("\n // ")
cert := strings.Trim(record[certIndex], "'")
generated.WriteString("\n// ")
generated.WriteString(record[nameIndex])
generated.WriteString("\n")
generated.WriteString(" pool.AppendCertsFromPEM([]byte(`")
cert := record[certIndex]
// Remove single quotes
cert = cert[1 : len(cert)-1]
generated.WriteString(cert)
generated.WriteString("`))\n")
generated.WriteString("\n")
}
generated.WriteString("\treturn pool\n}\n")
generated.WriteString("`\n}\n")
return os.WriteFile("common/certificate/mozilla.go", []byte(generated.String()), 0o644)
}
@@ -127,11 +121,8 @@ func updateChromeIncludedRootCAs() error {
package certificate
import "crypto/x509"
func newChromeIncluded() *x509.CertPool {
pool := x509.NewCertPool()
`)
func chromeIncludedPEM() string {
return ` + "`")
for {
record, err := reader.Read()
if err == io.EOF {
@@ -145,18 +136,13 @@ func newChromeIncluded() *x509.CertPool {
if chinaFingerprints[record[fingerprintIndex]] {
continue
}
generated.WriteString("\n // ")
cert := strings.Trim(record[certIndex], "'")
generated.WriteString("\n// ")
generated.WriteString(record[subjectIndex])
generated.WriteString("\n")
generated.WriteString(" pool.AppendCertsFromPEM([]byte(`")
cert := record[certIndex]
// Remove single quotes if present
if len(cert) > 0 && cert[0] == '\'' {
cert = cert[1 : len(cert)-1]
}
generated.WriteString(cert)
generated.WriteString("`))\n")
generated.WriteString("\n")
}
generated.WriteString("\treturn pool\n}\n")
generated.WriteString("`\n}\n")
return os.WriteFile("common/certificate/chrome.go", []byte(generated.String()), 0o644)
}
@@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"regexp"
"runtime"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
)
var (
flagRunInCI bool
flagRunNightly bool
)
type versionMetadata struct {
Version string `json:"version"`
GoVersion string `json:"go_version"`
}
func init() {
flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI")
flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly")
}
func main() {
flag.Parse()
newVersion := common.Must1(build_shared.ReadTag())
desktopPath := "../sing-box-for-desktop"
if flagRunInCI {
desktopPath = "clients/desktop"
}
desktopPath = common.Must1(filepath.Abs(desktopPath))
versionPath := filepath.Join(desktopPath, "version.json")
versionFile := common.Must1(os.Open(versionPath))
var metadata versionMetadata
common.Must(json.NewDecoder(versionFile).Decode(&metadata))
common.Must(versionFile.Close())
newGoVersion := runtime.Version()
if match := regexp.MustCompile(`^go\d+\.\d+(?:\.\d+)?`).FindString(newGoVersion); match != "" {
newGoVersion = match
}
versionUpdated := metadata.Version != newVersion
goVersionUpdated := metadata.GoVersion != newGoVersion
if !(versionUpdated || goVersionUpdated) {
log.Info("version not changed")
return
}
if versionUpdated {
log.Info("updated version from ", metadata.Version, " to ", newVersion)
}
if goVersionUpdated {
log.Info("updated Go version from ", metadata.GoVersion, " to ", newGoVersion)
}
if flagRunInCI && !flagRunNightly {
log.Fatal("version changed, commit changes first.")
}
metadata.Version = newVersion
metadata.GoVersion = newGoVersion
outputFile := common.Must1(os.Create(versionPath))
encoder := json.NewEncoder(outputFile)
encoder.SetIndent("", " ")
common.Must(encoder.Encode(metadata))
common.Must(outputFile.Close())
}
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/sagernet/sing-box/experimental/deprecated"
"github.com/sagernet/sing-box/include"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
@@ -52,7 +53,9 @@ func preRun(cmd *cobra.Command, args []string) {
globalCtx = filemanager.WithDefault(globalCtx, "", "", sudoUID, sudoGID)
}
if disableColor {
log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false).Logger())
logFactory := log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false)
common.Must(logFactory.Start())
log.SetStdLogger(logFactory.Logger())
}
if workingDir != "" {
_, err := os.Stat(workingDir)
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"os"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
commandAPIFlagURL string
commandAPIFlagSecret string
commandAPIServerURL string
)
var commandAPI = &cobra.Command{
Use: "api <command>",
Short: "API service client",
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
err := runAPI(args)
if err != nil {
log.Fatal(err)
}
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
targetCommand, remainingArgs, err := commandAPIRoot.Find(args)
if err != nil || len(remainingArgs) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return common.Map(common.Filter(targetCommand.Commands(), func(it *cobra.Command) bool {
return it.IsAvailableCommand() && strings.HasPrefix(it.Name(), toComplete)
}), func(it *cobra.Command) string {
return it.Name() + "\t" + it.Short
}), cobra.ShellCompDirectiveNoFileComp
},
}
var commandAPIRoot = &cobra.Command{
Use: "api",
Short: "API service client",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
func init() {
commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagURL, "url", "", "API service URL (default: $BOX_API_URL)")
commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagSecret, "secret", "", "API service secret (default: $BOX_API_SECRET)")
mainCommand.AddCommand(commandAPI)
}
func runAPI(args []string) error {
commandAPIRoot.SetArgs(append([]string{}, args...))
err := commandAPIRoot.Execute()
if err == nil {
return nil
}
grpcStatus, isStatus := status.FromError(err)
if !isStatus {
return err
}
switch grpcStatus.Code() {
case codes.Unavailable:
return E.New("failed to connect to API service at ", commandAPIServerURL, ": ", grpcStatus.Message())
case codes.Unknown:
return E.New(grpcStatus.Message())
case codes.Unimplemented:
return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message(), " (client API version ", daemon.APIVersion, ")")
default:
return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message())
}
}
func createAPIClient() (*grpc.ClientConn, daemon.StartedServiceClient, error) {
serverURL := commandAPIFlagURL
if serverURL == "" {
serverURL = os.Getenv("BOX_API_URL")
}
if serverURL == "" {
return nil, nil, E.New("missing API service URL, set --url or BOX_API_URL")
}
if !strings.Contains(serverURL, "://") {
serverURL = "http://" + serverURL
}
commandAPIServerURL = serverURL
secret := commandAPIFlagSecret
if secret == "" {
secret = os.Getenv("BOX_API_SECRET")
}
clientConn, err := daemon.NewRemoteClient(daemon.RemoteClientOptions{
ServerURL: serverURL,
Secret: secret,
})
if err != nil {
return nil, nil, err
}
return clientConn, daemon.NewStartedServiceClient(clientConn), nil
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/spf13/cobra"
)
var commandAPIConnection = &cobra.Command{
Use: "connection",
Short: "Manage connections",
}
func init() {
commandAPIRoot.AddCommand(commandAPIConnection)
}
func fetchConnections(client daemon.StartedServiceClient) ([]*daemon.Connection, error) {
stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)})
if err != nil {
return nil, err
}
events, err := stream.Recv()
if err != nil {
return nil, err
}
connections := common.FilterNotNil(common.Map(events.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection {
return it.GetConnection()
}))
common.SortBy(connections, func(it *daemon.Connection) int64 {
return it.GetCreatedAt()
})
return connections, nil
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIConnectionCloseFlagAll bool
var commandAPIConnectionClose = &cobra.Command{
Use: "close <id>",
Short: "Close connections",
Long: "Close connections.\n\nThe id must be a full UUID; the service reports success for an unknown or already closed connection.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionClose(args)
},
}
func init() {
commandAPIConnectionClose.Flags().BoolVar(&commandAPIConnectionCloseFlagAll, "all", false, "Close all connections")
commandAPIConnection.AddCommand(commandAPIConnectionClose)
}
func runAPIConnectionClose(args []string) error {
if commandAPIConnectionCloseFlagAll {
if len(args) > 0 {
return E.New("--all takes no connection id")
}
} else if len(args) == 0 {
return E.New("missing connection id")
}
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
if commandAPIConnectionCloseFlagAll {
_, err = client.CloseAllConnections(globalCtx, &emptypb.Empty{})
} else {
_, err = client.CloseConnection(globalCtx, &daemon.CloseConnectionRequest{Id: args[0]})
}
return err
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"slices"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/byteformats"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIConnectionListFlagColumns []string
type connectionRate struct {
uplink int64
downlink int64
}
type connectionColumn struct {
header string
value func(connection *daemon.Connection, rates map[string]connectionRate) string
}
var connectionColumnNames = []string{
"id", "network", "source", "destination", "inbound", "outbound",
"chain", "rule", "protocol", "user", "process", "created", "rate", "total",
}
var connectionColumns = map[string]connectionColumn{
"id": {"ID", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetId()
}},
"network": {"NETWORK", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetNetwork()
}},
"source": {"SOURCE", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetSource()
}},
"destination": {"DESTINATION", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connectionDestination(connection)
}},
"inbound": {"INBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string {
if connection.GetInbound() == "" {
return connection.GetInboundType()
}
return connection.GetInboundType() + "/" + connection.GetInbound()
}},
"outbound": {"OUTBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetOutbound()
}},
"chain": {"CHAIN", func(connection *daemon.Connection, _ map[string]connectionRate) string {
chain := slices.Clone(connection.GetChainList())
slices.Reverse(chain)
return strings.Join(chain, "/")
}},
"rule": {"RULE", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetRule()
}},
"protocol": {"PROTOCOL", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetProtocol()
}},
"user": {"USER", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetUser()
}},
"process": {"PROCESS", func(connection *daemon.Connection, _ map[string]connectionRate) string {
processInfo := connection.GetProcessInfo()
if processInfo.GetProcessPath() != "" {
return processInfo.GetProcessPath()
}
if len(processInfo.GetPackageNames()) > 0 {
return processInfo.GetPackageNames()[0]
}
return ""
}},
"created": {"CREATED", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return formatConnectionTime(connection.GetCreatedAt())
}},
"rate": {"RATE", func(connection *daemon.Connection, rates map[string]connectionRate) string {
rate, found := rates[connection.GetId()]
if !found || (rate.uplink == 0 && rate.downlink == 0) {
return ""
}
return "↑" + byteformats.FormatBytes(uint64(rate.uplink)) + "/s ↓" + byteformats.FormatBytes(uint64(rate.downlink)) + "/s"
}},
"total": {"TOTAL", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return "↑" + byteformats.FormatBytes(uint64(connection.GetUplinkTotal())) + " ↓" + byteformats.FormatBytes(uint64(connection.GetDownlinkTotal()))
}},
}
var commandAPIConnectionList = &cobra.Command{
Use: "list",
Short: "List open connections",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionList()
},
}
func init() {
commandAPIConnectionList.Flags().StringSliceVar(
&commandAPIConnectionListFlagColumns,
"columns",
[]string{"id", "network", "destination", "inbound", "outbound", "total"},
"Columns to display (available: "+strings.Join(connectionColumnNames, ", ")+")",
)
commandAPIConnection.AddCommand(commandAPIConnectionList)
}
func runAPIConnectionList() error {
columns := make([]connectionColumn, 0, len(commandAPIConnectionListFlagColumns))
sampleRates := false
for _, name := range commandAPIConnectionListFlagColumns {
column, found := connectionColumns[name]
if !found {
return E.New("unknown column: ", name, ", available: ", strings.Join(connectionColumnNames, ", "))
}
if name == "rate" {
sampleRates = true
}
columns = append(columns, column)
}
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
var (
connections []*daemon.Connection
rates map[string]connectionRate
)
if sampleRates {
connections, rates, err = fetchConnectionsAndRates(client)
} else {
connections, err = fetchConnections(client)
}
if err != nil {
return err
}
table := tableWriter{
header: common.Map(columns, func(it connectionColumn) string {
return it.header
}),
emptyMessage: "no connections",
}
for _, connection := range connections {
if connection.GetClosedAt() != 0 {
continue
}
table.addRow(common.Map(columns, func(it connectionColumn) string {
return it.value(connection, rates)
})...)
}
table.flush()
return nil
}
func connectionDestination(connection *daemon.Connection) string {
destination := connection.GetDestination()
domain := connection.GetDomain()
if domain == "" {
return destination
}
portIndex := strings.LastIndex(destination, ":")
if portIndex == -1 {
return domain
}
return domain + destination[portIndex:]
}
func fetchConnectionsAndRates(client daemon.StartedServiceClient) ([]*daemon.Connection, map[string]connectionRate, error) {
stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)})
if err != nil {
return nil, nil, err
}
initialEvents, err := stream.Recv()
if err != nil {
return nil, nil, err
}
connections := common.FilterNotNil(common.Map(initialEvents.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection {
return it.GetConnection()
}))
common.SortBy(connections, func(it *daemon.Connection) int64 {
return it.GetCreatedAt()
})
updateEvents, err := stream.Recv()
if err != nil {
return nil, nil, err
}
rates := make(map[string]connectionRate, len(updateEvents.GetEvents()))
for _, event := range updateEvents.GetEvents() {
rates[event.GetId()] = connectionRate{
uplink: event.GetUplinkDelta(),
downlink: event.GetDownlinkDelta(),
}
}
return connections, rates, nil
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/byteformats"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIConnectionShow = &cobra.Command{
Use: "show <id>",
Short: "Print connection details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionShow(args[0])
},
}
func init() {
commandAPIConnection.AddCommand(commandAPIConnectionShow)
}
func runAPIConnectionShow(connectionID string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
connections, err := fetchConnections(client)
if err != nil {
return err
}
connection := common.Find(connections, func(it *daemon.Connection) bool {
return it.GetId() == connectionID
})
if connection == nil {
return E.New("connection not found: ", connectionID)
}
state := "open"
if connection.GetClosedAt() != 0 {
state = "closed"
}
var ipVersion string
if connection.GetIpVersion() != 0 {
ipVersion = F.ToString(connection.GetIpVersion())
}
inbound := connection.GetInboundType()
if connection.GetInbound() != "" {
inbound = connection.GetInboundType() + "/" + connection.GetInbound()
}
outbound := connection.GetOutbound()
if outbound != "" && connection.GetOutboundType() != "" {
outbound = F.ToString(outbound, " (", connection.GetOutboundType(), ")")
}
var block blockWriter
block.addLine("ID", connection.GetId())
block.addLine("State", state)
block.addLine("Created", formatConnectionTime(connection.GetCreatedAt()))
block.addLine("Closed", formatConnectionTime(connection.GetClosedAt()))
block.addLine("Network", connection.GetNetwork())
block.addLine("IP version", ipVersion)
block.addLine("Protocol", connection.GetProtocol())
block.addLine("Inbound", inbound)
block.addLine("Source", connection.GetSource())
block.addLine("Destination", connection.GetDestination())
block.addLine("Domain", connection.GetDomain())
block.addLine("User", connection.GetUser())
block.addLine("Process", formatProcessInfo(connection.GetProcessInfo()))
block.addLine("Rule", connection.GetRule())
block.addLine("Outbound", outbound)
block.addLine("Chain", strings.Join(connection.GetChainList(), " <- "))
block.addLine("From outbound", connection.GetFromOutbound())
block.addLine("Uplink", byteformats.FormatBytes(uint64(connection.GetUplinkTotal())))
block.addLine("Downlink", byteformats.FormatBytes(uint64(connection.GetDownlinkTotal())))
block.flush()
return nil
}
func formatConnectionTime(timestamp int64) string {
if timestamp == 0 {
return ""
}
return time.UnixMilli(timestamp).Local().Format(time.RFC3339)
}
func formatProcessInfo(processInfo *daemon.ProcessInfo) string {
if processInfo == nil {
return ""
}
var process string
if processInfo.GetProcessPath() != "" {
process = processInfo.GetProcessPath()
} else if len(processInfo.GetPackageNames()) > 0 {
process = processInfo.GetPackageNames()[0]
}
if process == "" {
if processInfo.GetUserId() != -1 {
process = F.ToString(processInfo.GetUserId())
}
} else if processInfo.GetUserName() != "" {
process = F.ToString(process, " (", processInfo.GetUserName(), ")")
} else if processInfo.GetUserId() != -1 {
process = F.ToString(process, " (", processInfo.GetUserId(), ")")
}
return process
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIGroup = &cobra.Command{
Use: "group",
Short: "Manage outbound groups",
}
func init() {
commandAPIRoot.AddCommand(commandAPIGroup)
}
func fetchGroups(client daemon.StartedServiceClient) ([]*daemon.Group, error) {
stream, err := client.SubscribeGroups(globalCtx, &emptypb.Empty{})
if err != nil {
return nil, err
}
groups, err := stream.Recv()
if err != nil {
return nil, err
}
return groups.GetGroup(), nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPIGroupList = &cobra.Command{
Use: "list",
Short: "List outbound groups",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupList()
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupList)
}
func runAPIGroupList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
groups, err := fetchGroups(client)
if err != nil {
return err
}
table := tableWriter{
header: []string{"TAG", "TYPE", "SELECTED"},
emptyMessage: "no groups",
}
for _, group := range groups {
table.addRow(group.GetTag(), group.GetType(), group.GetSelected())
}
table.flush()
return nil
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupSelect = &cobra.Command{
Use: "select <group> <outbound>",
Short: "Select an outbound in a group",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupSelect(args[0], args[1])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupSelect)
}
func runAPIGroupSelect(groupTag string, outboundTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.SelectOutbound(globalCtx, &daemon.SelectOutboundRequest{
GroupTag: groupTag,
OutboundTag: outboundTag,
})
return err
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIGroupShow = &cobra.Command{
Use: "show <group>",
Short: "Show an outbound group",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupShow(args[0])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupShow)
}
func runAPIGroupShow(groupTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
groups, err := fetchGroups(client)
if err != nil {
return err
}
for _, group := range groups {
if group.GetTag() != groupTag {
continue
}
block := blockWriter{}
block.addLine("Tag", group.GetTag())
block.addLine("Type", group.GetType())
block.addLine("Selected", group.GetSelected())
block.flush()
table := tableWriter{
header: []string{"TAG", "TYPE", "DELAY"},
}
for _, item := range group.GetItems() {
table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay()))
}
table.flush()
return nil
}
return E.New("group not found: ", groupTag)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupURLTest = &cobra.Command{
Use: "urltest <group>",
Short: "Start a URL test",
Long: "Start a URL test.\n\nThe tests are only spawned: results appear in `outbounds --group <group>` a few seconds later.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupURLTest(args[0])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupURLTest)
}
func runAPIGroupURLTest(groupTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.URLTest(globalCtx, &daemon.URLTestRequest{OutboundTag: groupTag})
return err
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"os"
"os/signal"
"strings"
"syscall"
"github.com/sagernet/sing-box/log"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var (
commandAPILogsFlagFollow bool
commandAPILogsFlagLevel string
commandAPILogsFlagSearch string
)
var commandAPILogs = &cobra.Command{
Use: "logs",
Short: "Print the service logs",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPILogs()
},
}
func init() {
commandAPILogs.Flags().BoolVarP(&commandAPILogsFlagFollow, "follow", "f", false, "Keep printing new log entries until interrupted")
commandAPILogs.Flags().StringVar(&commandAPILogsFlagLevel, "level", "", "Print entries at this level or more severe (default: the service log level)")
commandAPILogs.Flags().StringVar(&commandAPILogsFlagSearch, "search", "", "Print entries containing this text, case-insensitive")
commandAPIRoot.AddCommand(commandAPILogs)
}
func runAPILogs() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
var level log.Level
if commandAPILogsFlagLevel != "" {
level, err = log.ParseLevel(commandAPILogsFlagLevel)
if err != nil {
return err
}
} else {
defaultLevel, levelErr := client.GetDefaultLogLevel(ctx, &emptypb.Empty{})
if levelErr != nil {
return levelErr
}
level = log.Level(defaultLevel.GetLevel())
}
stream, err := client.SubscribeLog(ctx, &emptypb.Empty{})
if err != nil {
return err
}
searchQuery := strings.ToLower(strings.TrimSpace(commandAPILogsFlagSearch))
for backlog := true; ; backlog = false {
message, recvErr := stream.Recv()
if recvErr != nil {
if ctx.Err() != nil {
return nil
}
return recvErr
}
if message.GetReset_() && len(message.GetMessages()) == 0 && !backlog {
writeStderrLine("log buffer cleared")
continue
}
var output strings.Builder
for _, entry := range message.GetMessages() {
if log.Level(entry.GetLevel()) > level {
continue
}
plainMessage := stripColors(entry.GetMessage())
if searchQuery != "" && !strings.Contains(strings.ToLower(plainMessage), searchQuery) {
continue
}
if stdoutIsTerminal {
output.WriteString(entry.GetMessage())
} else {
output.WriteString(plainMessage)
}
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
if backlog && !commandAPILogsFlagFollow {
return nil
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIMode = &cobra.Command{
Use: "mode",
Short: "Print the current clash mode",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIMode()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIMode)
}
func runAPIMode() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
currentMode := modeStatus.GetCurrentMode()
if currentMode == "" {
currentMode = "-"
}
os.Stdout.WriteString(currentMode + "\n")
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"os"
"strings"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIModeList = &cobra.Command{
Use: "list",
Short: "List clash modes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIModeList()
},
}
func init() {
commandAPIMode.AddCommand(commandAPIModeList)
}
func runAPIModeList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
if len(modeStatus.GetModeList()) == 0 {
writeStderrLine("no clash modes")
return nil
}
var output strings.Builder
for _, mode := range modeStatus.GetModeList() {
output.WriteString(mode)
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIModeSet = &cobra.Command{
Use: "set <mode>",
Short: "Set the clash mode",
Long: "Set the clash mode.\n\nThe value is not validated against the mode list: setting an unknown mode reports success.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIModeSet(args[0])
},
}
func init() {
commandAPIMode.AddCommand(commandAPIModeSet)
}
func runAPIModeSet(mode string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.SetClashMode(globalCtx, &daemon.ClashMode{Mode: mode})
return err
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/common/networkquality"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPINetworkQualityFlagConfigURL string
commandAPINetworkQualityFlagSerial bool
commandAPINetworkQualityFlagMaxRuntime int
commandAPINetworkQualityFlagHTTP3 bool
commandAPINetworkQualityFlagOutbound string
)
var commandAPINetworkQuality = &cobra.Command{
Use: "networkquality",
Short: "Run a network quality test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPINetworkQuality()
},
}
func init() {
commandAPINetworkQuality.Flags().StringVar(
&commandAPINetworkQualityFlagConfigURL,
"config-url", "",
"Network quality test config URL (default: Apple mensura)",
)
commandAPINetworkQuality.Flags().BoolVar(
&commandAPINetworkQualityFlagSerial,
"serial", false,
"Run download and upload tests sequentially instead of in parallel",
)
commandAPINetworkQuality.Flags().IntVar(
&commandAPINetworkQualityFlagMaxRuntime,
"max-runtime", int(networkquality.DefaultMaxRuntime/time.Second),
"Network quality maximum runtime in seconds",
)
commandAPINetworkQuality.Flags().BoolVar(
&commandAPINetworkQualityFlagHTTP3,
"http3", false,
"Use HTTP/3 (QUIC) for measurement traffic",
)
commandAPINetworkQuality.Flags().StringVarP(
&commandAPINetworkQualityFlagOutbound,
"outbound", "o", "",
"Use specified tag instead of default outbound",
)
commandAPIRoot.AddCommand(commandAPINetworkQuality)
}
func runAPINetworkQuality() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.StartNetworkQualityTest(globalCtx, &daemon.NetworkQualityTestRequest{
ConfigURL: commandAPINetworkQualityFlagConfigURL,
OutboundTag: commandAPINetworkQualityFlagOutbound,
Serial: commandAPINetworkQualityFlagSerial,
MaxRuntimeSeconds: int32(commandAPINetworkQualityFlagMaxRuntime),
Http3: commandAPINetworkQualityFlagHTTP3,
})
if err != nil {
return err
}
writeStderrLine("==== NETWORK QUALITY TEST ====")
for {
progress, recvErr := stream.Recv()
if recvErr != nil {
return recvErr
}
if !progress.GetIsFinal() {
writeNetworkQualityProgress(progress)
continue
}
writeStderrLine("")
if progress.GetError() != "" {
return E.New(progress.GetError())
}
writeStderrLine(strings.Repeat("-", 40))
fmt.Fprintf(os.Stdout, "Idle Latency: %d ms\n", progress.GetIdleLatencyMs())
fmt.Fprintf(os.Stdout, "Download Capacity: %-20s Accuracy: %s\n",
networkquality.FormatBitrate(progress.GetDownloadCapacity()),
networkquality.Accuracy(progress.GetDownloadCapacityAccuracy()))
fmt.Fprintf(os.Stdout, "Upload Capacity: %-20s Accuracy: %s\n",
networkquality.FormatBitrate(progress.GetUploadCapacity()),
networkquality.Accuracy(progress.GetUploadCapacityAccuracy()))
fmt.Fprintf(os.Stdout, "Download Responsiveness: %-20s Accuracy: %s\n",
fmt.Sprintf("%d RPM", progress.GetDownloadRPM()),
networkquality.Accuracy(progress.GetDownloadRPMAccuracy()))
fmt.Fprintf(os.Stdout, "Upload Responsiveness: %-20s Accuracy: %s\n",
fmt.Sprintf("%d RPM", progress.GetUploadRPM()),
networkquality.Accuracy(progress.GetUploadRPMAccuracy()))
return nil
}
}
func writeNetworkQualityProgress(progress *daemon.NetworkQualityTestProgress) {
if !commandAPINetworkQualityFlagSerial && networkquality.Phase(progress.GetPhase()) != networkquality.PhaseIdle {
writeProgress(fmt.Sprintf("Download: %s RPM: %d Upload: %s RPM: %d",
networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM(),
networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM()))
return
}
switch networkquality.Phase(progress.GetPhase()) {
case networkquality.PhaseIdle:
if progress.GetIdleLatencyMs() > 0 {
writeProgress(fmt.Sprintf("Idle Latency: %d ms", progress.GetIdleLatencyMs()))
} else {
writeProgress("Measuring idle latency...")
}
case networkquality.PhaseDownload:
writeProgress(fmt.Sprintf("Download: %s RPM: %d",
networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM()))
case networkquality.PhaseUpload:
writeProgress(fmt.Sprintf("Upload: %s RPM: %d",
networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM()))
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"errors"
"io"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIOpenConnect = &cobra.Command{
Use: "openconnect",
Short: "Manage OpenConnect authentication",
}
func init() {
commandAPIRoot.AddCommand(commandAPIOpenConnect)
}
func subscribeOpenConnectStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate], []*daemon.OpenConnectEndpointStatus, error) {
stream, err := client.SubscribeOpenConnectStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, nil, err
}
endpoints, err := recvOpenConnectStatus(stream)
if err != nil {
return nil, nil, err
}
return stream, endpoints, nil
}
func recvOpenConnectStatus(stream grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate]) ([]*daemon.OpenConnectEndpointStatus, error) {
update, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, E.New("api service closed the status stream")
}
return nil, err
}
return update.GetEndpoints(), nil
}
func openConnectChallengeSummary(challenge *daemon.OpenConnectAuthChallenge) string {
form := challenge.GetForm()
if form != nil {
return F.ToString("form (", len(form.GetFields()), " fields)")
}
browser := challenge.GetBrowser()
if browser == nil {
return "unknown"
}
mode, err := deriveOpenConnectBrowserMode(browser)
if err != nil {
return "browser (invalid)"
}
return "browser (" + mode + ")"
}
+297
View File
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPIOpenConnectAuthFlagEndpoint string
commandAPIOpenConnectAuthFlagCallbackPort uint16
)
var commandAPIOpenConnectAuth = &cobra.Command{
Use: "auth",
Short: "Answer OpenConnect authentication challenges",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runAPIOpenConnectAuth()
if errors.Is(err, errAuthInterrupted) {
writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openconnect auth" again, or "sing-box api openconnect cancel" to restart authentication`)
os.Exit(130)
}
return wrapAuthError("openconnect", err)
},
}
func init() {
commandAPIOpenConnectAuth.Flags().StringVar(&commandAPIOpenConnectAuthFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)")
commandAPIOpenConnectAuth.Flags().Uint16Var(&commandAPIOpenConnectAuthFlagCallbackPort, "callback-port", 8020, "Local port for the browser single sign-on callback listener")
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectAuth)
}
func runAPIOpenConnectAuth() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
stream, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectAuthFlagEndpoint, "openconnect")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
if endpointStatus.GetAuthChallenge() == nil {
switch endpointStatus.GetState() {
case adapter.OpenConnectStateConnected:
return E.New("endpoint ", endpointTag, " is already connected")
case adapter.OpenConnectStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
}
}
watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenConnectEndpointStatus, error) {
return recvOpenConnectStatus(stream)
})
err = openConnectAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag)
if err != nil && ctx.Err() != nil {
return errAuthInterrupted
}
return err
}
func openConnectAuthLoop(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
input *interactiveInput,
endpointTag string,
) error {
var (
renderedID string
waitingPrinted bool
)
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
return streamErr
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return E.New("endpoint not found: ", endpointTag)
}
endpointStatus := endpoints[index]
challenge := endpointStatus.GetAuthChallenge()
switch {
case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateConnected:
os.Stdout.WriteString(endpointTag + ": connected\n")
return nil
case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
case challenge != nil && challenge.GetId() != renderedID:
renderedID = challenge.GetId()
waitingPrinted = false
handleErr := handleOpenConnectChallenge(ctx, client, watcher, input, endpointTag, challenge)
switch {
case handleErr == nil:
case errors.Is(handleErr, errAuthChallengeWithdrawn):
writeAuthLine(errAuthChallengeWithdrawn.Error())
default:
return handleErr
}
continue
case challenge == nil && !waitingPrinted:
waitingPrinted = true
writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...")
}
select {
case <-updated:
case <-ctx.Done():
return errAuthInterrupted
}
}
}
func handleOpenConnectChallenge(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
input *interactiveInput,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
) error {
prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
go watchOpenConnectChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter)
form := challenge.GetForm()
browser := challenge.GetBrowser()
switch {
case form != nil:
return submitOpenConnectForm(ctx, client, prompter, endpointTag, challenge, form)
case browser != nil:
return submitOpenConnectBrowser(ctx, client, prompter, endpointTag, challenge, browser)
default:
return E.New("unsupported authentication challenge")
}
}
func watchOpenConnectChallenge(
ctx context.Context,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
endpointTag string,
challengeID string,
prompter *authPrompter,
) {
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
prompter.abort(streamErr)
return
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 || endpoints[index].GetAuthChallenge().GetId() != challengeID {
prompter.abort(errAuthChallengeWithdrawn)
return
}
select {
case <-updated:
case <-ctx.Done():
return
}
}
}
func submitOpenConnectForm(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
form *daemon.OpenConnectAuthForm,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
preambleWritten := false
if challenge.GetBanner() != "" {
writeAuthBanner(challenge.GetBanner())
preambleWritten = true
}
if challenge.GetError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetError())
preambleWritten = true
}
if challenge.GetMessage() != "" {
writeAuthLine(challenge.GetMessage())
preambleWritten = true
}
if preambleWritten {
writeAuthLine("")
}
for {
values := make(map[string]string, len(form.GetFields()))
for _, field := range form.GetFields() {
value, err := promptOpenConnectField(prompter, field)
if err != nil {
return err
}
values[field.GetSubmissionKey()] = value
}
_, err := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Response: &daemon.OpenConnectAuthResponseSubmission_Form{
Form: &daemon.OpenConnectAuthFormResponse{Values: values},
},
})
if err == nil {
return nil
}
outcome, message := classifySubmitError(err)
switch outcome {
case submitStale:
return errAuthChallengeWithdrawn
case submitFatal:
return err
}
writeAuthError("openconnect", "submit rejected: "+message)
}
}
func promptOpenConnectField(prompter *authPrompter, field *daemon.OpenConnectAuthFormField) (string, error) {
label := field.GetLabel()
if label == "" {
label = field.GetName()
}
switch field.GetKind() {
case "text":
return prompter.promptText(label, field.GetValue())
case "password":
return prompter.promptPassword(label, field.GetValue())
case "select":
return promptOpenConnectSelect(prompter, label, field.GetOptions(), field.GetValue())
default:
return "", E.New("unsupported authentication field kind: ", field.GetKind())
}
}
func promptOpenConnectSelect(prompter *authPrompter, label string, options []*daemon.OpenConnectAuthFormChoice, defaultValue string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
var menu strings.Builder
menu.WriteString(prompt + ":\n")
for index, option := range options {
optionLabel := option.GetLabel()
if optionLabel == "" {
optionLabel = option.GetValue()
}
menu.WriteString(" " + strconv.Itoa(index+1) + ") " + optionLabel)
if option.GetValue() == defaultValue {
menu.WriteString(" [default]")
}
menu.WriteString("\n")
}
os.Stderr.WriteString(menu.String())
for {
line, err := prompter.read(prompt+": ", false)
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" && defaultValue != "" {
return defaultValue, nil
}
selected, parseErr := strconv.Atoi(line)
if parseErr == nil && selected >= 1 && selected <= len(options) {
return options[selected-1].GetValue(), nil
}
if slices.ContainsFunc(options, func(it *daemon.OpenConnectAuthFormChoice) bool {
return it.GetValue() == line
}) {
return line, nil
}
writeAuthLine("select a number between 1 and " + strconv.Itoa(len(options)))
}
}
@@ -0,0 +1,290 @@
package main
import (
"context"
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
)
const (
openConnectBrowserModeCallback = "callback"
openConnectBrowserModeCookies = "cookies"
openConnectBrowserModeHeaders = "headers"
)
const openConnectCallbackPage = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>single sign-on</title></head>
<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4em">
<h3>Single sign-on completed</h3>
<p>You may close this tab and return to the terminal.</p>
</body>
</html>
`
func deriveOpenConnectBrowserMode(request *daemon.OpenConnectBrowserRequest) (string, error) {
callbackMode := len(request.GetCallbackURLPrefixes()) > 0
cookieMode := request.GetFinalURL() != "" || len(request.GetCookieNames()) > 0 || len(request.GetEarlyCookieNames()) > 0
headerMode := len(request.GetHeaderNames()) > 0
selectedModes := common.Filter([]bool{callbackMode, cookieMode, headerMode}, func(it bool) bool {
return it
})
invalidRequest := E.New("openconnect browser request must select exactly one completion mode")
if len(selectedModes) != 1 {
return "", invalidRequest
}
switch {
case callbackMode:
if len(common.Uniq(request.GetCallbackURLPrefixes())) != len(request.GetCallbackURLPrefixes()) {
return "", invalidRequest
}
return openConnectBrowserModeCallback, nil
case cookieMode:
cookieNames := append(slices.Clone(request.GetCookieNames()), request.GetEarlyCookieNames()...)
if len(common.Uniq(cookieNames)) != len(cookieNames) {
return "", invalidRequest
}
return openConnectBrowserModeCookies, nil
default:
headerNames := common.Map(request.GetHeaderNames(), strings.ToLower)
if len(common.Uniq(headerNames)) != len(headerNames) {
return "", invalidRequest
}
return openConnectBrowserModeHeaders, nil
}
}
func submitOpenConnectBrowser(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
request *daemon.OpenConnectBrowserRequest,
) error {
mode, err := deriveOpenConnectBrowserMode(request)
if err != nil {
return err
}
writeAuthHeader(endpointTag, "browser authentication")
if challenge.GetError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetError())
}
if challenge.GetMessage() != "" {
writeAuthLine(challenge.GetMessage())
}
for {
result, earlyFailure, collectErr := collectOpenConnectBrowserResult(ctx, prompter, mode, request)
if collectErr != nil {
return collectErr
}
warnPlaintextAPIConnection()
_, submitErr := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Response: &daemon.OpenConnectAuthResponseSubmission_Browser{Browser: result},
})
if submitErr == nil {
if earlyFailure {
writeAuthLine("single sign-on failed; the client will retry authentication")
}
return nil
}
outcome, message := classifySubmitError(submitErr)
switch outcome {
case submitStale:
return errAuthChallengeWithdrawn
case submitFatal:
return submitErr
}
writeAuthError("openconnect", "browser authentication rejected: "+message)
}
}
func collectOpenConnectBrowserResult(
ctx context.Context,
prompter *authPrompter,
mode string,
request *daemon.OpenConnectBrowserRequest,
) (*daemon.OpenConnectBrowserResult, bool, error) {
switch {
case mode == openConnectBrowserModeCallback:
target, err := parseOpenConnectCallbackTarget(request.GetCallbackURLPrefixes())
if err != nil {
return nil, false, err
}
if !authInputIsTerminal {
return nil, false, errAuthNotInteractive
}
finalURL, err := runOpenConnectCallbackListener(ctx, prompter, target, request.GetUrl())
if err != nil {
return nil, false, err
}
return &daemon.OpenConnectBrowserResult{FinalURL: finalURL}, false, nil
case mode == openConnectBrowserModeCookies && len(request.GetCookieNames()) > 0:
if !authInputIsTerminal {
return nil, false, errAuthNotInteractive
}
return promptOpenConnectBrowserCookies(prompter, request)
case mode == openConnectBrowserModeHeaders:
return nil, false, E.New("this single sign-on requires reading HTTP response headers, which cannot be done manually; use the sing-box desktop application")
default:
return nil, false, E.New("this single sign-on cannot be completed manually; use the sing-box desktop application")
}
}
type openConnectCallbackTarget struct {
scheme string
host string
port string
}
func (t openConnectCallbackTarget) resolve(requestURI string) string {
return t.scheme + "://" + net.JoinHostPort(t.host, t.port) + requestURI
}
func parseOpenConnectCallbackTarget(prefixes []string) (openConnectCallbackTarget, error) {
var target openConnectCallbackTarget
for _, prefix := range prefixes {
parsed, err := url.Parse(prefix)
if err != nil || !isLoopbackHost(parsed.Hostname()) {
return target, E.New("callback URL prefix is not on loopback: ", prefix)
}
if target.scheme == "" {
target.scheme = parsed.Scheme
target.host = parsed.Hostname()
}
if target.port == "" {
target.port = parsed.Port()
}
}
if target.port == "" {
target.port = strconv.Itoa(int(commandAPIOpenConnectAuthFlagCallbackPort))
}
return target, nil
}
func runOpenConnectCallbackListener(ctx context.Context, prompter *authPrompter, target openConnectCallbackTarget, loginURL string) (string, error) {
listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", target.port))
if err != nil {
return "", E.New("cannot listen on 127.0.0.1:", target.port, ": ", err.Error(), "; pass --callback-port")
}
defer listener.Close()
writeAuthLine("Complete single sign-on in your browser; this command finishes automatically.")
writeAuthLine("")
writeAuthLine(" listening on " + target.resolve("/"))
writeAuthLine(" url " + loginURL)
writeAuthLine("")
confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ")
if err != nil {
return "", err
}
if !confirmed {
writeAuthLine("waiting for the callback...")
} else {
openErr := openURLInBrowser(loginURL)
if openErr != nil {
writeAuthLine("failed to open the default browser: " + openErr.Error())
writeAuthLine("waiting for the callback...")
} else {
writeAuthLine("opened in the default browser; waiting for the callback...")
}
}
requestURIs := make(chan string, 1)
server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
select {
case requestURIs <- request.RequestURI:
default:
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.Header().Set("Connection", "close")
writer.WriteHeader(http.StatusOK)
writer.Write([]byte(openConnectCallbackPage))
})}
go func() {
_ = server.Serve(listener)
}()
defer server.Close()
select {
case requestURI := <-requestURIs:
writeAuthLine("received callback")
return target.resolve(requestURI), nil
case <-prompter.aborted:
return "", prompter.abortErr
case <-ctx.Done():
return "", errAuthInterrupted
}
}
func promptOpenConnectBrowserCookies(prompter *authPrompter, request *daemon.OpenConnectBrowserRequest) (*daemon.OpenConnectBrowserResult, bool, error) {
writeAuthLine("This single sign-on must be completed manually.")
writeAuthLine("")
step := 1
writeAuthLine(" " + strconv.Itoa(step) + ". Open this URL in any browser:")
writeAuthLine(" " + request.GetUrl())
if request.GetFinalURL() != "" {
step++
writeAuthLine(" " + strconv.Itoa(step) + ". Sign in until the browser lands on:")
writeAuthLine(" " + request.GetFinalURL())
}
step++
writeAuthLine(" " + strconv.Itoa(step) + ". Open the developer tools (F12) > Application > Cookies, and read the")
writeAuthLine(" value of the cookie listed below for that page.")
writeAuthLine("")
earlyCookieNames := request.GetEarlyCookieNames()
var cookies []*daemon.OpenConnectBrowserCookie
for index, name := range request.GetCookieNames() {
prompt := `Cookie "` + name + `": `
if index == 0 && len(earlyCookieNames) > 0 {
prompt = `Cookie "` + name + `" (or "!" if the page reported an error): `
}
for {
value, err := prompter.read(prompt, true)
if err != nil {
return nil, false, err
}
if index == 0 && len(earlyCookieNames) > 0 && value == "!" {
earlyCookie, earlyErr := promptOpenConnectEarlyCookie(prompter, earlyCookieNames[0])
if earlyErr != nil {
return nil, false, earlyErr
}
return &daemon.OpenConnectBrowserResult{Cookies: []*daemon.OpenConnectBrowserCookie{earlyCookie}}, true, nil
}
if value == "" {
writeAuthLine("cookie value must not be empty")
continue
}
cookies = append(cookies, &daemon.OpenConnectBrowserCookie{Name: name, Value: value})
break
}
}
if len(cookies) == 1 {
writeAuthLine("submitting 1 cookie")
} else {
writeAuthLine(F.ToString("submitting ", len(cookies), " cookies"))
}
return &daemon.OpenConnectBrowserResult{FinalURL: request.GetFinalURL(), Cookies: cookies}, false, nil
}
func promptOpenConnectEarlyCookie(prompter *authPrompter, name string) (*daemon.OpenConnectBrowserCookie, error) {
for {
value, err := prompter.read(`Error cookie "`+name+`": `, true)
if err != nil {
return nil, err
}
if value == "" {
writeAuthLine("cookie value must not be empty")
continue
}
return &daemon.OpenConnectBrowserCookie{Name: name, Value: value}, nil
}
}
@@ -0,0 +1,59 @@
package main
import (
"context"
"os"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenConnectCancelFlagEndpoint string
var commandAPIOpenConnectCancel = &cobra.Command{
Use: "cancel",
Short: "Cancel the pending OpenConnect authentication challenge",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenConnectCancel()
},
}
func init() {
commandAPIOpenConnectCancel.Flags().StringVar(&commandAPIOpenConnectCancelFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)")
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectCancel)
}
func runAPIOpenConnectCancel() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectCancelFlagEndpoint, "openconnect")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
challenge := endpointStatus.GetAuthChallenge()
if challenge == nil {
return E.New("no pending authentication challenge on ", endpointTag)
}
_, err = client.CancelOpenConnectAuthChallenge(ctx, &daemon.OpenConnectAuthChallengeCancel{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
})
if err != nil {
return err
}
os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client will restart authentication\n")
return nil
}
+331
View File
@@ -0,0 +1,331 @@
package main
import (
"context"
"net/netip"
"net/url"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/term"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
errAuthInterrupted = E.New("interrupted")
errAuthChallengeWithdrawn = E.New("challenge no longer pending, waiting for the next one")
errAuthDeadlineExpired = E.New("challenge deadline expired; the server will retry the connection")
errAuthNotInteractive = E.New("authentication requires an interactive terminal")
)
var authInputIsTerminal = term.IsTerminal(int(os.Stdin.Fd())) && stderrIsTerminal
type vpnEndpointStatus interface {
GetEndpointTag() string
}
func resolveVPNEndpoint[T vpnEndpointStatus](endpoints []T, endpointTag string, domain string) (T, error) {
var zero T
if endpointTag != "" {
index := slices.IndexFunc(endpoints, func(it T) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return zero, E.New("endpoint not found: ", endpointTag)
}
return endpoints[index], nil
}
switch len(endpoints) {
case 0:
return zero, E.New("no ", domain, " endpoint is configured")
case 1:
return endpoints[0], nil
default:
return zero, E.New("multiple ", domain, " endpoints; select one with -e: ", strings.Join(common.Map(endpoints, func(it T) string {
return it.GetEndpointTag()
}), ", "))
}
}
type vpnStatusWatcher[T any] struct {
access sync.Mutex
updated chan struct{}
endpoints []T
err error
}
func newVPNStatusWatcher[T any](endpoints []T, recv func() ([]T, error)) *vpnStatusWatcher[T] {
watcher := &vpnStatusWatcher[T]{
updated: make(chan struct{}),
endpoints: endpoints,
}
go watcher.run(recv)
return watcher
}
func (w *vpnStatusWatcher[T]) run(recv func() ([]T, error)) {
for {
endpoints, err := recv()
w.access.Lock()
if err != nil {
w.err = err
} else {
w.endpoints = endpoints
}
close(w.updated)
w.updated = make(chan struct{})
w.access.Unlock()
if err != nil {
return
}
}
}
func (w *vpnStatusWatcher[T]) current() ([]T, <-chan struct{}, error) {
w.access.Lock()
defer w.access.Unlock()
return w.endpoints, w.updated, w.err
}
type interactiveReadResult struct {
line string
err error
}
type interactiveReadRequest struct {
prompt string
hidden bool
result chan interactiveReadResult
}
type interactiveInput struct {
requests chan interactiveReadRequest
}
func newInteractiveInput() *interactiveInput {
input := &interactiveInput{requests: make(chan interactiveReadRequest)}
go input.run()
return input
}
func (i *interactiveInput) run() {
for request := range i.requests {
os.Stderr.WriteString(request.prompt)
line, err := readTerminalLine(request.hidden)
request.result <- interactiveReadResult{line: line, err: err}
}
}
func readTerminalLine(hidden bool) (string, error) {
if hidden {
line, err := term.ReadPassword(int(os.Stdin.Fd()))
os.Stderr.WriteString("\n")
if err != nil {
return "", err
}
return string(line), nil
}
var builder strings.Builder
buffer := make([]byte, 1)
for {
n, err := os.Stdin.Read(buffer)
if n > 0 {
if buffer[0] == '\n' {
break
}
builder.WriteByte(buffer[0])
}
if err != nil {
if builder.Len() == 0 {
return "", err
}
break
}
}
return strings.TrimSuffix(builder.String(), "\r"), nil
}
type authPrompter struct {
ctx context.Context
input *interactiveInput
once sync.Once
aborted chan struct{}
abortErr error
}
func (p *authPrompter) abort(cause error) {
p.once.Do(func() {
p.abortErr = cause
close(p.aborted)
})
}
func (p *authPrompter) read(prompt string, hidden bool) (string, error) {
result := make(chan interactiveReadResult, 1)
select {
case p.input.requests <- interactiveReadRequest{prompt: prompt, hidden: hidden, result: result}:
case <-p.aborted:
return "", p.abortErr
case <-p.ctx.Done():
return "", errAuthInterrupted
}
select {
case value := <-result:
return value.line, value.err
case <-p.aborted:
return "", p.abortErr
case <-p.ctx.Done():
return "", errAuthInterrupted
}
}
func (p *authPrompter) promptText(label string, value string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
if value != "" {
prompt += " [" + value + "]"
}
line, err := p.read(prompt+": ", false)
if err != nil {
return "", err
}
if line == "" {
return value, nil
}
return line, nil
}
func (p *authPrompter) promptPassword(label string, value string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
if value != "" {
prompt += " (unchanged)"
}
line, err := p.read(prompt+": ", true)
if err != nil {
return "", err
}
if line == "" {
return value, nil
}
return line, nil
}
func (p *authPrompter) promptConfirm(prompt string) (bool, error) {
line, err := p.read(prompt, false)
if err != nil {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "", "y", "yes":
return true, nil
default:
return false, nil
}
}
func writeAuthLine(message string) {
os.Stderr.WriteString(message + "\n")
}
func writeAuthError(domain string, message string) {
os.Stderr.WriteString(domain + " auth: " + message + "\n")
}
func writeAuthHeader(endpointTag string, title string) {
os.Stderr.WriteString("\n" + endpointTag + ": " + title + "\n")
}
func writeAuthBanner(banner string) {
var output strings.Builder
for line := range strings.SplitSeq(strings.ReplaceAll(banner, "\r\n", "\n"), "\n") {
output.WriteString(" " + line + "\n")
}
os.Stderr.WriteString(output.String())
}
type submitOutcome int
const (
submitRejected submitOutcome = iota
submitStale
submitFatal
)
func classifySubmitError(err error) (submitOutcome, string) {
grpcStatus, isStatus := status.FromError(err)
if !isStatus {
return submitFatal, err.Error()
}
switch grpcStatus.Code() {
case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded, codes.Unauthenticated, codes.Unimplemented:
return submitFatal, grpcStatus.Message()
}
if strings.Contains(grpcStatus.Message(), "no pending") {
return submitStale, grpcStatus.Message()
}
return submitRejected, grpcStatus.Message()
}
func wrapAuthError(domain string, err error) error {
if err == nil {
return nil
}
_, isStatus := status.FromError(err)
if isStatus {
return err
}
return E.Cause(err, domain+" auth")
}
func formatVPNConnectedSince(connectedSince int64) string {
if connectedSince == 0 {
return ""
}
since := time.Unix(connectedSince, 0).Local()
return since.Format(time.RFC3339) + " (" + time.Since(since).Truncate(time.Second).String() + ")"
}
func formatAuthDeadline(deadline int64) string {
if deadline == 0 {
return ""
}
return max(time.Until(time.Unix(deadline, 0)).Truncate(time.Second), 0).String()
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
address, err := netip.ParseAddr(host)
if err != nil {
return false
}
return address.IsLoopback()
}
func warnPlaintextAPIConnection() {
parsed, err := url.Parse(commandAPIServerURL)
if err != nil || parsed.Scheme == "https" || isLoopbackHost(parsed.Hostname()) {
return
}
writeAuthLine("warning: submitting single sign-on credentials over a plaintext API connection")
}
func openURLInBrowser(target string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", target).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Start()
default:
return exec.Command("xdg-open", target).Start()
}
}
@@ -0,0 +1,93 @@
package main
import (
"context"
"os"
"strings"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIOpenConnectStatus = &cobra.Command{
Use: "status",
Short: "Print OpenConnect endpoint status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenConnectStatus()
},
}
func init() {
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectStatus)
}
func runAPIOpenConnectStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
if len(endpoints) == 0 {
writeStderrLine("no openconnect endpoint is configured")
return nil
}
for index, endpointStatus := range endpoints {
if index > 0 {
os.Stdout.WriteString("\n")
}
writeOpenConnectStatusBlock(endpointStatus)
}
return nil
}
func writeOpenConnectStatusBlock(endpointStatus *daemon.OpenConnectEndpointStatus) {
var block blockWriter
block.addLine("Endpoint", endpointStatus.GetEndpointTag())
block.addLine("State", endpointStatus.GetState())
challenge := endpointStatus.GetAuthChallenge()
tunnelInfo := endpointStatus.GetTunnelInfo()
switch {
case challenge != nil:
block.addLine("Challenge", openConnectChallengeSummary(challenge))
if challenge.GetMessage() != "" {
block.addLine("Message", challenge.GetMessage())
}
if challenge.GetError() != "" {
block.addLine("Error", challenge.GetError())
}
case tunnelInfo != nil:
block.addLine("Server", tunnelInfo.GetServer())
block.addLine("Flavor", tunnelInfo.GetFlavor())
block.addLine("Transport", tunnelInfo.GetTransport())
if len(tunnelInfo.GetIpv4()) > 0 {
block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", "))
}
if len(tunnelInfo.GetIpv6()) > 0 {
block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", "))
}
if len(tunnelInfo.GetDns()) > 0 {
block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", "))
}
if tunnelInfo.GetMtu() > 0 {
block.addLine("MTU", F.ToString(tunnelInfo.GetMtu()))
}
block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince()))
case endpointStatus.GetState() == adapter.OpenConnectStateError:
block.addLine("Error", endpointStatus.GetError())
}
block.flush()
if challenge != nil {
writeStderrLine("")
writeStderrLine(`run "sing-box api openconnect auth" to answer`)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"errors"
"io"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
openVPNChallengeCredentials = "credentials"
openVPNChallengeSecret = "secret"
openVPNChallengeMessage = "message"
openVPNChallengeOpenURL = "open-url"
)
var commandAPIOpenVPN = &cobra.Command{
Use: "openvpn",
Short: "Manage OpenVPN authentication",
}
func init() {
commandAPIRoot.AddCommand(commandAPIOpenVPN)
}
func subscribeOpenVPNStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate], []*daemon.OpenVPNEndpointStatus, error) {
stream, err := client.SubscribeOpenVPNStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, nil, err
}
endpoints, err := recvOpenVPNStatus(stream)
if err != nil {
return nil, nil, err
}
return stream, endpoints, nil
}
func recvOpenVPNStatus(stream grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate]) ([]*daemon.OpenVPNEndpointStatus, error) {
update, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, E.New("api service closed the status stream")
}
return nil, err
}
return update.GetEndpoints(), nil
}
func openVPNChallengeSummary(challenge *daemon.OpenVPNChallenge) string {
switch challenge.GetKind() {
case openVPNChallengeMessage, openVPNChallengeOpenURL:
return challenge.GetKind() + " (not answerable)"
default:
return challenge.GetKind()
}
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"context"
"errors"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNAuthFlagEndpoint string
var commandAPIOpenVPNAuth = &cobra.Command{
Use: "auth",
Short: "Answer OpenVPN authentication challenges",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runAPIOpenVPNAuth()
if errors.Is(err, errAuthInterrupted) {
writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openvpn auth" again, or "sing-box api openvpn cancel" to stop the client`)
os.Exit(130)
}
return wrapAuthError("openvpn", err)
},
}
func init() {
commandAPIOpenVPNAuth.Flags().StringVar(&commandAPIOpenVPNAuthFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)")
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNAuth)
}
func runAPIOpenVPNAuth() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
stream, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNAuthFlagEndpoint, "openvpn")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
if endpointStatus.GetChallenge() == nil {
switch endpointStatus.GetState() {
case adapter.OpenVPNStateConnected:
return E.New("endpoint ", endpointTag, " is already connected")
case adapter.OpenVPNStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
}
}
watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenVPNEndpointStatus, error) {
return recvOpenVPNStatus(stream)
})
err = openVPNAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag)
if err != nil && ctx.Err() != nil {
return errAuthInterrupted
}
return err
}
func openVPNAuthLoop(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
input *interactiveInput,
endpointTag string,
) error {
var (
renderedID string
waitingPrinted bool
)
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
return streamErr
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return E.New("endpoint not found: ", endpointTag)
}
endpointStatus := endpoints[index]
challenge := endpointStatus.GetChallenge()
switch {
case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateConnected:
os.Stdout.WriteString(endpointTag + ": connected\n")
return nil
case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
case challenge != nil && challenge.GetId() != renderedID:
renderedID = challenge.GetId()
waitingPrinted = false
handleErr := handleOpenVPNChallenge(ctx, client, watcher, input, endpointTag, challenge)
switch {
case handleErr == nil:
case errors.Is(handleErr, errAuthChallengeWithdrawn):
writeAuthLine(errAuthChallengeWithdrawn.Error())
case errors.Is(handleErr, errAuthDeadlineExpired):
writeAuthError("openvpn", errAuthDeadlineExpired.Error())
default:
return handleErr
}
continue
case challenge == nil && !waitingPrinted:
waitingPrinted = true
writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...")
}
select {
case <-updated:
case <-ctx.Done():
return errAuthInterrupted
}
}
}
func handleOpenVPNChallenge(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
input *interactiveInput,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
go watchOpenVPNChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter)
switch challenge.GetKind() {
case openVPNChallengeCredentials:
return submitOpenVPNCredentials(ctx, client, prompter, endpointTag, challenge)
case openVPNChallengeSecret:
return submitOpenVPNSecret(ctx, client, prompter, endpointTag, challenge)
case openVPNChallengeMessage:
writeAuthHeader(endpointTag, "notice")
writeAuthLine(challenge.GetMessage() + openVPNRemainingSuffix(challenge))
return nil
case openVPNChallengeOpenURL:
return openOpenVPNChallengeURL(prompter, endpointTag, challenge)
default:
return E.New("unsupported authentication challenge kind: ", challenge.GetKind())
}
}
func watchOpenVPNChallenge(
ctx context.Context,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
endpointTag string,
challengeID string,
prompter *authPrompter,
) {
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
prompter.abort(streamErr)
return
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 || endpoints[index].GetChallenge().GetId() != challengeID {
prompter.abort(errAuthChallengeWithdrawn)
return
}
var expired <-chan time.Time
deadline := endpoints[index].GetChallenge().GetDeadline()
if deadline != 0 {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(time.Until(time.Unix(deadline, 0)))
expired = timer.C
}
select {
case <-updated:
case <-expired:
prompter.abort(errAuthDeadlineExpired)
return
case <-ctx.Done():
return
}
}
}
func submitOpenVPNCredentials(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
writeAuthLine("")
}
secretLabel := challenge.GetSecretMessage()
if secretLabel == "" {
secretLabel = "Secret"
}
for {
username, err := prompter.promptText("Username", challenge.GetUsername())
if err != nil {
return err
}
password, err := prompter.promptPassword("Password", "")
if err != nil {
return err
}
secret, err := prompter.read(strings.TrimSuffix(secretLabel, ":")+": ", !challenge.GetEcho())
if err != nil {
return err
}
answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Username: username,
Password: password,
Secret: secret,
})
if err != nil {
return err
}
if answered {
return nil
}
}
}
func submitOpenVPNSecret(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
contextWritten := false
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
contextWritten = true
}
if challenge.GetUsername() != "" {
writeAuthLine("user: " + challenge.GetUsername())
contextWritten = true
}
if contextWritten {
writeAuthLine("")
}
label := challenge.GetMessage()
if challenge.GetDeadline() != 0 {
if label != "" {
writeAuthLine(label + openVPNRemainingSuffix(challenge))
}
label = "Code"
}
if label == "" {
label = "Secret"
}
for {
secret, err := prompter.read(strings.TrimSuffix(label, ":")+": ", !challenge.GetEcho())
if err != nil {
return err
}
answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Secret: secret,
})
if err != nil {
return err
}
if answered {
return nil
}
}
}
func submitOpenVPNChallengeResponse(ctx context.Context, client daemon.StartedServiceClient, submission *daemon.OpenVPNChallengeSubmission) (bool, error) {
_, err := client.SubmitOpenVPNChallengeResponse(ctx, submission)
if err == nil {
return true, nil
}
outcome, message := classifySubmitError(err)
switch outcome {
case submitStale:
return false, errAuthChallengeWithdrawn
case submitFatal:
return false, err
}
writeAuthError("openvpn", "submit rejected: "+message)
return false, nil
}
func openOpenVPNChallengeURL(prompter *authPrompter, endpointTag string, challenge *daemon.OpenVPNChallenge) error {
writeAuthHeader(endpointTag, "authentication")
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
}
writeAuthLine("Complete authentication in your browser:")
writeAuthLine("")
writeAuthLine(" " + challenge.GetUrl())
writeAuthLine("")
if authInputIsTerminal {
confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ")
if err != nil {
return err
}
if confirmed {
openErr := openURLInBrowser(challenge.GetUrl())
if openErr != nil {
writeAuthLine("failed to open the default browser: " + openErr.Error())
} else {
writeAuthLine("opened in the default browser; waiting for the server" + openVPNRemainingSuffix(challenge))
return nil
}
}
}
writeAuthLine("waiting for the server" + openVPNRemainingSuffix(challenge))
return nil
}
func openVPNRemainingSuffix(challenge *daemon.OpenVPNChallenge) string {
if challenge.GetDeadline() == 0 {
return ""
}
return " (" + formatAuthDeadline(challenge.GetDeadline()) + " remaining)"
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"os"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNCancelFlagEndpoint string
var commandAPIOpenVPNCancel = &cobra.Command{
Use: "cancel",
Short: "Cancel the pending OpenVPN challenge and stop the client",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenVPNCancel()
},
}
func init() {
commandAPIOpenVPNCancel.Flags().StringVar(&commandAPIOpenVPNCancelFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)")
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNCancel)
}
func runAPIOpenVPNCancel() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNCancelFlagEndpoint, "openvpn")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
challenge := endpointStatus.GetChallenge()
if challenge == nil {
return E.New("no pending authentication challenge on ", endpointTag)
}
_, err = client.CancelOpenVPNChallenge(ctx, &daemon.OpenVPNChallengeCancel{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
})
if err != nil {
return err
}
// sing-openvpn treats a canceled challenge as terminal: unlike OpenConnect, the client does not
// reconnect afterwards.
os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client has stopped\n")
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"os"
"strings"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNStatus = &cobra.Command{
Use: "status",
Short: "Print OpenVPN endpoint status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenVPNStatus()
},
}
func init() {
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNStatus)
}
func runAPIOpenVPNStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
if len(endpoints) == 0 {
writeStderrLine("no openvpn endpoint is configured")
return nil
}
for index, endpointStatus := range endpoints {
if index > 0 {
os.Stdout.WriteString("\n")
}
writeOpenVPNStatusBlock(endpointStatus)
}
return nil
}
func writeOpenVPNStatusBlock(endpointStatus *daemon.OpenVPNEndpointStatus) {
var block blockWriter
block.addLine("Endpoint", endpointStatus.GetEndpointTag())
block.addLine("State", endpointStatus.GetState())
challenge := endpointStatus.GetChallenge()
tunnelInfo := endpointStatus.GetTunnelInfo()
switch {
case challenge != nil:
block.addLine("Challenge", openVPNChallengeSummary(challenge))
if challenge.GetMessage() != "" {
block.addLine("Message", challenge.GetMessage())
}
if challenge.GetUrl() != "" {
block.addLine("URL", challenge.GetUrl())
}
if challenge.GetDeadline() != 0 {
block.addLine("Deadline", "in "+formatAuthDeadline(challenge.GetDeadline()))
}
if challenge.GetPreviousError() != "" {
block.addLine("Error", challenge.GetPreviousError())
}
case tunnelInfo != nil:
block.addLine("Server", tunnelInfo.GetServer())
block.addLine("Network", tunnelInfo.GetNetwork())
block.addLine("Cipher", tunnelInfo.GetCipher())
if len(tunnelInfo.GetIpv4()) > 0 {
block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", "))
}
if len(tunnelInfo.GetIpv6()) > 0 {
block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", "))
}
if len(tunnelInfo.GetDns()) > 0 {
block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", "))
}
if tunnelInfo.GetMtu() > 0 {
block.addLine("MTU", F.ToString(tunnelInfo.GetMtu()))
}
block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince()))
case endpointStatus.GetState() == adapter.OpenVPNStateError:
block.addLine("Error", endpointStatus.GetError())
}
block.flush()
if challenge != nil {
writeStderrLine("")
writeStderrLine(`run "sing-box api openvpn auth" to continue`)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIOutbounds = &cobra.Command{
Use: "outbounds",
Short: "List outbounds",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOutbounds()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIOutbounds)
}
func runAPIOutbounds() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.SubscribeOutbounds(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
outbounds, err := stream.Recv()
if err != nil {
return err
}
table := tableWriter{
header: []string{"TAG", "TYPE", "DELAY"},
emptyMessage: "no outbounds",
}
for _, item := range outbounds.GetOutbounds() {
table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay()))
}
table.flush()
return nil
}
func formatDelay(delay int32) string {
if delay <= 0 {
return ""
}
return F.ToString(delay, " ms")
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"os"
"strings"
"github.com/sagernet/sing/common"
"github.com/mattn/go-runewidth"
"golang.org/x/term"
)
var (
stdoutIsTerminal = term.IsTerminal(int(os.Stdout.Fd()))
stderrIsTerminal = term.IsTerminal(int(os.Stderr.Fd()))
)
func writeStderrLine(message string) {
if !stderrIsTerminal {
return
}
os.Stderr.WriteString(message + "\n")
}
func writeProgress(message string) {
if !stderrIsTerminal {
return
}
os.Stderr.WriteString("\r" + message)
}
func stripColors(message string) string {
if !strings.Contains(message, "\x1b[") {
return message
}
var builder strings.Builder
start := 0
for index := 0; index < len(message); {
if message[index] != '\x1b' || index+1 >= len(message) || message[index+1] != '[' {
index++
continue
}
end := index + 2
for end < len(message) && message[end] != 'm' {
end++
}
if end >= len(message) {
break
}
builder.WriteString(message[start:index])
index = end + 1
start = index
}
builder.WriteString(message[start:])
return builder.String()
}
type tableWriter struct {
header []string
emptyMessage string
rows [][]string
}
func (t *tableWriter) addRow(cells ...string) {
t.rows = append(t.rows, common.Map(cells, func(it string) string {
if it == "" {
return "-"
}
return it
}))
}
func (t *tableWriter) flush() {
if len(t.rows) == 0 {
writeStderrLine(t.emptyMessage)
return
}
if !stdoutIsTerminal {
var output strings.Builder
for _, row := range t.rows {
output.WriteString(strings.Join(row, "\t"))
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
return
}
widths := common.Map(t.header, func(it string) int {
return runewidth.StringWidth(it)
})
for _, row := range t.rows {
for index, cell := range row {
widths[index] = max(widths[index], runewidth.StringWidth(cell))
}
}
renderRow := func(cells []string) string {
var builder strings.Builder
for index, cell := range cells {
if index > 0 {
builder.WriteString(" ")
}
builder.WriteString(cell)
if index < len(cells)-1 {
builder.WriteString(strings.Repeat(" ", widths[index]-runewidth.StringWidth(cell)))
}
}
return builder.String()
}
writeStderrLine(renderRow(t.header))
var output strings.Builder
for _, row := range t.rows {
output.WriteString(renderRow(row))
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
}
type blockLine struct {
label string
value string
}
type blockWriter struct {
lines []blockLine
}
func (b *blockWriter) addLine(label string, value string) {
if value == "" {
value = "-"
}
b.lines = append(b.lines, blockLine{label: label, value: value})
}
func (b *blockWriter) flush() {
if len(b.lines) == 0 {
return
}
labelWidth := len(common.MaxBy(b.lines, func(it blockLine) int {
return len(it.label)
}).label) + 3
var output strings.Builder
for _, line := range b.lines {
output.WriteString(line.label)
output.WriteString(":")
output.WriteString(strings.Repeat(" ", labelWidth-len(line.label)-1))
output.WriteString(line.value)
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"strings"
"sync"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common/byteformats"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIStatus = &cobra.Command{
Use: "status",
Short: "Print the service status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIStatus()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIStatus)
}
func runAPIStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
statusStream, err := client.SubscribeStatus(ctx, &daemon.SubscribeStatusRequest{Interval: int64(time.Second)})
if err != nil {
return err
}
var (
waitGroup sync.WaitGroup
serviceStatus *daemon.ServiceStatus
startedAt *daemon.StartedAt
)
waitGroup.Go(func() {
serviceStatusStream, statusErr := client.SubscribeServiceStatus(ctx, &emptypb.Empty{})
if statusErr != nil {
return
}
serviceStatus, _ = serviceStatusStream.Recv()
})
waitGroup.Go(func() {
startedAt, _ = client.GetStartedAt(ctx, &emptypb.Empty{})
})
status, err := statusStream.Recv()
if err != nil {
return err
}
rateStatus, err := statusStream.Recv()
if err == nil {
status = rateStatus
}
waitGroup.Wait()
var state string
if serviceStatus != nil {
state = strings.ToLower(serviceStatus.GetStatus().String())
}
var uptime string
if startedAt.GetStartedAt() > 0 {
uptime = time.Since(time.UnixMilli(startedAt.GetStartedAt())).Truncate(time.Second).String()
}
var connections string
if status.GetTrafficAvailable() {
connections = F.ToString(status.GetConnectionsIn(), " in / ", status.GetConnectionsOut(), " out")
} else {
connections = F.ToString("- in / ", status.GetConnectionsOut(), " out")
}
var uplink, downlink string
if status.GetTrafficAvailable() {
uplink = F.ToString(byteformats.FormatBytes(uint64(status.GetUplink())), "/s (", byteformats.FormatBytes(uint64(status.GetUplinkTotal())), " total)")
downlink = F.ToString(byteformats.FormatBytes(uint64(status.GetDownlink())), "/s (", byteformats.FormatBytes(uint64(status.GetDownlinkTotal())), " total)")
}
var block blockWriter
block.addLine("State", state)
block.addLine("Uptime", uptime)
block.addLine("Memory", byteformats.FormatMemoryBytes(status.GetMemory()))
block.addLine("Goroutines", F.ToString(status.GetGoroutines()))
block.addLine("Connections", connections)
block.addLine("Uplink", uplink)
block.addLine("Downlink", downlink)
if serviceStatus.GetStatus() == daemon.ServiceStatus_FATAL {
block.addLine("Error", serviceStatus.GetErrorMessage())
}
block.flush()
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"fmt"
"os"
"github.com/sagernet/sing-box/common/stun"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPISTUNFlagServer string
commandAPISTUNFlagOutbound string
)
var commandAPISTUN = &cobra.Command{
Use: "stun",
Short: "Run a STUN test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPISTUN()
},
}
func init() {
commandAPISTUN.Flags().StringVar(&commandAPISTUNFlagServer, "server", stun.DefaultServer, "STUN server address")
commandAPISTUN.Flags().StringVarP(&commandAPISTUNFlagOutbound, "outbound", "o", "", "Use specified tag instead of default outbound")
commandAPIRoot.AddCommand(commandAPISTUN)
}
func runAPISTUN() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.StartSTUNTest(globalCtx, &daemon.STUNTestRequest{
Server: commandAPISTUNFlagServer,
OutboundTag: commandAPISTUNFlagOutbound,
})
if err != nil {
return err
}
writeStderrLine("==== STUN TEST ====")
for {
progress, recvErr := stream.Recv()
if recvErr != nil {
return recvErr
}
if !progress.GetIsFinal() {
switch stun.Phase(progress.GetPhase()) {
case stun.PhaseBinding:
if progress.GetExternalAddr() != "" {
writeProgress(fmt.Sprintf("External Address: %s (%d ms)", progress.GetExternalAddr(), progress.GetLatencyMs()))
} else {
writeProgress("Sending binding request...")
}
case stun.PhaseNATMapping:
writeProgress("Detecting NAT mapping behavior...")
case stun.PhaseNATFiltering:
writeProgress("Detecting NAT filtering behavior...")
}
continue
}
writeStderrLine("")
if progress.GetError() != "" {
return E.New(progress.GetError())
}
fmt.Fprintf(os.Stdout, "External Address: %s\n", progress.GetExternalAddr())
fmt.Fprintf(os.Stdout, "Latency: %d ms\n", progress.GetLatencyMs())
if progress.GetNatTypeSupported() {
fmt.Fprintf(os.Stdout, "NAT Mapping: %s\n", stun.NATMapping(progress.GetNatMapping()))
fmt.Fprintf(os.Stdout, "NAT Filtering: %s\n", stun.NATFiltering(progress.GetNatFiltering()))
} else {
fmt.Fprintln(os.Stdout, "NAT Type Detection: not supported by server")
}
return nil
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"context"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
const commandAPITailscaleEndpointUsage = "Tailscale endpoint tag (default: the only Tailscale endpoint)"
var commandAPITailscaleFlagEndpoint string
var commandAPITailscale = &cobra.Command{
Use: "tailscale",
Short: "Manage Tailscale endpoints",
}
func init() {
commandAPIRoot.AddCommand(commandAPITailscale)
}
func fetchTailscaleStatus(client daemon.StartedServiceClient) ([]*daemon.TailscaleEndpointStatus, error) {
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
stream, err := client.SubscribeTailscaleStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, err
}
update, err := stream.Recv()
if err != nil {
return nil, err
}
endpoints := update.GetEndpoints()
common.SortBy(endpoints, func(it *daemon.TailscaleEndpointStatus) string {
return it.GetEndpointTag()
})
return endpoints, nil
}
func resolveTailscaleEndpointStatus(endpoints []*daemon.TailscaleEndpointStatus) (*daemon.TailscaleEndpointStatus, error) {
if len(endpoints) == 0 {
return nil, E.New("no tailscale endpoint found")
}
if commandAPITailscaleFlagEndpoint != "" {
endpoint := common.Find(endpoints, func(it *daemon.TailscaleEndpointStatus) bool {
return it.GetEndpointTag() == commandAPITailscaleFlagEndpoint
})
if endpoint == nil {
return nil, E.New("unknown tailscale endpoint: ", commandAPITailscaleFlagEndpoint, "\nknown endpoints:\n", formatTailscaleEndpointTags(endpoints))
}
return endpoint, nil
}
if len(endpoints) > 1 {
return nil, E.New("multiple tailscale endpoints, use --endpoint to select one:\n", formatTailscaleEndpointTags(endpoints))
}
return endpoints[0], nil
}
func fetchTailscaleEndpoint(client daemon.StartedServiceClient) (*daemon.TailscaleEndpointStatus, error) {
endpoints, err := fetchTailscaleStatus(client)
if err != nil {
return nil, err
}
return resolveTailscaleEndpointStatus(endpoints)
}
func resolveTailscaleEndpointTag(client daemon.StartedServiceClient) (string, error) {
if commandAPITailscaleFlagEndpoint != "" {
return commandAPITailscaleFlagEndpoint, nil
}
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return "", err
}
return endpoint.GetEndpointTag(), nil
}
func formatTailscaleEndpointTags(endpoints []*daemon.TailscaleEndpointStatus) string {
return strings.Join(common.Map(endpoints, func(it *daemon.TailscaleEndpointStatus) string {
return " " + it.GetEndpointTag()
}), "\n")
}
@@ -0,0 +1,15 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificate = &cobra.Command{
Use: "certificate",
Short: "Manage Tailscale HTTPS certificates",
}
func init() {
commandAPITailscaleCertificate.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleCertificate)
}
@@ -0,0 +1,108 @@
package main
import (
"os"
"slices"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPITailscaleCertificateExportFlagCertificateFile string
commandAPITailscaleCertificateExportFlagKeyFile string
commandAPITailscaleCertificateExportFlagMinValidity time.Duration
)
var commandAPITailscaleCertificateExport = &cobra.Command{
Use: "export <domain>",
Short: "Export the HTTPS certificate and private key for a domain",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateExport(args[0])
},
}
func init() {
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagCertificateFile, "cert-file", "", "Output certificate file (default: <domain>.crt, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagKeyFile, "key-file", "", "Output private key file (default: <domain>.key, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().DurationVar(&commandAPITailscaleCertificateExportFlagMinValidity, "min-validity", 0, "Renew the certificate if it expires within this duration")
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateExport)
}
func runAPITailscaleCertificateExport(domain string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
certDomains := endpoint.GetCertDomains()
if len(certDomains) == 0 {
return E.New("no certificate domains, enable HTTPS in the Tailscale admin console")
}
if !slices.Contains(certDomains, domain) {
return E.New("unknown certificate domain: ", domain, "\nknown domains:\n", formatTailscaleCertificateDomains(certDomains))
}
certificateFile := commandAPITailscaleCertificateExportFlagCertificateFile
if certificateFile == "" {
certificateFile = domain + ".crt"
}
keyFile := commandAPITailscaleCertificateExportFlagKeyFile
if keyFile == "" {
keyFile = domain + ".key"
}
writeStderrLine("fetching certificate for " + domain)
certificate, err := client.GetTailscaleCertificate(globalCtx, &daemon.TailscaleCertificateRequest{
EndpointTag: endpoint.GetEndpointTag(),
Domain: domain,
MinValiditySeconds: int64(commandAPITailscaleCertificateExportFlagMinValidity / time.Second),
})
if err != nil {
return err
}
if certificateFile == "-" && keyFile == "-" {
_, err = os.Stdout.Write(certificate.GetCertificatePEM())
if err != nil {
return err
}
_, err = os.Stdout.Write(certificate.GetPrivateKeyPEM())
return err
}
err = writeTailscaleCertificateFile(certificateFile, certificate.GetCertificatePEM(), 0o644)
if err != nil {
return E.Cause(err, "write certificate")
}
err = writeTailscaleCertificateFile(keyFile, certificate.GetPrivateKeyPEM(), 0o600)
if err != nil {
return E.Cause(err, "write private key")
}
return nil
}
func writeTailscaleCertificateFile(path string, content []byte, mode os.FileMode) error {
if path == "-" {
_, err := os.Stdout.Write(content)
return err
}
err := os.WriteFile(path, content, mode)
if err != nil {
return err
}
writeStderrLine("wrote " + path)
return nil
}
func formatTailscaleCertificateDomains(domains []string) string {
return strings.Join(common.Map(domains, func(it string) string {
return " " + it
}), "\n")
}
@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificateList = &cobra.Command{
Use: "list",
Short: "List domains that certificates can be issued for",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateList()
},
}
func init() {
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateList)
}
func runAPITailscaleCertificateList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
table := tableWriter{
header: []string{"DOMAIN"},
emptyMessage: "no certificate domains, enable HTTPS in the Tailscale admin console",
}
for _, domain := range endpoint.GetCertDomains() {
table.addRow(domain)
}
table.flush()
return nil
}
@@ -0,0 +1,48 @@
package main
import (
"os"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNode = &cobra.Command{
Use: "exit-node",
Short: "Print the current Tailscale exit node",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNode()
},
}
func init() {
commandAPITailscaleExitNode.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleExitNode)
}
func runAPITailscaleExitNode() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
exitNode := endpoint.GetExitNode()
if exitNode == nil {
os.Stdout.WriteString("none\n")
return nil
}
name := tailscalePeerName(exitNode)
address := tailscalePeerAddress(exitNode)
if address == "" {
os.Stdout.WriteString(name + "\n")
return nil
}
os.Stdout.WriteString(F.ToString(name, " (", address, ")", "\n"))
return nil
}
@@ -0,0 +1,36 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeClear = &cobra.Command{
Use: "clear",
Short: "Stop using a Tailscale exit node",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeClear()
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeClear)
}
func runAPITailscaleExitNodeClear() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpointTag, err := resolveTailscaleEndpointTag(client)
if err != nil {
return err
}
_, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{
EndpointTag: endpointTag,
})
return err
}
@@ -0,0 +1,55 @@
package main
import (
"github.com/sagernet/sing/common"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeList = &cobra.Command{
Use: "list",
Short: "List available Tailscale exit nodes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeList()
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeList)
}
func runAPITailscaleExitNodeList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
selectedStableID := endpoint.GetExitNode().GetStableID()
candidates := common.Filter(tailscalePeerEntries(endpoint), func(it tailscalePeerEntry) bool {
return !it.self && it.peer.GetExitNodeOption()
})
sortTailscalePeerEntries(candidates)
table := tableWriter{
header: []string{"DNS NAME", "IP", "ONLINE", "STATUS"},
emptyMessage: "no exit nodes",
}
for _, entry := range candidates {
var exitNodeStatus string
if selectedStableID != "" && entry.peer.GetStableID() == selectedStableID {
exitNodeStatus = "selected"
}
table.addRow(
tailscalePeerName(entry.peer),
tailscalePeerAddress(entry.peer),
formatYesNo(entry.peer.GetOnline()),
exitNodeStatus,
)
}
table.flush()
return nil
}
@@ -0,0 +1,41 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeSet = &cobra.Command{
Use: "set <peer>",
Short: "Use a Tailscale peer as exit node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeSet(args[0])
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeSet)
}
func runAPITailscaleExitNodeSet(selector string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector)
if err != nil {
return err
}
_, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{
EndpointTag: endpoint.GetEndpointTag(),
StableID: entry.peer.GetStableID(),
})
return err
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleLogout = &cobra.Command{
Use: "logout",
Short: "Log out of the Tailscale network",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleLogout()
},
}
func init() {
commandAPITailscaleLogout.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleLogout)
}
func runAPITailscaleLogout() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpointTag, err := resolveTailscaleEndpointTag(client)
if err != nil {
return err
}
_, err = client.TailscaleLogout(globalCtx, &daemon.TailscaleLogoutRequest{
EndpointTag: endpointTag,
})
return err
}

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