Compare commits

..

170 Commits

Author SHA1 Message Date
世界
3bf4216bd3 Fix cloudflared quic 2026-04-16 16:49:14 +08:00
世界
a4fde8012f Fix use-after-free of pooled value buffers in bbolt Batch writes 2026-04-16 16:49:04 +08:00
世界
17dd36ce31 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-04-16 16:49:04 +08:00
世界
52df438e02 Fix legacy rule-set download_detour blocked by empty direct check 2026-04-16 16:49:03 +08:00
世界
2ceecf9b3c Add TLS spoof support 2026-04-16 16:49:03 +08:00
世界
89ce67c9f4 Standardize hosts path 2026-04-16 16:49:02 +08:00
世界
73d9acd274 Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines 2026-04-16 16:49:02 +08:00
世界
f5f7269962 Bump version 2026-04-16 16:49:02 +08:00
世界
a6fd0604cc oom-killer: Record report before reset network 2026-04-16 16:49:01 +08:00
世界
ca63351ec2 Add optimistic DNS cache 2026-04-16 16:49:01 +08:00
世界
d89783ba3d Fix tailscale error 2026-04-16 16:49:01 +08:00
世界
845e94d7d3 Fix darwin cgo DNS again 2026-04-16 16:49:01 +08:00
世界
bf3454ee5e Fix stun test 2026-04-16 16:49:01 +08:00
世界
83c67939fa documentation: Fix missing update for ip_version and query_type 2026-04-16 16:49:01 +08:00
世界
00adb23810 Add cloudflared inbound 2026-04-16 16:48:49 +08:00
世界
34fdc29f18 Fix lint errors 2026-04-16 16:48:23 +08:00
世界
1b5fd56232 platform: Wrap command RPC error returns with E.Cause 2026-04-16 16:48:23 +08:00
世界
49704b49cc Add package_name_regex route, DNS and headless rule item 2026-04-16 16:48:22 +08:00
世界
539302511a documentation: Fixes 2026-04-16 16:48:22 +08:00
世界
e80b662da8 Un-deprecate ip_accept_any DNS rule item 2026-04-16 16:48:22 +08:00
世界
c80f4add73 tools: Tailscale status 2026-04-16 16:48:22 +08:00
世界
32a34ef434 Fix darwin local DNS transport 2026-04-16 16:48:21 +08:00
世界
f72c4c1f88 Fix rules lock 2026-04-16 16:48:21 +08:00
世界
90c342e941 Revert "Also enable certificate store by default on Apple platforms"
This reverts commit 62cb06c02f.
2026-04-16 16:48:21 +08:00
世界
d70bfb9bb6 tools: Tailscale status 2026-04-16 16:48:21 +08:00
世界
47f09c96a9 platform: Fix darwin signal handler 2026-04-16 16:48:20 +08:00
世界
3769ad4296 tools: Network Quality & STUN 2026-04-16 16:48:20 +08:00
世界
fcb43d7cf9 oom-killer: Free memory on pressure notification and use gradual interval backoff 2026-04-16 16:48:20 +08:00
世界
16e81c4c68 Fix deprecated warning double-formatting on localized clients 2026-04-16 16:48:19 +08:00
世界
6912e272ea platform: Fix set local 2026-04-16 16:48:19 +08:00
nekohasekai
c208f4aea4 Add evaluate DNS rule action and related rule items 2026-04-16 16:48:19 +08:00
世界
e4450ec230 Also enable certificate store by default on Apple platforms
`SecTrustEvaluateWithError` is serial
2026-04-16 16:48:18 +08:00
世界
d4dacfc480 platform: Add OOM Report & Crash Report 2026-04-16 16:48:08 +08:00
世界
a45639370f Add BBR profile and hop interval randomization for Hysteria2 2026-04-16 16:46:15 +08:00
nekohasekai
986de07d0c Refactor ACME support to certificate provider 2026-04-16 16:46:15 +08:00
世界
284c4d8494 documentation: Update descriptions for neighbor rules 2026-04-16 16:46:14 +08:00
世界
129396f490 Add macOS support for MAC and hostname rule items 2026-04-16 16:46:14 +08:00
世界
df806c96fb Add Android support for MAC and hostname rule items 2026-04-16 16:46:14 +08:00
世界
72a039a8f3 Add MAC and hostname rule items 2026-04-16 16:46:13 +08:00
世界
9b155ba467 Fix rdrc cache 2026-04-16 16:45:50 +08:00
世界
7ed5ef6da4 sing: Fix udpnat2 timeout 2026-04-16 16:45:38 +08:00
世界
bb3ad9c694 documentation: Fix typo 2026-04-14 16:00:47 +08:00
世界
d5adb54bc6 Bump version 2026-04-14 14:33:19 +08:00
世界
1cfcea769f Update Go to 1.25.9 2026-04-14 14:26:59 +08:00
世界
f43fc797d4 Update naiveproxy to v147.0.7727.49-1 2026-04-14 14:24:21 +08:00
世界
8e3176b789 Fix FakeIP returning error for unconfigured address family
Return SUCCESS with empty answers instead of an error when the
queried address family has no range configured. Reject configurations
where neither inet4_range nor inet6_range is set.
2026-04-14 14:15:20 +08:00
世界
025b947a24 Bump version 2026-04-10 16:23:45 +08:00
世界
76fa3c2e5e tun: Fixes 2026-04-10 14:13:06 +08:00
世界
53db1f178c Fix tailscale crash 2026-04-10 14:09:03 +08:00
世界
55ec8abf17 Fix local DNS server for Android 2026-04-10 14:08:57 +08:00
Berkay Özdemirci
5a957fd750 Fix EDNS OPT record corruption in DNS cache
The TTL computation and assignment loops treat OPT record's Hdr.Ttl
as a regular TTL, but per RFC 6891 it encodes EDNS0 metadata
(ExtRCode|Version|Flags). This corrupts cached responses causing
systemd-resolved to reject them with EDNS version 255.

Also fix pointer aliasing: storeCache() stored raw *dns.Msg pointer
so subsequent mutations by Exchange() corrupted cached data.

- Skip OPT records in all TTL loops (Exchange + loadResponse)
- Use message.Copy() in storeCache() to isolate cache from mutations
2026-04-10 14:08:24 +08:00
TargetLocked
7c3d8cf8db Fix disable tcp keep alive 2026-04-10 13:29:15 +08:00
世界
813b634d08 Bump version 2026-04-06 23:09:11 +08:00
hdrover
d9b435fb62 Fix naive inbound padding bytes 2026-04-06 22:33:11 +08:00
世界
354b4b040e sing: Fix vectorised readv iovec length calculation
This does not seem to affect any actual paths in the sing-box.
2026-04-01 16:16:58 +08:00
世界
7ffdc48b49 Bump version 2026-03-30 23:03:43 +08:00
世界
e15bdf11eb sing: Minor fixes 2026-03-30 22:58:11 +08:00
世界
e3bcb06c3e platform: Add HTTPResponse.WriteToWithProgress 2026-03-30 22:42:36 +08:00
世界
84d2280960 quic: Fix protocol client close & Sync hysteria bbr fix 2026-03-30 22:42:36 +08:00
世界
4fd2532b0a Fix naive quic error message 2026-03-30 22:42:36 +08:00
Zhengchao Ding
02ccde6c71 fix(rpm): add vendor field to fpm config to avoid (none) vendor
Co-authored-by: Hyper <hypar@disroot.org>
2026-03-30 22:09:54 +08:00
世界
e98b4ad449 Fix WireGuard shutdown race crashing
Stop peer goroutines before closing the TUN device to prevent
RoutineSequentialReceiver from calling Write on a nil dispatcher.
2026-03-26 16:33:21 +08:00
世界
d09182614c Bump version 2026-03-26 13:28:33 +08:00
世界
6381de7bab route: Fix query_type never matching in rule_set headless rules 2026-03-26 13:26:18 +08:00
世界
b0c6762bc1 route: merge rule_set branches into outer rules
Treat rule_set items as merged branches instead of standalone boolean
sub-items.

Evaluate each branch inside a referenced rule-set as if it were merged
into the outer rule and keep OR semantics between branches. This lets
outer grouped fields satisfy matching groups inside a branch without
introducing a standalone outer fallback or cross-branch state union.

Keep inherited grouped state outside inverted default and logical
branches. Negated rule-set branches now evaluate !(...) against their
own conditions and only reapply the outer grouped match after negation
succeeds, so configs like outer-group && !inner-condition continue to
work.

Add regression tests for same-group merged matches, cross-group and
extra-AND failures, DNS merged-branch behaviour, and inverted merged
branches. Update the route and DNS rule docs to clarify that rule-set
branches merge into the outer rule while keeping OR semantics between
branches.
2026-03-25 14:00:29 +08:00
世界
7425100bac release: Refactor release tracks for Linux packages and Docker
Support 4 release tracks instead of 2:
- sing-box / latest (stable release)
- sing-box-beta / latest-beta (stable pre-release)
- sing-box-testing / latest-testing (testing branch)
- sing-box-oldstable / latest-oldstable (oldstable branch)

Track is detected via git branch --contains and git tag,
replacing the old version-string hyphen check.
2026-03-24 15:03:43 +08:00
世界
d454aa0fdf route: formalize nested rule_set group-state semantics
Before 795d1c289, nested rule-set evaluation reused the parent rule
match cache. In practice, this meant these fields leaked across nested
evaluation:

- SourceAddressMatch
- SourcePortMatch
- DestinationAddressMatch
- DestinationPortMatch
- DidMatch

That leak had two opposite effects.

First, it made included rule-sets partially behave like the docs'
"merged" semantics. For example, if an outer route rule had:

  rule_set = ["geosite-additional-!cn"]
  ip_cidr  = 104.26.10.0/24

and the inline rule-set matched `domain_suffix = speedtest.net`, the
inner match could set `DestinationAddressMatch = true` and the outer
rule would then pass its destination-address group check. This is why
some `rule_set + ip_cidr` combinations used to work.

But the same leak also polluted sibling rules and sibling rule-sets.
A branch could partially match one group, then fail later, and still
leave that group cache set for the next branch. This broke cases such
as gh-3485: with `rule_set = [test1, test2]`, `test1` could touch
destination-address cache before an AdGuard `@@` exclusion made the
whole branch fail, and `test2` would then run against dirty state.

795d1c289 fixed that by cloning metadata for nested rule-set/rule
evaluation and resetting the rule match cache for each branch. That
stopped sibling pollution, but it also removed the only mechanism by
which a successful nested branch could affect the parent rule's grouped
matching state.

As a result, nested rule-sets became pure boolean sub-items against the
outer rule. The previous example stopped working: the inner
`domain_suffix = speedtest.net` still matched, but the outer rule no
longer observed any destination-address-group success, so it fell
through to `final`.

This change makes the semantics explicit instead of relying on cache
side effects:

- `rule_set: ["a", "b"]` is OR
- rules inside one rule-set are OR
- each nested branch is evaluated in isolation
- failed branches contribute no grouped match state
- a successful branch contributes its grouped match state back to the
  parent rule
- grouped state from different rule-sets must not be combined together
  to satisfy one outer rule

In other words, rule-sets now behave as "OR branches whose successful
group matches merge into the outer rule", which matches the documented
intent without reintroducing cross-branch cache leakage.
2026-03-24 15:03:43 +08:00
世界
a3623eb41a tun: Fix system stack rewriting TUN subnet destinations to loopback 2026-03-23 19:38:55 +08:00
世界
72bc4c1f87 Fix DNS transport returning error for empty AAAA response
Closes #3925
2026-03-23 19:21:55 +08:00
世界
9ac1e2ff32 Match package_name in process_path rule on Android 2026-03-23 18:57:35 +08:00
世界
0045103d14 Fix package_name shared uid matching 2026-03-23 18:57:35 +08:00
世界
d2a933784c Optimize Darwin process finder 2026-03-23 18:57:35 +08:00
世界
3f05a37f65 Optimize Linux process finder 2026-03-23 18:57:35 +08:00
世界
b8e5a71450 Add process information cache to avoid duplicate lookups
PreMatch and full match phases each created a fresh InboundContext,
causing process search (expensive OS syscalls) to run twice per
connection. Use a freelru ShardedLRU cache with 200ms TTL to serve
the second lookup from cache.
2026-03-23 14:26:45 +08:00
世界
c13faa8e3c tailscale: Only set ProcessLocalIPs/ProcessSubnets for fake TUN 2026-03-23 14:16:40 +08:00
世界
7623bcd19e Fix DialerForICMPDestination 2026-03-23 13:58:55 +08:00
世界
795d1c2892 Fix nested rule-set match cache isolation 2026-03-23 12:26:19 +08:00
世界
6913b11e0a Reject removed legacy inbound fields instead of silently ignoring 2026-03-21 17:16:10 +08:00
世界
1e57c06295 daemon: Allow StartOrReloadService to recover from FATAL state 2026-03-21 13:37:14 +08:00
世界
ea464cef8d daemon: Fix CloseService leaving instance non-nil on close error 2026-03-21 13:23:57 +08:00
Andrew Novikov
a8e3cd3256 tun: Fix nfqueue not working in prerouting 2026-03-17 11:05:40 +08:00
世界
686cf1f304 documentation: Fix Chinese link anchors 2026-03-16 12:24:10 +08:00
世界
9fbfb87723 documentation: Fix unicode heading anchors 2026-03-16 12:10:32 +08:00
世界
d2fa21d07b Deprecate Socksaddr.IsFqdn: do not reject potentially valid domain names 2026-03-16 09:37:59 +08:00
世界
d3768cca36 Bump version 2026-03-15 17:56:37 +08:00
世界
0889ddd001 Fix connector canceled dial cleanup 2026-03-15 17:56:37 +08:00
深鸣
f46fbf188a documentation: Minor fixes 2026-03-15 17:56:37 +08:00
世界
f2d15139f5 tun: Fix nftables single include_uid not working 2026-03-15 16:58:34 +08:00
世界
041646b728 Fix kTLS crash 2026-03-14 21:38:38 +08:00
世界
b990de2e12 tun: Fix "Fix auto_redirect dropping SO_BINDTODEVICE traffic" 2026-03-14 21:38:38 +08:00
世界
fe585157d2 Bump version 2026-03-14 21:38:38 +08:00
世界
eed6a36e5d tun:Fix auto_redirect dropping SO_BINDTODEVICE traffic 2026-03-14 21:38:38 +08:00
世界
eb0f38544c tailscale: Fix system interface rules 2026-03-14 21:38:38 +08:00
世界
54468a1a2a platform: Add f-droid update helpers 2026-03-11 20:41:29 +08:00
世界
8289bbd846 Add Alpine APK packaging to CI build
Add fpm-based Alpine APK packaging alongside existing DEB/RPM/Pacman
packages. Alpine APKs use `linux` in the filename to distinguish from
OpenWrt APKs which use the `openwrt` prefix.
2026-03-11 20:41:29 +08:00
世界
49c450d942 ccm/ocm: Fix missing metering for 1M context and /fast mode
CCM: Fix 1M context detection - use prefix match for versioned
beta strings (e.g. "context-1m-2025-08-07") and include cache
tokens in the 200K threshold check per Anthropic billing docs.

OCM: Add GPT-5.4 family pricing (standard/priority/flex) with
extended context (>272K) premium pricing support. Add context
window tracking to usage combinations, mirroring CCM's pattern.
Update normalizeGPT5Model defaults to latest known models.
2026-03-11 20:41:29 +08:00
世界
a7ee943216 Fix tailscale connections 2026-03-11 00:27:15 +08:00
世界
8bb4c4dd32 documentation: Update ocm/ccm examples 2026-03-10 22:04:12 +08:00
世界
67621ee6ba Fix OCM websocket proxy lifecycle and headers 2026-03-10 22:04:11 +08:00
世界
a09ffe6a0f ccm/ocm: Add by_user_and_week cost summary 2026-03-10 22:04:11 +08:00
世界
e0be8743f6 ocm: Add Responses WebSocket API proxy and fix client config docs
Support the OpenAI Responses WebSocket API (`wss://.../v1/responses`)
for bidirectional frame proxying with usage tracking.
Fix Codex CLI client config examples to use profiles and correct flags.

Update openai-go v3.24.0 → v3.26.0.
2026-03-10 22:04:11 +08:00
世界
0b04528803 tailscaile: Fix using TUN auto redirect with tailscale system interface 2026-03-10 22:04:11 +08:00
世界
65875e6dac tailscale: Use system dialer for system interface
* Revert "Fix netstack TCP connections with system interface
2026-03-10 19:50:16 +08:00
世界
4d6fb1d38d Fix legacy DNS client_subnet options not working 2026-03-09 20:18:47 +08:00
世界
305b930d90 release: Fix default config 2026-03-09 20:18:43 +08:00
世界
bc3884ca91 release: Add openwrt apk build 2026-03-09 20:18:40 +08:00
世界
df0bf927e4 Fix missing with_gvisor build tag for tailscale 2026-03-09 20:18:28 +08:00
世界
efe20ea51c release: Backport Go 1.25 to macOS 10.13 2026-03-09 20:13:36 +08:00
世界
e21a72fcd1 Fix websocket connection and goroutine leaks in Clash API
Co-authored-by: traitman <112139837+traitman@users.noreply.github.com>
2026-03-09 20:06:34 +08:00
世界
e1477bd065 documentation: Update cronet-go descriptions 2026-03-09 20:06:34 +08:00
世界
aa495fce38 Fix local DNS transport CNAME chain broken with systemd-resolved
Replace D-Bus ResolveRecord API with direct raw DNS queries to upstream
servers obtained from systemd-resolved's per-interface link properties.
2026-03-09 20:06:34 +08:00
世界
9cd60c28c0 tailscale: Fix inbound UDP packet connection 2026-03-09 20:06:34 +08:00
Heng lu
2ba896c5ac Fix netns fd leak in ListenNetworkNamespace 2026-03-09 20:06:34 +08:00
Oleg Artyomov
1d388547ee service/ccm: strip Accept-Encoding before forwarding to avoid untracked usage
When clients (e.g. Node.js Anthropic SDK) explicitly set Accept-Encoding: gzip,
Go's http.Transport does not transparently decompress the response body, because
it only does so when it added the header itself. This causes CCM's json.Unmarshal
to receive raw gzip bytes, silently failing to parse usage data and leaving the
usage counter unchanged.

Fix: remove Accept-Encoding from the outgoing proxy request. Transport adds it
automatically and transparently decompresses response.Body before CCM reads it.

Wire compression (CCM→Anthropic) is preserved — Transport still negotiates gzip.
Only CCM→localhost path is affected; compression on loopback has no practical
benefit.
2026-03-09 20:06:34 +08:00
世界
e343cec4d5 Fix legacy DNS defaults on final transport 2026-03-09 20:06:34 +08:00
世界
d58efc5d01 cronet-go: Fix library search path 2026-03-09 20:06:34 +08:00
世界
4b26ab16fb Bump version 2026-03-07 16:13:23 +08:00
世界
0e27312eda Update Go to 1.25.8 2026-03-07 16:13:23 +08:00
世界
4e0a953b98 sing: Revert "Relax domain name validation to support non-standard characters" 2026-03-07 15:44:40 +08:00
世界
27c5b0b1af Fix DNS exchange failure and recursion deadlock in connector
Co-authored-by: everyx <lunt.luo@gmail.com>
2026-03-06 15:31:22 +08:00
dyhkwong
84019b06d9 Fix v2ray HTTP transport server 2026-03-06 10:13:39 +08:00
世界
7fd21f8bf4 Bump version 2026-03-05 21:46:27 +08:00
世界
88695b0d1f Rename branches and update release workflows
stable-next → oldstable, main-next → stable, dev-next → testing, new unstable
2026-03-05 21:12:02 +08:00
世界
fb269c9032 tun: Fix darwin batch loop not exit on EBADF 2026-03-05 20:38:19 +08:00
世界
e62dc7bfa2 Fix rule_set_ip_cidr_accept_empty not working 2026-03-04 11:48:22 +08:00
世界
f295e195b5 tailscale: Fix netstack TCP connections with system interface 2026-03-03 22:06:54 +08:00
世界
ab76062a41 Fix fake-ip address allocation 2026-03-03 21:37:24 +08:00
世界
d14417d392 Fix naive client close 2026-03-03 21:21:09 +08:00
世界
96c5c27610 sing: reject IP literals in IsDomainName 2026-03-03 21:21:09 +08:00
世界
91f92bee49 release: Unify default build tags and linker flags into shared files
Move hardcoded build tags and ldflags from Makefile, Dockerfile, CI
workflows, and local build scripts into canonical files under release/:

- release/DEFAULT_BUILD_TAGS (Linux common archs, Darwin, Android)
- release/DEFAULT_BUILD_TAGS_WINDOWS (includes with_purego)
- release/DEFAULT_BUILD_TAGS_OTHERS (no with_naive_outbound)
- release/LDFLAGS (shared linker flags)
2026-03-03 21:21:09 +08:00
世界
1803471e02 endpoint: Fix UDP resolved destination 2026-03-02 13:55:26 +08:00
世界
3de56d344e Update external dependencies 2026-03-02 06:53:10 +08:00
世界
c71abbdfb8 Update dependencies 2026-03-02 06:52:35 +08:00
世界
ed15121e95 sing: Relax domain name validation to support non-standard characters 2026-03-01 19:45:19 +08:00
世界
46c6945da5 documentation: Update mkdcos-material 2026-03-01 18:37:31 +08:00
traitman
1beb4cb002 clash-api: Fix websocket connection not closed after config reload via SIGHUP
Co-authored-by: TraitMan <traitman@maildog.top>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-01 12:30:43 +08:00
dyhkwong
4c65fea1ac Fix IPv6 local DNS on Windows 2026-03-01 12:30:43 +08:00
世界
8ae93a98e5 Remove overdue deprecated features 2026-03-01 12:30:43 +08:00
世界
6da7e538e1 Bump version 2026-02-28 14:42:39 +08:00
世界
13e6ba4cb2 Update tfo-go 2026-02-27 19:55:32 +08:00
世界
93b7328c3f Fix missing Tailscale in ProxyDisplayName 2026-02-27 19:39:52 +08:00
世界
11dc5bcbe1 Fixes in cronet-go 2026-02-27 19:39:52 +08:00
世界
fa3ab87b11 platform: Fix gorelease build 2026-02-27 15:07:16 +08:00
世界
9bd9e9a58b dialer: use KeepAliveConfig for TCP keepalive 2026-02-27 14:58:06 +08:00
世界
9d6dee7451 release: Fix pacman package 2026-02-27 14:58:06 +08:00
世界
9c2cdc7203 Fix per-outbound bind_interface 2026-02-27 14:58:06 +08:00
世界
65150f5cc3 platform: Improve OOM killer for iOS 2026-02-27 14:58:06 +08:00
世界
21a1512e6c tailscale: Fix AdvertiseTags 2026-02-27 14:58:06 +08:00
世界
cf4791f1ad platform: Improve iOS OOM killer 2026-02-26 14:13:32 +08:00
世界
0bc66e5a56 service/ccm,ocm: Fixes and improvements 2026-02-26 13:36:46 +08:00
世界
d48236da94 Fix wireguard reserved 2026-02-24 15:49:52 +08:00
世界
4c05d7b888 Add advertise tags support for Tailscale endpoint 2026-02-24 15:31:57 +08:00
世界
94ed42caf1 Bump version 2026-02-23 18:17:47 +08:00
世界
e0c18cc3d4 tun: Fix nftablesCreateLocalAddressSets 2026-02-23 18:17:47 +08:00
世界
0817c25f4c release: Fix Docker build for loong64 and mipsle 2026-02-23 16:31:19 +08:00
世界
7745a97cca daemon: Fix started service leak 2026-02-23 14:49:58 +08:00
世界
9bcd715d31 Bump version 2026-02-21 13:55:31 +08:00
世界
6a95c66bc7 Pin Go version to 1.25.7 2026-02-21 13:55:31 +08:00
世界
b5800847ae More linux builds for naive 2026-02-21 13:55:31 +08:00
世界
aa85cbb86e Treat H3 RequestCanceled as closed 2026-02-21 09:31:11 +08:00
世界
c59991420e Minor fixes for naive 2026-02-18 01:26:29 +08:00
世界
c0304b8362 Bump version 2026-02-16 12:46:43 +08:00
世界
d1f1271a02 quic-go: Minor fixes 2026-02-16 12:46:29 +08:00
世界
de4fdbe553 platform: Add semver helper 2026-02-16 11:28:54 +08:00
世界
804606042f Bump version 2026-02-15 21:13:55 +08:00
世界
53f2db3f97 platform: Add windows build 2026-02-15 21:10:44 +08:00
世界
1f2fdec89d release: Fix update_apple_version command 2026-02-15 21:09:14 +08:00
世界
8714c157c9 Fix matching multi predefined 2026-02-15 21:09:06 +08:00
世界
657fba4ca5 Fix matching rule-set invert 2026-02-15 21:08:33 +08:00
世界
0a69621207 wireguard: Fix missing fallback for gso 2026-02-15 21:08:26 +08:00
511 changed files with 47239 additions and 12120 deletions

23
.fpm_pacman Normal file
View File

@@ -0,0 +1,23 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>"
--config-files etc/sing-box/config.json
--after-install release/config/sing-box.postinst
release/config/config.json=/etc/sing-box/config.json
release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service
release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service
release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf
release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules
release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE

View File

@@ -4,6 +4,7 @@
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--vendor SagerNet
--maintainer "nekohasekai <contact-git@sekai.icu>"
--deb-field "Bug: https://github.com/SagerNet/sing-box/issues"
--no-deb-generate-changes

View File

@@ -1 +1 @@
dc1cda1fe28740ba069934ab62aeb8ef85388332
e4926ba205fae5351e3d3eeafff7e7029654424a

81
.github/build_alpine_apk.sh vendored Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -e -o pipefail
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
trap 'rm -rf "$ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm755 "$PROJECT/release/config/sing-box.initd" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/sing-box.confd" "$ROOT_DIR/etc/conf.d/sing-box"
# Service files
install -Dm644 "$PROJECT/release/config/sing-box.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box.service"
install -Dm644 "$PROJECT/release/config/sing-box@.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box@.service"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/conf.d/sing-box
/etc/init.d/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later with name use or association addition" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"

80
.github/build_openwrt_apk.sh vendored Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -e -o pipefail
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
trap 'rm -rf "$ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm644 "$PROJECT/release/config/openwrt.conf" "$ROOT_DIR/etc/config/sing-box"
install -Dm755 "$PROJECT/release/config/openwrt.init" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/openwrt.keep" "$ROOT_DIR/lib/upgrade/keep.d/sing-box"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/config/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \
--info "provider-priority:100" \
--script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"

33
.github/detect_track.sh vendored Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
branches=$(git branch -r --contains HEAD)
if echo "$branches" | grep -q 'origin/stable'; then
track=stable
elif echo "$branches" | grep -q 'origin/testing'; then
track=testing
elif echo "$branches" | grep -q 'origin/oldstable'; then
track=oldstable
else
echo "ERROR: HEAD is not on any known release branch (stable/testing/oldstable)" >&2
exit 1
fi
if [[ "$track" == "stable" ]]; then
tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [[ -n "$tag" && "$tag" == *"-"* ]]; then
track=beta
fi
fi
case "$track" in
stable) name=sing-box; docker_tag=latest ;;
beta) name=sing-box-beta; docker_tag=latest-beta ;;
testing) name=sing-box-testing; docker_tag=latest-testing ;;
oldstable) name=sing-box-oldstable; docker_tag=latest-oldstable ;;
esac
echo "track=${track} name=${name} docker_tag=${docker_tag}" >&2
echo "TRACK=${track}" >> "$GITHUB_ENV"
echo "NAME=${name}" >> "$GITHUB_ENV"
echo "DOCKER_TAG=${docker_tag}" >> "$GITHUB_ENV"

View File

@@ -6,7 +6,7 @@
":disableRateLimiting"
],
"baseBranches": [
"dev-next"
"unstable"
],
"golang": {
"enabled": false

45
.github/setup_go_for_macos1013.sh vendored Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.25.9"
PATCH_COMMITS=(
"afe69d3cec1c6dcf0f1797b20546795730850070"
"1ed289b0cf87dc5aae9c6fe1aa5f200a83412938"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.darwin-arm64.tar.gz"
tar -xzf "go${VERSION}.darwin-arm64.tar.gz"
#cp -a go go_bootstrap
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/
# 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"
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done
# Rebuild is not needed: we build with CGO_ENABLED=1, so Apple's external
# linker handles LC_BUILD_VERSION via MACOSX_DEPLOYMENT_TARGET, and the
# stdlib (crypto/x509) is compiled from patched src automatically.
#cd src
#GOROOT_BOOTSTRAP="$HOME/go/go_bootstrap" ./make.bash
#cd ../..
#rm -rf go_bootstrap "go${VERSION}.darwin-arm64.tar.gz"

View File

@@ -1,16 +1,35 @@
#!/usr/bin/env bash
VERSION="1.25.7"
set -euo pipefail
mkdir -p $HOME/go
cd $HOME/go
VERSION="1.25.9"
PATCH_COMMITS=(
"466f6c7a29bc098b0d4c987b803c779222894a11"
"1bdabae205052afe1dadb2ad6f1ba612cdbc532a"
"a90777dcf692dd2168577853ba743b4338721b06"
"f6bddda4e8ff58a957462a1a09562924d5f3d05c"
"bed309eff415bcb3c77dd4bc3277b682b89a388d"
"34b899c2fb39b092db4fa67c4417e41dc046be4b"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.linux-amd64.tar.gz"
tar -xzf "go${VERSION}.linux-amd64.tar.gz"
mv go go_win7
cd go_win7
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# this patch file only works on golang1.25.x
# 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/
# revert:
@@ -18,10 +37,10 @@ cd go_win7
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
# 48042aa09c2f878c4faa576948b07fe625c4707a: "syscall: remove Windows 7 console handle workaround"
# a17d959debdb04cd550016a3501dd09d50cd62e7: "runtime: always use LoadLibraryEx to load system libraries"
# fixes:
# bed309eff415bcb3c77dd4bc3277b682b89a388d: "Fix os.RemoveAll not working on Windows7"
# 34b899c2fb39b092db4fa67c4417e41dc046be4b: "Revert \"os: remove 5ms sleep on Windows in (*Process).Wait\""
alias curl='curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"'
curl https://github.com/MetaCubeX/go/commit/8cb5472d94c34b88733a81091bd328e70ee565a4.diff | patch --verbose -p 1
curl https://github.com/MetaCubeX/go/commit/6788c4c6f9fafb56729bad6b660f7ee2272d699f.diff | patch --verbose -p 1
curl https://github.com/MetaCubeX/go/commit/a5b2168bb836ed9d6601c626f95e56c07923f906.diff | patch --verbose -p 1
curl https://github.com/MetaCubeX/go/commit/f56f1e23507e646c85243a71bde7b9629b2f970c.diff | patch --verbose -p 1
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/MetaCubeX/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done

View File

@@ -25,8 +25,9 @@ on:
- publish-android
push:
branches:
- main-next
- dev-next
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
@@ -46,7 +47,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -71,33 +72,41 @@ jobs:
include:
- { os: linux, arch: amd64, variant: purego, naive: true }
- { os: linux, arch: amd64, variant: glibc, naive: true }
- { os: linux, arch: amd64, variant: musl, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64, openwrt: "x86_64" }
- { os: linux, arch: amd64, variant: musl, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64, alpine: x86_64, openwrt: "x86_64" }
- { os: linux, arch: arm64, variant: purego, naive: true }
- { os: linux, arch: arm64, variant: glibc, naive: true }
- { os: linux, arch: arm64, variant: musl, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" }
- { os: linux, arch: arm64, variant: musl, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64, alpine: aarch64, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" }
- { os: linux, arch: "386", go386: sse2 }
- { os: linux, arch: "386", variant: glibc, naive: true, go386: sse2 }
- { os: linux, arch: "386", variant: musl, naive: true, go386: sse2, debian: i386, rpm: i386, openwrt: "i386_pentium4" }
- { os: linux, arch: "386", variant: musl, naive: true, go386: sse2, debian: i386, rpm: i386, alpine: x86, openwrt: "i386_pentium4" }
- { os: linux, arch: arm, goarm: "7" }
- { os: linux, arch: arm, variant: glibc, naive: true, goarm: "7" }
- { os: linux, arch: arm, variant: musl, naive: true, goarm: "7", debian: armhf, rpm: armv7hl, pacman: armv7hl, openwrt: "arm_cortex-a5_vfpv4 arm_cortex-a7_neon-vfpv4 arm_cortex-a7_vfpv4 arm_cortex-a8_vfpv3 arm_cortex-a9_neon arm_cortex-a9_vfpv3-d16 arm_cortex-a15_neon-vfpv4" }
- { os: linux, arch: arm, variant: musl, naive: true, goarm: "7", debian: armhf, rpm: armv7hl, pacman: armv7hl, alpine: armv7, openwrt: "arm_cortex-a5_vfpv4 arm_cortex-a7_neon-vfpv4 arm_cortex-a7_vfpv4 arm_cortex-a8_vfpv3 arm_cortex-a9_neon arm_cortex-a9_vfpv3-d16 arm_cortex-a15_neon-vfpv4" }
- { os: linux, arch: mipsle, gomips: hardfloat, naive: true, variant: glibc }
- { os: linux, arch: mipsle, gomips: softfloat, naive: true, variant: musl, debian: mipsel, rpm: mipsel, openwrt: "mipsel_24kc mipsel_74kc mipsel_mips32" }
- { os: linux, arch: mips64le, gomips: hardfloat, naive: true, variant: glibc, debian: mips64el, rpm: mips64el }
- { os: linux, arch: riscv64, naive: true, variant: glibc }
- { os: linux, arch: riscv64, naive: true, variant: musl, debian: riscv64, rpm: riscv64, alpine: riscv64, openwrt: "riscv64_generic" }
- { os: linux, arch: loong64, naive: true, variant: glibc }
- { os: linux, arch: loong64, naive: true, variant: musl, debian: loongarch64, rpm: loongarch64, alpine: loongarch64, openwrt: "loongarch64_generic" }
- { os: linux, arch: "386", go386: softfloat, openwrt: "i386_pentium-mmx" }
- { os: linux, arch: arm, goarm: "5", openwrt: "arm_arm926ej-s arm_cortex-a7 arm_cortex-a9 arm_fa526 arm_xscale" }
- { os: linux, arch: arm, goarm: "6", debian: armel, rpm: armv6hl, openwrt: "arm_arm1176jzf-s_vfp" }
- { os: linux, arch: mips, gomips: softfloat, openwrt: "mips_24kc mips_4kec mips_mips32" }
- { os: linux, arch: mipsle, gomips: hardfloat, debian: mipsel, rpm: mipsel, openwrt: "mipsel_24kc_24kf" }
- { os: linux, arch: mipsle, gomips: softfloat, openwrt: "mipsel_24kc mipsel_74kc mipsel_mips32" }
- { os: linux, arch: mipsle, gomips: hardfloat, openwrt: "mipsel_24kc_24kf" }
- { os: linux, arch: mipsle, gomips: softfloat }
- { os: linux, arch: mips64, gomips: softfloat, openwrt: "mips64_mips64r2 mips64_octeonplus" }
- { os: linux, arch: mips64le, gomips: hardfloat, debian: mips64el, rpm: mips64el }
- { os: linux, arch: mips64le, gomips: hardfloat }
- { os: linux, arch: mips64le, gomips: softfloat, openwrt: "mips64el_mips64r2" }
- { os: linux, arch: s390x, debian: s390x, rpm: s390x }
- { os: linux, arch: ppc64le, debian: ppc64el, rpm: ppc64le }
- { os: linux, arch: riscv64, debian: riscv64, rpm: riscv64, openwrt: "riscv64_generic" }
- { os: linux, arch: loong64, debian: loongarch64, rpm: loongarch64, openwrt: "loongarch64_generic" }
- { os: linux, arch: riscv64 }
- { os: linux, arch: loong64 }
- { os: windows, arch: amd64, legacy_win7: true, legacy_name: "windows-7" }
- { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" }
@@ -112,15 +121,10 @@ jobs:
with:
fetch-depth: 0
- name: Setup Go
if: ${{ ! (matrix.legacy_win7 || matrix.legacy_go124) }}
if: ${{ ! matrix.legacy_win7 }}
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
- name: Setup Go 1.24
if: matrix.legacy_go124
uses: actions/setup-go@v5
with:
go-version: ~1.24.10
go-version: ~1.25.9
- name: Cache Go for Windows 7
if: matrix.legacy_win7
id: cache-go-for-windows7
@@ -128,9 +132,11 @@ jobs:
with:
path: |
~/go/go_win7
key: go_win7_1255
key: go_win7_1258
- name: Setup Go for Windows 7
if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |-
.github/setup_go_for_windows7.sh
- name: Setup Go for Windows 7
@@ -154,14 +160,23 @@ jobs:
git -C ~/cronet-go fetch --depth=1 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
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/Release+Asserts
~/cronet-go/naiveproxy/src/out/sysroot-build
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-${{ matrix.variant }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
@@ -190,9 +205,10 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="${TAGS},with_naive_outbound"
TAGS=$(cat release/DEFAULT_BUILD_TAGS)
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
if [[ "${{ matrix.variant }}" == "purego" ]]; then
TAGS="${TAGS},with_purego"
@@ -200,13 +216,16 @@ jobs:
TAGS="${TAGS},with_musl"
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (purego)
if: matrix.variant == 'purego'
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -228,7 +247,7 @@ jobs:
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
@@ -236,6 +255,8 @@ jobs:
GOARCH: ${{ matrix.arch }}
GO386: ${{ matrix.go386 }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
GOMIPS64: ${{ matrix.gomips }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (musl)
if: matrix.variant == 'musl'
@@ -243,7 +264,7 @@ jobs:
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
@@ -251,6 +272,8 @@ jobs:
GOARCH: ${{ matrix.arch }}
GO386: ${{ matrix.go386 }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
GOMIPS64: ${{ matrix.gomips }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (non-variant)
if: matrix.os != 'android' && matrix.variant == ''
@@ -258,7 +281,7 @@ jobs:
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -278,7 +301,7 @@ jobs:
export CXX="${CC}++"
mkdir -p dist
GOOS=$BUILD_GOOS GOARCH=$BUILD_GOARCH build go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
@@ -352,7 +375,7 @@ jobs:
sudo gem install fpm
sudo apt-get update
sudo apt-get install -y libarchive-tools
cp .fpm_systemd .fpm
cp .fpm_pacman .fpm
fpm -t pacman \
-v "$PKG_VERSION" \
-p "dist/sing-box_${{ needs.calculate_version.outputs.version }}_${{ matrix.os }}_${{ matrix.pacman }}.pkg.tar.zst" \
@@ -373,6 +396,30 @@ jobs:
.github/deb2ipk.sh "$architecture" "dist/openwrt.deb" "dist/sing-box_${{ needs.calculate_version.outputs.version }}_openwrt_${architecture}.ipk"
done
rm "dist/openwrt.deb"
- name: Install apk-tools
if: matrix.openwrt != '' || matrix.alpine != ''
run: |-
docker run --rm -v /usr/local/bin:/mnt alpine:edge sh -c "apk add --no-cache apk-tools-static && cp /sbin/apk.static /mnt/apk && chmod +x /mnt/apk"
- name: Package OpenWrt APK
if: matrix.openwrt != ''
run: |-
set -xeuo pipefail
for architecture in ${{ matrix.openwrt }}; do
.github/build_openwrt_apk.sh \
"$architecture" \
"${{ needs.calculate_version.outputs.version }}" \
"dist/sing-box" \
"dist/sing-box_${{ needs.calculate_version.outputs.version }}_openwrt_${architecture}.apk"
done
- name: Package Alpine APK
if: matrix.alpine != ''
run: |-
set -xeuo pipefail
.github/build_alpine_apk.sh \
"${{ matrix.alpine }}" \
"${{ needs.calculate_version.outputs.version }}" \
"dist/sing-box" \
"dist/sing-box_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.alpine }}.apk"
- name: Archive
run: |
set -xeuo pipefail
@@ -408,22 +455,36 @@ jobs:
include:
- { arch: amd64 }
- { arch: arm64 }
- { arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
- { arch: amd64, legacy_osx: true, legacy_name: "macos-10.13" }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
if: ${{ ! matrix.legacy_go124 }}
if: ${{ ! matrix.legacy_osx }}
uses: actions/setup-go@v5
with:
go-version: ^1.25.3
- name: Setup Go 1.24
if: matrix.legacy_go124
uses: actions/setup-go@v5
- name: Cache Go for macOS 10.13
if: matrix.legacy_osx
id: cache-go-for-macos1013
uses: actions/cache@v4
with:
go-version: ~1.24.6
path: |
~/go/go_osx
key: go_osx_1258
- name: Setup Go for macOS 10.13
if: matrix.legacy_osx && steps.cache-go-for-macos1013.outputs.cache-hit != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |-
.github/setup_go_for_macos1013.sh
- name: Setup Go for macOS 10.13
if: matrix.legacy_osx
run: |-
echo "PATH=$HOME/go/go_osx/bin:$PATH" >> $GITHUB_ENV
echo "GOROOT=$HOME/go/go_osx" >> $GITHUB_ENV
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
@@ -431,22 +492,27 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.legacy_go124 }}" != "true" ]]; then
TAGS="${TAGS},with_naive_outbound"
if [[ "${{ matrix.legacy_osx }}" != "true" ]]; then
TAGS=$(cat release/DEFAULT_BUILD_TAGS)
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: darwin
GOARCH: ${{ matrix.arch }}
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.legacy_osx && '10.13' || '' }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set name
run: |-
@@ -499,9 +565,11 @@ jobs:
- name: Build
if: matrix.naive
run: |
$TAGS = Get-Content release/DEFAULT_BUILD_TAGS_WINDOWS
$LDFLAGS_SHARED = Get-Content release/LDFLAGS
mkdir -p dist
go build -v -trimpath -o dist/sing-box.exe -tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0" `
-ldflags "-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0" `
go build -v -trimpath -o dist/sing-box.exe -tags "$TAGS" `
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' $LDFLAGS_SHARED -s -w -buildid=" `
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -511,9 +579,11 @@ jobs:
- name: Build
if: ${{ !matrix.naive }}
run: |
$TAGS = Get-Content release/DEFAULT_BUILD_TAGS_OTHERS
$LDFLAGS_SHARED = Get-Content release/LDFLAGS
mkdir -p dist
go build -v -trimpath -o dist/sing-box.exe -tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0" `
-ldflags "-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0" `
go build -v -trimpath -o dist/sing-box.exe -tags "$TAGS" `
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' $LDFLAGS_SHARED -s -w -buildid=" `
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -558,7 +628,7 @@ jobs:
path: "dist"
build_android:
name: Build Android
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android'
if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android') && github.ref != 'refs/heads/oldstable'
runs-on: ubuntu-latest
needs:
- calculate_version
@@ -571,7 +641,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
@@ -594,12 +664,12 @@ jobs:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Checkout main branch
if: github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch'
if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch'
run: |-
cd clients/android
git checkout main
- name: Checkout dev branch
if: github.ref == 'refs/heads/dev-next'
if: github.ref == 'refs/heads/testing'
run: |-
cd clients/android
git checkout dev
@@ -648,7 +718,7 @@ jobs:
path: 'dist'
publish_android:
name: Publish Android
if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android'
if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android' && github.ref != 'refs/heads/oldstable'
runs-on: ubuntu-latest
needs:
- calculate_version
@@ -661,7 +731,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
@@ -684,12 +754,12 @@ jobs:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Checkout main branch
if: github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch'
if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch'
run: |-
cd clients/android
git checkout main
- name: Checkout dev branch
if: github.ref == 'refs/heads/dev-next'
if: github.ref == 'refs/heads/testing'
run: |-
cd clients/android
git checkout dev
@@ -760,7 +830,7 @@ jobs:
if: matrix.if
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Set tag
if: matrix.if
run: |-
@@ -768,12 +838,12 @@ jobs:
git tag v${{ needs.calculate_version.outputs.version }} -f
echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV"
- name: Checkout main branch
if: matrix.if && github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch'
if: matrix.if && github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch'
run: |-
cd clients/apple
git checkout main
- name: Checkout dev branch
if: matrix.if && github.ref == 'refs/heads/dev-next'
if: matrix.if && github.ref == 'refs/heads/testing'
run: |-
cd clients/apple
git checkout dev
@@ -859,7 +929,7 @@ jobs:
-authenticationKeyID $ASC_KEY_ID \
-authenticationKeyIssuerID $ASC_KEY_ISSUER_ID
- name: Publish to TestFlight
if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' && github.ref =='refs/heads/dev-next'
if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' && github.ref =='refs/heads/testing'
run: |-
go run -v ./cmd/internal/app_store_connect publish_testflight ${{ matrix.platform }}
- name: Build image

View File

@@ -3,8 +3,8 @@ name: Publish Docker Images
on:
#push:
# branches:
# - main-next
# - dev-next
# - stable
# - testing
release:
types:
- published
@@ -29,10 +29,12 @@ jobs:
- { arch: arm64, naive: true, docker_platform: "linux/arm64" }
- { arch: "386", naive: true, docker_platform: "linux/386" }
- { arch: arm, goarm: "7", naive: true, docker_platform: "linux/arm/v7" }
- { arch: mipsle, gomips: softfloat, naive: true, docker_platform: "linux/mipsle" }
- { arch: riscv64, naive: true, docker_platform: "linux/riscv64" }
- { arch: loong64, naive: true, docker_platform: "linux/loong64" }
# Non-naive builds
- { arch: arm, goarm: "6", docker_platform: "linux/arm/v6" }
- { arch: ppc64le, docker_platform: "linux/ppc64le" }
- { arch: riscv64, docker_platform: "linux/riscv64" }
- { arch: s390x, docker_platform: "linux/s390x" }
steps:
- name: Get commit to build
@@ -53,7 +55,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.4
go-version: ~1.25.9
- name: Clone cronet-go
if: matrix.naive
run: |
@@ -64,14 +66,23 @@ jobs:
git -C ~/cronet-go fetch --depth=1 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
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/Release+Asserts
~/cronet-go/naiveproxy/src/out/sysroot-build
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
@@ -93,29 +104,34 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="${TAGS},with_naive_outbound,with_musl"
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=${VERSION}\" -X 'internal/godebug.defaultGODEBUG=multipathtcp=0' -s -w -buildid= -checklinkname=0" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=${VERSION}\" -X 'internal/godebug.defaultGODEBUG=multipathtcp=0' -s -w -buildid= -checklinkname=0" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -148,15 +164,17 @@ jobs:
strategy:
fail-fast: true
matrix:
platform:
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64
- linux/386
- linux/ppc64le
- linux/riscv64
- linux/s390x
include:
- { platform: "linux/amd64" }
- { platform: "linux/arm/v6" }
- { platform: "linux/arm/v7" }
- { platform: "linux/arm64" }
- { platform: "linux/386" }
# mipsle: no base Docker image available for this platform
- { platform: "linux/ppc64le" }
- { platform: "linux/riscv64" }
- { platform: "linux/s390x" }
- { platform: "linux/loong64", base_image: "ghcr.io/loong64/alpine:edge" }
steps:
- name: Get commit to build
id: ref
@@ -209,6 +227,8 @@ jobs:
platforms: ${{ matrix.platform }}
context: .
file: Dockerfile.binary
build-args: |
BASE_IMAGE=${{ matrix.base_image || 'alpine' }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
@@ -224,6 +244,7 @@ jobs:
if-no-files-found: error
retention-days: 1
merge:
if: github.event_name != 'push'
runs-on: ubuntu-latest
needs:
- build_docker
@@ -238,13 +259,13 @@ jobs:
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
if [[ $ref == *"-"* ]]; then
latest=latest-beta
else
latest=latest
fi
echo "latest=$latest"
echo "latest=$latest" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Detect track
run: bash .github/detect_track.sh
- name: Download digests
uses: actions/download-artifact@v5
with:
@@ -264,11 +285,11 @@ jobs:
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }}" \
-t "${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}" \
-t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}" \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
if: github.event_name != 'push'
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }}
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}

View File

@@ -3,18 +3,20 @@ name: Lint
on:
push:
branches:
- stable-next
- main-next
- dev-next
- oldstable
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/lint.yml'
pull_request:
branches:
- stable-next
- main-next
- dev-next
- oldstable
- stable
- testing
- unstable
jobs:
build:

View File

@@ -3,19 +3,14 @@ name: Build Linux Packages
on:
#push:
# branches:
# - main-next
# - dev-next
# - stable
# - testing
workflow_dispatch:
inputs:
version:
description: "Version name"
required: true
type: string
forceBeta:
description: "Force beta"
required: false
type: boolean
default: false
release:
types:
- published
@@ -34,7 +29,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -61,14 +56,14 @@ jobs:
- { os: linux, arch: arm64, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64 }
- { os: linux, arch: "386", naive: true, debian: i386, rpm: i386 }
- { os: linux, arch: arm, goarm: "7", naive: true, debian: armhf, rpm: armv7hl, pacman: armv7hl }
- { os: linux, arch: mipsle, gomips: softfloat, naive: true, debian: mipsel, rpm: mipsel }
- { os: linux, arch: riscv64, naive: true, debian: riscv64, rpm: riscv64 }
- { os: linux, arch: loong64, naive: true, debian: loongarch64, rpm: loongarch64 }
# Non-naive builds (unsupported architectures)
- { os: linux, arch: arm, goarm: "6", debian: armel, rpm: armv6hl }
- { os: linux, arch: mips64le, debian: mips64el, rpm: mips64el }
- { os: linux, arch: mipsle, debian: mipsel, rpm: mipsel }
- { os: linux, arch: s390x, debian: s390x, rpm: s390x }
- { os: linux, arch: ppc64le, debian: ppc64el, rpm: ppc64le }
- { os: linux, arch: riscv64, debian: riscv64, rpm: riscv64 }
- { os: linux, arch: loong64, debian: loongarch64, rpm: loongarch64 }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
@@ -77,7 +72,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.7
go-version: ~1.25.9
- name: Clone cronet-go
if: matrix.naive
run: |
@@ -88,14 +83,23 @@ jobs:
git -C ~/cronet-go fetch --depth=1 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
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/Release+Asserts
~/cronet-go/naiveproxy/src/out/sysroot-build
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
@@ -116,24 +120,30 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="${TAGS},with_naive_outbound,with_musl"
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
GOMIPS64: ${{ matrix.gomips }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
@@ -141,7 +151,7 @@ jobs:
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
@@ -152,14 +162,8 @@ jobs:
- name: Set mtime
run: |-
TZ=UTC touch -t '197001010000' dist/sing-box
- name: Set name
if: (! contains(needs.calculate_version.outputs.version, '-')) && !inputs.forceBeta
run: |-
echo "NAME=sing-box" >> "$GITHUB_ENV"
- name: Set beta name
if: contains(needs.calculate_version.outputs.version, '-') || inputs.forceBeta
run: |-
echo "NAME=sing-box-beta" >> "$GITHUB_ENV"
- name: Detect track
run: bash .github/detect_track.sh
- name: Set version
run: |-
PKG_VERSION="${{ needs.calculate_version.outputs.version }}"

55
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Test
on:
push:
branches:
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- 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.24
- ~1.25
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" ./...

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@
/*.jar
/*.aar
/*.xcframework/
/experimental/libbox/*.aar
/experimental/libbox/*.xcframework/
/experimental/libbox/*.nupkg
.DS_Store
/config.d/
/venv/

View File

@@ -9,12 +9,16 @@ run:
- with_utls
- with_acme
- with_clash_api
- with_tailscale
- with_ccm
- with_ocm
- badlinkname
- tfogo_checklinkname0
linters:
default: none
enable:
- govet
- ineffassign
- paralleltest
- staticcheck
settings:
staticcheck:

View File

@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
COPY . /go/src/github.com/sagernet/sing-box
WORKDIR /go/src/github.com/sagernet/sing-box
@@ -12,10 +12,11 @@ RUN set -ex \
&& apk add git build-base \
&& export COMMIT=$(git rev-parse --short HEAD) \
&& export VERSION=$(go run ./cmd/internal/read_tag) \
&& go build -v -trimpath -tags \
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0" \
&& export TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) \
&& export LDFLAGS_SHARED=$(cat release/LDFLAGS) \
&& go build -v -trimpath -tags "$TAGS" \
-o /go/bin/sing-box \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -X 'internal/godebug.defaultGODEBUG=multipathtcp=0' -s -w -buildid= -checklinkname=0" \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" $LDFLAGS_SHARED -s -w -buildid=" \
./cmd/sing-box
FROM --platform=$TARGETPLATFORM alpine AS dist
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"

View File

@@ -1,8 +1,14 @@
FROM alpine
ARG BASE_IMAGE=alpine
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& apk add --no-cache --upgrade bash tzdata ca-certificates nftables
&& if command -v apk > /dev/null; then \
apk add --no-cache --upgrade bash tzdata ca-certificates nftables; \
else \
apt-get update && apt-get install -y --no-install-recommends bash tzdata ca-certificates nftables \
&& rm -rf /var/lib/apt/lists/*; \
fi
COPY sing-box-${TARGETARCH}${TARGETVARIANT} /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]

View File

@@ -1,15 +1,18 @@
NAME = sing-box
COMMIT = $(shell git rev-parse --short HEAD)
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0
TAGS ?= $(shell cat release/DEFAULT_BUILD_TAGS_OTHERS)
GOHOSTOS = $(shell go env GOHOSTOS)
GOHOSTARCH = $(shell go env GOHOSTARCH)
VERSION=$(shell CGO_ENABLED=0 GOOS=$(GOHOSTOS) GOARCH=$(GOHOSTARCH) go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest)
PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$(VERSION)' -X 'internal/godebug.defaultGODEBUG=multipathtcp=0' -s -w -buildid= -checklinkname=0"
LDFLAGS_SHARED = $(shell cat release/LDFLAGS)
PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$(VERSION)' $(LDFLAGS_SHARED) -s -w -buildid="
MAIN_PARAMS = $(PARAMS) -tags "$(TAGS)"
MAIN = ./cmd/sing-box
PREFIX ?= $(shell go env GOPATH)
SING_FFI ?= sing-ffi
LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json
.PHONY: test release docs build
@@ -49,7 +52,7 @@ lint:
GOOS=android golangci-lint run ./...
GOOS=windows golangci-lint run ./...
GOOS=darwin golangci-lint run ./...
GOOS=freebsd golangci-lint run ./...
# GOOS=freebsd golangci-lint run ./...
lint_install:
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
@@ -237,15 +240,18 @@ lib_android:
lib_apple:
go run ./cmd/internal/build_libbox -target apple
lib_windows:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type csharp
lib_android_new:
go run ./cmd/internal/build_libbox_newffi -target android
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type android
lib_apple_new:
go run ./cmd/internal/build_libbox_newffi -target apple
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple
lib_install:
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.11
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.11
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12
docs:
venv/bin/mkdocs serve
@@ -254,8 +260,8 @@ publish_docs:
venv/bin/mkdocs gh-deploy -m "Update" --force --ignore-version --no-history
docs_install:
python -m venv venv
source ./venv/bin/activate && pip install --force-reinstall mkdocs-material=="9.*" mkdocs-static-i18n=="1.2.*"
python3 -m venv venv
source ./venv/bin/activate && pip install --force-reinstall mkdocs-material=="9.7.2" mkdocs-static-i18n=="1.2.*"
clean:
rm -rf bin dist sing-box

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
}

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
}

View File

@@ -0,0 +1,72 @@
package certificate
import (
"context"
"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) 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
}

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
}

View File

@@ -9,6 +9,10 @@ import (
type ConnectionManager interface {
Lifecycle
Count() int
CloseAll()
TrackConn(conn net.Conn) net.Conn
TrackPacketConn(conn net.PacketConn) net.PacketConn
NewConnection(ctx context.Context, this N.Dialer, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
}

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"
@@ -25,18 +26,19 @@ 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)
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
ClientSubnet netip.Prefix
}
func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) {
@@ -49,11 +51,12 @@ func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptio
return nil, 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{}),
Transport: transport,
Strategy: C.DomainStrategy(options.Strategy),
DisableCache: options.DisableCache,
DisableOptimisticCache: options.DisableOptimisticCache,
RewriteTTL: options.RewriteTTL,
ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}),
}, nil
}
@@ -63,6 +66,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 +82,6 @@ type DNSTransport interface {
Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error)
}
type LegacyDNSTransport interface {
LegacyStrategy() C.DomainStrategy
LegacyClientSubnet() netip.Prefix
}
type DNSTransportRegistry interface {
option.DNSTransportOptionsRegistry
CreateDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) (DNSTransport, error)

View File

@@ -47,6 +47,12 @@ type CacheFile interface {
StoreRDRC() bool
RDRCStore
StoreDNS() bool
DNSCacheStore
SetDisableExpire(disableExpire bool)
SetOptimisticTimeout(timeout time.Duration)
LoadMode() string
StoreMode(mode string) error
LoadSelected(group string) string

43
adapter/http.go Normal file
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()
}
}

View File

@@ -2,6 +2,7 @@ package adapter
import (
"context"
"net"
"net/netip"
"time"
@@ -9,6 +10,8 @@ import (
"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 {
@@ -62,13 +65,10 @@ type InboundContext struct {
// cache
// Deprecated: implement in rule action
InboundDetour string
LastInbound string
OriginDestination M.Socksaddr
RouteOriginalDestination M.Socksaddr
// Deprecated: to be removed
//nolint:staticcheck
InboundOptions option.InboundOptions
InboundDetour string
LastInbound string
OriginDestination M.Socksaddr
RouteOriginalDestination M.Socksaddr
UDPDisableDomainUnmapping bool
UDPConnect bool
UDPTimeout time.Duration
@@ -81,12 +81,16 @@ type InboundContext struct {
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
DestinationAddressMatchFromResponse bool
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
SourceMACAddress net.HardwareAddr
SourceHostname string
QueryType uint16
FakeIP bool
// rule cache
@@ -104,6 +108,10 @@ type InboundContext struct {
func (c *InboundContext) ResetRuleCache() {
c.IPCIDRMatchSource = false
c.IPCIDRAcceptEmpty = false
c.ResetRuleMatchCache()
}
func (c *InboundContext) ResetRuleMatchCache() {
c.SourceAddressMatch = false
c.SourcePortMatch = false
c.DestinationAddressMatch = false
@@ -111,6 +119,51 @@ func (c *InboundContext) ResetRuleCache() {
c.DidMatch = false
}
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{}
func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context {

45
adapter/inbound_test.go Normal file
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())
}

23
adapter/neighbor.go Normal file
View File

@@ -0,0 +1,23 @@
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)
Start() error
Close() error
}
type NeighborUpdateListener interface {
UpdateNeighborTable(entries []NeighborEntry)
}

View File

@@ -36,6 +36,10 @@ type PlatformInterface interface {
UsePlatformNotification() bool
SendNotification(notification *Notification) error
UsePlatformNeighborResolver() bool
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
}
type FindConnectionOwnerRequest struct {
@@ -47,11 +51,11 @@ type FindConnectionOwnerRequest struct {
}
type ConnectionOwner struct {
ProcessID uint32
UserId int32
UserName string
ProcessPath string
AndroidPackageName string
ProcessID uint32
UserId int32
UserName string
ProcessPath string
AndroidPackageNames []string
}
type Notification struct {

View File

@@ -2,17 +2,11 @@ package adapter
import (
"context"
"crypto/tls"
"net"
"net/http"
"sync"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-tun"
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"
@@ -26,6 +20,8 @@ type Router interface {
RuleSet(tag string) (RuleSet, bool)
Rules() []Rule
NeedFindProcess() bool
NeedFindNeighbor() bool
NeighborResolver() NeighborResolver
AppendTracker(tracker ConnectionTracker)
ResetNetwork()
}
@@ -64,51 +60,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
}

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,9 @@ type Rule interface {
type DNSRule interface {
Rule
LegacyPreMatch(metadata *InboundContext) bool
WithAddressLimit() bool
MatchAddressLimit(metadata *InboundContext) bool
MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool
}
type RuleAction interface {
@@ -29,7 +32,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

49
adapter/tailscale.go Normal file
View File

@@ -0,0 +1,49 @@
package adapter
import "context"
type TailscaleEndpoint interface {
SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error
StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error
}
type TailscalePingResult struct {
LatencyMs float64
IsDirect bool
Endpoint string
DERPRegionID int32
DERPRegionCode string
Error string
}
type TailscaleEndpointStatus struct {
BackendState string
AuthURL string
NetworkName string
MagicDNSSuffix string
Self *TailscalePeer
UserGroups []*TailscaleUserGroup
}
type TailscaleUserGroup struct {
UserID int64
LoginName string
DisplayName string
ProfilePicURL string
Peers []*TailscalePeer
}
type TailscalePeer struct {
HostName string
DNSName string
OS string
TailscaleIPs []string
Online bool
ExitNode bool
ExitNodeOption bool
Active bool
RxBytes int64
TxBytes int64
UserID int64
KeyExpiry int64
}

181
box.go
View File

@@ -9,19 +9,21 @@ import (
"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"
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/taskmonitor"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/dns/transport/local"
"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"
@@ -37,20 +39,22 @@ 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
service *boxService.Manager
dnsTransport *dns.TransportManager
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
internalService []adapter.LifecycleService
done chan struct{}
createdAt time.Time
logFactory log.Factory
logger log.ContextLogger
network *route.NetworkManager
endpoint *endpoint.Manager
inbound *inbound.Manager
outbound *outbound.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 {
@@ -66,6 +70,7 @@ func Context(
endpointRegistry adapter.EndpointRegistry,
dnsTransportRegistry adapter.DNSTransportRegistry,
serviceRegistry adapter.ServiceRegistry,
certificateProviderRegistry adapter.CertificateProviderRegistry,
) context.Context {
if service.FromContext[option.InboundOptionsRegistry](ctx) == nil ||
service.FromContext[adapter.InboundRegistry](ctx) == nil {
@@ -90,6 +95,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
}
@@ -106,6 +115,7 @@ func New(options Options) (*Box, error) {
outboundRegistry := service.FromContext[adapter.OutboundRegistry](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")
@@ -122,10 +132,16 @@ 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)
applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug))
err := applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug))
if err != nil {
return nil, err
}
var needCacheFile bool
var needClashAPI bool
var needV2RayAPI bool
@@ -156,6 +172,7 @@ func New(options Options) (*Box, error) {
}
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 ||
@@ -168,21 +185,25 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.CertificateStore](ctx, certificateStore)
internalServices = append(internalServices, certificateStore)
}
routeOptions := common.PtrValueOrDefault(options.Route)
dnsOptions := common.PtrValueOrDefault(options.DNS)
endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry)
inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager)
outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final)
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.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")
@@ -190,6 +211,10 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.NetworkManager](ctx, networkManager)
connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection"))
service.MustRegister[adapter.ConnectionManager](ctx, connectionManager)
// 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, routeOptions, dnsOptions)
service.MustRegister[adapter.Router](ctx, router)
err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet)
@@ -269,6 +294,24 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize inbound[", i, "]")
}
}
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 != "" {
@@ -295,22 +338,22 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize outbound[", 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) {
@@ -323,13 +366,20 @@ func New(options Options) (*Box, error) {
)
})
dnsTransportManager.Initialize(func() (adapter.DNSTransport, error) {
return local.NewTransport(
return dnsTransportRegistry.CreateDNSTransport(
ctx,
logFactory.NewLogger("dns/local"),
"local",
option.LocalDNSServerOptions{},
C.DNSTypeLocal,
&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 {
@@ -337,7 +387,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)
}
@@ -380,20 +430,22 @@ func New(options Options) (*Box, error) {
internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service"))
}
return &Box{
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
createdAt: createdAt,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
certificateProvider: certificateProviderManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
httpClientService: httpClientService,
createdAt: createdAt,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
}, nil
}
@@ -447,11 +499,19 @@ func (s *Box) preStart() error {
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.service)
err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service, s.certificateProvider)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router)
err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.network, s.connection)
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStateStart, []adapter.LifecycleService{s.httpClientService})
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.router, s.dnsRouter)
if err != nil {
return err
}
@@ -467,11 +527,19 @@ func (s *Box) start() error {
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service)
err = adapter.Start(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.service)
err = adapter.Start(s.logger, adapter.StartStateStart, s.certificateProvider)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.service)
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.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
@@ -479,7 +547,7 @@ func (s *Box) start() error {
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.service)
err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
@@ -503,8 +571,9 @@ func (s *Box) Close() error {
service adapter.Lifecycle
}{
{"service", s.service},
{"endpoint", s.endpoint},
{"inbound", s.inbound},
{"certificate-provider", s.certificateProvider},
{"endpoint", s.endpoint},
{"outbound", s.outbound},
{"router", s.router},
{"connection", s.connection},
@@ -519,6 +588,14 @@ func (s *Box) Close() error {
})
s.logger.Trace("close ", closeItem.name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
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 {
s.logger.Trace("close ", lifecycleService.Name())
startTime := time.Now()
@@ -552,6 +629,10 @@ func (s *Box) Outbound() adapter.OutboundManager {
return s.outbound
}
func (s *Box) Endpoint() adapter.EndpointManager {
return s.endpoint
}
func (s *Box) LogFactory() log.Factory {
return s.logFactory
}

View File

@@ -63,7 +63,7 @@ func init() {
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")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "with_conntrack", "badlinkname", "tfogo_checklinkname0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_naive_outbound", "with_clash_api", "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")
@@ -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, ","))

View File

@@ -1,93 +0,0 @@
package main
import (
"flag"
"os"
"os/exec"
"path/filepath"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common/rw"
)
var target string
func init() {
flag.StringVar(&target, "target", "android", "target platform (android or apple)")
}
func main() {
flag.Parse()
args := []string{
"generate",
"-v",
"--config", "experimental/libbox/ffi.json",
"--platform-type", target,
}
command := exec.Command("sing-ffi", args...)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
err := command.Run()
if err != nil {
log.Fatal(err)
}
copyArtifacts(target)
}
func copyArtifacts(target string) {
switch target {
case "android":
copyPath := filepath.Join("..", "sing-box-for-android", "app", "libs")
if rw.IsDir(copyPath) {
copyPath, _ = filepath.Abs(copyPath)
for _, name := range []string{"libbox.aar", "libbox-legacy.aar"} {
artifactPath, found := findArtifactPath(name)
if !found {
continue
}
targetPath := filepath.Join(target, artifactPath)
os.RemoveAll(targetPath)
err := os.Rename(artifactPath, targetPath)
if err != nil {
log.Fatal(err)
}
log.Info("copied ", name, " to ", copyPath)
}
}
case "apple":
copyPath := filepath.Join("..", "sing-box-for-apple")
if rw.IsDir(copyPath) {
sourceDir, found := findArtifactPath("Libbox.xcframework")
if !found {
log.Fatal("Libbox.xcframework not found in current directory or experimental/libbox")
}
targetDir := filepath.Join(copyPath, "Libbox.xcframework")
targetDir, _ = filepath.Abs(targetDir)
err := os.RemoveAll(targetDir)
if err != nil {
log.Fatal(err)
}
err = os.Rename(sourceDir, targetDir)
if err != nil {
log.Fatal(err)
}
log.Info("copied ", sourceDir, " to ", targetDir)
}
}
}
func findArtifactPath(name string) (string, bool) {
candidates := []string{
name,
filepath.Join("experimental", "libbox", name),
}
for _, candidate := range candidates {
if rw.IsFile(candidate) || rw.IsDir(candidate) {
return candidate, true
}
}
return "", false
}

View File

@@ -71,12 +71,12 @@ func findAndReplace(objectsMap map[string]any, projectContent string, bundleIDLi
indexEnd := indexStart + strings.Index(projectContent[indexStart:], "}")
versionStart := indexStart + strings.Index(projectContent[indexStart:indexEnd], "MARKETING_VERSION = ") + 20
versionEnd := versionStart + strings.Index(projectContent[versionStart:indexEnd], ";")
version := projectContent[versionStart:versionEnd]
version := strings.Trim(projectContent[versionStart:versionEnd], "\"")
if version == newVersion {
continue
}
updated = true
projectContent = projectContent[:versionStart] + newVersion + projectContent[versionEnd:]
projectContent = projectContent[:versionStart] + "\"" + newVersion + "\"" + projectContent[versionEnd:]
}
return projectContent, updated
}

View File

@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/sagernet/sing-box/log"
@@ -35,21 +36,9 @@ func updateMozillaIncludedRootCAs() error {
return err
}
geoIndex := slices.Index(header, "Geographic Focus")
nameIndex := slices.Index(header, "Common Name or Certificate Name")
certIndex := slices.Index(header, "PEM Info")
generated := strings.Builder{}
generated.WriteString(`// Code generated by 'make update_certificates'. DO NOT EDIT.
package certificate
import "crypto/x509"
var mozillaIncluded *x509.CertPool
func init() {
mozillaIncluded = x509.NewCertPool()
`)
pemBundle := strings.Builder{}
for {
record, err := reader.Read()
if err == io.EOF {
@@ -60,18 +49,12 @@ func init() {
if record[geoIndex] == "China" {
continue
}
generated.WriteString("\n // ")
generated.WriteString(record[nameIndex])
generated.WriteString("\n")
generated.WriteString(" mozillaIncluded.AppendCertsFromPEM([]byte(`")
cert := record[certIndex]
// Remove single quotes
cert = cert[1 : len(cert)-1]
generated.WriteString(cert)
generated.WriteString("`))\n")
pemBundle.WriteString(cert)
pemBundle.WriteString("\n")
}
generated.WriteString("}\n")
return os.WriteFile("common/certificate/mozilla.go", []byte(generated.String()), 0o644)
return writeGeneratedCertificateBundle("mozilla", "mozillaIncluded", pemBundle.String())
}
func fetchChinaFingerprints() (map[string]bool, error) {
@@ -119,23 +102,11 @@ func updateChromeIncludedRootCAs() error {
if err != nil {
return err
}
subjectIndex := slices.Index(header, "Subject")
statusIndex := slices.Index(header, "Google Chrome Status")
certIndex := slices.Index(header, "X.509 Certificate (PEM)")
fingerprintIndex := slices.Index(header, "SHA-256 Fingerprint")
generated := strings.Builder{}
generated.WriteString(`// Code generated by 'make update_certificates'. DO NOT EDIT.
package certificate
import "crypto/x509"
var chromeIncluded *x509.CertPool
func init() {
chromeIncluded = x509.NewCertPool()
`)
pemBundle := strings.Builder{}
for {
record, err := reader.Read()
if err == io.EOF {
@@ -149,18 +120,39 @@ func init() {
if chinaFingerprints[record[fingerprintIndex]] {
continue
}
generated.WriteString("\n // ")
generated.WriteString(record[subjectIndex])
generated.WriteString("\n")
generated.WriteString(" chromeIncluded.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")
pemBundle.WriteString(cert)
pemBundle.WriteString("\n")
}
generated.WriteString("}\n")
return os.WriteFile("common/certificate/chrome.go", []byte(generated.String()), 0o644)
return writeGeneratedCertificateBundle("chrome", "chromeIncluded", pemBundle.String())
}
func writeGeneratedCertificateBundle(name string, variableName string, pemBundle string) error {
goSource := `// Code generated by 'make update_certificates'. DO NOT EDIT.
package certificate
import (
"crypto/x509"
_ "embed"
)
//go:embed ` + name + `.pem
var ` + variableName + `PEM string
var ` + variableName + ` *x509.CertPool
func init() {
` + variableName + ` = x509.NewCertPool()
` + variableName + `.AppendCertsFromPEM([]byte(` + variableName + `PEM))
}
`
err := os.WriteFile(filepath.Join("common/certificate", name+".pem"), []byte(pemBundle), 0o644)
if err != nil {
return err
}
return os.WriteFile(filepath.Join("common/certificate", name+".go"), []byte(goSource), 0o644)
}

View File

@@ -82,6 +82,11 @@ func compileRuleSet(sourcePath string) error {
}
func downgradeRuleSetVersion(version uint8, options option.PlainRuleSet) uint8 {
if version == C.RuleSetVersion5 && !rule.HasHeadlessRule(options.Rules, func(rule option.DefaultHeadlessRule) bool {
return len(rule.PackageNameRegex) > 0
}) {
version = C.RuleSetVersion4
}
if version == C.RuleSetVersion4 && !rule.HasHeadlessRule(options.Rules, func(rule option.DefaultHeadlessRule) bool {
return rule.NetworkInterfaceAddress != nil && rule.NetworkInterfaceAddress.Size() > 0 ||
len(rule.DefaultInterfaceAddress) > 0

View File

@@ -0,0 +1,121 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/common/networkquality"
"github.com/sagernet/sing-box/log"
"github.com/spf13/cobra"
)
var (
commandNetworkQualityFlagConfigURL string
commandNetworkQualityFlagSerial bool
commandNetworkQualityFlagMaxRuntime int
commandNetworkQualityFlagHTTP3 bool
)
var commandNetworkQuality = &cobra.Command{
Use: "networkquality",
Short: "Run a network quality test",
Run: func(cmd *cobra.Command, args []string) {
err := runNetworkQuality()
if err != nil {
log.Fatal(err)
}
},
}
func init() {
commandNetworkQuality.Flags().StringVar(
&commandNetworkQualityFlagConfigURL,
"config-url", "",
"Network quality test config URL (default: Apple mensura)",
)
commandNetworkQuality.Flags().BoolVar(
&commandNetworkQualityFlagSerial,
"serial", false,
"Run download and upload tests sequentially instead of in parallel",
)
commandNetworkQuality.Flags().IntVar(
&commandNetworkQualityFlagMaxRuntime,
"max-runtime", int(networkquality.DefaultMaxRuntime/time.Second),
"Network quality maximum runtime in seconds",
)
commandNetworkQuality.Flags().BoolVar(
&commandNetworkQualityFlagHTTP3,
"http3", false,
"Use HTTP/3 (QUIC) for measurement traffic",
)
commandTools.AddCommand(commandNetworkQuality)
}
func runNetworkQuality() error {
instance, err := createPreStartedClient()
if err != nil {
return err
}
defer instance.Close()
dialer, err := createDialer(instance, commandToolsFlagOutbound)
if err != nil {
return err
}
httpClient := networkquality.NewHTTPClient(dialer)
defer httpClient.CloseIdleConnections()
measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(dialer, commandNetworkQualityFlagHTTP3)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "==== NETWORK QUALITY TEST ====")
result, err := networkquality.Run(networkquality.Options{
ConfigURL: commandNetworkQualityFlagConfigURL,
HTTPClient: httpClient,
NewMeasurementClient: measurementClientFactory,
Serial: commandNetworkQualityFlagSerial,
MaxRuntime: time.Duration(commandNetworkQualityFlagMaxRuntime) * time.Second,
Context: globalCtx,
OnProgress: func(p networkquality.Progress) {
if !commandNetworkQualityFlagSerial && p.Phase != networkquality.PhaseIdle {
fmt.Fprintf(os.Stderr, "\rDownload: %s RPM: %d Upload: %s RPM: %d",
networkquality.FormatBitrate(p.DownloadCapacity), p.DownloadRPM,
networkquality.FormatBitrate(p.UploadCapacity), p.UploadRPM)
return
}
switch networkquality.Phase(p.Phase) {
case networkquality.PhaseIdle:
if p.IdleLatencyMs > 0 {
fmt.Fprintf(os.Stderr, "\rIdle Latency: %d ms", p.IdleLatencyMs)
} else {
fmt.Fprint(os.Stderr, "\rMeasuring idle latency...")
}
case networkquality.PhaseDownload:
fmt.Fprintf(os.Stderr, "\rDownload: %s RPM: %d",
networkquality.FormatBitrate(p.DownloadCapacity), p.DownloadRPM)
case networkquality.PhaseUpload:
fmt.Fprintf(os.Stderr, "\rUpload: %s RPM: %d",
networkquality.FormatBitrate(p.UploadCapacity), p.UploadRPM)
}
},
})
if err != nil {
return err
}
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, strings.Repeat("-", 40))
fmt.Fprintf(os.Stderr, "Idle Latency: %d ms\n", result.IdleLatencyMs)
fmt.Fprintf(os.Stderr, "Download Capacity: %-20s Accuracy: %s\n", networkquality.FormatBitrate(result.DownloadCapacity), result.DownloadCapacityAccuracy)
fmt.Fprintf(os.Stderr, "Upload Capacity: %-20s Accuracy: %s\n", networkquality.FormatBitrate(result.UploadCapacity), result.UploadCapacityAccuracy)
fmt.Fprintf(os.Stderr, "Download Responsiveness: %-20s Accuracy: %s\n", fmt.Sprintf("%d RPM", result.DownloadRPM), result.DownloadRPMAccuracy)
fmt.Fprintf(os.Stderr, "Upload Responsiveness: %-20s Accuracy: %s\n", fmt.Sprintf("%d RPM", result.UploadRPM), result.UploadRPMAccuracy)
return nil
}

View File

@@ -0,0 +1,79 @@
package main
import (
"fmt"
"os"
"github.com/sagernet/sing-box/common/stun"
"github.com/sagernet/sing-box/log"
"github.com/spf13/cobra"
)
var commandSTUNFlagServer string
var commandSTUN = &cobra.Command{
Use: "stun",
Short: "Run a STUN test",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
err := runSTUN()
if err != nil {
log.Fatal(err)
}
},
}
func init() {
commandSTUN.Flags().StringVarP(&commandSTUNFlagServer, "server", "s", stun.DefaultServer, "STUN server address")
commandTools.AddCommand(commandSTUN)
}
func runSTUN() error {
instance, err := createPreStartedClient()
if err != nil {
return err
}
defer instance.Close()
dialer, err := createDialer(instance, commandToolsFlagOutbound)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "==== STUN TEST ====")
result, err := stun.Run(stun.Options{
Server: commandSTUNFlagServer,
Dialer: dialer,
Context: globalCtx,
OnProgress: func(p stun.Progress) {
switch p.Phase {
case stun.PhaseBinding:
if p.ExternalAddr != "" {
fmt.Fprintf(os.Stderr, "\rExternal Address: %s (%d ms)", p.ExternalAddr, p.LatencyMs)
} else {
fmt.Fprint(os.Stderr, "\rSending binding request...")
}
case stun.PhaseNATMapping:
fmt.Fprint(os.Stderr, "\rDetecting NAT mapping behavior...")
case stun.PhaseNATFiltering:
fmt.Fprint(os.Stderr, "\rDetecting NAT filtering behavior...")
}
},
})
if err != nil {
return err
}
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "External Address: %s\n", result.ExternalAddr)
fmt.Fprintf(os.Stderr, "Latency: %d ms\n", result.LatencyMs)
if result.NATTypeSupported {
fmt.Fprintf(os.Stderr, "NAT Mapping: %s\n", result.NATMapping)
fmt.Fprintf(os.Stderr, "NAT Filtering: %s\n", result.NATFiltering)
} else {
fmt.Fprintln(os.Stderr, "NAT Type Detection: not supported by server")
}
return nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,10 @@ var _ adapter.CertificateStore = (*Store)(nil)
type Store struct {
access sync.RWMutex
store string
systemPool *x509.CertPool
currentPool *x509.CertPool
currentPEM []string
certificate string
certificatePaths []string
certificateDirectoryPaths []string
@@ -61,6 +63,7 @@ func NewStore(ctx context.Context, logger logger.Logger, options option.Certific
return nil, E.New("unknown certificate store: ", options.Store)
}
store := &Store{
store: options.Store,
systemPool: systemPool,
certificate: strings.Join(options.Certificate, "\n"),
certificatePaths: options.CertificatePath,
@@ -123,19 +126,37 @@ func (s *Store) Pool() *x509.CertPool {
return s.currentPool
}
func (s *Store) StoreKind() string {
return s.store
}
func (s *Store) CurrentPEM() []string {
s.access.RLock()
defer s.access.RUnlock()
return append([]string(nil), s.currentPEM...)
}
func (s *Store) update() error {
s.access.Lock()
defer s.access.Unlock()
var currentPool *x509.CertPool
var currentPEM []string
if s.systemPool == nil {
currentPool = x509.NewCertPool()
} else {
currentPool = s.systemPool.Clone()
}
switch s.store {
case C.CertificateStoreMozilla:
currentPEM = append(currentPEM, mozillaIncludedPEM)
case C.CertificateStoreChrome:
currentPEM = append(currentPEM, chromeIncludedPEM)
}
if s.certificate != "" {
if !currentPool.AppendCertsFromPEM([]byte(s.certificate)) {
return E.New("invalid certificate PEM strings")
}
currentPEM = append(currentPEM, s.certificate)
}
for _, path := range s.certificatePaths {
pemContent, err := os.ReadFile(path)
@@ -145,6 +166,7 @@ func (s *Store) update() error {
if !currentPool.AppendCertsFromPEM(pemContent) {
return E.New("invalid certificate PEM file: ", path)
}
currentPEM = append(currentPEM, string(pemContent))
}
var firstErr error
for _, directoryPath := range s.certificateDirectoryPaths {
@@ -157,8 +179,8 @@ func (s *Store) update() error {
}
for _, directoryEntry := range directoryEntries {
pemContent, err := os.ReadFile(filepath.Join(directoryPath, directoryEntry.Name()))
if err == nil {
currentPool.AppendCertsFromPEM(pemContent)
if err == nil && currentPool.AppendCertsFromPEM(pemContent) {
currentPEM = append(currentPEM, string(pemContent))
}
}
}
@@ -166,6 +188,7 @@ func (s *Store) update() error {
return firstErr
}
s.currentPool = currentPool
s.currentPEM = currentPEM
return nil
}

View File

@@ -1,54 +0,0 @@
package conntrack
import (
"io"
"net"
"github.com/sagernet/sing/common/x/list"
)
type Conn struct {
net.Conn
element *list.Element[io.Closer]
}
func NewConn(conn net.Conn) (net.Conn, error) {
connAccess.Lock()
element := openConnection.PushBack(conn)
connAccess.Unlock()
if KillerEnabled {
err := KillerCheck()
if err != nil {
conn.Close()
return nil, err
}
}
return &Conn{
Conn: conn,
element: element,
}, nil
}
func (c *Conn) Close() error {
if c.element.Value != nil {
connAccess.Lock()
if c.element.Value != nil {
openConnection.Remove(c.element)
c.element.Value = nil
}
connAccess.Unlock()
}
return c.Conn.Close()
}
func (c *Conn) Upstream() any {
return c.Conn
}
func (c *Conn) ReaderReplaceable() bool {
return true
}
func (c *Conn) WriterReplaceable() bool {
return true
}

View File

@@ -1,35 +0,0 @@
package conntrack
import (
runtimeDebug "runtime/debug"
"time"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/memory"
)
var (
KillerEnabled bool
MemoryLimit uint64
killerLastCheck time.Time
)
func KillerCheck() error {
if !KillerEnabled {
return nil
}
nowTime := time.Now()
if nowTime.Sub(killerLastCheck) < 3*time.Second {
return nil
}
killerLastCheck = nowTime
if memory.Total() > MemoryLimit {
Close()
go func() {
time.Sleep(time.Second)
runtimeDebug.FreeOSMemory()
}()
return E.New("out of memory")
}
return nil
}

View File

@@ -1,55 +0,0 @@
package conntrack
import (
"io"
"net"
"github.com/sagernet/sing/common/bufio"
"github.com/sagernet/sing/common/x/list"
)
type PacketConn struct {
net.PacketConn
element *list.Element[io.Closer]
}
func NewPacketConn(conn net.PacketConn) (net.PacketConn, error) {
connAccess.Lock()
element := openConnection.PushBack(conn)
connAccess.Unlock()
if KillerEnabled {
err := KillerCheck()
if err != nil {
conn.Close()
return nil, err
}
}
return &PacketConn{
PacketConn: conn,
element: element,
}, nil
}
func (c *PacketConn) Close() error {
if c.element.Value != nil {
connAccess.Lock()
if c.element.Value != nil {
openConnection.Remove(c.element)
c.element.Value = nil
}
connAccess.Unlock()
}
return c.PacketConn.Close()
}
func (c *PacketConn) Upstream() any {
return bufio.NewPacketConn(c.PacketConn)
}
func (c *PacketConn) ReaderReplaceable() bool {
return true
}
func (c *PacketConn) WriterReplaceable() bool {
return true
}

View File

@@ -1,47 +0,0 @@
package conntrack
import (
"io"
"sync"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/x/list"
)
var (
connAccess sync.RWMutex
openConnection list.List[io.Closer]
)
func Count() int {
if !Enabled {
return 0
}
return openConnection.Len()
}
func List() []io.Closer {
if !Enabled {
return nil
}
connAccess.RLock()
defer connAccess.RUnlock()
connList := make([]io.Closer, 0, openConnection.Len())
for element := openConnection.Front(); element != nil; element = element.Next() {
connList = append(connList, element.Value)
}
return connList
}
func Close() {
if !Enabled {
return
}
connAccess.Lock()
defer connAccess.Unlock()
for element := openConnection.Front(); element != nil; element = element.Next() {
common.Close(element.Value)
element.Value = nil
}
openConnection.Init()
}

View File

@@ -1,5 +0,0 @@
//go:build !with_conntrack
package conntrack
const Enabled = false

View File

@@ -1,5 +0,0 @@
//go:build with_conntrack
package conntrack
const Enabled = true

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/conntrack"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
@@ -37,6 +36,7 @@ type DefaultDialer struct {
udpAddr4 string
udpAddr6 string
netns string
connectionManager adapter.ConnectionManager
networkManager adapter.NetworkManager
networkStrategy *C.NetworkStrategy
defaultNetworkStrategy bool
@@ -47,6 +47,7 @@ type DefaultDialer struct {
}
func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDialer, error) {
connectionManager := service.FromContext[adapter.ConnectionManager](ctx)
networkManager := service.FromContext[adapter.NetworkManager](ctx)
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
@@ -89,7 +90,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
if networkManager != nil {
defaultOptions := networkManager.DefaultOptions()
if defaultOptions.BindInterface != "" {
if defaultOptions.BindInterface != "" && !disableDefaultBind {
bindFunc := control.BindToInterface(networkManager.InterfaceFinder(), defaultOptions.BindInterface, -1)
dialer.Control = control.Append(dialer.Control, bindFunc)
listener.Control = control.Append(listener.Control, bindFunc)
@@ -148,7 +149,10 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
} else {
dialer.Timeout = C.TCPConnectTimeout
}
if !options.DisableTCPKeepAlive {
if options.DisableTCPKeepAlive {
dialer.KeepAlive = -1
dialer.KeepAliveConfig.Enable = false
} else {
keepIdle := time.Duration(options.TCPKeepAlive)
if keepIdle == 0 {
keepIdle = C.TCPKeepAliveInitial
@@ -157,8 +161,11 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
if keepInterval == 0 {
keepInterval = C.TCPKeepAliveInterval
}
dialer.KeepAlive = keepIdle
dialer.Control = control.Append(dialer.Control, control.SetKeepAlivePeriod(keepIdle, keepInterval))
dialer.KeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: keepIdle,
Interval: keepInterval,
}
}
var udpFragment bool
if options.UDPFragment != nil {
@@ -206,6 +213,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
udpAddr4: udpAddr4,
udpAddr6: udpAddr6,
netns: options.NetNs,
connectionManager: connectionManager,
networkManager: networkManager,
networkStrategy: networkStrategy,
defaultNetworkStrategy: defaultNetworkStrategy,
@@ -234,11 +242,11 @@ func setMarkWrapper(networkManager adapter.NetworkManager, mark uint32, isDefaul
func (d *DefaultDialer) DialContext(ctx context.Context, network string, address M.Socksaddr) (net.Conn, error) {
if !address.IsValid() {
return nil, E.New("invalid address")
} else if address.IsFqdn() {
} else if address.IsDomain() {
return nil, E.New("domain not resolved")
}
if d.networkStrategy == nil {
return trackConn(listener.ListenNetworkNamespace[net.Conn](d.netns, func() (net.Conn, error) {
return d.trackConn(listener.ListenNetworkNamespace[net.Conn](d.netns, func() (net.Conn, error) {
switch N.NetworkName(network) {
case N.NetworkUDP:
if !address.IsIPv6() {
@@ -303,12 +311,12 @@ func (d *DefaultDialer) DialParallelInterface(ctx context.Context, network strin
if !fastFallback && !isPrimary {
d.networkLastFallback.Store(time.Now())
}
return trackConn(conn, nil)
return d.trackConn(conn, nil)
}
func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
if d.networkStrategy == nil {
return trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](d.netns, func() (net.PacketConn, error) {
return d.trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](d.netns, func() (net.PacketConn, error) {
if destination.IsIPv6() {
return d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr6)
} else if destination.IsIPv4() && !destination.Addr.IsUnspecified() {
@@ -324,9 +332,9 @@ func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksadd
func (d *DefaultDialer) DialerForICMPDestination(destination netip.Addr) net.Dialer {
if !destination.Is6() {
return d.dialer6.Dialer
} else {
return d.dialer4.Dialer
} else {
return d.dialer6.Dialer
}
}
@@ -360,23 +368,23 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina
return nil, err
}
}
return trackPacketConn(packetConn, nil)
return d.trackPacketConn(packetConn, nil)
}
func (d *DefaultDialer) WireGuardControl() control.Func {
return d.udpListener.Control
}
func trackConn(conn net.Conn, err error) (net.Conn, error) {
if !conntrack.Enabled || err != nil {
func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) {
if d.connectionManager == nil || err != nil {
return conn, err
}
return conntrack.NewConn(conn)
return d.connectionManager.TrackConn(conn), nil
}
func trackPacketConn(conn net.PacketConn, err error) (net.PacketConn, error) {
if !conntrack.Enabled || err != nil {
func (d *DefaultDialer) trackPacketConn(conn net.PacketConn, err error) (net.PacketConn, error) {
if d.connectionManager == nil || err != nil {
return conn, err
}
return conntrack.NewPacketConn(conn)
return d.connectionManager.TrackPacketConn(conn), nil
}

View File

@@ -17,19 +17,27 @@ type DirectDialer interface {
}
type DetourDialer struct {
outboundManager adapter.OutboundManager
detour string
legacyDNSDialer bool
dialer N.Dialer
initOnce sync.Once
initErr error
outboundManager adapter.OutboundManager
detour string
defaultOutbound bool
disableEmptyDirectCheck bool
dialer N.Dialer
initOnce sync.Once
initErr error
}
func NewDetour(outboundManager adapter.OutboundManager, detour string, legacyDNSDialer bool) N.Dialer {
func NewDetour(outboundManager adapter.OutboundManager, detour string, disableEmptyDirectCheck bool) N.Dialer {
return &DetourDialer{
outboundManager: outboundManager,
detour: detour,
disableEmptyDirectCheck: disableEmptyDirectCheck,
}
}
func NewDefaultOutboundDetour(outboundManager adapter.OutboundManager) N.Dialer {
return &DetourDialer{
outboundManager: outboundManager,
detour: detour,
legacyDNSDialer: legacyDNSDialer,
defaultOutbound: true,
}
}
@@ -47,12 +55,18 @@ func (d *DetourDialer) Dialer() (N.Dialer, error) {
}
func (d *DetourDialer) init() {
dialer, loaded := d.outboundManager.Outbound(d.detour)
if !loaded {
d.initErr = E.New("outbound detour not found: ", d.detour)
return
var dialer adapter.Outbound
if d.detour != "" {
var loaded bool
dialer, loaded = d.outboundManager.Outbound(d.detour)
if !loaded {
d.initErr = E.New("outbound detour not found: ", d.detour)
return
}
} else {
dialer = d.outboundManager.Default()
}
if !d.legacyDNSDialer {
if !d.defaultOutbound && !d.disableEmptyDirectCheck {
if directDialer, isDirect := dialer.(DirectDialer); isDirect {
if directDialer.IsEmpty() {
d.initErr = E.New("detour to an empty direct outbound makes no sense")

View File

@@ -17,14 +17,15 @@ import (
)
type Options struct {
Context context.Context
Options option.DialerOptions
RemoteIsDomain bool
DirectResolver bool
ResolverOnDetour bool
NewDialer bool
LegacyDNSDialer bool
DirectOutbound bool
Context context.Context
Options option.DialerOptions
RemoteIsDomain bool
DirectResolver bool
ResolverOnDetour bool
NewDialer bool
DisableEmptyDirectCheck bool
DirectOutbound bool
DefaultOutbound bool
}
// TODO: merge with NewWithOptions
@@ -42,19 +43,26 @@ func NewWithOptions(options Options) (N.Dialer, error) {
dialer N.Dialer
err error
)
hasDetour := dialOptions.Detour != "" || options.DefaultOutbound
if dialOptions.Detour != "" {
outboundManager := service.FromContext[adapter.OutboundManager](options.Context)
if outboundManager == nil {
return nil, E.New("missing outbound manager")
}
dialer = NewDetour(outboundManager, dialOptions.Detour, options.LegacyDNSDialer)
dialer = NewDetour(outboundManager, dialOptions.Detour, options.DisableEmptyDirectCheck)
} else if options.DefaultOutbound {
outboundManager := service.FromContext[adapter.OutboundManager](options.Context)
if outboundManager == nil {
return nil, E.New("missing outbound manager")
}
dialer = NewDefaultOutboundDetour(outboundManager)
} else {
dialer, err = NewDefault(options.Context, dialOptions)
if err != nil {
return nil, err
}
}
if options.RemoteIsDomain && (dialOptions.Detour == "" || options.ResolverOnDetour || dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "") {
if options.RemoteIsDomain && (!hasDetour || options.ResolverOnDetour || dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "") {
networkManager := service.FromContext[adapter.NetworkManager](options.Context)
dnsTransport := service.FromContext[adapter.DNSTransportManager](options.Context)
var defaultOptions adapter.NetworkOptions
@@ -87,11 +95,12 @@ func NewWithOptions(options Options) (N.Dialer, error) {
}
server = dialOptions.DomainResolver.Server
dnsQueryOptions = adapter.DNSQueryOptions{
Transport: transport,
Strategy: strategy,
DisableCache: dialOptions.DomainResolver.DisableCache,
RewriteTTL: dialOptions.DomainResolver.RewriteTTL,
ClientSubnet: dialOptions.DomainResolver.ClientSubnet.Build(netip.Prefix{}),
Transport: transport,
Strategy: strategy,
DisableCache: dialOptions.DomainResolver.DisableCache,
DisableOptimisticCache: dialOptions.DomainResolver.DisableOptimisticCache,
RewriteTTL: dialOptions.DomainResolver.RewriteTTL,
ClientSubnet: dialOptions.DomainResolver.ClientSubnet.Build(netip.Prefix{}),
}
resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay)
} else if options.DirectResolver {
@@ -145,3 +154,7 @@ type ParallelNetworkDialer interface {
DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error)
ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error)
}
type PacketDialerWithDestination interface {
ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error)
}

View File

@@ -96,7 +96,7 @@ func (d *resolveDialer) DialContext(ctx context.Context, network string, destina
if err != nil {
return nil, err
}
if !destination.IsFqdn() {
if !destination.IsDomain() {
return d.dialer.DialContext(ctx, network, destination)
}
ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
@@ -116,7 +116,7 @@ func (d *resolveDialer) ListenPacket(ctx context.Context, destination M.Socksadd
if err != nil {
return nil, err
}
if !destination.IsFqdn() {
if !destination.IsDomain() {
return d.dialer.ListenPacket(ctx, destination)
}
ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
@@ -144,7 +144,7 @@ func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context
if err != nil {
return nil, err
}
if !destination.IsFqdn() {
if !destination.IsDomain() {
return d.dialer.DialContext(ctx, network, destination)
}
ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
@@ -167,7 +167,7 @@ func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.C
if err != nil {
return nil, err
}
if !destination.IsFqdn() {
if !destination.IsDomain() {
return d.dialer.ListenPacket(ctx, destination)
}
ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)

View File

@@ -0,0 +1,423 @@
//go:build darwin && cgo
package httpclient
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Foundation -framework Security
#include <stdlib.h>
#include "apple_transport_darwin.h"
*/
import "C"
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/sagernet/sing-box/common/proxybridge"
boxTLS "github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
)
const applePinnedHashSize = sha256.Size
func verifyApplePinnedPublicKeySHA256(flatHashes []byte, leafCertificate []byte) error {
if len(flatHashes)%applePinnedHashSize != 0 {
return E.New("invalid pinned public key list")
}
knownHashes := make([][]byte, 0, len(flatHashes)/applePinnedHashSize)
for offset := 0; offset < len(flatHashes); offset += applePinnedHashSize {
knownHashes = append(knownHashes, append([]byte(nil), flatHashes[offset:offset+applePinnedHashSize]...))
}
return boxTLS.VerifyPublicKeySHA256(knownHashes, [][]byte{leafCertificate})
}
//export box_apple_http_verify_public_key_sha256
func box_apple_http_verify_public_key_sha256(knownHashValues *C.uint8_t, knownHashValuesLen C.size_t, leafCert *C.uint8_t, leafCertLen C.size_t) *C.char {
flatHashes := C.GoBytes(unsafe.Pointer(knownHashValues), C.int(knownHashValuesLen))
leafCertificate := C.GoBytes(unsafe.Pointer(leafCert), C.int(leafCertLen))
err := verifyApplePinnedPublicKeySHA256(flatHashes, leafCertificate)
if err == nil {
return nil
}
return C.CString(err.Error())
}
type appleSessionConfig struct {
serverName string
minVersion uint16
maxVersion uint16
insecure bool
anchorPEM string
anchorOnly bool
pinnedPublicKeySHA256s []byte
}
type appleTransportShared struct {
logger logger.ContextLogger
bridge *proxybridge.Bridge
config appleSessionConfig
timeFunc func() time.Time
refs atomic.Int32
}
type appleTransport struct {
shared *appleTransportShared
access sync.Mutex
session *C.box_apple_http_session_t
closed bool
}
func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) {
sessionConfig, err := newAppleSessionConfig(ctx, options)
if err != nil {
return nil, err
}
bridge, err := proxybridge.New(ctx, logger, "apple http proxy", rawDialer)
if err != nil {
return nil, err
}
shared := &appleTransportShared{
logger: logger,
bridge: bridge,
config: sessionConfig,
timeFunc: ntp.TimeFuncFromContext(ctx),
}
shared.refs.Store(1)
session, err := shared.newSession()
if err != nil {
bridge.Close()
return nil, err
}
return &appleTransport{
shared: shared,
session: session,
}, nil
}
func newAppleSessionConfig(ctx context.Context, options option.HTTPClientOptions) (appleSessionConfig, error) {
version := options.Version
if version == 0 {
version = 2
}
switch version {
case 2:
case 1:
return appleSessionConfig{}, E.New("HTTP/1.1 is unsupported in Apple HTTP engine")
case 3:
return appleSessionConfig{}, E.New("HTTP/3 is unsupported in Apple HTTP engine")
default:
return appleSessionConfig{}, E.New("unknown HTTP version: ", version)
}
if options.DisableVersionFallback {
return appleSessionConfig{}, E.New("disable_version_fallback is unsupported in Apple HTTP engine")
}
if options.HTTP2Options != (option.HTTP2Options{}) {
return appleSessionConfig{}, E.New("HTTP/2 options are unsupported in Apple HTTP engine")
}
if options.HTTP3Options != (option.QUICOptions{}) {
return appleSessionConfig{}, E.New("QUIC options are unsupported in Apple HTTP engine")
}
tlsOptions := common.PtrValueOrDefault(options.TLS)
if tlsOptions.Engine != "" {
return appleSessionConfig{}, E.New("tls.engine is unsupported in Apple HTTP engine")
}
if len(tlsOptions.ALPN) > 0 {
return appleSessionConfig{}, E.New("tls.alpn is unsupported in Apple HTTP engine")
}
validated, err := boxTLS.ValidateAppleTLSOptions(ctx, tlsOptions, "Apple HTTP engine")
if err != nil {
return appleSessionConfig{}, err
}
config := appleSessionConfig{
serverName: tlsOptions.ServerName,
minVersion: validated.MinVersion,
maxVersion: validated.MaxVersion,
insecure: tlsOptions.Insecure || len(tlsOptions.CertificatePublicKeySHA256) > 0,
anchorPEM: validated.AnchorPEM,
anchorOnly: validated.AnchorOnly,
}
if len(tlsOptions.CertificatePublicKeySHA256) > 0 {
config.pinnedPublicKeySHA256s = make([]byte, 0, len(tlsOptions.CertificatePublicKeySHA256)*applePinnedHashSize)
for _, hashValue := range tlsOptions.CertificatePublicKeySHA256 {
if len(hashValue) != applePinnedHashSize {
return appleSessionConfig{}, E.New("invalid certificate_public_key_sha256 length: ", len(hashValue))
}
config.pinnedPublicKeySHA256s = append(config.pinnedPublicKeySHA256s, hashValue...)
}
}
return config, nil
}
func (s *appleTransportShared) retain() {
s.refs.Add(1)
}
func (s *appleTransportShared) release() error {
if s.refs.Add(-1) == 0 {
return s.bridge.Close()
}
return nil
}
func (s *appleTransportShared) newSession() (*C.box_apple_http_session_t, error) {
cProxyHost := C.CString("127.0.0.1")
defer C.free(unsafe.Pointer(cProxyHost))
cProxyUsername := C.CString(s.bridge.Username())
defer C.free(unsafe.Pointer(cProxyUsername))
cProxyPassword := C.CString(s.bridge.Password())
defer C.free(unsafe.Pointer(cProxyPassword))
var cAnchorPEM *C.char
if s.config.anchorPEM != "" {
cAnchorPEM = C.CString(s.config.anchorPEM)
defer C.free(unsafe.Pointer(cAnchorPEM))
}
var pinnedPointer *C.uint8_t
if len(s.config.pinnedPublicKeySHA256s) > 0 {
pinnedPointer = (*C.uint8_t)(C.CBytes(s.config.pinnedPublicKeySHA256s))
defer C.free(unsafe.Pointer(pinnedPointer))
}
cConfig := C.box_apple_http_session_config_t{
proxy_host: cProxyHost,
proxy_port: C.int(s.bridge.Port()),
proxy_username: cProxyUsername,
proxy_password: cProxyPassword,
min_tls_version: C.uint16_t(s.config.minVersion),
max_tls_version: C.uint16_t(s.config.maxVersion),
insecure: C.bool(s.config.insecure),
anchor_pem: cAnchorPEM,
anchor_pem_len: C.size_t(len(s.config.anchorPEM)),
anchor_only: C.bool(s.config.anchorOnly),
pinned_public_key_sha256: pinnedPointer,
pinned_public_key_sha256_len: C.size_t(len(s.config.pinnedPublicKeySHA256s)),
}
var cErr *C.char
session := C.box_apple_http_session_create(&cConfig, &cErr)
if session != nil {
return session, nil
}
return nil, appleCStringError(cErr, "create Apple HTTP session")
}
func (t *appleTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if requestRequiresHTTP1(request) {
return nil, E.New("HTTP upgrade requests are unsupported in Apple HTTP engine")
}
if request.URL == nil {
return nil, E.New("missing request URL")
}
switch request.URL.Scheme {
case "http", "https":
default:
return nil, E.New("unsupported URL scheme: ", request.URL.Scheme)
}
if request.URL.Scheme == "https" && t.shared.config.serverName != "" && !strings.EqualFold(t.shared.config.serverName, request.URL.Hostname()) {
return nil, E.New("tls.server_name is unsupported in Apple HTTP engine unless it matches request host")
}
var body []byte
if request.Body != nil && request.Body != http.NoBody {
defer request.Body.Close()
var err error
body, err = io.ReadAll(request.Body)
if err != nil {
return nil, err
}
}
headerKeys, headerValues := flattenRequestHeaders(request)
cMethod := C.CString(request.Method)
defer C.free(unsafe.Pointer(cMethod))
cURL := C.CString(request.URL.String())
defer C.free(unsafe.Pointer(cURL))
cHeaderKeys := make([]*C.char, len(headerKeys))
cHeaderValues := make([]*C.char, len(headerValues))
defer func() {
for _, ptr := range cHeaderKeys {
C.free(unsafe.Pointer(ptr))
}
for _, ptr := range cHeaderValues {
C.free(unsafe.Pointer(ptr))
}
}()
for index, value := range headerKeys {
cHeaderKeys[index] = C.CString(value)
}
for index, value := range headerValues {
cHeaderValues[index] = C.CString(value)
}
var headerKeysPointer **C.char
var headerValuesPointer **C.char
if len(cHeaderKeys) > 0 {
pointerArraySize := C.size_t(len(cHeaderKeys)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))
headerKeysPointer = (**C.char)(C.malloc(pointerArraySize))
defer C.free(unsafe.Pointer(headerKeysPointer))
headerValuesPointer = (**C.char)(C.malloc(pointerArraySize))
defer C.free(unsafe.Pointer(headerValuesPointer))
copy(unsafe.Slice(headerKeysPointer, len(cHeaderKeys)), cHeaderKeys)
copy(unsafe.Slice(headerValuesPointer, len(cHeaderValues)), cHeaderValues)
}
var bodyPointer *C.uint8_t
if len(body) > 0 {
bodyPointer = (*C.uint8_t)(C.CBytes(body))
defer C.free(unsafe.Pointer(bodyPointer))
}
var (
hasVerifyTime bool
verifyTimeUnixMilli int64
)
if t.shared.timeFunc != nil {
hasVerifyTime = true
verifyTimeUnixMilli = t.shared.timeFunc().UnixMilli()
}
cRequest := C.box_apple_http_request_t{
method: cMethod,
url: cURL,
header_keys: (**C.char)(headerKeysPointer),
header_values: (**C.char)(headerValuesPointer),
header_count: C.size_t(len(cHeaderKeys)),
body: bodyPointer,
body_len: C.size_t(len(body)),
has_verify_time: C.bool(hasVerifyTime),
verify_time_unix_millis: C.int64_t(verifyTimeUnixMilli),
}
var cErr *C.char
var task *C.box_apple_http_task_t
t.access.Lock()
if t.session == nil {
t.access.Unlock()
return nil, net.ErrClosed
}
// Keep the session attached until NSURLSession has created the task.
task = C.box_apple_http_session_send_async(t.session, &cRequest, &cErr)
t.access.Unlock()
if task == nil {
return nil, appleCStringError(cErr, "create Apple HTTP request")
}
cancelDone := make(chan struct{})
cancelExit := make(chan struct{})
go func() {
defer close(cancelExit)
select {
case <-request.Context().Done():
C.box_apple_http_task_cancel(task)
case <-cancelDone:
}
}()
cResponse := C.box_apple_http_task_wait(task, &cErr)
close(cancelDone)
<-cancelExit
C.box_apple_http_task_close(task)
if cResponse == nil {
err := appleCStringError(cErr, "Apple HTTP request failed")
if request.Context().Err() != nil {
return nil, request.Context().Err()
}
return nil, err
}
defer C.box_apple_http_response_free(cResponse)
return parseAppleHTTPResponse(request, cResponse), nil
}
func (t *appleTransport) CloseIdleConnections() {
t.access.Lock()
if t.closed {
t.access.Unlock()
return
}
t.access.Unlock()
newSession, err := t.shared.newSession()
if err != nil {
t.shared.logger.Error(E.Cause(err, "reset Apple HTTP session"))
return
}
t.access.Lock()
if t.closed {
t.access.Unlock()
C.box_apple_http_session_close(newSession)
return
}
oldSession := t.session
t.session = newSession
t.access.Unlock()
C.box_apple_http_session_retire(oldSession)
}
func (t *appleTransport) Close() error {
t.access.Lock()
if t.closed {
t.access.Unlock()
return nil
}
t.closed = true
session := t.session
t.session = nil
t.access.Unlock()
C.box_apple_http_session_close(session)
return t.shared.release()
}
func flattenRequestHeaders(request *http.Request) ([]string, []string) {
var (
keys []string
values []string
)
for key, headerValues := range request.Header {
for _, value := range headerValues {
keys = append(keys, key)
values = append(values, value)
}
}
if request.Host != "" {
keys = append(keys, "Host")
values = append(values, request.Host)
}
return keys, values
}
func parseAppleHTTPResponse(request *http.Request, response *C.box_apple_http_response_t) *http.Response {
headers := make(http.Header)
headerKeys := unsafe.Slice(response.header_keys, int(response.header_count))
headerValues := unsafe.Slice(response.header_values, int(response.header_count))
for index := range headerKeys {
headers.Add(C.GoString(headerKeys[index]), C.GoString(headerValues[index]))
}
body := bytes.NewReader(C.GoBytes(unsafe.Pointer(response.body), C.int(response.body_len)))
// NSURLSession's completion-handler API does not expose the negotiated protocol;
// callers that read Response.Proto will see HTTP/1.1 even when the wire was HTTP/2.
return &http.Response{
StatusCode: int(response.status_code),
Status: fmt.Sprintf("%d %s", int(response.status_code), http.StatusText(int(response.status_code))),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: headers,
Body: io.NopCloser(body),
ContentLength: int64(body.Len()),
Request: request,
}
}
func appleCStringError(cErr *C.char, message string) error {
if cErr == nil {
return E.New(message)
}
defer C.free(unsafe.Pointer(cErr))
return E.New(message, ": ", C.GoString(cErr))
}

View File

@@ -0,0 +1,71 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef struct box_apple_http_session box_apple_http_session_t;
typedef struct box_apple_http_task box_apple_http_task_t;
typedef struct box_apple_http_session_config {
const char *proxy_host;
int proxy_port;
const char *proxy_username;
const char *proxy_password;
uint16_t min_tls_version;
uint16_t max_tls_version;
bool insecure;
const char *anchor_pem;
size_t anchor_pem_len;
bool anchor_only;
const uint8_t *pinned_public_key_sha256;
size_t pinned_public_key_sha256_len;
} box_apple_http_session_config_t;
typedef struct box_apple_http_request {
const char *method;
const char *url;
const char **header_keys;
const char **header_values;
size_t header_count;
const uint8_t *body;
size_t body_len;
bool has_verify_time;
int64_t verify_time_unix_millis;
} box_apple_http_request_t;
typedef struct box_apple_http_response {
int status_code;
char **header_keys;
char **header_values;
size_t header_count;
uint8_t *body;
size_t body_len;
char *error;
} box_apple_http_response_t;
box_apple_http_session_t *box_apple_http_session_create(
const box_apple_http_session_config_t *config,
char **error_out
);
void box_apple_http_session_retire(box_apple_http_session_t *session);
void box_apple_http_session_close(box_apple_http_session_t *session);
box_apple_http_task_t *box_apple_http_session_send_async(
box_apple_http_session_t *session,
const box_apple_http_request_t *request,
char **error_out
);
box_apple_http_response_t *box_apple_http_task_wait(
box_apple_http_task_t *task,
char **error_out
);
void box_apple_http_task_cancel(box_apple_http_task_t *task);
void box_apple_http_task_close(box_apple_http_task_t *task);
void box_apple_http_response_free(box_apple_http_response_t *response);
char *box_apple_http_verify_public_key_sha256(
uint8_t *known_hash_values,
size_t known_hash_values_len,
uint8_t *leaf_cert,
size_t leaf_cert_len
);

View File

@@ -0,0 +1,398 @@
#import "apple_transport_darwin.h"
#import <CoreFoundation/CFStream.h>
#import <Foundation/Foundation.h>
#import <Security/Security.h>
#import <dispatch/dispatch.h>
#import <stdlib.h>
#import <string.h>
typedef struct box_apple_http_session {
void *handle;
} box_apple_http_session_t;
typedef struct box_apple_http_task {
void *task;
void *done_semaphore;
box_apple_http_response_t *response;
char *error;
} box_apple_http_task_t;
static NSString *const box_apple_http_verify_time_key = @"sing-box.verify-time";
static void box_set_error_string(char **error_out, NSString *message) {
if (error_out == NULL || *error_out != NULL) {
return;
}
const char *utf8 = [message UTF8String];
*error_out = strdup(utf8 != NULL ? utf8 : "unknown error");
}
static void box_set_error_from_nserror(char **error_out, NSError *error) {
if (error == nil) {
box_set_error_string(error_out, @"unknown error");
return;
}
box_set_error_string(error_out, error.localizedDescription ?: error.description);
}
static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len) {
if (pem == NULL || pem_len == 0) {
return @[];
}
NSString *content = [[NSString alloc] initWithBytes:pem length:pem_len encoding:NSUTF8StringEncoding];
if (content == nil) {
return @[];
}
NSString *beginMarker = @"-----BEGIN CERTIFICATE-----";
NSString *endMarker = @"-----END CERTIFICATE-----";
NSMutableArray *certificates = [NSMutableArray array];
NSUInteger searchFrom = 0;
while (searchFrom < content.length) {
NSRange beginRange = [content rangeOfString:beginMarker options:0 range:NSMakeRange(searchFrom, content.length - searchFrom)];
if (beginRange.location == NSNotFound) {
break;
}
NSUInteger bodyStart = beginRange.location + beginRange.length;
NSRange endRange = [content rangeOfString:endMarker options:0 range:NSMakeRange(bodyStart, content.length - bodyStart)];
if (endRange.location == NSNotFound) {
break;
}
NSString *base64Section = [content substringWithRange:NSMakeRange(bodyStart, endRange.location - bodyStart)];
NSArray<NSString *> *components = [base64Section componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
NSString *base64Content = [components componentsJoinedByString:@""];
NSData *der = [[NSData alloc] initWithBase64EncodedString:base64Content options:0];
if (der != nil) {
SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)der);
if (certificate != NULL) {
[certificates addObject:(__bridge id)certificate];
CFRelease(certificate);
}
}
searchFrom = endRange.location + endRange.length;
}
return certificates;
}
static bool box_evaluate_trust(SecTrustRef trustRef, NSArray *anchors, bool anchor_only, NSDate *verifyDate) {
if (trustRef == NULL) {
return false;
}
if (verifyDate != nil && SecTrustSetVerifyDate(trustRef, (__bridge CFDateRef)verifyDate) != errSecSuccess) {
return false;
}
if (anchors.count > 0 || anchor_only) {
CFMutableArrayRef anchorArray = CFArrayCreateMutable(NULL, 0, &kCFTypeArrayCallBacks);
for (id certificate in anchors) {
CFArrayAppendValue(anchorArray, (__bridge const void *)certificate);
}
SecTrustSetAnchorCertificates(trustRef, anchorArray);
SecTrustSetAnchorCertificatesOnly(trustRef, anchor_only);
CFRelease(anchorArray);
}
CFErrorRef error = NULL;
bool result = SecTrustEvaluateWithError(trustRef, &error);
if (error != NULL) {
CFRelease(error);
}
return result;
}
static NSDate *box_apple_http_verify_date_for_request(NSURLRequest *request) {
if (request == nil) {
return nil;
}
id value = [NSURLProtocol propertyForKey:box_apple_http_verify_time_key inRequest:request];
if (![value isKindOfClass:[NSNumber class]]) {
return nil;
}
return [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)value longLongValue] / 1000.0];
}
static box_apple_http_response_t *box_create_response(NSHTTPURLResponse *httpResponse, NSData *data) {
box_apple_http_response_t *response = calloc(1, sizeof(box_apple_http_response_t));
response->status_code = (int)httpResponse.statusCode;
NSDictionary *headers = httpResponse.allHeaderFields;
response->header_count = headers.count;
if (response->header_count > 0) {
response->header_keys = calloc(response->header_count, sizeof(char *));
response->header_values = calloc(response->header_count, sizeof(char *));
NSUInteger index = 0;
for (id key in headers) {
NSString *keyString = [[key description] copy];
NSString *valueString = [[headers[key] description] copy];
response->header_keys[index] = strdup(keyString.UTF8String ?: "");
response->header_values[index] = strdup(valueString.UTF8String ?: "");
index++;
}
}
if (data.length > 0) {
response->body_len = data.length;
response->body = malloc(data.length);
memcpy(response->body, data.bytes, data.length);
}
return response;
}
@interface BoxAppleHTTPSessionDelegate : NSObject <NSURLSessionTaskDelegate, NSURLSessionDataDelegate>
@property(nonatomic, assign) BOOL insecure;
@property(nonatomic, assign) BOOL anchorOnly;
@property(nonatomic, strong) NSArray *anchors;
@property(nonatomic, strong) NSData *pinnedPublicKeyHashes;
@end
@implementation BoxAppleHTTPSessionDelegate
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
willPerformHTTPRedirection:(NSHTTPURLResponse *)response
newRequest:(NSURLRequest *)request
completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {
completionHandler(nil);
}
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler {
if (![challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
return;
}
SecTrustRef trustRef = challenge.protectionSpace.serverTrust;
if (trustRef == NULL) {
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
return;
}
NSDate *verifyDate = box_apple_http_verify_date_for_request(task.currentRequest ?: task.originalRequest);
BOOL needsCustomHandling = self.insecure || self.anchorOnly || self.anchors.count > 0 || self.pinnedPublicKeyHashes.length > 0 || verifyDate != nil;
if (!needsCustomHandling) {
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
return;
}
BOOL ok = YES;
if (!self.insecure) {
ok = box_evaluate_trust(trustRef, self.anchors, self.anchorOnly, verifyDate);
}
if (ok && self.pinnedPublicKeyHashes.length > 0) {
CFArrayRef certificateChain = SecTrustCopyCertificateChain(trustRef);
SecCertificateRef leafCertificate = NULL;
if (certificateChain != NULL && CFArrayGetCount(certificateChain) > 0) {
leafCertificate = (SecCertificateRef)CFArrayGetValueAtIndex(certificateChain, 0);
}
if (leafCertificate == NULL) {
ok = NO;
} else {
NSData *leafData = CFBridgingRelease(SecCertificateCopyData(leafCertificate));
char *pinError = box_apple_http_verify_public_key_sha256(
(uint8_t *)self.pinnedPublicKeyHashes.bytes,
self.pinnedPublicKeyHashes.length,
(uint8_t *)leafData.bytes,
leafData.length
);
if (pinError != NULL) {
free(pinError);
ok = NO;
}
}
if (certificateChain != NULL) {
CFRelease(certificateChain);
}
}
if (!ok) {
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
return;
}
completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:trustRef]);
}
@end
@interface BoxAppleHTTPSessionHandle : NSObject
@property(nonatomic, strong) NSURLSession *session;
@property(nonatomic, strong) BoxAppleHTTPSessionDelegate *delegate;
@end
@implementation BoxAppleHTTPSessionHandle
@end
box_apple_http_session_t *box_apple_http_session_create(
const box_apple_http_session_config_t *config,
char **error_out
) {
@autoreleasepool {
NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration ephemeralSessionConfiguration];
sessionConfig.URLCache = nil;
sessionConfig.HTTPCookieStorage = nil;
sessionConfig.URLCredentialStorage = nil;
sessionConfig.HTTPShouldSetCookies = NO;
if (config != NULL && config->proxy_host != NULL && config->proxy_port > 0) {
NSMutableDictionary *proxyDictionary = [NSMutableDictionary dictionary];
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSProxyHost] = [NSString stringWithUTF8String:config->proxy_host];
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSProxyPort] = @(config->proxy_port);
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSVersion] = (__bridge NSString *)kCFStreamSocketSOCKSVersion5;
if (config->proxy_username != NULL) {
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSUser] = [NSString stringWithUTF8String:config->proxy_username];
}
if (config->proxy_password != NULL) {
proxyDictionary[(__bridge NSString *)kCFStreamPropertySOCKSPassword] = [NSString stringWithUTF8String:config->proxy_password];
}
sessionConfig.connectionProxyDictionary = proxyDictionary;
}
if (config != NULL && config->min_tls_version != 0) {
sessionConfig.TLSMinimumSupportedProtocolVersion = (tls_protocol_version_t)config->min_tls_version;
}
if (config != NULL && config->max_tls_version != 0) {
sessionConfig.TLSMaximumSupportedProtocolVersion = (tls_protocol_version_t)config->max_tls_version;
}
BoxAppleHTTPSessionDelegate *delegate = [[BoxAppleHTTPSessionDelegate alloc] init];
if (config != NULL) {
delegate.insecure = config->insecure;
delegate.anchorOnly = config->anchor_only;
delegate.anchors = box_parse_certificates_from_pem(config->anchor_pem, config->anchor_pem_len);
if (config->pinned_public_key_sha256 != NULL && config->pinned_public_key_sha256_len > 0) {
delegate.pinnedPublicKeyHashes = [NSData dataWithBytes:config->pinned_public_key_sha256 length:config->pinned_public_key_sha256_len];
}
}
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:delegate delegateQueue:nil];
if (session == nil) {
box_set_error_string(error_out, @"create URLSession");
return NULL;
}
BoxAppleHTTPSessionHandle *handle = [[BoxAppleHTTPSessionHandle alloc] init];
handle.session = session;
handle.delegate = delegate;
box_apple_http_session_t *sessionHandle = calloc(1, sizeof(box_apple_http_session_t));
sessionHandle->handle = (__bridge_retained void *)handle;
return sessionHandle;
}
}
void box_apple_http_session_retire(box_apple_http_session_t *session) {
if (session == NULL || session->handle == NULL) {
return;
}
BoxAppleHTTPSessionHandle *handle = (__bridge_transfer BoxAppleHTTPSessionHandle *)session->handle;
[handle.session finishTasksAndInvalidate];
free(session);
}
void box_apple_http_session_close(box_apple_http_session_t *session) {
if (session == NULL || session->handle == NULL) {
return;
}
BoxAppleHTTPSessionHandle *handle = (__bridge_transfer BoxAppleHTTPSessionHandle *)session->handle;
[handle.session invalidateAndCancel];
free(session);
}
box_apple_http_task_t *box_apple_http_session_send_async(
box_apple_http_session_t *session,
const box_apple_http_request_t *request,
char **error_out
) {
@autoreleasepool {
if (session == NULL || session->handle == NULL || request == NULL || request->method == NULL || request->url == NULL) {
box_set_error_string(error_out, @"invalid apple HTTP request");
return NULL;
}
BoxAppleHTTPSessionHandle *handle = (__bridge BoxAppleHTTPSessionHandle *)session->handle;
NSURL *requestURL = [NSURL URLWithString:[NSString stringWithUTF8String:request->url]];
if (requestURL == nil) {
box_set_error_string(error_out, @"invalid request URL");
return NULL;
}
NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:requestURL];
urlRequest.HTTPMethod = [NSString stringWithUTF8String:request->method];
for (size_t index = 0; index < request->header_count; index++) {
const char *key = request->header_keys[index];
const char *value = request->header_values[index];
if (key == NULL || value == NULL) {
continue;
}
[urlRequest addValue:[NSString stringWithUTF8String:value] forHTTPHeaderField:[NSString stringWithUTF8String:key]];
}
if (request->body != NULL && request->body_len > 0) {
urlRequest.HTTPBody = [NSData dataWithBytes:request->body length:request->body_len];
}
if (request->has_verify_time) {
[NSURLProtocol setProperty:@(request->verify_time_unix_millis) forKey:box_apple_http_verify_time_key inRequest:urlRequest];
}
box_apple_http_task_t *task = calloc(1, sizeof(box_apple_http_task_t));
dispatch_semaphore_t doneSemaphore = dispatch_semaphore_create(0);
task->done_semaphore = (__bridge_retained void *)doneSemaphore;
NSURLSessionDataTask *dataTask = [handle.session dataTaskWithRequest:urlRequest completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error != nil) {
box_set_error_from_nserror(&task->error, error);
} else if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
box_set_error_string(&task->error, @"unexpected HTTP response type");
} else {
task->response = box_create_response((NSHTTPURLResponse *)response, data ?: [NSData data]);
}
dispatch_semaphore_signal((__bridge dispatch_semaphore_t)task->done_semaphore);
}];
if (dataTask == nil) {
box_set_error_string(error_out, @"create data task");
box_apple_http_task_close(task);
return NULL;
}
task->task = (__bridge_retained void *)dataTask;
[dataTask resume];
return task;
}
}
box_apple_http_response_t *box_apple_http_task_wait(
box_apple_http_task_t *task,
char **error_out
) {
if (task == NULL || task->done_semaphore == NULL) {
box_set_error_string(error_out, @"invalid apple HTTP task");
return NULL;
}
dispatch_semaphore_wait((__bridge dispatch_semaphore_t)task->done_semaphore, DISPATCH_TIME_FOREVER);
if (task->error != NULL) {
box_set_error_string(error_out, [NSString stringWithUTF8String:task->error]);
return NULL;
}
return task->response;
}
void box_apple_http_task_cancel(box_apple_http_task_t *task) {
if (task == NULL || task->task == NULL) {
return;
}
NSURLSessionTask *nsTask = (__bridge NSURLSessionTask *)task->task;
[nsTask cancel];
}
void box_apple_http_task_close(box_apple_http_task_t *task) {
if (task == NULL) {
return;
}
if (task->task != NULL) {
__unused NSURLSessionTask *nsTask = (__bridge_transfer NSURLSessionTask *)task->task;
task->task = NULL;
}
if (task->done_semaphore != NULL) {
__unused dispatch_semaphore_t doneSemaphore = (__bridge_transfer dispatch_semaphore_t)task->done_semaphore;
task->done_semaphore = NULL;
}
free(task->error);
free(task);
}
void box_apple_http_response_free(box_apple_http_response_t *response) {
if (response == NULL) {
return;
}
for (size_t index = 0; index < response->header_count; index++) {
free(response->header_keys[index]);
free(response->header_values[index]);
}
free(response->header_keys);
free(response->header_values);
free(response->body);
free(response->error);
free(response);
}

View File

@@ -0,0 +1,855 @@
//go:build darwin && cgo
package httpclient
import (
"bytes"
"context"
"crypto/sha256"
stdtls "crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
boxTLS "github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/route"
"github.com/sagernet/sing/common/json/badoption"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
)
const appleHTTPTestTimeout = 5 * time.Second
const appleHTTPRecoveryLoops = 5
type appleHTTPTestDialer struct {
dialer net.Dialer
listener net.ListenConfig
hostMap map[string]string
}
type appleHTTPObservedRequest struct {
method string
body string
host string
values []string
protoMajor int
}
type appleHTTPTestServer struct {
server *httptest.Server
baseURL string
dialHost string
certificate stdtls.Certificate
certificatePEM string
publicKeyHash []byte
}
func TestNewAppleSessionConfig(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
serverHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
otherHash := bytes.Repeat([]byte{0x7f}, applePinnedHashSize)
testCases := []struct {
name string
options option.HTTPClientOptions
check func(t *testing.T, config appleSessionConfig)
wantErr string
}{
{
name: "success with certificate anchors",
options: option.HTTPClientOptions{
Version: 2,
DialerOptions: option.DialerOptions{
ConnectTimeout: badoption.Duration(2 * time.Second),
},
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.3",
Certificate: badoption.Listable[string]{serverCertificatePEM},
},
},
},
check: func(t *testing.T, config appleSessionConfig) {
t.Helper()
if config.serverName != "localhost" {
t.Fatalf("unexpected server name: %q", config.serverName)
}
if config.minVersion != stdtls.VersionTLS12 {
t.Fatalf("unexpected min version: %x", config.minVersion)
}
if config.maxVersion != stdtls.VersionTLS13 {
t.Fatalf("unexpected max version: %x", config.maxVersion)
}
if config.insecure {
t.Fatal("unexpected insecure flag")
}
if !config.anchorOnly {
t.Fatal("expected anchor_only")
}
if !strings.Contains(config.anchorPEM, "BEGIN CERTIFICATE") {
t.Fatalf("unexpected anchor pem: %q", config.anchorPEM)
}
if len(config.pinnedPublicKeySHA256s) != 0 {
t.Fatalf("unexpected pinned hashes: %d", len(config.pinnedPublicKeySHA256s))
}
},
},
{
name: "success with flattened pins",
options: option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash, otherHash},
},
},
},
check: func(t *testing.T, config appleSessionConfig) {
t.Helper()
if !config.insecure {
t.Fatal("expected insecure flag")
}
if len(config.pinnedPublicKeySHA256s) != 2*applePinnedHashSize {
t.Fatalf("unexpected flattened pin length: %d", len(config.pinnedPublicKeySHA256s))
}
if !bytes.Equal(config.pinnedPublicKeySHA256s[:applePinnedHashSize], serverHash) {
t.Fatal("unexpected first pin")
}
if !bytes.Equal(config.pinnedPublicKeySHA256s[applePinnedHashSize:], otherHash) {
t.Fatal("unexpected second pin")
}
if config.anchorPEM != "" {
t.Fatalf("unexpected anchor pem: %q", config.anchorPEM)
}
if config.anchorOnly {
t.Fatal("unexpected anchor_only")
}
},
},
{
name: "http11 unsupported",
options: option.HTTPClientOptions{Version: 1},
wantErr: "HTTP/1.1 is unsupported in Apple HTTP engine",
},
{
name: "http3 unsupported",
options: option.HTTPClientOptions{Version: 3},
wantErr: "HTTP/3 is unsupported in Apple HTTP engine",
},
{
name: "unknown version",
options: option.HTTPClientOptions{Version: 9},
wantErr: "unknown HTTP version: 9",
},
{
name: "disable version fallback unsupported",
options: option.HTTPClientOptions{
DisableVersionFallback: true,
},
wantErr: "disable_version_fallback is unsupported in Apple HTTP engine",
},
{
name: "http2 options unsupported",
options: option.HTTPClientOptions{
HTTP2Options: option.HTTP2Options{
IdleTimeout: badoption.Duration(time.Second),
},
},
wantErr: "HTTP/2 options are unsupported in Apple HTTP engine",
},
{
name: "quic options unsupported",
options: option.HTTPClientOptions{
HTTP3Options: option.QUICOptions{
InitialPacketSize: 1200,
},
},
wantErr: "QUIC options are unsupported in Apple HTTP engine",
},
{
name: "tls engine unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{Engine: "go"},
},
},
wantErr: "tls.engine is unsupported in Apple HTTP engine",
},
{
name: "disable sni unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{DisableSNI: true},
},
},
wantErr: "disable_sni is unsupported in Apple HTTP engine",
},
{
name: "alpn unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ALPN: badoption.Listable[string]{"h2"},
},
},
},
wantErr: "tls.alpn is unsupported in Apple HTTP engine",
},
{
name: "cipher suites unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CipherSuites: badoption.Listable[string]{"TLS_AES_128_GCM_SHA256"},
},
},
},
wantErr: "cipher_suites is unsupported in Apple HTTP engine",
},
{
name: "curve preferences unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CurvePreferences: badoption.Listable[option.CurvePreference]{option.CurvePreference(option.X25519)},
},
},
},
wantErr: "curve_preferences is unsupported in Apple HTTP engine",
},
{
name: "client certificate unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ClientCertificate: badoption.Listable[string]{"client-certificate"},
ClientKey: badoption.Listable[string]{"client-key"},
},
},
},
wantErr: "client certificate is unsupported in Apple HTTP engine",
},
{
name: "tls fragment unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{Fragment: true},
},
},
wantErr: "tls fragment is unsupported in Apple HTTP engine",
},
{
name: "ktls unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{KernelTx: true},
},
},
wantErr: "ktls is unsupported in Apple HTTP engine",
},
{
name: "ech unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
ECH: &option.OutboundECHOptions{Enabled: true},
},
},
},
wantErr: "ech is unsupported in Apple HTTP engine",
},
{
name: "utls unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
UTLS: &option.OutboundUTLSOptions{Enabled: true},
},
},
},
wantErr: "utls is unsupported in Apple HTTP engine",
},
{
name: "reality unsupported",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Reality: &option.OutboundRealityOptions{Enabled: true},
},
},
},
wantErr: "reality is unsupported in Apple HTTP engine",
},
{
name: "pin and certificate conflict",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Certificate: badoption.Listable[string]{serverCertificatePEM},
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash},
},
},
},
wantErr: "certificate_public_key_sha256 is conflict with certificate or certificate_path",
},
{
name: "invalid min version",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{MinVersion: "bogus"},
},
},
wantErr: "parse min_version",
},
{
name: "invalid max version",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{MaxVersion: "bogus"},
},
},
wantErr: "parse max_version",
},
{
name: "invalid pin length",
options: option.HTTPClientOptions{
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
CertificatePublicKeySHA256: badoption.Listable[[]byte]{{0x01, 0x02}},
},
},
},
wantErr: "invalid certificate_public_key_sha256 length: 2",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
config, err := newAppleSessionConfig(context.Background(), testCase.options)
if testCase.wantErr != "" {
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), testCase.wantErr) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if testCase.check != nil {
testCase.check(t, config)
}
})
}
}
func TestAppleTransportVerifyPublicKeySHA256(t *testing.T) {
serverCertificate, _ := newAppleHTTPTestCertificate(t, "localhost")
goodHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
badHash := append([]byte(nil), goodHash...)
badHash[0] ^= 0xff
err := verifyApplePinnedPublicKeySHA256(goodHash, serverCertificate.Certificate[0])
if err != nil {
t.Fatalf("expected correct pin to succeed: %v", err)
}
err = verifyApplePinnedPublicKeySHA256(badHash, serverCertificate.Certificate[0])
if err == nil {
t.Fatal("expected incorrect pin to fail")
}
if !strings.Contains(err.Error(), "unrecognized remote public key") {
t.Fatalf("unexpected pin mismatch error: %v", err)
}
err = verifyApplePinnedPublicKeySHA256(goodHash[:applePinnedHashSize-1], serverCertificate.Certificate[0])
if err == nil {
t.Fatal("expected malformed pin list to fail")
}
if !strings.Contains(err.Error(), "invalid pinned public key list") {
t.Fatalf("unexpected malformed pin error: %v", err)
}
}
func TestAppleTransportRoundTripHTTPS(t *testing.T) {
requests := make(chan appleHTTPObservedRequest, 1)
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
requests <- appleHTTPObservedRequest{
method: r.Method,
body: string(body),
host: r.Host,
values: append([]string(nil), r.Header.Values("X-Test")...),
protoMajor: r.ProtoMajor,
}
w.Header().Set("X-Reply", "apple")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("response body"))
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
request, err := http.NewRequest(http.MethodPost, server.URL("/roundtrip"), bytes.NewReader([]byte("request body")))
if err != nil {
t.Fatal(err)
}
request.Header.Add("X-Test", "one")
request.Header.Add("X-Test", "two")
request.Host = "custom.example"
response, err := transport.RoundTrip(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
responseBody := readResponseBody(t, response)
if response.StatusCode != http.StatusCreated {
t.Fatalf("unexpected status code: %d", response.StatusCode)
}
if response.Status != "201 Created" {
t.Fatalf("unexpected status: %q", response.Status)
}
if response.Header.Get("X-Reply") != "apple" {
t.Fatalf("unexpected response header: %q", response.Header.Get("X-Reply"))
}
if responseBody != "response body" {
t.Fatalf("unexpected response body: %q", responseBody)
}
if response.ContentLength != int64(len(responseBody)) {
t.Fatalf("unexpected content length: %d", response.ContentLength)
}
observed := waitObservedRequest(t, requests)
if observed.method != http.MethodPost {
t.Fatalf("unexpected method: %q", observed.method)
}
if observed.body != "request body" {
t.Fatalf("unexpected request body: %q", observed.body)
}
if observed.host != "custom.example" {
t.Fatalf("unexpected host: %q", observed.host)
}
if observed.protoMajor != 2 {
t.Fatalf("expected HTTP/2 request, got HTTP/%d", observed.protoMajor)
}
var normalizedValues []string
for _, value := range observed.values {
for _, part := range strings.Split(value, ",") {
normalizedValues = append(normalizedValues, strings.TrimSpace(part))
}
}
slices.Sort(normalizedValues)
if !slices.Equal(normalizedValues, []string{"one", "two"}) {
t.Fatalf("unexpected header values: %#v", observed.values)
}
}
func TestAppleTransportPinnedPublicKey(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("pinned"))
})
goodTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{server.publicKeyHash},
},
},
})
response, err := goodTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/good"), nil))
if err != nil {
t.Fatalf("expected pinned request to succeed: %v", err)
}
response.Body.Close()
badHash := append([]byte(nil), server.publicKeyHash...)
badHash[0] ^= 0xff
badTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Insecure: true,
CertificatePublicKeySHA256: badoption.Listable[[]byte]{badHash},
},
},
})
response, err = badTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/bad"), nil))
if err == nil {
response.Body.Close()
t.Fatal("expected incorrect pinned public key to fail")
}
}
func TestAppleTransportGuardrails(t *testing.T) {
testCases := []struct {
name string
options option.HTTPClientOptions
buildRequest func(t *testing.T) *http.Request
wantErrSubstr string
}{
{
name: "websocket upgrade rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
request := newAppleHTTPRequest(t, http.MethodGet, "https://localhost/socket", nil)
request.Header.Set("Connection", "Upgrade")
request.Header.Set("Upgrade", "websocket")
return request
},
wantErrSubstr: "HTTP upgrade requests are unsupported in Apple HTTP engine",
},
{
name: "missing url rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return &http.Request{Method: http.MethodGet}
},
wantErrSubstr: "missing request URL",
},
{
name: "unsupported scheme rejected",
options: option.HTTPClientOptions{
Version: 2,
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return newAppleHTTPRequest(t, http.MethodGet, "ftp://localhost/file", nil)
},
wantErrSubstr: "unsupported URL scheme: ftp",
},
{
name: "server name mismatch rejected",
options: option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: &option.OutboundTLSOptions{
Enabled: true,
ServerName: "example.com",
},
},
},
buildRequest: func(t *testing.T) *http.Request {
t.Helper()
return newAppleHTTPRequest(t, http.MethodGet, "https://localhost/path", nil)
},
wantErrSubstr: "tls.server_name is unsupported in Apple HTTP engine unless it matches request host",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
transport := newAppleHTTPTestTransport(t, nil, testCase.options)
response, err := transport.RoundTrip(testCase.buildRequest(t))
if err == nil {
response.Body.Close()
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), testCase.wantErrSubstr) {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestAppleTransportCancellationRecovery(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/block":
select {
case <-r.Context().Done():
return
case <-time.After(appleHTTPTestTimeout):
http.Error(w, "request was not canceled", http.StatusGatewayTimeout)
}
default:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
for index := 0; index < appleHTTPRecoveryLoops; index++ {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
request := newAppleHTTPRequestWithContext(t, ctx, http.MethodGet, server.URL("/block"), nil)
response, err := transport.RoundTrip(request)
cancel()
if err == nil {
response.Body.Close()
t.Fatalf("iteration %d: expected cancellation error", index)
}
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
t.Fatalf("iteration %d: unexpected cancellation error: %v", index, err)
}
response, err = transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/ok"), nil))
if err != nil {
t.Fatalf("iteration %d: follow-up request failed: %v", index, err)
}
if body := readResponseBody(t, response); body != "ok" {
response.Body.Close()
t.Fatalf("iteration %d: unexpected follow-up body: %q", index, body)
}
response.Body.Close()
}
}
func TestAppleTransportLifecycle(t *testing.T) {
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
Version: 2,
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
TLS: appleHTTPServerTLSOptions(server),
},
})
assertAppleHTTPSucceeds(t, transport, server.URL("/original"))
transport.CloseIdleConnections()
assertAppleHTTPSucceeds(t, transport, server.URL("/reset"))
innerTransport := transport.(*appleTransport)
if err := innerTransport.Close(); err != nil {
t.Fatal(err)
}
response, err := innerTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/closed"), nil))
if err == nil {
response.Body.Close()
t.Fatal("expected closed transport to fail")
}
if !errors.Is(err, net.ErrClosed) {
t.Fatalf("unexpected closed transport error: %v", err)
}
}
func startAppleHTTPTestServer(t *testing.T, handler http.HandlerFunc) *appleHTTPTestServer {
t.Helper()
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
server := httptest.NewUnstartedServer(handler)
server.EnableHTTP2 = true
server.TLS = &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
}
server.StartTLS()
t.Cleanup(server.Close)
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
baseURL := *parsedURL
baseURL.Host = net.JoinHostPort("localhost", parsedURL.Port())
return &appleHTTPTestServer{
server: server,
baseURL: baseURL.String(),
dialHost: parsedURL.Hostname(),
certificate: serverCertificate,
certificatePEM: serverCertificatePEM,
publicKeyHash: certificatePublicKeySHA256(t, serverCertificate.Certificate[0]),
}
}
func (s *appleHTTPTestServer) URL(path string) string {
if path == "" {
return s.baseURL
}
if strings.HasPrefix(path, "/") {
return s.baseURL + path
}
return s.baseURL + "/" + path
}
func newAppleHTTPTestTransport(t *testing.T, server *appleHTTPTestServer, options option.HTTPClientOptions) innerTransport {
t.Helper()
ctx := service.ContextWith[adapter.ConnectionManager](
context.Background(),
route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")),
)
dialer := &appleHTTPTestDialer{
hostMap: make(map[string]string),
}
if server != nil {
dialer.hostMap["localhost"] = server.dialHost
}
transport, err := newAppleTransport(ctx, log.NewNOPFactory().NewLogger("httpclient"), dialer, options)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = transport.Close()
})
return transport
}
func (d *appleHTTPTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
host := destination.AddrString()
if destination.IsDomain() {
host = destination.Fqdn
if mappedHost, loaded := d.hostMap[host]; loaded {
host = mappedHost
}
}
return d.dialer.DialContext(ctx, network, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
}
func (d *appleHTTPTestDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
host := destination.AddrString()
if destination.IsDomain() {
host = destination.Fqdn
if mappedHost, loaded := d.hostMap[host]; loaded {
host = mappedHost
}
}
if host == "" {
host = "127.0.0.1"
}
return d.listener.ListenPacket(ctx, N.NetworkUDP, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
}
func newAppleHTTPTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) {
t.Helper()
privateKeyPEM, certificatePEM, err := boxTLS.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour))
if err != nil {
t.Fatal(err)
}
certificate, err := stdtls.X509KeyPair(certificatePEM, privateKeyPEM)
if err != nil {
t.Fatal(err)
}
return certificate, string(certificatePEM)
}
func certificatePublicKeySHA256(t *testing.T, certificateDER []byte) []byte {
t.Helper()
certificate, err := x509.ParseCertificate(certificateDER)
if err != nil {
t.Fatal(err)
}
publicKeyDER, err := x509.MarshalPKIXPublicKey(certificate.PublicKey)
if err != nil {
t.Fatal(err)
}
hashValue := sha256.Sum256(publicKeyDER)
return append([]byte(nil), hashValue[:]...)
}
func appleHTTPServerTLSOptions(server *appleHTTPTestServer) *option.OutboundTLSOptions {
return &option.OutboundTLSOptions{
Enabled: true,
ServerName: "localhost",
Certificate: badoption.Listable[string]{server.certificatePEM},
}
}
func newAppleHTTPRequest(t *testing.T, method string, rawURL string, body []byte) *http.Request {
t.Helper()
return newAppleHTTPRequestWithContext(t, context.Background(), method, rawURL, body)
}
func newAppleHTTPRequestWithContext(t *testing.T, ctx context.Context, method string, rawURL string, body []byte) *http.Request {
t.Helper()
request, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
return request
}
func waitObservedRequest(t *testing.T, requests <-chan appleHTTPObservedRequest) appleHTTPObservedRequest {
t.Helper()
select {
case request := <-requests:
return request
case <-time.After(appleHTTPTestTimeout):
t.Fatal("timed out waiting for observed request")
return appleHTTPObservedRequest{}
}
}
func readResponseBody(t *testing.T, response *http.Response) string {
t.Helper()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return string(body)
}
func assertAppleHTTPSucceeds(t *testing.T, transport http.RoundTripper, rawURL string) {
t.Helper()
response, err := transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, rawURL, nil))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if body := readResponseBody(t, response); body != "ok" {
t.Fatalf("unexpected response body: %q", body)
}
}

View File

@@ -0,0 +1,16 @@
//go:build !darwin || !cgo
package httpclient
import (
"context"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) {
return nil, E.New("Apple HTTP engine is not available on non-Apple platforms")
}

131
common/httpclient/client.go Normal file
View File

@@ -0,0 +1,131 @@
package httpclient
import (
"context"
"time"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, options option.HTTPClientOptions) (*ManagedTransport, error) {
rawDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: options.DialerOptions,
RemoteIsDomain: true,
DirectResolver: options.DirectResolver,
ResolverOnDetour: options.ResolveOnDetour,
NewDialer: options.ResolveOnDetour,
DisableEmptyDirectCheck: options.DisableEmptyDirectCheck,
DefaultOutbound: options.DefaultOutbound,
})
if err != nil {
return nil, err
}
headers := options.Headers.Build()
host := headers.Get("Host")
headers.Del("Host")
var cheapRebuild bool
switch options.Engine {
case C.TLSEngineApple:
inner, transportErr := newAppleTransport(ctx, logger, rawDialer, options)
if transportErr != nil {
return nil, transportErr
}
managedTransport := &ManagedTransport{
dialer: rawDialer,
headers: headers,
host: host,
tag: tag,
factory: func() (innerTransport, error) {
return newAppleTransport(ctx, logger, rawDialer, options)
},
}
managedTransport.epoch.Store(&transportEpoch{transport: inner})
return managedTransport, nil
case C.TLSEngineDefault, "go":
cheapRebuild = true
default:
return nil, E.New("unknown HTTP engine: ", options.Engine)
}
tlsOptions := common.PtrValueOrDefault(options.TLS)
tlsOptions.Enabled = true
baseTLSConfig, err := tls.NewClientWithOptions(tls.ClientOptions{
Context: ctx,
Logger: logger,
Options: tlsOptions,
AllowEmptyServerName: true,
})
if err != nil {
return nil, err
}
inner, err := newTransport(rawDialer, baseTLSConfig, options)
if err != nil {
return nil, err
}
managedTransport := &ManagedTransport{
cheapRebuild: cheapRebuild,
dialer: rawDialer,
headers: headers,
host: host,
tag: tag,
factory: func() (innerTransport, error) {
return newTransport(rawDialer, baseTLSConfig, options)
},
}
managedTransport.epoch.Store(&transportEpoch{transport: inner})
return managedTransport, nil
}
func newTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTPClientOptions) (innerTransport, error) {
version := options.Version
if version == 0 {
version = 2
}
fallbackDelay := time.Duration(options.DialerOptions.FallbackDelay)
if fallbackDelay == 0 {
fallbackDelay = 300 * time.Millisecond
}
var transport innerTransport
var err error
switch version {
case 1:
transport = newHTTP1Transport(rawDialer, baseTLSConfig)
case 2:
if options.DisableVersionFallback {
transport, err = newHTTP2Transport(rawDialer, baseTLSConfig, options.HTTP2Options)
} else {
transport, err = newHTTP2FallbackTransport(rawDialer, baseTLSConfig, options.HTTP2Options)
}
case 3:
if baseTLSConfig != nil {
_, err = baseTLSConfig.STDConfig()
if err != nil {
return nil, err
}
}
if options.DisableVersionFallback {
transport, err = newHTTP3Transport(rawDialer, baseTLSConfig, options.HTTP3Options)
} else {
var h2Fallback innerTransport
h2Fallback, err = newHTTP2FallbackTransport(rawDialer, baseTLSConfig, options.HTTP2Options)
if err != nil {
return nil, err
}
transport, err = newHTTP3FallbackTransport(rawDialer, baseTLSConfig, h2Fallback, options.HTTP3Options, fallbackDelay)
}
default:
return nil, E.New("unknown HTTP version: ", version)
}
if err != nil {
return nil, err
}
return transport, nil
}

View File

@@ -0,0 +1,14 @@
package httpclient
import "context"
type transportKey struct{}
func contextWithTransportTag(ctx context.Context, transportTag string) context.Context {
return context.WithValue(ctx, transportKey{}, transportTag)
}
func transportTagFromContext(ctx context.Context) (string, bool) {
value, loaded := ctx.Value(transportKey{}).(string)
return value, loaded
}

View File

@@ -0,0 +1,86 @@
package httpclient
import (
"context"
stdTLS "crypto/tls"
"io"
"net"
"net/http"
"strings"
"github.com/sagernet/sing-box/common/tls"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func dialTLS(ctx context.Context, rawDialer N.Dialer, baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string, expectProto string) (net.Conn, error) {
if baseTLSConfig == nil {
return nil, E.New("TLS transport unavailable")
}
tlsConfig := baseTLSConfig.Clone()
if tlsConfig.ServerName() == "" && destination.IsValid() {
tlsConfig.SetServerName(destination.AddrString())
}
tlsConfig.SetNextProtos(nextProtos)
conn, err := rawDialer.DialContext(ctx, N.NetworkTCP, destination)
if err != nil {
return nil, err
}
tlsConn, err := tls.ClientHandshake(ctx, conn, tlsConfig)
if err != nil {
conn.Close()
return nil, err
}
if expectProto != "" && tlsConn.ConnectionState().NegotiatedProtocol != expectProto {
tlsConn.Close()
return nil, errHTTP2Fallback
}
return tlsConn, nil
}
func applyHeaders(request *http.Request, headers http.Header, host string) {
for header, values := range headers {
request.Header[header] = append([]string(nil), values...)
}
if host != "" {
request.Host = host
}
}
func requestRequiresHTTP1(request *http.Request) bool {
return strings.Contains(strings.ToLower(request.Header.Get("Connection")), "upgrade") &&
strings.EqualFold(request.Header.Get("Upgrade"), "websocket")
}
func requestReplayable(request *http.Request) bool {
return request.Body == nil || request.Body == http.NoBody || request.GetBody != nil
}
func cloneRequestForRetry(request *http.Request) *http.Request {
cloned := request.Clone(request.Context())
if request.Body != nil && request.Body != http.NoBody && request.GetBody != nil {
cloned.Body = mustGetBody(request)
}
return cloned
}
func mustGetBody(request *http.Request) io.ReadCloser {
body, err := request.GetBody()
if err != nil {
panic(err)
}
return body
}
func buildSTDTLSConfig(baseTLSConfig tls.Config, destination M.Socksaddr, nextProtos []string) (*stdTLS.Config, error) {
if baseTLSConfig == nil {
return nil, nil
}
tlsConfig := baseTLSConfig.Clone()
if tlsConfig.ServerName() == "" && destination.IsValid() {
tlsConfig.SetServerName(destination.AddrString())
}
tlsConfig.SetNextProtos(nextProtos)
return tlsConfig.STDConfig()
}

View File

@@ -0,0 +1,42 @@
package httpclient
import (
"context"
"net"
"net/http"
"github.com/sagernet/sing-box/common/tls"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
type http1Transport struct {
transport *http.Transport
}
func newHTTP1Transport(rawDialer N.Dialer, baseTLSConfig tls.Config) *http1Transport {
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return rawDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
}
if baseTLSConfig != nil {
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{"http/1.1"}, "")
}
}
return &http1Transport{transport: transport}
}
func (t *http1Transport) RoundTrip(request *http.Request) (*http.Response, error) {
return t.transport.RoundTrip(request)
}
func (t *http1Transport) CloseIdleConnections() {
t.transport.CloseIdleConnections()
}
func (t *http1Transport) Close() error {
t.CloseIdleConnections()
return nil
}

View File

@@ -0,0 +1,42 @@
package httpclient
import (
stdTLS "crypto/tls"
"net/http"
"time"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/net/http2"
)
func CloneHTTP2Transport(transport *http2.Transport) *http2.Transport {
return &http2.Transport{
ReadIdleTimeout: transport.ReadIdleTimeout,
PingTimeout: transport.PingTimeout,
DialTLSContext: transport.DialTLSContext,
}
}
func ConfigureHTTP2Transport(options option.HTTP2Options) (*http2.Transport, error) {
stdTransport := &http.Transport{
TLSClientConfig: &stdTLS.Config{},
HTTP2: &http.HTTP2Config{
MaxReceiveBufferPerStream: int(options.StreamReceiveWindow.Value()),
MaxReceiveBufferPerConnection: int(options.ConnectionReceiveWindow.Value()),
MaxConcurrentStreams: options.MaxConcurrentStreams,
SendPingTimeout: time.Duration(options.KeepAlivePeriod),
PingTimeout: time.Duration(options.IdleTimeout),
},
}
h2Transport, err := http2.ConfigureTransports(stdTransport)
if err != nil {
return nil, E.Cause(err, "configure HTTP/2 transport")
}
// ConfigureTransports binds ConnPool to the throwaway http.Transport; sever it so DialTLSContext is used directly.
h2Transport.ConnPool = nil
h2Transport.ReadIdleTimeout = time.Duration(options.KeepAlivePeriod)
h2Transport.PingTimeout = time.Duration(options.IdleTimeout)
return h2Transport, nil
}

View File

@@ -0,0 +1,84 @@
package httpclient
import (
"context"
stdTLS "crypto/tls"
"errors"
"net"
"net/http"
"sync/atomic"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/net/http2"
)
var errHTTP2Fallback = E.New("fallback to HTTP/1.1")
type http2FallbackTransport struct {
h2Transport *http2.Transport
h1Transport *http1Transport
h2Fallback *atomic.Bool
}
func newHTTP2FallbackTransport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2FallbackTransport, error) {
h1 := newHTTP1Transport(rawDialer, baseTLSConfig)
var fallback atomic.Bool
h2Transport, err := ConfigureHTTP2Transport(options)
if err != nil {
return nil, err
}
h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) {
conn, dialErr := dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS, "http/1.1"}, http2.NextProtoTLS)
if dialErr != nil {
if errors.Is(dialErr, errHTTP2Fallback) {
fallback.Store(true)
}
return nil, dialErr
}
return conn, nil
}
return &http2FallbackTransport{
h2Transport: h2Transport,
h1Transport: h1,
h2Fallback: &fallback,
}, nil
}
func (t *http2FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return t.roundTrip(request, true)
}
func (t *http2FallbackTransport) roundTrip(request *http.Request, allowHTTP1Fallback bool) (*http.Response, error) {
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
return t.h1Transport.RoundTrip(request)
}
if t.h2Fallback.Load() {
if !allowHTTP1Fallback {
return nil, errHTTP2Fallback
}
return t.h1Transport.RoundTrip(request)
}
response, err := t.h2Transport.RoundTrip(request)
if err == nil {
return response, nil
}
if !errors.Is(err, errHTTP2Fallback) || !allowHTTP1Fallback {
return nil, err
}
return t.h1Transport.RoundTrip(cloneRequestForRetry(request))
}
func (t *http2FallbackTransport) CloseIdleConnections() {
t.h1Transport.CloseIdleConnections()
t.h2Transport.CloseIdleConnections()
}
func (t *http2FallbackTransport) Close() error {
t.CloseIdleConnections()
return nil
}

View File

@@ -0,0 +1,52 @@
package httpclient
import (
"context"
stdTLS "crypto/tls"
"net"
"net/http"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/net/http2"
)
type http2Transport struct {
h2Transport *http2.Transport
h1Transport *http1Transport
}
func newHTTP2Transport(rawDialer N.Dialer, baseTLSConfig tls.Config, options option.HTTP2Options) (*http2Transport, error) {
h1 := newHTTP1Transport(rawDialer, baseTLSConfig)
h2Transport, err := ConfigureHTTP2Transport(options)
if err != nil {
return nil, err
}
h2Transport.DialTLSContext = func(ctx context.Context, network, addr string, _ *stdTLS.Config) (net.Conn, error) {
return dialTLS(ctx, rawDialer, baseTLSConfig, M.ParseSocksaddr(addr), []string{http2.NextProtoTLS}, http2.NextProtoTLS)
}
return &http2Transport{
h2Transport: h2Transport,
h1Transport: h1,
}, nil
}
func (t *http2Transport) RoundTrip(request *http.Request) (*http.Response, error) {
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
return t.h1Transport.RoundTrip(request)
}
return t.h2Transport.RoundTrip(request)
}
func (t *http2Transport) CloseIdleConnections() {
t.h1Transport.CloseIdleConnections()
t.h2Transport.CloseIdleConnections()
}
func (t *http2Transport) Close() error {
t.CloseIdleConnections()
return nil
}

View File

@@ -0,0 +1,297 @@
//go:build with_quic
package httpclient
import (
"context"
stdTLS "crypto/tls"
"errors"
"net/http"
"sync"
"time"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
type http3Transport struct {
h3Transport *http3.Transport
}
type http3FallbackTransport struct {
h3Transport *http3.Transport
h2Fallback innerTransport
fallbackDelay time.Duration
brokenAccess sync.Mutex
brokenUntil time.Time
brokenBackoff time.Duration
}
func newHTTP3RoundTripper(
rawDialer N.Dialer,
baseTLSConfig tls.Config,
options option.QUICOptions,
) *http3.Transport {
var handshakeTimeout time.Duration
if baseTLSConfig != nil {
handshakeTimeout = baseTLSConfig.HandshakeTimeout()
}
quicConfig := &quic.Config{
InitialStreamReceiveWindow: options.StreamReceiveWindow.Value(),
MaxStreamReceiveWindow: options.StreamReceiveWindow.Value(),
InitialConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
MaxConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(),
KeepAlivePeriod: time.Duration(options.KeepAlivePeriod),
MaxIdleTimeout: time.Duration(options.IdleTimeout),
DisablePathMTUDiscovery: options.DisablePathMTUDiscovery,
}
if options.InitialPacketSize > 0 {
quicConfig.InitialPacketSize = uint16(options.InitialPacketSize)
}
if options.MaxConcurrentStreams > 0 {
quicConfig.MaxIncomingStreams = int64(options.MaxConcurrentStreams)
}
if handshakeTimeout > 0 {
quicConfig.HandshakeIdleTimeout = handshakeTimeout
}
h3Transport := &http3.Transport{
TLSClientConfig: &stdTLS.Config{},
QUICConfig: quicConfig,
Dial: func(ctx context.Context, addr string, tlsConfig *stdTLS.Config, quicConfig *quic.Config) (*quic.Conn, error) {
if handshakeTimeout > 0 && quicConfig.HandshakeIdleTimeout == 0 {
quicConfig = quicConfig.Clone()
quicConfig.HandshakeIdleTimeout = handshakeTimeout
}
if baseTLSConfig != nil {
var err error
tlsConfig, err = buildSTDTLSConfig(baseTLSConfig, M.ParseSocksaddr(addr), []string{http3.NextProtoH3})
if err != nil {
return nil, err
}
} else {
tlsConfig = tlsConfig.Clone()
tlsConfig.NextProtos = []string{http3.NextProtoH3}
}
conn, err := rawDialer.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddr(addr))
if err != nil {
return nil, err
}
quicConn, err := quic.DialEarly(ctx, bufio.NewUnbindPacketConn(conn), conn.RemoteAddr(), tlsConfig, quicConfig)
if err != nil {
conn.Close()
return nil, err
}
return quicConn, nil
},
}
return h3Transport
}
func newHTTP3Transport(
rawDialer N.Dialer,
baseTLSConfig tls.Config,
options option.QUICOptions,
) (innerTransport, error) {
return &http3Transport{
h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options),
}, nil
}
func newHTTP3FallbackTransport(
rawDialer N.Dialer,
baseTLSConfig tls.Config,
h2Fallback innerTransport,
options option.QUICOptions,
fallbackDelay time.Duration,
) (innerTransport, error) {
return &http3FallbackTransport{
h3Transport: newHTTP3RoundTripper(rawDialer, baseTLSConfig, options),
h2Fallback: h2Fallback,
fallbackDelay: fallbackDelay,
}, nil
}
func (t *http3Transport) RoundTrip(request *http.Request) (*http.Response, error) {
return t.h3Transport.RoundTrip(request)
}
func (t *http3Transport) CloseIdleConnections() {
t.h3Transport.CloseIdleConnections()
}
func (t *http3Transport) Close() error {
t.CloseIdleConnections()
return t.h3Transport.Close()
}
func (t *http3FallbackTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if request.URL.Scheme != "https" || requestRequiresHTTP1(request) {
return t.h2Fallback.RoundTrip(request)
}
return t.roundTripHTTP3(request)
}
func (t *http3FallbackTransport) roundTripHTTP3(request *http.Request) (*http.Response, error) {
if t.h3Broken() {
return t.h2FallbackRoundTrip(request)
}
response, err := t.h3Transport.RoundTripOpt(request, http3.RoundTripOpt{OnlyCachedConn: true})
if err == nil {
t.clearH3Broken()
return response, nil
}
if !errors.Is(err, http3.ErrNoCachedConn) {
t.markH3Broken()
return t.h2FallbackRoundTrip(cloneRequestForRetry(request))
}
if !requestReplayable(request) {
response, err = t.h3Transport.RoundTrip(request)
if err == nil {
t.clearH3Broken()
return response, nil
}
t.markH3Broken()
return nil, err
}
return t.roundTripHTTP3Race(request)
}
func (t *http3FallbackTransport) roundTripHTTP3Race(request *http.Request) (*http.Response, error) {
ctx, cancel := context.WithCancel(request.Context())
defer cancel()
type result struct {
response *http.Response
err error
h3 bool
}
results := make(chan result, 2)
startRoundTrip := func(request *http.Request, useH3 bool) {
request = request.WithContext(ctx)
var (
response *http.Response
err error
)
if useH3 {
response, err = t.h3Transport.RoundTrip(request)
} else {
response, err = t.h2FallbackRoundTrip(request)
}
results <- result{response: response, err: err, h3: useH3}
}
goroutines := 1
received := 0
drainRemaining := func() {
cancel()
for range goroutines - received {
go func() {
loser := <-results
if loser.response != nil && loser.response.Body != nil {
loser.response.Body.Close()
}
}()
}
}
go startRoundTrip(cloneRequestForRetry(request), true)
timer := time.NewTimer(t.fallbackDelay)
defer timer.Stop()
var (
h3Err error
fallbackErr error
)
for {
select {
case <-timer.C:
if goroutines == 1 {
goroutines++
go startRoundTrip(cloneRequestForRetry(request), false)
}
case raceResult := <-results:
received++
if raceResult.err == nil {
if raceResult.h3 {
t.clearH3Broken()
}
drainRemaining()
return raceResult.response, nil
}
if raceResult.h3 {
t.markH3Broken()
h3Err = raceResult.err
if goroutines == 1 {
goroutines++
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
go startRoundTrip(cloneRequestForRetry(request), false)
}
} else {
fallbackErr = raceResult.err
}
if received < goroutines {
continue
}
drainRemaining()
switch {
case h3Err != nil && fallbackErr != nil:
return nil, E.Errors(h3Err, fallbackErr)
case fallbackErr != nil:
return nil, fallbackErr
default:
return nil, h3Err
}
}
}
}
func (t *http3FallbackTransport) h2FallbackRoundTrip(request *http.Request) (*http.Response, error) {
if fallback, isFallback := t.h2Fallback.(*http2FallbackTransport); isFallback {
return fallback.roundTrip(request, true)
}
return t.h2Fallback.RoundTrip(request)
}
func (t *http3FallbackTransport) CloseIdleConnections() {
t.h3Transport.CloseIdleConnections()
t.h2Fallback.CloseIdleConnections()
}
func (t *http3FallbackTransport) Close() error {
t.CloseIdleConnections()
return t.h3Transport.Close()
}
func (t *http3FallbackTransport) h3Broken() bool {
t.brokenAccess.Lock()
defer t.brokenAccess.Unlock()
return !t.brokenUntil.IsZero() && time.Now().Before(t.brokenUntil)
}
func (t *http3FallbackTransport) clearH3Broken() {
t.brokenAccess.Lock()
t.brokenUntil = time.Time{}
t.brokenBackoff = 0
t.brokenAccess.Unlock()
}
func (t *http3FallbackTransport) markH3Broken() {
t.brokenAccess.Lock()
defer t.brokenAccess.Unlock()
if t.brokenBackoff == 0 {
t.brokenBackoff = 5 * time.Minute
} else {
t.brokenBackoff *= 2
if t.brokenBackoff > 48*time.Hour {
t.brokenBackoff = 48 * time.Hour
}
}
t.brokenUntil = time.Now().Add(t.brokenBackoff)
}

View File

@@ -0,0 +1,30 @@
//go:build !with_quic
package httpclient
import (
"time"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
)
func newHTTP3FallbackTransport(
rawDialer N.Dialer,
baseTLSConfig tls.Config,
h2Fallback innerTransport,
options option.QUICOptions,
fallbackDelay time.Duration,
) (innerTransport, error) {
return nil, E.New("HTTP/3 requires building with the with_quic tag")
}
func newHTTP3Transport(
rawDialer N.Dialer,
baseTLSConfig tls.Config,
options option.QUICOptions,
) (innerTransport, error) {
return nil, E.New("HTTP/3 requires building with the with_quic tag")
}

View File

@@ -0,0 +1,209 @@
package httpclient
import (
"io"
"net/http"
"sync"
"sync/atomic"
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
)
type innerTransport interface {
http.RoundTripper
CloseIdleConnections()
Close() error
}
var _ adapter.HTTPTransport = (*ManagedTransport)(nil)
type ManagedTransport struct {
epoch atomic.Pointer[transportEpoch]
rebuildAccess sync.Mutex
factory func() (innerTransport, error)
cheapRebuild bool
dialer N.Dialer
headers http.Header
host string
tag string
}
type transportEpoch struct {
transport innerTransport
active atomic.Int64
marked atomic.Bool
closeOnce sync.Once
}
type managedResponseBody struct {
body io.ReadCloser
release func()
once sync.Once
}
func (e *transportEpoch) tryClose() {
e.closeOnce.Do(func() {
e.transport.Close()
})
}
func (b *managedResponseBody) Read(p []byte) (int, error) {
return b.body.Read(p)
}
func (b *managedResponseBody) Close() error {
err := b.body.Close()
b.once.Do(b.release)
return err
}
func (t *ManagedTransport) getEpoch() (*transportEpoch, error) {
epoch := t.epoch.Load()
if epoch != nil {
return epoch, nil
}
t.rebuildAccess.Lock()
defer t.rebuildAccess.Unlock()
epoch = t.epoch.Load()
if epoch != nil {
return epoch, nil
}
inner, err := t.factory()
if err != nil {
return nil, err
}
epoch = &transportEpoch{transport: inner}
t.epoch.Store(epoch)
return epoch, nil
}
func (t *ManagedTransport) acquireEpoch() (*transportEpoch, error) {
for {
epoch, err := t.getEpoch()
if err != nil {
return nil, err
}
epoch.active.Add(1)
if epoch == t.epoch.Load() {
return epoch, nil
}
t.releaseEpoch(epoch)
}
}
func (t *ManagedTransport) releaseEpoch(epoch *transportEpoch) {
if epoch.active.Add(-1) == 0 && epoch.marked.Load() {
epoch.tryClose()
}
}
func (t *ManagedTransport) retireEpoch(epoch *transportEpoch) {
if epoch == nil {
return
}
epoch.marked.Store(true)
if epoch.active.Load() == 0 {
epoch.tryClose()
}
}
func (t *ManagedTransport) RoundTrip(request *http.Request) (*http.Response, error) {
epoch, err := t.acquireEpoch()
if err != nil {
return nil, E.Cause(err, "rebuild http transport")
}
if t.tag != "" {
if transportTag, loaded := transportTagFromContext(request.Context()); loaded && transportTag == t.tag {
t.releaseEpoch(epoch)
return nil, E.New("HTTP request loopback in transport[", t.tag, "]")
}
request = request.Clone(contextWithTransportTag(request.Context(), t.tag))
} else if len(t.headers) > 0 || t.host != "" {
request = request.Clone(request.Context())
}
applyHeaders(request, t.headers, t.host)
response, roundTripErr := epoch.transport.RoundTrip(request)
if roundTripErr != nil || response == nil || response.Body == nil {
t.releaseEpoch(epoch)
return response, roundTripErr
}
response.Body = &managedResponseBody{
body: response.Body,
release: func() { t.releaseEpoch(epoch) },
}
return response, roundTripErr
}
func (t *ManagedTransport) CloseIdleConnections() {
oldEpoch := t.epoch.Swap(nil)
if oldEpoch == nil {
return
}
oldEpoch.transport.CloseIdleConnections()
t.retireEpoch(oldEpoch)
}
func (t *ManagedTransport) Reset() {
oldEpoch := t.epoch.Swap(nil)
if t.cheapRebuild {
t.rebuildAccess.Lock()
if t.epoch.Load() == nil {
inner, err := t.factory()
if err == nil {
t.epoch.Store(&transportEpoch{transport: inner})
}
}
t.rebuildAccess.Unlock()
}
t.retireEpoch(oldEpoch)
}
func (t *ManagedTransport) close() error {
epoch := t.epoch.Swap(nil)
if epoch != nil {
return epoch.transport.Close()
}
return nil
}
var _ adapter.HTTPTransport = (*sharedRef)(nil)
type sharedRef struct {
managed *ManagedTransport
shared *sharedState
idle atomic.Bool
}
type sharedState struct {
activeRefs atomic.Int32
}
func newSharedRef(managed *ManagedTransport, shared *sharedState) *sharedRef {
shared.activeRefs.Add(1)
return &sharedRef{
managed: managed,
shared: shared,
}
}
func (r *sharedRef) RoundTrip(request *http.Request) (*http.Response, error) {
if r.idle.CompareAndSwap(true, false) {
r.shared.activeRefs.Add(1)
}
return r.managed.RoundTrip(request)
}
func (r *sharedRef) CloseIdleConnections() {
if r.idle.CompareAndSwap(false, true) {
if r.shared.activeRefs.Add(-1) == 0 {
r.managed.CloseIdleConnections()
}
}
}
func (r *sharedRef) Reset() {
r.managed.Reset()
}

View File

@@ -0,0 +1,175 @@
package httpclient
import (
"context"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
)
var (
_ adapter.HTTPClientManager = (*Manager)(nil)
_ adapter.LifecycleService = (*Manager)(nil)
)
type Manager struct {
ctx context.Context
logger log.ContextLogger
access sync.Mutex
defines map[string]option.HTTPClient
sharedTransports map[string]*sharedManagedTransport
managedTransports []*ManagedTransport
defaultTag string
defaultTransport *sharedManagedTransport
defaultTransportFallback func() (*ManagedTransport, error)
}
type sharedManagedTransport struct {
managed *ManagedTransport
shared *sharedState
}
func NewManager(ctx context.Context, logger log.ContextLogger, clients []option.HTTPClient, defaultHTTPClient string) *Manager {
defines := make(map[string]option.HTTPClient, len(clients))
for _, client := range clients {
defines[client.Tag] = client
}
defaultTag := defaultHTTPClient
if defaultTag == "" && len(clients) > 0 {
defaultTag = clients[0].Tag
}
return &Manager{
ctx: ctx,
logger: logger,
defines: defines,
sharedTransports: make(map[string]*sharedManagedTransport),
defaultTag: defaultTag,
}
}
func (m *Manager) Initialize(defaultTransportFallback func() (*ManagedTransport, error)) {
m.defaultTransportFallback = defaultTransportFallback
}
func (m *Manager) Name() string {
return "http-client"
}
func (m *Manager) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
if m.defaultTag != "" {
sharedTransport, err := m.resolveShared(m.defaultTag)
if err != nil {
return E.Cause(err, "resolve default http client")
}
m.defaultTransport = sharedTransport
} else if m.defaultTransportFallback != nil {
transport, err := m.defaultTransportFallback()
if err != nil {
return E.Cause(err, "create default http client")
}
m.trackTransport(transport)
m.defaultTransport = &sharedManagedTransport{
managed: transport,
shared: &sharedState{},
}
}
return nil
}
func (m *Manager) DefaultTransport() adapter.HTTPTransport {
if m.defaultTransport == nil {
return nil
}
return newSharedRef(m.defaultTransport.managed, m.defaultTransport.shared)
}
func (m *Manager) ResolveTransport(ctx context.Context, logger logger.ContextLogger, options option.HTTPClientOptions) (adapter.HTTPTransport, error) {
if options.Tag != "" {
if options.ResolveOnDetour {
define, loaded := m.defines[options.Tag]
if !loaded {
return nil, E.New("http_client not found: ", options.Tag)
}
resolvedOptions := define.Options()
resolvedOptions.ResolveOnDetour = true
transport, err := NewTransport(ctx, logger, options.Tag, resolvedOptions)
if err != nil {
return nil, err
}
m.trackTransport(transport)
return transport, nil
}
sharedTransport, err := m.resolveShared(options.Tag)
if err != nil {
return nil, err
}
return newSharedRef(sharedTransport.managed, sharedTransport.shared), nil
}
transport, err := NewTransport(ctx, logger, "", options)
if err != nil {
return nil, err
}
m.trackTransport(transport)
return transport, nil
}
func (m *Manager) trackTransport(transport *ManagedTransport) {
m.access.Lock()
defer m.access.Unlock()
m.managedTransports = append(m.managedTransports, transport)
}
func (m *Manager) resolveShared(tag string) (*sharedManagedTransport, error) {
m.access.Lock()
defer m.access.Unlock()
if sharedTransport, loaded := m.sharedTransports[tag]; loaded {
return sharedTransport, nil
}
define, loaded := m.defines[tag]
if !loaded {
return nil, E.New("http_client not found: ", tag)
}
transport, err := NewTransport(m.ctx, m.logger, tag, define.Options())
if err != nil {
return nil, E.Cause(err, "create shared http_client[", tag, "]")
}
sharedTransport := &sharedManagedTransport{
managed: transport,
shared: &sharedState{},
}
m.sharedTransports[tag] = sharedTransport
m.managedTransports = append(m.managedTransports, transport)
return sharedTransport, nil
}
func (m *Manager) ResetNetwork() {
m.access.Lock()
defer m.access.Unlock()
for _, transport := range m.managedTransports {
transport.Reset()
}
}
func (m *Manager) Close() error {
m.access.Lock()
defer m.access.Unlock()
if m.managedTransports == nil {
return nil
}
var err error
for _, transport := range m.managedTransports {
err = E.Append(err, transport.close(), func(err error) error {
return E.Cause(err, "close http client")
})
}
m.managedTransports = nil
m.sharedTransports = nil
return err
}

View File

@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net"
"unsafe"
)
func (c *Conn) Read(b []byte) (int, error) {
@@ -229,7 +230,7 @@ func (c *Conn) readRawRecord() (typ uint8, data []byte, err error) {
record := c.rawConn.RawInput.Next(recordHeaderLen + n)
data, typ, err = c.rawConn.In.Decrypt(record)
if err != nil {
err = c.rawConn.In.SetErrorLocked(c.sendAlert(uint8(err.(tls.AlertError))))
err = c.rawConn.In.SetErrorLocked(c.sendAlert(*(*uint8)((*[2]unsafe.Pointer)(unsafe.Pointer(&err))[1])))
return
}
return

View File

@@ -151,6 +151,7 @@ func ListenNetworkNamespace[T any](nameOrPath string, block func() (T, error)) (
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "get current netns")
}
defer currentNs.Close()
defer netns.Set(currentNs)
var targetNs netns.NsHandle
if strings.HasPrefix(nameOrPath, "/") {

View File

@@ -37,7 +37,10 @@ func (l *Listener) ListenTCP() (net.Listener, error) {
if l.listenOptions.ReuseAddr {
listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr())
}
if !l.listenOptions.DisableTCPKeepAlive {
if l.listenOptions.DisableTCPKeepAlive {
listenConfig.KeepAlive = -1
listenConfig.KeepAliveConfig.Enable = false
} else {
keepIdle := time.Duration(l.listenOptions.TCPKeepAlive)
if keepIdle == 0 {
keepIdle = C.TCPKeepAliveInitial
@@ -99,8 +102,6 @@ func (l *Listener) loopTCPIn() {
}
//nolint:staticcheck
metadata.InboundDetour = l.listenOptions.Detour
//nolint:staticcheck
metadata.InboundOptions = l.listenOptions.InboundOptions
metadata.Source = M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap()
metadata.OriginDestination = M.SocksaddrFromNet(conn.LocalAddr()).Unwrap()
ctx := log.ContextWithNewID(l.ctx)

View File

@@ -0,0 +1,142 @@
package networkquality
import (
"context"
"fmt"
"net"
"net/http"
"strings"
C "github.com/sagernet/sing-box/constant"
sBufio "github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func FormatBitrate(bps int64) string {
switch {
case bps >= 1_000_000_000:
return fmt.Sprintf("%.1f Gbps", float64(bps)/1_000_000_000)
case bps >= 1_000_000:
return fmt.Sprintf("%.1f Mbps", float64(bps)/1_000_000)
case bps >= 1_000:
return fmt.Sprintf("%.1f Kbps", float64(bps)/1_000)
default:
return fmt.Sprintf("%d bps", bps)
}
}
func NewHTTPClient(dialer N.Dialer) *http.Client {
transport := &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: C.TCPTimeout,
}
if dialer != nil {
transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
}
return &http.Client{Transport: transport}
}
func baseTransportFromClient(client *http.Client) (*http.Transport, error) {
if client == nil {
return nil, E.New("http client is nil")
}
if client.Transport == nil {
return http.DefaultTransport.(*http.Transport).Clone(), nil
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
return nil, E.New("http client transport must be *http.Transport")
}
return transport.Clone(), nil
}
func newMeasurementClient(
baseClient *http.Client,
connectEndpoint string,
singleConnection bool,
disableKeepAlives bool,
readCounters []N.CountFunc,
writeCounters []N.CountFunc,
) (*http.Client, error) {
transport, err := baseTransportFromClient(baseClient)
if err != nil {
return nil, err
}
transport.DisableCompression = true
transport.DisableKeepAlives = disableKeepAlives
if singleConnection {
transport.MaxConnsPerHost = 1
transport.MaxIdleConnsPerHost = 1
transport.MaxIdleConns = 1
}
baseDialContext := transport.DialContext
if baseDialContext == nil {
dialer := &net.Dialer{}
baseDialContext = dialer.DialContext
}
transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) {
dialAddr := addr
if connectEndpoint != "" {
dialAddr = rewriteDialAddress(addr, connectEndpoint)
}
conn, dialErr := baseDialContext(ctx, network, dialAddr)
if dialErr != nil {
return nil, dialErr
}
if len(readCounters) > 0 || len(writeCounters) > 0 {
return sBufio.NewCounterConn(conn, readCounters, writeCounters), nil
}
return conn, nil
}
return &http.Client{
Transport: transport,
CheckRedirect: baseClient.CheckRedirect,
Jar: baseClient.Jar,
Timeout: baseClient.Timeout,
}, nil
}
type MeasurementClientFactory func(
connectEndpoint string,
singleConnection bool,
disableKeepAlives bool,
readCounters []N.CountFunc,
writeCounters []N.CountFunc,
) (*http.Client, error)
func defaultMeasurementClientFactory(baseClient *http.Client) MeasurementClientFactory {
return func(connectEndpoint string, singleConnection, disableKeepAlives bool, readCounters, writeCounters []N.CountFunc) (*http.Client, error) {
return newMeasurementClient(baseClient, connectEndpoint, singleConnection, disableKeepAlives, readCounters, writeCounters)
}
}
func NewOptionalHTTP3Factory(dialer N.Dialer, useHTTP3 bool) (MeasurementClientFactory, error) {
if !useHTTP3 {
return nil, nil
}
return NewHTTP3MeasurementClientFactory(dialer)
}
func rewriteDialAddress(addr string, connectEndpoint string) string {
connectEndpoint = strings.TrimSpace(connectEndpoint)
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
endpointHost, endpointPort, err := net.SplitHostPort(connectEndpoint)
if err == nil {
host = endpointHost
if endpointPort != "" {
port = endpointPort
}
} else if connectEndpoint != "" {
host = connectEndpoint
}
return net.JoinHostPort(host, port)
}

View File

@@ -0,0 +1,55 @@
//go:build with_quic
package networkquality
import (
"context"
"crypto/tls"
"net"
"net/http"
"github.com/sagernet/quic-go"
"github.com/sagernet/quic-go/http3"
sBufio "github.com/sagernet/sing/common/bufio"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func NewHTTP3MeasurementClientFactory(dialer N.Dialer) (MeasurementClientFactory, error) {
// singleConnection and disableKeepAlives are not applied:
// HTTP/3 multiplexes streams over a single QUIC connection by default.
return func(connectEndpoint string, _, _ bool, readCounters, writeCounters []N.CountFunc) (*http.Client, error) {
transport := &http3.Transport{
Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
dialAddr := addr
if connectEndpoint != "" {
dialAddr = rewriteDialAddress(addr, connectEndpoint)
}
destination := M.ParseSocksaddr(dialAddr)
var udpConn net.Conn
var dialErr error
if dialer != nil {
udpConn, dialErr = dialer.DialContext(ctx, N.NetworkUDP, destination)
} else {
var netDialer net.Dialer
udpConn, dialErr = netDialer.DialContext(ctx, N.NetworkUDP, destination.String())
}
if dialErr != nil {
return nil, dialErr
}
wrappedConn := udpConn
if len(readCounters) > 0 || len(writeCounters) > 0 {
wrappedConn = sBufio.NewCounterConn(udpConn, readCounters, writeCounters)
}
packetConn := sBufio.NewUnbindPacketConn(wrappedConn)
quicConn, dialErr := quic.DialEarly(ctx, packetConn, udpConn.RemoteAddr(), tlsCfg, cfg)
if dialErr != nil {
udpConn.Close()
return nil, dialErr
}
return quicConn, nil
},
}
return &http.Client{Transport: transport}, nil
}, nil
}

View File

@@ -0,0 +1,12 @@
//go:build !with_quic
package networkquality
import (
C "github.com/sagernet/sing-box/constant"
N "github.com/sagernet/sing/common/network"
)
func NewHTTP3MeasurementClientFactory(dialer N.Dialer) (MeasurementClientFactory, error) {
return nil, C.ErrQUICNotIncluded
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ import (
type Searcher interface {
FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error)
Close() error
}
var ErrNotFound = E.New("process not found")
@@ -28,7 +29,7 @@ func FindProcessInfo(searcher Searcher, ctx context.Context, network string, sou
if err != nil {
return nil, err
}
if info.UserId != -1 {
if info.UserId != -1 && info.UserName == "" {
osUser, _ := user.LookupId(F.ToString(info.UserId))
if osUser != nil {
info.UserName = osUser.Username

View File

@@ -6,6 +6,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing/common"
)
var _ Searcher = (*androidSearcher)(nil)
@@ -18,22 +19,30 @@ func NewSearcher(config Config) (Searcher, error) {
return &androidSearcher{config.PackageManager}, nil
}
func (s *androidSearcher) Close() error {
return nil
}
func (s *androidSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
_, uid, err := resolveSocketByNetlink(network, source, destination)
family, protocol, err := socketDiagSettings(network, source)
if err != nil {
return nil, err
}
if sharedPackage, loaded := s.packageManager.SharedPackageByID(uid % 100000); loaded {
return &adapter.ConnectionOwner{
UserId: int32(uid),
AndroidPackageName: sharedPackage,
}, nil
_, uid, err := querySocketDiagOnce(family, protocol, source)
if err != nil {
return nil, err
}
if packageName, loaded := s.packageManager.PackageByID(uid % 100000); loaded {
return &adapter.ConnectionOwner{
UserId: int32(uid),
AndroidPackageName: packageName,
}, nil
appID := uid % 100000
var packageNames []string
if sharedPackage, loaded := s.packageManager.SharedPackageByID(appID); loaded {
packageNames = append(packageNames, sharedPackage)
}
return &adapter.ConnectionOwner{UserId: int32(uid)}, nil
if packages, loaded := s.packageManager.PackagesByID(appID); loaded {
packageNames = append(packageNames, packages...)
}
packageNames = common.Uniq(packageNames)
return &adapter.ConnectionOwner{
UserId: int32(uid),
AndroidPackageNames: packageNames,
}, nil
}

View File

@@ -1,19 +1,15 @@
//go:build darwin
package process
import (
"context"
"encoding/binary"
"net/netip"
"os"
"strconv"
"strings"
"syscall"
"unsafe"
"github.com/sagernet/sing-box/adapter"
N "github.com/sagernet/sing/common/network"
"golang.org/x/sys/unix"
)
var _ Searcher = (*darwinSearcher)(nil)
@@ -24,12 +20,12 @@ func NewSearcher(_ Config) (Searcher, error) {
return &darwinSearcher{}, nil
}
func (d *darwinSearcher) Close() error {
return nil
}
func (d *darwinSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
processName, err := findProcessName(network, source.Addr(), int(source.Port()))
if err != nil {
return nil, err
}
return &adapter.ConnectionOwner{ProcessPath: processName, UserId: -1}, nil
return FindDarwinConnectionOwner(network, source, destination)
}
var structSize = func() int {
@@ -47,107 +43,3 @@ var structSize = func() int {
return 384
}
}()
func findProcessName(network string, ip netip.Addr, port int) (string, error) {
var spath string
switch network {
case N.NetworkTCP:
spath = "net.inet.tcp.pcblist_n"
case N.NetworkUDP:
spath = "net.inet.udp.pcblist_n"
default:
return "", os.ErrInvalid
}
isIPv4 := ip.Is4()
value, err := unix.SysctlRaw(spath)
if err != nil {
return "", err
}
buf := value
// from darwin-xnu/bsd/netinet/in_pcblist.c:get_pcblist_n
// size/offset are round up (aligned) to 8 bytes in darwin
// rup8(sizeof(xinpcb_n)) + rup8(sizeof(xsocket_n)) +
// 2 * rup8(sizeof(xsockbuf_n)) + rup8(sizeof(xsockstat_n))
itemSize := structSize
if network == N.NetworkTCP {
// rup8(sizeof(xtcpcb_n))
itemSize += 208
}
var fallbackUDPProcess string
// skip the first xinpgen(24 bytes) block
for i := 24; i+itemSize <= len(buf); i += itemSize {
// offset of xinpcb_n and xsocket_n
inp, so := i, i+104
srcPort := binary.BigEndian.Uint16(buf[inp+18 : inp+20])
if uint16(port) != srcPort {
continue
}
// xinpcb_n.inp_vflag
flag := buf[inp+44]
var srcIP netip.Addr
srcIsIPv4 := false
switch {
case flag&0x1 > 0 && isIPv4:
// ipv4
srcIP = netip.AddrFrom4([4]byte(buf[inp+76 : inp+80]))
srcIsIPv4 = true
case flag&0x2 > 0 && !isIPv4:
// ipv6
srcIP = netip.AddrFrom16([16]byte(buf[inp+64 : inp+80]))
default:
continue
}
if ip == srcIP {
// xsocket_n.so_last_pid
pid := readNativeUint32(buf[so+68 : so+72])
return getExecPathFromPID(pid)
}
// udp packet connection may be not equal with srcIP
if network == N.NetworkUDP && srcIP.IsUnspecified() && isIPv4 == srcIsIPv4 {
pid := readNativeUint32(buf[so+68 : so+72])
fallbackUDPProcess, _ = getExecPathFromPID(pid)
}
}
if network == N.NetworkUDP && len(fallbackUDPProcess) > 0 {
return fallbackUDPProcess, nil
}
return "", ErrNotFound
}
func getExecPathFromPID(pid uint32) (string, error) {
const (
procpidpathinfo = 0xb
procpidpathinfosize = 1024
proccallnumpidinfo = 0x2
)
buf := make([]byte, procpidpathinfosize)
_, _, errno := syscall.Syscall6(
syscall.SYS_PROC_INFO,
proccallnumpidinfo,
uintptr(pid),
procpidpathinfo,
0,
uintptr(unsafe.Pointer(&buf[0])),
procpidpathinfosize)
if errno != 0 {
return "", errno
}
return unix.ByteSliceToString(buf), nil
}
func readNativeUint32(b []byte) uint32 {
return *(*uint32)(unsafe.Pointer(&b[0]))
}

View File

@@ -0,0 +1,269 @@
//go:build darwin
package process
import (
"encoding/binary"
"net/netip"
"os"
"sync"
"syscall"
"time"
"unsafe"
"github.com/sagernet/sing-box/adapter"
N "github.com/sagernet/sing/common/network"
"golang.org/x/sys/unix"
)
const (
darwinSnapshotTTL = 200 * time.Millisecond
darwinXinpgenSize = 24
darwinXsocketOffset = 104
darwinXinpcbForeignPort = 16
darwinXinpcbLocalPort = 18
darwinXinpcbVFlag = 44
darwinXinpcbForeignAddr = 48
darwinXinpcbLocalAddr = 64
darwinXinpcbIPv4Addr = 12
darwinXsocketUID = 64
darwinXsocketLastPID = 68
darwinTCPExtraStructSize = 208
)
type darwinConnectionEntry struct {
localAddr netip.Addr
remoteAddr netip.Addr
localPort uint16
remotePort uint16
pid uint32
uid int32
}
type darwinConnectionMatchKind uint8
const (
darwinConnectionMatchExact darwinConnectionMatchKind = iota
darwinConnectionMatchLocalFallback
darwinConnectionMatchWildcardFallback
)
type darwinSnapshot struct {
createdAt time.Time
entries []darwinConnectionEntry
}
type darwinConnectionFinder struct {
access sync.Mutex
ttl time.Duration
snapshots map[string]darwinSnapshot
builder func(string) (darwinSnapshot, error)
}
var sharedDarwinConnectionFinder = newDarwinConnectionFinder(darwinSnapshotTTL)
func newDarwinConnectionFinder(ttl time.Duration) *darwinConnectionFinder {
return &darwinConnectionFinder{
ttl: ttl,
snapshots: make(map[string]darwinSnapshot),
builder: buildDarwinSnapshot,
}
}
func FindDarwinConnectionOwner(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
return sharedDarwinConnectionFinder.find(network, source, destination)
}
func (f *darwinConnectionFinder) find(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
networkName := N.NetworkName(network)
source = normalizeDarwinAddrPort(source)
destination = normalizeDarwinAddrPort(destination)
var lastOwner *adapter.ConnectionOwner
for attempt := 0; attempt < 2; attempt++ {
snapshot, fromCache, err := f.loadSnapshot(networkName, attempt > 0)
if err != nil {
return nil, err
}
entry, matchKind, err := matchDarwinConnectionEntry(snapshot.entries, networkName, source, destination)
if err != nil {
if err == ErrNotFound && fromCache {
continue
}
return nil, err
}
if fromCache && matchKind != darwinConnectionMatchExact {
continue
}
owner := &adapter.ConnectionOwner{
UserId: entry.uid,
}
lastOwner = owner
if entry.pid == 0 {
return owner, nil
}
processPath, err := getExecPathFromPID(entry.pid)
if err == nil {
owner.ProcessPath = processPath
return owner, nil
}
if fromCache {
continue
}
return owner, nil
}
if lastOwner != nil {
return lastOwner, nil
}
return nil, ErrNotFound
}
func (f *darwinConnectionFinder) loadSnapshot(network string, forceRefresh bool) (darwinSnapshot, bool, error) {
f.access.Lock()
defer f.access.Unlock()
if !forceRefresh {
if snapshot, loaded := f.snapshots[network]; loaded && time.Since(snapshot.createdAt) < f.ttl {
return snapshot, true, nil
}
}
snapshot, err := f.builder(network)
if err != nil {
return darwinSnapshot{}, false, err
}
f.snapshots[network] = snapshot
return snapshot, false, nil
}
func buildDarwinSnapshot(network string) (darwinSnapshot, error) {
spath, itemSize, err := darwinSnapshotSettings(network)
if err != nil {
return darwinSnapshot{}, err
}
value, err := unix.SysctlRaw(spath)
if err != nil {
return darwinSnapshot{}, err
}
return darwinSnapshot{
createdAt: time.Now(),
entries: parseDarwinSnapshot(value, itemSize),
}, nil
}
func darwinSnapshotSettings(network string) (string, int, error) {
itemSize := structSize
switch network {
case N.NetworkTCP:
return "net.inet.tcp.pcblist_n", itemSize + darwinTCPExtraStructSize, nil
case N.NetworkUDP:
return "net.inet.udp.pcblist_n", itemSize, nil
default:
return "", 0, os.ErrInvalid
}
}
func parseDarwinSnapshot(buf []byte, itemSize int) []darwinConnectionEntry {
entries := make([]darwinConnectionEntry, 0, (len(buf)-darwinXinpgenSize)/itemSize)
for i := darwinXinpgenSize; i+itemSize <= len(buf); i += itemSize {
inp := i
so := i + darwinXsocketOffset
entry, ok := parseDarwinConnectionEntry(buf[inp:so], buf[so:so+structSize-darwinXsocketOffset])
if ok {
entries = append(entries, entry)
}
}
return entries
}
func parseDarwinConnectionEntry(inp []byte, so []byte) (darwinConnectionEntry, bool) {
if len(inp) < darwinXsocketOffset || len(so) < structSize-darwinXsocketOffset {
return darwinConnectionEntry{}, false
}
entry := darwinConnectionEntry{
remotePort: binary.BigEndian.Uint16(inp[darwinXinpcbForeignPort : darwinXinpcbForeignPort+2]),
localPort: binary.BigEndian.Uint16(inp[darwinXinpcbLocalPort : darwinXinpcbLocalPort+2]),
pid: binary.NativeEndian.Uint32(so[darwinXsocketLastPID : darwinXsocketLastPID+4]),
uid: int32(binary.NativeEndian.Uint32(so[darwinXsocketUID : darwinXsocketUID+4])),
}
flag := inp[darwinXinpcbVFlag]
switch {
case flag&0x1 != 0:
entry.remoteAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr : darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr+4]))
entry.localAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr : darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr+4]))
return entry, true
case flag&0x2 != 0:
entry.remoteAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbForeignAddr : darwinXinpcbForeignAddr+16]))
entry.localAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbLocalAddr : darwinXinpcbLocalAddr+16]))
return entry, true
default:
return darwinConnectionEntry{}, false
}
}
func matchDarwinConnectionEntry(entries []darwinConnectionEntry, network string, source netip.AddrPort, destination netip.AddrPort) (darwinConnectionEntry, darwinConnectionMatchKind, error) {
sourceAddr := source.Addr()
if !sourceAddr.IsValid() {
return darwinConnectionEntry{}, darwinConnectionMatchExact, os.ErrInvalid
}
var localFallback darwinConnectionEntry
var hasLocalFallback bool
var wildcardFallback darwinConnectionEntry
var hasWildcardFallback bool
for _, entry := range entries {
if entry.localPort != source.Port() || sourceAddr.BitLen() != entry.localAddr.BitLen() {
continue
}
if entry.localAddr == sourceAddr && destination.IsValid() && entry.remotePort == destination.Port() && entry.remoteAddr == destination.Addr() {
return entry, darwinConnectionMatchExact, nil
}
if !destination.IsValid() && entry.localAddr == sourceAddr {
return entry, darwinConnectionMatchExact, nil
}
if network != N.NetworkUDP {
continue
}
if !hasLocalFallback && entry.localAddr == sourceAddr {
hasLocalFallback = true
localFallback = entry
}
if !hasWildcardFallback && entry.localAddr.IsUnspecified() {
hasWildcardFallback = true
wildcardFallback = entry
}
}
if hasLocalFallback {
return localFallback, darwinConnectionMatchLocalFallback, nil
}
if hasWildcardFallback {
return wildcardFallback, darwinConnectionMatchWildcardFallback, nil
}
return darwinConnectionEntry{}, darwinConnectionMatchExact, ErrNotFound
}
func normalizeDarwinAddrPort(addrPort netip.AddrPort) netip.AddrPort {
if !addrPort.IsValid() {
return addrPort
}
return netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
}
func getExecPathFromPID(pid uint32) (string, error) {
const (
procpidpathinfo = 0xb
procpidpathinfosize = 1024
proccallnumpidinfo = 0x2
)
buf := make([]byte, procpidpathinfosize)
_, _, errno := syscall.Syscall6(
syscall.SYS_PROC_INFO,
proccallnumpidinfo,
uintptr(pid),
procpidpathinfo,
0,
uintptr(unsafe.Pointer(&buf[0])),
procpidpathinfosize)
if errno != 0 {
return "", errno
}
return unix.ByteSliceToString(buf), nil
}

View File

@@ -4,33 +4,82 @@ package process
import (
"context"
"errors"
"net/netip"
"syscall"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
E "github.com/sagernet/sing/common/exceptions"
)
var _ Searcher = (*linuxSearcher)(nil)
type linuxSearcher struct {
logger log.ContextLogger
logger log.ContextLogger
diagConns [4]*socketDiagConn
processPathCache *uidProcessPathCache
}
func NewSearcher(config Config) (Searcher, error) {
return &linuxSearcher{config.Logger}, nil
searcher := &linuxSearcher{
logger: config.Logger,
processPathCache: newUIDProcessPathCache(time.Second),
}
for _, family := range []uint8{syscall.AF_INET, syscall.AF_INET6} {
for _, protocol := range []uint8{syscall.IPPROTO_TCP, syscall.IPPROTO_UDP} {
searcher.diagConns[socketDiagConnIndex(family, protocol)] = newSocketDiagConn(family, protocol)
}
}
return searcher, nil
}
func (s *linuxSearcher) Close() error {
var errs []error
for _, conn := range s.diagConns {
if conn == nil {
continue
}
errs = append(errs, conn.Close())
}
return E.Errors(errs...)
}
func (s *linuxSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
inode, uid, err := resolveSocketByNetlink(network, source, destination)
inode, uid, err := s.resolveSocketByNetlink(network, source, destination)
if err != nil {
return nil, err
}
processPath, err := resolveProcessNameByProcSearch(inode, uid)
processInfo := &adapter.ConnectionOwner{
UserId: int32(uid),
}
processPath, err := s.processPathCache.findProcessPath(inode, uid)
if err != nil {
s.logger.DebugContext(ctx, "find process path: ", err)
} else {
processInfo.ProcessPath = processPath
}
return &adapter.ConnectionOwner{
UserId: int32(uid),
ProcessPath: processPath,
}, nil
return processInfo, nil
}
func (s *linuxSearcher) resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) {
family, protocol, err := socketDiagSettings(network, source)
if err != nil {
return 0, 0, err
}
conn := s.diagConns[socketDiagConnIndex(family, protocol)]
if conn == nil {
return 0, 0, E.New("missing socket diag connection for family=", family, " protocol=", protocol)
}
if destination.IsValid() && source.Addr().BitLen() == destination.Addr().BitLen() {
inode, uid, err = conn.query(source, destination)
if err == nil {
return inode, uid, nil
}
if !errors.Is(err, ErrNotFound) {
return 0, 0, err
}
}
return querySocketDiagOnce(family, protocol, source)
}

View File

@@ -3,43 +3,67 @@
package process
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"errors"
"net/netip"
"os"
"path"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unicode"
"unsafe"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/contrab/freelru"
"github.com/sagernet/sing/contrab/maphash"
)
// from https://github.com/vishvananda/netlink/blob/bca67dfc8220b44ef582c9da4e9172bf1c9ec973/nl/nl_linux.go#L52-L62
var nativeEndian = func() binary.ByteOrder {
var x uint32 = 0x01020304
if *(*byte)(unsafe.Pointer(&x)) == 0x01 {
return binary.BigEndian
}
return binary.LittleEndian
}()
const (
sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + 8 + 48
socketDiagByFamily = 20
pathProc = "/proc"
sizeOfSocketDiagRequestData = 56
sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + sizeOfSocketDiagRequestData
socketDiagResponseMinSize = 72
socketDiagByFamily = 20
pathProc = "/proc"
)
func resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) {
var family uint8
var protocol uint8
type socketDiagConn struct {
access sync.Mutex
family uint8
protocol uint8
fd int
}
type uidProcessPathCache struct {
cache freelru.Cache[uint32, *uidProcessPaths]
}
type uidProcessPaths struct {
entries map[uint32]string
}
func newSocketDiagConn(family, protocol uint8) *socketDiagConn {
return &socketDiagConn{
family: family,
protocol: protocol,
fd: -1,
}
}
func socketDiagConnIndex(family, protocol uint8) int {
index := 0
if protocol == syscall.IPPROTO_UDP {
index += 2
}
if family == syscall.AF_INET6 {
index++
}
return index
}
func socketDiagSettings(network string, source netip.AddrPort) (family, protocol uint8, err error) {
switch network {
case N.NetworkTCP:
protocol = syscall.IPPROTO_TCP
@@ -48,151 +72,308 @@ func resolveSocketByNetlink(network string, source netip.AddrPort, destination n
default:
return 0, 0, os.ErrInvalid
}
if source.Addr().Is4() {
switch {
case source.Addr().Is4():
family = syscall.AF_INET
} else {
case source.Addr().Is6():
family = syscall.AF_INET6
default:
return 0, 0, os.ErrInvalid
}
req := packSocketDiagRequest(family, protocol, source)
socket, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_DGRAM, syscall.NETLINK_INET_DIAG)
if err != nil {
return 0, 0, E.Cause(err, "dial netlink")
}
defer syscall.Close(socket)
syscall.SetsockoptTimeval(socket, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &syscall.Timeval{Usec: 100})
syscall.SetsockoptTimeval(socket, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &syscall.Timeval{Usec: 100})
err = syscall.Connect(socket, &syscall.SockaddrNetlink{
Family: syscall.AF_NETLINK,
Pad: 0,
Pid: 0,
Groups: 0,
})
if err != nil {
return
}
_, err = syscall.Write(socket, req)
if err != nil {
return 0, 0, E.Cause(err, "write netlink request")
}
buffer := buf.New()
defer buffer.Release()
n, err := syscall.Read(socket, buffer.FreeBytes())
if err != nil {
return 0, 0, E.Cause(err, "read netlink response")
}
buffer.Truncate(n)
messages, err := syscall.ParseNetlinkMessage(buffer.Bytes())
if err != nil {
return 0, 0, E.Cause(err, "parse netlink message")
} else if len(messages) == 0 {
return 0, 0, E.New("unexcepted netlink response")
}
message := messages[0]
if message.Header.Type&syscall.NLMSG_ERROR != 0 {
return 0, 0, E.New("netlink message: NLMSG_ERROR")
}
inode, uid = unpackSocketDiagResponse(&messages[0])
return
return family, protocol, nil
}
func packSocketDiagRequest(family, protocol byte, source netip.AddrPort) []byte {
s := make([]byte, 16)
copy(s, source.Addr().AsSlice())
buf := make([]byte, sizeOfSocketDiagRequest)
nativeEndian.PutUint32(buf[0:4], sizeOfSocketDiagRequest)
nativeEndian.PutUint16(buf[4:6], socketDiagByFamily)
nativeEndian.PutUint16(buf[6:8], syscall.NLM_F_REQUEST|syscall.NLM_F_DUMP)
nativeEndian.PutUint32(buf[8:12], 0)
nativeEndian.PutUint32(buf[12:16], 0)
buf[16] = family
buf[17] = protocol
buf[18] = 0
buf[19] = 0
nativeEndian.PutUint32(buf[20:24], 0xFFFFFFFF)
binary.BigEndian.PutUint16(buf[24:26], source.Port())
binary.BigEndian.PutUint16(buf[26:28], 0)
copy(buf[28:44], s)
copy(buf[44:60], net.IPv6zero)
nativeEndian.PutUint32(buf[60:64], 0)
nativeEndian.PutUint64(buf[64:72], 0xFFFFFFFFFFFFFFFF)
return buf
func newUIDProcessPathCache(ttl time.Duration) *uidProcessPathCache {
cache := common.Must1(freelru.NewSharded[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32))
cache.SetLifetime(ttl)
return &uidProcessPathCache{cache: cache}
}
func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) {
if len(msg.Data) < 72 {
return 0, 0
func (c *uidProcessPathCache) findProcessPath(targetInode, uid uint32) (string, error) {
if cached, ok := c.cache.Get(uid); ok {
if processPath, found := cached.entries[targetInode]; found {
return processPath, nil
}
}
data := msg.Data
uid = nativeEndian.Uint32(data[64:68])
inode = nativeEndian.Uint32(data[68:72])
return
}
func resolveProcessNameByProcSearch(inode, uid uint32) (string, error) {
files, err := os.ReadDir(pathProc)
processPaths, err := buildProcessPathByUIDCache(uid)
if err != nil {
return "", err
}
c.cache.Add(uid, &uidProcessPaths{entries: processPaths})
processPath, found := processPaths[targetInode]
if !found {
return "", E.New("process of uid(", uid, "), inode(", targetInode, ") not found")
}
return processPath, nil
}
func (c *socketDiagConn) Close() error {
c.access.Lock()
defer c.access.Unlock()
return c.closeLocked()
}
func (c *socketDiagConn) query(source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) {
c.access.Lock()
defer c.access.Unlock()
request := packSocketDiagRequest(c.family, c.protocol, source, destination, false)
for attempt := 0; attempt < 2; attempt++ {
err = c.ensureOpenLocked()
if err != nil {
return 0, 0, E.Cause(err, "dial netlink")
}
inode, uid, err = querySocketDiag(c.fd, request)
if err == nil || errors.Is(err, ErrNotFound) {
return inode, uid, err
}
if !shouldRetrySocketDiag(err) {
return 0, 0, err
}
_ = c.closeLocked()
}
return 0, 0, err
}
func querySocketDiagOnce(family, protocol uint8, source netip.AddrPort) (inode, uid uint32, err error) {
fd, err := openSocketDiag()
if err != nil {
return 0, 0, E.Cause(err, "dial netlink")
}
defer syscall.Close(fd)
return querySocketDiag(fd, packSocketDiagRequest(family, protocol, source, netip.AddrPort{}, true))
}
func (c *socketDiagConn) ensureOpenLocked() error {
if c.fd != -1 {
return nil
}
fd, err := openSocketDiag()
if err != nil {
return err
}
c.fd = fd
return nil
}
func openSocketDiag() (int, error) {
fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_DGRAM|syscall.SOCK_CLOEXEC, syscall.NETLINK_INET_DIAG)
if err != nil {
return -1, err
}
timeout := &syscall.Timeval{Usec: 100}
if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, timeout); err != nil {
syscall.Close(fd)
return -1, err
}
if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, timeout); err != nil {
syscall.Close(fd)
return -1, err
}
if err = syscall.Connect(fd, &syscall.SockaddrNetlink{
Family: syscall.AF_NETLINK,
Pid: 0,
Groups: 0,
}); err != nil {
syscall.Close(fd)
return -1, err
}
return fd, nil
}
func (c *socketDiagConn) closeLocked() error {
if c.fd == -1 {
return nil
}
err := syscall.Close(c.fd)
c.fd = -1
return err
}
func packSocketDiagRequest(family, protocol byte, source netip.AddrPort, destination netip.AddrPort, dump bool) []byte {
request := make([]byte, sizeOfSocketDiagRequest)
binary.NativeEndian.PutUint32(request[0:4], sizeOfSocketDiagRequest)
binary.NativeEndian.PutUint16(request[4:6], socketDiagByFamily)
flags := uint16(syscall.NLM_F_REQUEST)
if dump {
flags |= syscall.NLM_F_DUMP
}
binary.NativeEndian.PutUint16(request[6:8], flags)
binary.NativeEndian.PutUint32(request[8:12], 0)
binary.NativeEndian.PutUint32(request[12:16], 0)
request[16] = family
request[17] = protocol
request[18] = 0
request[19] = 0
if dump {
binary.NativeEndian.PutUint32(request[20:24], 0xFFFFFFFF)
}
requestSource := source
requestDestination := destination
if protocol == syscall.IPPROTO_UDP && !dump && destination.IsValid() {
// udp_dump_one expects the exact-match endpoints reversed for historical reasons.
requestSource, requestDestination = destination, source
}
binary.BigEndian.PutUint16(request[24:26], requestSource.Port())
binary.BigEndian.PutUint16(request[26:28], requestDestination.Port())
if family == syscall.AF_INET6 {
copy(request[28:44], requestSource.Addr().AsSlice())
if requestDestination.IsValid() {
copy(request[44:60], requestDestination.Addr().AsSlice())
}
} else {
copy(request[28:32], requestSource.Addr().AsSlice())
if requestDestination.IsValid() {
copy(request[44:48], requestDestination.Addr().AsSlice())
}
}
binary.NativeEndian.PutUint32(request[60:64], 0)
binary.NativeEndian.PutUint64(request[64:72], 0xFFFFFFFFFFFFFFFF)
return request
}
func querySocketDiag(fd int, request []byte) (inode, uid uint32, err error) {
_, err = syscall.Write(fd, request)
if err != nil {
return 0, 0, E.Cause(err, "write netlink request")
}
buffer := make([]byte, 64<<10)
n, err := syscall.Read(fd, buffer)
if err != nil {
return 0, 0, E.Cause(err, "read netlink response")
}
messages, err := syscall.ParseNetlinkMessage(buffer[:n])
if err != nil {
return 0, 0, E.Cause(err, "parse netlink message")
}
return unpackSocketDiagMessages(messages)
}
func unpackSocketDiagMessages(messages []syscall.NetlinkMessage) (inode, uid uint32, err error) {
for _, message := range messages {
switch message.Header.Type {
case syscall.NLMSG_DONE:
continue
case syscall.NLMSG_ERROR:
err = unpackSocketDiagError(&message)
if err != nil {
return 0, 0, err
}
case socketDiagByFamily:
inode, uid = unpackSocketDiagResponse(&message)
if inode != 0 || uid != 0 {
return inode, uid, nil
}
}
}
return 0, 0, ErrNotFound
}
func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) {
if len(msg.Data) < socketDiagResponseMinSize {
return 0, 0
}
uid = binary.NativeEndian.Uint32(msg.Data[64:68])
inode = binary.NativeEndian.Uint32(msg.Data[68:72])
return inode, uid
}
func unpackSocketDiagError(msg *syscall.NetlinkMessage) error {
if len(msg.Data) < 4 {
return E.New("netlink message: NLMSG_ERROR")
}
errno := int32(binary.NativeEndian.Uint32(msg.Data[:4]))
if errno == 0 {
return nil
}
if errno < 0 {
errno = -errno
}
sysErr := syscall.Errno(errno)
switch sysErr {
case syscall.ENOENT, syscall.ESRCH:
return ErrNotFound
default:
return E.New("netlink message: ", sysErr)
}
}
func shouldRetrySocketDiag(err error) bool {
return err != nil && !errors.Is(err, ErrNotFound)
}
func buildProcessPathByUIDCache(uid uint32) (map[uint32]string, error) {
files, err := os.ReadDir(pathProc)
if err != nil {
return nil, err
}
buffer := make([]byte, syscall.PathMax)
socket := []byte(fmt.Sprintf("socket:[%d]", inode))
for _, f := range files {
if !f.IsDir() || !isPid(f.Name()) {
processPaths := make(map[uint32]string)
for _, file := range files {
if !file.IsDir() || !isPid(file.Name()) {
continue
}
info, err := f.Info()
info, err := file.Info()
if err != nil {
return "", err
if isIgnorableProcError(err) {
continue
}
return nil, err
}
if info.Sys().(*syscall.Stat_t).Uid != uid {
continue
}
processPath := path.Join(pathProc, f.Name())
fdPath := path.Join(processPath, "fd")
processPath := filepath.Join(pathProc, file.Name())
fdPath := filepath.Join(processPath, "fd")
exePath, err := os.Readlink(filepath.Join(processPath, "exe"))
if err != nil {
if isIgnorableProcError(err) {
continue
}
return nil, err
}
fds, err := os.ReadDir(fdPath)
if err != nil {
continue
}
for _, fd := range fds {
n, err := syscall.Readlink(path.Join(fdPath, fd.Name()), buffer)
n, err := syscall.Readlink(filepath.Join(fdPath, fd.Name()), buffer)
if err != nil {
continue
}
if bytes.Equal(buffer[:n], socket) {
return os.Readlink(path.Join(processPath, "exe"))
inode, ok := parseSocketInode(buffer[:n])
if !ok {
continue
}
if _, loaded := processPaths[inode]; !loaded {
processPaths[inode] = exePath
}
}
}
return processPaths, nil
}
return "", fmt.Errorf("process of uid(%d),inode(%d) not found", uid, inode)
func isIgnorableProcError(err error) bool {
return os.IsNotExist(err) || os.IsPermission(err)
}
func parseSocketInode(link []byte) (uint32, bool) {
const socketPrefix = "socket:["
if len(link) <= len(socketPrefix) || string(link[:len(socketPrefix)]) != socketPrefix || link[len(link)-1] != ']' {
return 0, false
}
var inode uint64
for _, char := range link[len(socketPrefix) : len(link)-1] {
if char < '0' || char > '9' {
return 0, false
}
inode = inode*10 + uint64(char-'0')
if inode > uint64(^uint32(0)) {
return 0, false
}
}
return uint32(inode), true
}
func isPid(s string) bool {

View File

@@ -0,0 +1,60 @@
//go:build linux
package process
import (
"net"
"net/netip"
"os"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestQuerySocketDiagUDPExact(t *testing.T) {
t.Parallel()
server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
require.NoError(t, err)
defer server.Close()
client, err := net.DialUDP("udp4", nil, server.LocalAddr().(*net.UDPAddr))
require.NoError(t, err)
defer client.Close()
err = client.SetDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
_, err = client.Write([]byte{0})
require.NoError(t, err)
err = server.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
buffer := make([]byte, 1)
_, _, err = server.ReadFromUDP(buffer)
require.NoError(t, err)
source := addrPortFromUDPAddr(t, client.LocalAddr())
destination := addrPortFromUDPAddr(t, client.RemoteAddr())
fd, err := openSocketDiag()
require.NoError(t, err)
defer syscall.Close(fd)
inode, uid, err := querySocketDiag(fd, packSocketDiagRequest(syscall.AF_INET, syscall.IPPROTO_UDP, source, destination, false))
require.NoError(t, err)
require.NotZero(t, inode)
require.EqualValues(t, os.Getuid(), uid)
}
func addrPortFromUDPAddr(t *testing.T, addr net.Addr) netip.AddrPort {
t.Helper()
udpAddr, ok := addr.(*net.UDPAddr)
require.True(t, ok)
ip, ok := netip.AddrFromSlice(udpAddr.IP)
require.True(t, ok)
return netip.AddrPortFrom(ip.Unmap(), uint16(udpAddr.Port))
}

View File

@@ -28,6 +28,10 @@ func initWin32API() error {
return winiphlpapi.LoadExtendedTable()
}
func (s *windowsSearcher) Close() error {
return nil
}
func (s *windowsSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) {
pid, err := winiphlpapi.FindPid(network, source)
if err != nil {

View File

@@ -0,0 +1,115 @@
package proxybridge
import (
std_bufio "bufio"
"context"
"crypto/rand"
"encoding/hex"
"net"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/auth"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/protocol/socks"
"github.com/sagernet/sing/service"
)
type Bridge struct {
ctx context.Context
logger logger.ContextLogger
tag string
dialer N.Dialer
connection adapter.ConnectionManager
tcpListener *net.TCPListener
username string
password string
authenticator *auth.Authenticator
}
func New(ctx context.Context, logger logger.ContextLogger, tag string, dialer N.Dialer) (*Bridge, error) {
username := randomHex(16)
password := randomHex(16)
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
return nil, err
}
bridge := &Bridge{
ctx: ctx,
logger: logger,
tag: tag,
dialer: dialer,
connection: service.FromContext[adapter.ConnectionManager](ctx),
tcpListener: tcpListener,
username: username,
password: password,
authenticator: auth.NewAuthenticator([]auth.User{{Username: username, Password: password}}),
}
go bridge.acceptLoop()
return bridge, nil
}
func randomHex(size int) string {
raw := make([]byte, size)
rand.Read(raw)
return hex.EncodeToString(raw)
}
func (b *Bridge) Port() uint16 {
return M.SocksaddrFromNet(b.tcpListener.Addr()).Port
}
func (b *Bridge) Username() string {
return b.username
}
func (b *Bridge) Password() string {
return b.password
}
func (b *Bridge) Close() error {
return common.Close(b.tcpListener)
}
func (b *Bridge) acceptLoop() {
for {
tcpConn, err := b.tcpListener.AcceptTCP()
if err != nil {
return
}
ctx := log.ContextWithNewID(b.ctx)
go func() {
hErr := socks.HandleConnectionEx(ctx, tcpConn, std_bufio.NewReader(tcpConn), b.authenticator, b, nil, 0, M.SocksaddrFromNet(tcpConn.RemoteAddr()), nil)
if hErr == nil {
return
}
if E.IsClosedOrCanceled(hErr) {
b.logger.DebugContext(ctx, E.Cause(hErr, b.tag, " connection closed"))
return
}
b.logger.ErrorContext(ctx, E.Cause(hErr, b.tag))
}()
}
}
func (b *Bridge) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
var metadata adapter.InboundContext
metadata.Source = source
metadata.Destination = destination
metadata.Network = N.NetworkTCP
b.logger.InfoContext(ctx, b.tag, " connection to ", metadata.Destination)
b.connection.NewConnection(ctx, b.dialer, conn, metadata, onClose)
}
func (b *Bridge) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
var metadata adapter.InboundContext
metadata.Source = source
metadata.Destination = destination
metadata.Network = N.NetworkUDP
b.logger.InfoContext(ctx, b.tag, " packet connection to ", metadata.Destination)
b.connection.NewPacketConnection(ctx, b.dialer, conn, metadata, onClose)
}

View File

@@ -46,6 +46,7 @@ const (
ruleItemNetworkIsConstrained
ruleItemNetworkInterfaceAddress
ruleItemDefaultInterfaceAddress
ruleItemPackageNameRegex
ruleItemFinal uint8 = 0xFF
)
@@ -215,6 +216,8 @@ func readDefaultRule(reader varbin.Reader, recover bool) (rule option.DefaultHea
rule.ProcessPathRegex, err = readRuleItemString(reader)
case ruleItemPackageName:
rule.PackageName, err = readRuleItemString(reader)
case ruleItemPackageNameRegex:
rule.PackageNameRegex, err = readRuleItemString(reader)
case ruleItemWIFISSID:
rule.WIFISSID, err = readRuleItemString(reader)
case ruleItemWIFIBSSID:
@@ -394,6 +397,15 @@ func writeDefaultRule(writer varbin.Writer, rule option.DefaultHeadlessRule, gen
return err
}
}
if len(rule.PackageNameRegex) > 0 {
if generateVersion < C.RuleSetVersion5 {
return E.New("`package_name_regex` rule item is only supported in version 5 or later")
}
err = writeRuleItemString(writer, ruleItemPackageNameRegex, rule.PackageNameRegex)
if err != nil {
return err
}
}
if len(rule.NetworkType) > 0 {
if generateVersion < C.RuleSetVersion3 {
return E.New("`network_type` rule item is only supported in version 3 or later")

612
common/stun/stun.go Normal file
View File

@@ -0,0 +1,612 @@
package stun
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"net"
"net/netip"
"time"
"github.com/sagernet/sing/common/bufio"
"github.com/sagernet/sing/common/bufio/deadline"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
const (
DefaultServer = "stun.voipgate.com:3478"
magicCookie = 0x2112A442
headerSize = 20
bindingRequest = 0x0001
bindingSuccessResponse = 0x0101
bindingErrorResponse = 0x0111
attrMappedAddress = 0x0001
attrChangeRequest = 0x0003
attrErrorCode = 0x0009
attrXORMappedAddress = 0x0020
attrOtherAddress = 0x802c
familyIPv4 = 0x01
familyIPv6 = 0x02
changeIP = 0x04
changePort = 0x02
defaultRTO = 500 * time.Millisecond
minRTO = 250 * time.Millisecond
maxRetransmit = 2
)
type Phase int32
const (
PhaseBinding Phase = iota
PhaseNATMapping
PhaseNATFiltering
PhaseDone
)
type NATMapping int32
const (
NATMappingUnknown NATMapping = iota
_ // reserved
NATMappingEndpointIndependent
NATMappingAddressDependent
NATMappingAddressAndPortDependent
)
func (m NATMapping) String() string {
switch m {
case NATMappingEndpointIndependent:
return "Endpoint Independent"
case NATMappingAddressDependent:
return "Address Dependent"
case NATMappingAddressAndPortDependent:
return "Address and Port Dependent"
default:
return "Unknown"
}
}
type NATFiltering int32
const (
NATFilteringUnknown NATFiltering = iota
NATFilteringEndpointIndependent
NATFilteringAddressDependent
NATFilteringAddressAndPortDependent
)
func (f NATFiltering) String() string {
switch f {
case NATFilteringEndpointIndependent:
return "Endpoint Independent"
case NATFilteringAddressDependent:
return "Address Dependent"
case NATFilteringAddressAndPortDependent:
return "Address and Port Dependent"
default:
return "Unknown"
}
}
type TransactionID [12]byte
type Options struct {
Server string
Dialer N.Dialer
Context context.Context
OnProgress func(Progress)
}
type Progress struct {
Phase Phase
ExternalAddr string
LatencyMs int32
NATMapping NATMapping
NATFiltering NATFiltering
}
type Result struct {
ExternalAddr string
LatencyMs int32
NATMapping NATMapping
NATFiltering NATFiltering
NATTypeSupported bool
}
type parsedResponse struct {
xorMappedAddr netip.AddrPort
mappedAddr netip.AddrPort
otherAddr netip.AddrPort
}
func (r *parsedResponse) externalAddr() (netip.AddrPort, bool) {
if r.xorMappedAddr.IsValid() {
return r.xorMappedAddr, true
}
if r.mappedAddr.IsValid() {
return r.mappedAddr, true
}
return netip.AddrPort{}, false
}
type stunAttribute struct {
typ uint16
value []byte
}
func newTransactionID() TransactionID {
var id TransactionID
_, _ = rand.Read(id[:])
return id
}
func buildBindingRequest(txID TransactionID, attrs ...stunAttribute) []byte {
attrLen := 0
for _, attr := range attrs {
attrLen += 4 + len(attr.value) + paddingLen(len(attr.value))
}
buf := make([]byte, headerSize+attrLen)
binary.BigEndian.PutUint16(buf[0:2], bindingRequest)
binary.BigEndian.PutUint16(buf[2:4], uint16(attrLen))
binary.BigEndian.PutUint32(buf[4:8], magicCookie)
copy(buf[8:20], txID[:])
offset := headerSize
for _, attr := range attrs {
binary.BigEndian.PutUint16(buf[offset:offset+2], attr.typ)
binary.BigEndian.PutUint16(buf[offset+2:offset+4], uint16(len(attr.value)))
copy(buf[offset+4:offset+4+len(attr.value)], attr.value)
offset += 4 + len(attr.value) + paddingLen(len(attr.value))
}
return buf
}
func changeRequestAttr(flags byte) stunAttribute {
return stunAttribute{
typ: attrChangeRequest,
value: []byte{0, 0, 0, flags},
}
}
func parseResponse(data []byte, expectedTxID TransactionID) (*parsedResponse, error) {
if len(data) < headerSize {
return nil, E.New("response too short")
}
msgType := binary.BigEndian.Uint16(data[0:2])
if msgType&0xC000 != 0 {
return nil, E.New("invalid STUN message: top 2 bits not zero")
}
cookie := binary.BigEndian.Uint32(data[4:8])
if cookie != magicCookie {
return nil, E.New("invalid magic cookie")
}
var txID TransactionID
copy(txID[:], data[8:20])
if txID != expectedTxID {
return nil, E.New("transaction ID mismatch")
}
msgLen := int(binary.BigEndian.Uint16(data[2:4]))
if msgLen > len(data)-headerSize {
return nil, E.New("message length exceeds data")
}
attrData := data[headerSize : headerSize+msgLen]
if msgType == bindingErrorResponse {
return nil, parseErrorResponse(attrData)
}
if msgType != bindingSuccessResponse {
return nil, E.New("unexpected message type: ", fmt.Sprintf("0x%04x", msgType))
}
resp := &parsedResponse{}
offset := 0
for offset+4 <= len(attrData) {
attrType := binary.BigEndian.Uint16(attrData[offset : offset+2])
attrLen := int(binary.BigEndian.Uint16(attrData[offset+2 : offset+4]))
if offset+4+attrLen > len(attrData) {
break
}
attrValue := attrData[offset+4 : offset+4+attrLen]
switch attrType {
case attrXORMappedAddress:
addr, err := parseXORMappedAddress(attrValue, txID)
if err == nil {
resp.xorMappedAddr = addr
}
case attrMappedAddress:
addr, err := parseMappedAddress(attrValue)
if err == nil {
resp.mappedAddr = addr
}
case attrOtherAddress:
addr, err := parseMappedAddress(attrValue)
if err == nil {
resp.otherAddr = addr
}
}
offset += 4 + attrLen + paddingLen(attrLen)
}
return resp, nil
}
func parseErrorResponse(data []byte) error {
offset := 0
for offset+4 <= len(data) {
attrType := binary.BigEndian.Uint16(data[offset : offset+2])
attrLen := int(binary.BigEndian.Uint16(data[offset+2 : offset+4]))
if offset+4+attrLen > len(data) {
break
}
if attrType == attrErrorCode && attrLen >= 4 {
attrValue := data[offset+4 : offset+4+attrLen]
class := int(attrValue[2] & 0x07)
number := int(attrValue[3])
code := class*100 + number
if attrLen > 4 {
return E.New("STUN error ", code, ": ", string(attrValue[4:]))
}
return E.New("STUN error ", code)
}
offset += 4 + attrLen + paddingLen(attrLen)
}
return E.New("STUN error response")
}
func parseXORMappedAddress(data []byte, txID TransactionID) (netip.AddrPort, error) {
if len(data) < 4 {
return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS too short")
}
family := data[1]
xPort := binary.BigEndian.Uint16(data[2:4])
port := xPort ^ uint16(magicCookie>>16)
switch family {
case familyIPv4:
if len(data) < 8 {
return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS IPv4 too short")
}
var ip [4]byte
binary.BigEndian.PutUint32(ip[:], binary.BigEndian.Uint32(data[4:8])^magicCookie)
return netip.AddrPortFrom(netip.AddrFrom4(ip), port), nil
case familyIPv6:
if len(data) < 20 {
return netip.AddrPort{}, E.New("XOR-MAPPED-ADDRESS IPv6 too short")
}
var ip [16]byte
var xorKey [16]byte
binary.BigEndian.PutUint32(xorKey[0:4], magicCookie)
copy(xorKey[4:16], txID[:])
for i := range 16 {
ip[i] = data[4+i] ^ xorKey[i]
}
return netip.AddrPortFrom(netip.AddrFrom16(ip), port), nil
default:
return netip.AddrPort{}, E.New("unknown address family: ", family)
}
}
func parseMappedAddress(data []byte) (netip.AddrPort, error) {
if len(data) < 4 {
return netip.AddrPort{}, E.New("MAPPED-ADDRESS too short")
}
family := data[1]
port := binary.BigEndian.Uint16(data[2:4])
switch family {
case familyIPv4:
if len(data) < 8 {
return netip.AddrPort{}, E.New("MAPPED-ADDRESS IPv4 too short")
}
return netip.AddrPortFrom(
netip.AddrFrom4([4]byte{data[4], data[5], data[6], data[7]}), port,
), nil
case familyIPv6:
if len(data) < 20 {
return netip.AddrPort{}, E.New("MAPPED-ADDRESS IPv6 too short")
}
var ip [16]byte
copy(ip[:], data[4:20])
return netip.AddrPortFrom(netip.AddrFrom16(ip), port), nil
default:
return netip.AddrPort{}, E.New("unknown address family: ", family)
}
}
func roundTrip(conn net.PacketConn, addr net.Addr, txID TransactionID, attrs []stunAttribute, rto time.Duration) (*parsedResponse, time.Duration, error) {
request := buildBindingRequest(txID, attrs...)
currentRTO := rto
retransmitCount := 0
sendTime := time.Now()
_, err := conn.WriteTo(request, addr)
if err != nil {
return nil, 0, E.Cause(err, "send STUN request")
}
buf := make([]byte, 1024)
for {
err = conn.SetReadDeadline(sendTime.Add(currentRTO))
if err != nil {
return nil, 0, E.Cause(err, "set read deadline")
}
n, _, readErr := conn.ReadFrom(buf)
if readErr != nil {
if E.IsTimeout(readErr) && retransmitCount < maxRetransmit {
retransmitCount++
currentRTO *= 2
sendTime = time.Now()
_, err = conn.WriteTo(request, addr)
if err != nil {
return nil, 0, E.Cause(err, "retransmit STUN request")
}
continue
}
return nil, 0, E.Cause(readErr, "read STUN response")
}
if n < headerSize || buf[0]&0xC0 != 0 ||
binary.BigEndian.Uint32(buf[4:8]) != magicCookie {
continue
}
var receivedTxID TransactionID
copy(receivedTxID[:], buf[8:20])
if receivedTxID != txID {
continue
}
latency := time.Since(sendTime)
resp, parseErr := parseResponse(buf[:n], txID)
if parseErr != nil {
return nil, 0, parseErr
}
return resp, latency, nil
}
}
func Run(options Options) (*Result, error) {
ctx := options.Context
if ctx == nil {
ctx = context.Background()
}
server := options.Server
if server == "" {
server = DefaultServer
}
serverSocksaddr := M.ParseSocksaddr(server)
if serverSocksaddr.Port == 0 {
serverSocksaddr.Port = 3478
}
reportProgress := options.OnProgress
if reportProgress == nil {
reportProgress = func(Progress) {}
}
var (
packetConn net.PacketConn
serverAddr net.Addr
err error
)
if options.Dialer != nil {
packetConn, err = options.Dialer.ListenPacket(ctx, serverSocksaddr)
if err != nil {
return nil, E.Cause(err, "create UDP socket")
}
serverAddr = serverSocksaddr
} else {
serverUDPAddr, resolveErr := net.ResolveUDPAddr("udp", serverSocksaddr.String())
if resolveErr != nil {
return nil, E.Cause(resolveErr, "resolve STUN server")
}
packetConn, err = net.ListenPacket("udp", "")
if err != nil {
return nil, E.Cause(err, "create UDP socket")
}
serverAddr = serverUDPAddr
}
defer func() {
_ = packetConn.Close()
}()
if deadline.NeedAdditionalReadDeadline(packetConn) {
packetConn = deadline.NewPacketConn(bufio.NewPacketConn(packetConn))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
rto := defaultRTO
// Phase 1: Binding
reportProgress(Progress{Phase: PhaseBinding})
txID := newTransactionID()
resp, latency, err := roundTrip(packetConn, serverAddr, txID, nil, rto)
if err != nil {
return nil, E.Cause(err, "binding request")
}
rto = max(minRTO, 3*latency)
externalAddr, ok := resp.externalAddr()
if !ok {
return nil, E.New("no mapped address in response")
}
result := &Result{
ExternalAddr: externalAddr.String(),
LatencyMs: int32(latency.Milliseconds()),
}
reportProgress(Progress{
Phase: PhaseBinding,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
})
otherAddr := resp.otherAddr
if !otherAddr.IsValid() {
result.NATTypeSupported = false
reportProgress(Progress{
Phase: PhaseDone,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
})
return result, nil
}
result.NATTypeSupported = true
select {
case <-ctx.Done():
return result, nil
default:
}
// Phase 2: NAT Mapping Detection (RFC 5780 Section 4.3)
reportProgress(Progress{
Phase: PhaseNATMapping,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
})
result.NATMapping = detectNATMapping(
packetConn, serverSocksaddr.Port, externalAddr, otherAddr, rto,
)
reportProgress(Progress{
Phase: PhaseNATMapping,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
NATMapping: result.NATMapping,
})
select {
case <-ctx.Done():
return result, nil
default:
}
// Phase 3: NAT Filtering Detection (RFC 5780 Section 4.4)
reportProgress(Progress{
Phase: PhaseNATFiltering,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
NATMapping: result.NATMapping,
})
result.NATFiltering = detectNATFiltering(packetConn, serverAddr, rto)
reportProgress(Progress{
Phase: PhaseDone,
ExternalAddr: result.ExternalAddr,
LatencyMs: result.LatencyMs,
NATMapping: result.NATMapping,
NATFiltering: result.NATFiltering,
})
return result, nil
}
func detectNATMapping(
conn net.PacketConn,
serverPort uint16,
externalAddr netip.AddrPort,
otherAddr netip.AddrPort,
rto time.Duration,
) NATMapping {
// Mapping Test II: Send to other_ip:server_port
testIIAddr := net.UDPAddrFromAddrPort(
netip.AddrPortFrom(otherAddr.Addr(), serverPort),
)
txID2 := newTransactionID()
resp2, _, err := roundTrip(conn, testIIAddr, txID2, nil, rto)
if err != nil {
return NATMappingUnknown
}
externalAddr2, ok := resp2.externalAddr()
if !ok {
return NATMappingUnknown
}
if externalAddr == externalAddr2 {
return NATMappingEndpointIndependent
}
// Mapping Test III: Send to other_ip:other_port
testIIIAddr := net.UDPAddrFromAddrPort(otherAddr)
txID3 := newTransactionID()
resp3, _, err := roundTrip(conn, testIIIAddr, txID3, nil, rto)
if err != nil {
return NATMappingUnknown
}
externalAddr3, ok := resp3.externalAddr()
if !ok {
return NATMappingUnknown
}
if externalAddr2 == externalAddr3 {
return NATMappingAddressDependent
}
return NATMappingAddressAndPortDependent
}
func detectNATFiltering(
conn net.PacketConn,
serverAddr net.Addr,
rto time.Duration,
) NATFiltering {
// Filtering Test II: Request response from different IP and port
txID := newTransactionID()
_, _, err := roundTrip(conn, serverAddr, txID,
[]stunAttribute{changeRequestAttr(changeIP | changePort)}, rto)
if err == nil {
return NATFilteringEndpointIndependent
}
// Filtering Test III: Request response from different port only
txID = newTransactionID()
_, _, err = roundTrip(conn, serverAddr, txID,
[]stunAttribute{changeRequestAttr(changePort)}, rto)
if err == nil {
return NATFilteringAddressDependent
}
return NATFilteringAddressAndPortDependent
}
func paddingLen(n int) int {
if n%4 == 0 {
return 0
}
return 4 - n%4
}

View File

@@ -38,37 +38,6 @@ func (w *acmeWrapper) Close() error {
return nil
}
type acmeLogWriter struct {
logger logger.Logger
}
func (w *acmeLogWriter) Write(p []byte) (n int, err error) {
logLine := strings.ReplaceAll(string(p), " ", ": ")
switch {
case strings.HasPrefix(logLine, "error: "):
w.logger.Error(logLine[7:])
case strings.HasPrefix(logLine, "warn: "):
w.logger.Warn(logLine[6:])
case strings.HasPrefix(logLine, "info: "):
w.logger.Info(logLine[6:])
case strings.HasPrefix(logLine, "debug: "):
w.logger.Debug(logLine[7:])
default:
w.logger.Debug(logLine)
}
return len(p), nil
}
func (w *acmeLogWriter) Sync() error {
return nil
}
func encoderConfig() zapcore.EncoderConfig {
config := zap.NewProductionEncoderConfig()
config.TimeKey = zapcore.OmitKey
return config
}
func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) {
var acmeServer string
switch options.Provider {
@@ -91,8 +60,8 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
storage = certmagic.Default.Storage
}
zapLogger := zap.New(zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig()),
&acmeLogWriter{logger: logger},
zapcore.NewConsoleEncoder(ACMEEncoderConfig()),
&ACMELogWriter{Logger: logger},
zap.DebugLevel,
))
config := &certmagic.Config{
@@ -158,7 +127,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
} else {
tlsConfig = &tls.Config{
GetCertificate: config.GetCertificate,
NextProtos: []string{ACMETLS1Protocol},
NextProtos: []string{C.ACMETLS1Protocol},
}
}
return tlsConfig, &acmeWrapper{ctx: ctx, cfg: config, cache: cache, domain: options.Domain}, nil

View File

@@ -1,3 +0,0 @@
package tls
const ACMETLS1Protocol = "acme-tls/1"

41
common/tls/acme_logger.go Normal file
View File

@@ -0,0 +1,41 @@
package tls
import (
"strings"
"github.com/sagernet/sing/common/logger"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type ACMELogWriter struct {
Logger logger.Logger
}
func (w *ACMELogWriter) Write(p []byte) (n int, err error) {
logLine := strings.ReplaceAll(string(p), " ", ": ")
switch {
case strings.HasPrefix(logLine, "error: "):
w.Logger.Error(logLine[7:])
case strings.HasPrefix(logLine, "warn: "):
w.Logger.Warn(logLine[6:])
case strings.HasPrefix(logLine, "info: "):
w.Logger.Info(logLine[6:])
case strings.HasPrefix(logLine, "debug: "):
w.Logger.Debug(logLine[7:])
default:
w.Logger.Debug(logLine)
}
return len(p), nil
}
func (w *ACMELogWriter) Sync() error {
return nil
}
func ACMEEncoderConfig() zapcore.EncoderConfig {
config := zap.NewProductionEncoderConfig()
config.TimeKey = zapcore.OmitKey
return config
}

221
common/tls/apple_client.go Normal file
View File

@@ -0,0 +1,221 @@
//go:build darwin && cgo
package tls
import (
"context"
"net"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
boxConstant "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/service"
)
type appleCertificateStore interface {
StoreKind() string
CurrentPEM() []string
}
type appleClientConfig struct {
serverName string
nextProtos []string
handshakeTimeout time.Duration
minVersion uint16
maxVersion uint16
insecure bool
anchorPEM string
anchorOnly bool
certificatePublicKeySHA256 [][]byte
timeFunc func() time.Time
}
func (c *appleClientConfig) ServerName() string {
return c.serverName
}
func (c *appleClientConfig) SetServerName(serverName string) {
c.serverName = serverName
}
func (c *appleClientConfig) NextProtos() []string {
return c.nextProtos
}
func (c *appleClientConfig) SetNextProtos(nextProto []string) {
c.nextProtos = append(c.nextProtos[:0], nextProto...)
}
func (c *appleClientConfig) HandshakeTimeout() time.Duration {
return c.handshakeTimeout
}
func (c *appleClientConfig) SetHandshakeTimeout(timeout time.Duration) {
c.handshakeTimeout = timeout
}
func (c *appleClientConfig) STDConfig() (*STDConfig, error) {
return nil, E.New("unsupported usage for Apple TLS engine")
}
func (c *appleClientConfig) Client(conn net.Conn) (Conn, error) {
return nil, os.ErrInvalid
}
func (c *appleClientConfig) Clone() Config {
return &appleClientConfig{
serverName: c.serverName,
nextProtos: append([]string(nil), c.nextProtos...),
handshakeTimeout: c.handshakeTimeout,
minVersion: c.minVersion,
maxVersion: c.maxVersion,
insecure: c.insecure,
anchorPEM: c.anchorPEM,
anchorOnly: c.anchorOnly,
certificatePublicKeySHA256: append([][]byte(nil), c.certificatePublicKeySHA256...),
timeFunc: c.timeFunc,
}
}
func newAppleClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
validated, err := ValidateAppleTLSOptions(ctx, options, "Apple TLS engine")
if err != nil {
return nil, err
}
var serverName string
if options.ServerName != "" {
serverName = options.ServerName
} else if serverAddress != "" {
serverName = serverAddress
}
if serverName == "" && !options.Insecure && !allowEmptyServerName {
return nil, errMissingServerName
}
var handshakeTimeout time.Duration
if options.HandshakeTimeout > 0 {
handshakeTimeout = options.HandshakeTimeout.Build()
} else {
handshakeTimeout = boxConstant.TCPTimeout
}
return &appleClientConfig{
serverName: serverName,
nextProtos: append([]string(nil), options.ALPN...),
handshakeTimeout: handshakeTimeout,
minVersion: validated.MinVersion,
maxVersion: validated.MaxVersion,
insecure: options.Insecure || len(options.CertificatePublicKeySHA256) > 0,
anchorPEM: validated.AnchorPEM,
anchorOnly: validated.AnchorOnly,
certificatePublicKeySHA256: append([][]byte(nil), options.CertificatePublicKeySHA256...),
timeFunc: ntp.TimeFuncFromContext(ctx),
}, nil
}
type AppleTLSValidated struct {
MinVersion uint16
MaxVersion uint16
AnchorPEM string
AnchorOnly bool
}
func ValidateAppleTLSOptions(ctx context.Context, options option.OutboundTLSOptions, engineName string) (AppleTLSValidated, error) {
if options.Reality != nil && options.Reality.Enabled {
return AppleTLSValidated{}, E.New("reality is unsupported in ", engineName)
}
if options.UTLS != nil && options.UTLS.Enabled {
return AppleTLSValidated{}, E.New("utls is unsupported in ", engineName)
}
if options.ECH != nil && options.ECH.Enabled {
return AppleTLSValidated{}, E.New("ech is unsupported in ", engineName)
}
if options.DisableSNI {
return AppleTLSValidated{}, E.New("disable_sni is unsupported in ", engineName)
}
if len(options.CipherSuites) > 0 {
return AppleTLSValidated{}, E.New("cipher_suites is unsupported in ", engineName)
}
if len(options.CurvePreferences) > 0 {
return AppleTLSValidated{}, E.New("curve_preferences is unsupported in ", engineName)
}
if len(options.ClientCertificate) > 0 || options.ClientCertificatePath != "" || len(options.ClientKey) > 0 || options.ClientKeyPath != "" {
return AppleTLSValidated{}, E.New("client certificate is unsupported in ", engineName)
}
if options.Fragment || options.RecordFragment {
return AppleTLSValidated{}, E.New("tls fragment is unsupported in ", engineName)
}
if options.KernelTx || options.KernelRx {
return AppleTLSValidated{}, E.New("ktls is unsupported in ", engineName)
}
if options.Spoof != "" || options.SpoofMethod != "" {
return AppleTLSValidated{}, E.New("spoof is unsupported in ", engineName)
}
if len(options.CertificatePublicKeySHA256) > 0 && (len(options.Certificate) > 0 || options.CertificatePath != "") {
return AppleTLSValidated{}, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path")
}
var minVersion uint16
if options.MinVersion != "" {
var err error
minVersion, err = ParseTLSVersion(options.MinVersion)
if err != nil {
return AppleTLSValidated{}, E.Cause(err, "parse min_version")
}
}
var maxVersion uint16
if options.MaxVersion != "" {
var err error
maxVersion, err = ParseTLSVersion(options.MaxVersion)
if err != nil {
return AppleTLSValidated{}, E.Cause(err, "parse max_version")
}
}
anchorPEM, anchorOnly, err := AppleAnchorPEM(ctx, options)
if err != nil {
return AppleTLSValidated{}, err
}
return AppleTLSValidated{
MinVersion: minVersion,
MaxVersion: maxVersion,
AnchorPEM: anchorPEM,
AnchorOnly: anchorOnly,
}, nil
}
func AppleAnchorPEM(ctx context.Context, options option.OutboundTLSOptions) (string, bool, error) {
if len(options.Certificate) > 0 {
return strings.Join(options.Certificate, "\n"), true, nil
}
if options.CertificatePath != "" {
content, err := os.ReadFile(options.CertificatePath)
if err != nil {
return "", false, E.Cause(err, "read certificate")
}
return string(content), true, nil
}
certificateStore := service.FromContext[adapter.CertificateStore](ctx)
if certificateStore == nil {
return "", false, nil
}
store, ok := certificateStore.(appleCertificateStore)
if !ok {
return "", false, nil
}
switch store.StoreKind() {
case boxConstant.CertificateStoreSystem, "":
return strings.Join(store.CurrentPEM(), "\n"), false, nil
case boxConstant.CertificateStoreMozilla, boxConstant.CertificateStoreChrome, boxConstant.CertificateStoreNone:
return strings.Join(store.CurrentPEM(), "\n"), true, nil
default:
return "", false, E.New("unsupported certificate store for Apple TLS engine: ", store.StoreKind())
}
}

View File

@@ -0,0 +1,517 @@
//go:build darwin && cgo
package tls
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Foundation -framework Network -framework Security
#include <stdlib.h>
#include "apple_client_platform_darwin.h"
*/
import "C"
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"io"
"math"
"net"
"os"
"strings"
"sync"
"syscall"
"time"
"unsafe"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
func (c *appleClientConfig) ClientHandshake(ctx context.Context, conn net.Conn) (Conn, error) {
rawSyscallConn, ok := common.Cast[syscall.Conn](conn)
if !ok {
return nil, E.New("apple TLS: requires fd-backed TCP connection")
}
syscallConn, err := rawSyscallConn.SyscallConn()
if err != nil {
return nil, E.Cause(err, "access raw connection")
}
var dupFD int
controlErr := syscallConn.Control(func(fd uintptr) {
dupFD, err = unix.Dup(int(fd))
})
if controlErr != nil {
return nil, E.Cause(controlErr, "access raw connection")
}
if err != nil {
return nil, E.Cause(err, "duplicate raw connection")
}
serverName := c.serverName
serverNamePtr := cStringOrNil(serverName)
defer cFree(serverNamePtr)
alpn := strings.Join(c.nextProtos, "\n")
alpnPtr := cStringOrNil(alpn)
defer cFree(alpnPtr)
anchorPEMPtr := cStringOrNil(c.anchorPEM)
defer cFree(anchorPEMPtr)
var (
hasVerifyTime bool
verifyTimeUnixMilli int64
)
if c.timeFunc != nil {
hasVerifyTime = true
verifyTimeUnixMilli = c.timeFunc().UnixMilli()
}
var errorPtr *C.char
client := C.box_apple_tls_client_create(
C.int(dupFD),
serverNamePtr,
alpnPtr,
C.size_t(len(alpn)),
C.uint16_t(c.minVersion),
C.uint16_t(c.maxVersion),
C.bool(c.insecure),
anchorPEMPtr,
C.size_t(len(c.anchorPEM)),
C.bool(c.anchorOnly),
C.bool(hasVerifyTime),
C.int64_t(verifyTimeUnixMilli),
&errorPtr,
)
if client == nil {
if errorPtr != nil {
defer C.free(unsafe.Pointer(errorPtr))
return nil, E.New(C.GoString(errorPtr))
}
return nil, E.New("apple TLS: create connection")
}
if err = waitAppleTLSClientReady(ctx, client); err != nil {
C.box_apple_tls_client_cancel(client)
C.box_apple_tls_client_free(client)
return nil, err
}
var state C.box_apple_tls_state_t
stateOK := C.box_apple_tls_client_copy_state(client, &state, &errorPtr)
if !bool(stateOK) {
C.box_apple_tls_client_cancel(client)
C.box_apple_tls_client_free(client)
if errorPtr != nil {
defer C.free(unsafe.Pointer(errorPtr))
return nil, E.New(C.GoString(errorPtr))
}
return nil, E.New("apple TLS: read metadata")
}
defer C.box_apple_tls_state_free(&state)
connectionState, rawCerts, err := parseAppleTLSState(&state)
if err != nil {
C.box_apple_tls_client_cancel(client)
C.box_apple_tls_client_free(client)
return nil, err
}
if len(c.certificatePublicKeySHA256) > 0 {
err = VerifyPublicKeySHA256(c.certificatePublicKeySHA256, rawCerts)
if err != nil {
C.box_apple_tls_client_cancel(client)
C.box_apple_tls_client_free(client)
return nil, err
}
}
return &appleTLSConn{
rawConn: conn,
client: client,
state: connectionState,
closed: make(chan struct{}),
}, nil
}
const appleTLSHandshakePollInterval = 100 * time.Millisecond
func waitAppleTLSClientReady(ctx context.Context, client *C.box_apple_tls_client_t) error {
for {
if err := ctx.Err(); err != nil {
C.box_apple_tls_client_cancel(client)
return err
}
waitTimeout := appleTLSHandshakePollInterval
if deadline, loaded := ctx.Deadline(); loaded {
remaining := time.Until(deadline)
if remaining <= 0 {
C.box_apple_tls_client_cancel(client)
if err := ctx.Err(); err != nil {
return err
}
return context.DeadlineExceeded
}
if remaining < waitTimeout {
waitTimeout = remaining
}
}
var errorPtr *C.char
waitResult := C.box_apple_tls_client_wait_ready(client, C.int(timeoutFromDuration(waitTimeout)), &errorPtr)
switch waitResult {
case 1:
return nil
case -2:
continue
case 0:
if errorPtr != nil {
defer C.free(unsafe.Pointer(errorPtr))
return E.New(C.GoString(errorPtr))
}
return E.New("apple TLS: handshake failed")
default:
return E.New("apple TLS: invalid handshake state")
}
}
}
type appleTLSConn struct {
rawConn net.Conn
client *C.box_apple_tls_client_t
state tls.ConnectionState
readAccess sync.Mutex
writeAccess sync.Mutex
stateAccess sync.RWMutex
closeOnce sync.Once
ioAccess sync.Mutex
ioGroup sync.WaitGroup
closed chan struct{}
readEOF bool
deadlineAccess sync.Mutex
readDeadline time.Time
writeDeadline time.Time
readTimedOut bool
writeTimedOut bool
}
func (c *appleTLSConn) Read(p []byte) (int, error) {
c.readAccess.Lock()
defer c.readAccess.Unlock()
if c.readEOF {
return 0, io.EOF
}
if len(p) == 0 {
return 0, nil
}
timeoutMs, err := c.prepareReadTimeout()
if err != nil {
return 0, err
}
client, err := c.acquireClient()
if err != nil {
return 0, err
}
defer c.releaseClient()
var eof C.bool
var errorPtr *C.char
n := C.box_apple_tls_client_read(client, unsafe.Pointer(&p[0]), C.size_t(len(p)), C.int(timeoutMs), &eof, &errorPtr)
switch {
case n == -2:
c.markReadTimedOut()
return 0, os.ErrDeadlineExceeded
case n >= 0:
if bool(eof) {
c.readEOF = true
if n == 0 {
return 0, io.EOF
}
}
return int(n), nil
default:
if errorPtr != nil {
defer C.free(unsafe.Pointer(errorPtr))
if c.isClosed() {
return 0, net.ErrClosed
}
return 0, E.New(C.GoString(errorPtr))
}
return 0, net.ErrClosed
}
}
func (c *appleTLSConn) Write(p []byte) (int, error) {
c.writeAccess.Lock()
defer c.writeAccess.Unlock()
if len(p) == 0 {
return 0, nil
}
timeoutMs, err := c.prepareWriteTimeout()
if err != nil {
return 0, err
}
client, err := c.acquireClient()
if err != nil {
return 0, err
}
defer c.releaseClient()
var errorPtr *C.char
n := C.box_apple_tls_client_write(client, unsafe.Pointer(&p[0]), C.size_t(len(p)), C.int(timeoutMs), &errorPtr)
switch {
case n == -2:
c.markWriteTimedOut()
return 0, os.ErrDeadlineExceeded
case n >= 0:
return int(n), nil
}
if errorPtr != nil {
defer C.free(unsafe.Pointer(errorPtr))
if c.isClosed() {
return 0, net.ErrClosed
}
return 0, E.New(C.GoString(errorPtr))
}
return 0, net.ErrClosed
}
func (c *appleTLSConn) Close() error {
var closeErr error
c.closeOnce.Do(func() {
close(c.closed)
C.box_apple_tls_client_cancel(c.client)
closeErr = c.rawConn.Close()
c.ioAccess.Lock()
c.ioGroup.Wait()
C.box_apple_tls_client_free(c.client)
c.client = nil
c.ioAccess.Unlock()
})
return closeErr
}
func (c *appleTLSConn) LocalAddr() net.Addr {
return c.rawConn.LocalAddr()
}
func (c *appleTLSConn) RemoteAddr() net.Addr {
return c.rawConn.RemoteAddr()
}
// SetDeadline installs deadlines for subsequent Read and Write calls.
//
// Deadlines only apply to subsequent Read or Write calls; an in-flight call
// does not observe later updates to its deadline. Callers that need to cancel
// an in-flight I/O must Close the connection instead.
//
// Once an active Read or Write trips its deadline, the underlying
// nw_connection is cancelled and the conn is no longer usable — callers must
// Close after a deadline error.
func (c *appleTLSConn) SetDeadline(t time.Time) error {
c.deadlineAccess.Lock()
c.readDeadline = t
c.writeDeadline = t
c.readTimedOut = false
c.writeTimedOut = false
c.deadlineAccess.Unlock()
return nil
}
func (c *appleTLSConn) SetReadDeadline(t time.Time) error {
c.deadlineAccess.Lock()
c.readDeadline = t
c.readTimedOut = false
c.deadlineAccess.Unlock()
return nil
}
func (c *appleTLSConn) SetWriteDeadline(t time.Time) error {
c.deadlineAccess.Lock()
c.writeDeadline = t
c.writeTimedOut = false
c.deadlineAccess.Unlock()
return nil
}
func (c *appleTLSConn) prepareReadTimeout() (int, error) {
c.deadlineAccess.Lock()
defer c.deadlineAccess.Unlock()
if c.readTimedOut {
return 0, os.ErrDeadlineExceeded
}
timeoutMs, expired := deadlineTimeoutMs(c.readDeadline)
if expired {
c.readTimedOut = true
return 0, os.ErrDeadlineExceeded
}
return timeoutMs, nil
}
func (c *appleTLSConn) prepareWriteTimeout() (int, error) {
c.deadlineAccess.Lock()
defer c.deadlineAccess.Unlock()
if c.writeTimedOut {
return 0, os.ErrDeadlineExceeded
}
timeoutMs, expired := deadlineTimeoutMs(c.writeDeadline)
if expired {
c.writeTimedOut = true
return 0, os.ErrDeadlineExceeded
}
return timeoutMs, nil
}
func (c *appleTLSConn) markReadTimedOut() {
c.deadlineAccess.Lock()
c.readTimedOut = true
c.deadlineAccess.Unlock()
}
func (c *appleTLSConn) markWriteTimedOut() {
c.deadlineAccess.Lock()
c.writeTimedOut = true
c.deadlineAccess.Unlock()
}
func deadlineTimeoutMs(deadline time.Time) (int, bool) {
if deadline.IsZero() {
return -1, false
}
remaining := time.Until(deadline)
if remaining <= 0 {
return 0, true
}
return timeoutFromDuration(remaining), false
}
func (c *appleTLSConn) isClosed() bool {
select {
case <-c.closed:
return true
default:
return false
}
}
func (c *appleTLSConn) acquireClient() (*C.box_apple_tls_client_t, error) {
c.ioAccess.Lock()
defer c.ioAccess.Unlock()
if c.isClosed() {
return nil, net.ErrClosed
}
client := c.client
if client == nil {
return nil, net.ErrClosed
}
c.ioGroup.Add(1)
return client, nil
}
func (c *appleTLSConn) releaseClient() {
c.ioGroup.Done()
}
func (c *appleTLSConn) NetConn() net.Conn {
return c.rawConn
}
func (c *appleTLSConn) HandshakeContext(ctx context.Context) error {
return nil
}
func (c *appleTLSConn) ConnectionState() ConnectionState {
c.stateAccess.RLock()
defer c.stateAccess.RUnlock()
return c.state
}
func parseAppleTLSState(state *C.box_apple_tls_state_t) (tls.ConnectionState, [][]byte, error) {
rawCerts, peerCertificates, err := parseAppleCertChain(state.peer_cert_chain, state.peer_cert_chain_len)
if err != nil {
return tls.ConnectionState{}, nil, err
}
var negotiatedProtocol string
if state.alpn != nil {
negotiatedProtocol = C.GoString(state.alpn)
}
var serverName string
if state.server_name != nil {
serverName = C.GoString(state.server_name)
}
return tls.ConnectionState{
Version: uint16(state.version),
HandshakeComplete: true,
CipherSuite: uint16(state.cipher_suite),
NegotiatedProtocol: negotiatedProtocol,
ServerName: serverName,
PeerCertificates: peerCertificates,
}, rawCerts, nil
}
func parseAppleCertChain(chain *C.uint8_t, chainLen C.size_t) ([][]byte, []*x509.Certificate, error) {
if chain == nil || chainLen == 0 {
return nil, nil, nil
}
chainBytes := C.GoBytes(unsafe.Pointer(chain), C.int(chainLen))
var (
rawCerts [][]byte
peerCertificates []*x509.Certificate
)
for len(chainBytes) >= 4 {
certificateLen := binary.BigEndian.Uint32(chainBytes[:4])
chainBytes = chainBytes[4:]
if len(chainBytes) < int(certificateLen) {
return nil, nil, E.New("apple TLS: invalid certificate chain")
}
certificateData := append([]byte(nil), chainBytes[:certificateLen]...)
certificate, err := x509.ParseCertificate(certificateData)
if err != nil {
return nil, nil, E.Cause(err, "parse peer certificate")
}
rawCerts = append(rawCerts, certificateData)
peerCertificates = append(peerCertificates, certificate)
chainBytes = chainBytes[certificateLen:]
}
if len(chainBytes) != 0 {
return nil, nil, E.New("apple TLS: invalid certificate chain")
}
return rawCerts, peerCertificates, nil
}
func timeoutFromDuration(timeout time.Duration) int {
if timeout <= 0 {
return 0
}
timeoutMilliseconds := int64(timeout / time.Millisecond)
if timeout%time.Millisecond != 0 {
timeoutMilliseconds++
}
if timeoutMilliseconds > math.MaxInt32 {
return math.MaxInt32
}
return int(timeoutMilliseconds)
}
func cStringOrNil(value string) *C.char {
if value == "" {
return nil
}
return C.CString(value)
}
func cFree(pointer *C.char) {
if pointer != nil {
C.free(unsafe.Pointer(pointer))
}
}

View File

@@ -0,0 +1,39 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <unistd.h>
typedef struct box_apple_tls_client box_apple_tls_client_t;
typedef struct box_apple_tls_state {
uint16_t version;
uint16_t cipher_suite;
char *alpn;
char *server_name;
uint8_t *peer_cert_chain;
size_t peer_cert_chain_len;
} box_apple_tls_state_t;
box_apple_tls_client_t *box_apple_tls_client_create(
int connected_socket,
const char *server_name,
const char *alpn,
size_t alpn_len,
uint16_t min_version,
uint16_t max_version,
bool insecure,
const char *anchor_pem,
size_t anchor_pem_len,
bool anchor_only,
bool has_verify_time,
int64_t verify_time_unix_millis,
char **error_out
);
int box_apple_tls_client_wait_ready(box_apple_tls_client_t *client, int timeout_msec, char **error_out);
void box_apple_tls_client_cancel(box_apple_tls_client_t *client);
void box_apple_tls_client_free(box_apple_tls_client_t *client);
ssize_t box_apple_tls_client_read(box_apple_tls_client_t *client, void *buffer, size_t buffer_len, int timeout_msec, bool *eof_out, char **error_out);
ssize_t box_apple_tls_client_write(box_apple_tls_client_t *client, const void *buffer, size_t buffer_len, int timeout_msec, char **error_out);
bool box_apple_tls_client_copy_state(box_apple_tls_client_t *client, box_apple_tls_state_t *state, char **error_out);
void box_apple_tls_state_free(box_apple_tls_state_t *state);

View File

@@ -0,0 +1,667 @@
#import "apple_client_platform_darwin.h"
#import <Foundation/Foundation.h>
#import <Network/Network.h>
#import <Security/Security.h>
#import <Security/SecProtocolMetadata.h>
#import <Security/SecProtocolOptions.h>
#import <Security/SecProtocolTypes.h>
#import <arpa/inet.h>
#import <dlfcn.h>
#import <dispatch/dispatch.h>
#import <stdatomic.h>
#import <stdlib.h>
#import <string.h>
#import <unistd.h>
typedef nw_connection_t _Nullable (*box_nw_connection_create_with_connected_socket_and_parameters_f)(int connected_socket, nw_parameters_t parameters);
typedef const char * _Nullable (*box_sec_protocol_metadata_string_accessor_f)(sec_protocol_metadata_t metadata);
typedef struct box_apple_tls_client {
void *connection;
void *queue;
void *ready_semaphore;
atomic_int ref_count;
atomic_bool ready;
atomic_bool ready_done;
char *ready_error;
box_apple_tls_state_t state;
} box_apple_tls_client_t;
static nw_connection_t box_apple_tls_connection(box_apple_tls_client_t *client) {
if (client == NULL || client->connection == NULL) {
return nil;
}
return (__bridge nw_connection_t)client->connection;
}
static dispatch_queue_t box_apple_tls_client_queue(box_apple_tls_client_t *client) {
if (client == NULL || client->queue == NULL) {
return nil;
}
return (__bridge dispatch_queue_t)client->queue;
}
static dispatch_semaphore_t box_apple_tls_ready_semaphore(box_apple_tls_client_t *client) {
if (client == NULL || client->ready_semaphore == NULL) {
return nil;
}
return (__bridge dispatch_semaphore_t)client->ready_semaphore;
}
static void box_apple_tls_state_reset(box_apple_tls_state_t *state) {
if (state == NULL) {
return;
}
free(state->alpn);
free(state->server_name);
free(state->peer_cert_chain);
memset(state, 0, sizeof(box_apple_tls_state_t));
}
static void box_apple_tls_client_destroy(box_apple_tls_client_t *client) {
free(client->ready_error);
box_apple_tls_state_reset(&client->state);
if (client->ready_semaphore != NULL) {
CFBridgingRelease(client->ready_semaphore);
}
if (client->connection != NULL) {
CFBridgingRelease(client->connection);
}
if (client->queue != NULL) {
CFBridgingRelease(client->queue);
}
free(client);
}
static void box_apple_tls_client_release(box_apple_tls_client_t *client) {
if (client == NULL) {
return;
}
if (atomic_fetch_sub(&client->ref_count, 1) == 1) {
box_apple_tls_client_destroy(client);
}
}
static void box_set_error_string(char **error_out, NSString *message) {
if (error_out == NULL || *error_out != NULL) {
return;
}
const char *utf8 = [message UTF8String];
*error_out = strdup(utf8 != NULL ? utf8 : "unknown error");
}
static void box_set_error_message(char **error_out, const char *message) {
if (error_out == NULL || *error_out != NULL) {
return;
}
*error_out = strdup(message != NULL ? message : "unknown error");
}
static void box_set_error_from_nw_error(char **error_out, nw_error_t error) {
if (error == NULL) {
box_set_error_message(error_out, "unknown network error");
return;
}
CFErrorRef cfError = nw_error_copy_cf_error(error);
if (cfError == NULL) {
box_set_error_message(error_out, "unknown network error");
return;
}
NSString *description = [(__bridge NSError *)cfError description];
box_set_error_string(error_out, description);
CFRelease(cfError);
}
static char *box_apple_tls_metadata_copy_negotiated_protocol(sec_protocol_metadata_t metadata) {
static box_sec_protocol_metadata_string_accessor_f copy_fn;
static box_sec_protocol_metadata_string_accessor_f get_fn;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_copy_negotiated_protocol");
get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_get_negotiated_protocol");
});
if (copy_fn != NULL) {
return (char *)copy_fn(metadata);
}
if (get_fn != NULL) {
const char *protocol = get_fn(metadata);
if (protocol != NULL) {
return strdup(protocol);
}
}
return NULL;
}
static char *box_apple_tls_metadata_copy_server_name(sec_protocol_metadata_t metadata) {
static box_sec_protocol_metadata_string_accessor_f copy_fn;
static box_sec_protocol_metadata_string_accessor_f get_fn;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
copy_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_copy_server_name");
get_fn = (box_sec_protocol_metadata_string_accessor_f)dlsym(RTLD_DEFAULT, "sec_protocol_metadata_get_server_name");
});
if (copy_fn != NULL) {
return (char *)copy_fn(metadata);
}
if (get_fn != NULL) {
const char *server_name = get_fn(metadata);
if (server_name != NULL) {
return strdup(server_name);
}
}
return NULL;
}
static NSArray<NSString *> *box_split_lines(const char *content, size_t content_len) {
if (content == NULL || content_len == 0) {
return @[];
}
NSString *string = [[NSString alloc] initWithBytes:content length:content_len encoding:NSUTF8StringEncoding];
if (string == nil) {
return @[];
}
NSMutableArray<NSString *> *lines = [NSMutableArray array];
[string enumerateLinesUsingBlock:^(NSString *line, BOOL *stop) {
if (line.length > 0) {
[lines addObject:line];
}
}];
return lines;
}
static NSArray *box_parse_certificates_from_pem(const char *pem, size_t pem_len) {
if (pem == NULL || pem_len == 0) {
return @[];
}
NSString *content = [[NSString alloc] initWithBytes:pem length:pem_len encoding:NSUTF8StringEncoding];
if (content == nil) {
return @[];
}
NSString *beginMarker = @"-----BEGIN CERTIFICATE-----";
NSString *endMarker = @"-----END CERTIFICATE-----";
NSMutableArray *certificates = [NSMutableArray array];
NSUInteger searchFrom = 0;
while (searchFrom < content.length) {
NSRange beginRange = [content rangeOfString:beginMarker options:0 range:NSMakeRange(searchFrom, content.length - searchFrom)];
if (beginRange.location == NSNotFound) {
break;
}
NSUInteger bodyStart = beginRange.location + beginRange.length;
NSRange endRange = [content rangeOfString:endMarker options:0 range:NSMakeRange(bodyStart, content.length - bodyStart)];
if (endRange.location == NSNotFound) {
break;
}
NSString *base64Section = [content substringWithRange:NSMakeRange(bodyStart, endRange.location - bodyStart)];
NSArray<NSString *> *components = [base64Section componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
NSString *base64Content = [components componentsJoinedByString:@""];
NSData *der = [[NSData alloc] initWithBase64EncodedString:base64Content options:0];
if (der != nil) {
SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)der);
if (certificate != NULL) {
[certificates addObject:(__bridge id)certificate];
CFRelease(certificate);
}
}
searchFrom = endRange.location + endRange.length;
}
return certificates;
}
static bool box_evaluate_trust(sec_trust_t trust, NSArray *anchors, bool anchor_only, NSDate *verify_date) {
bool result = false;
SecTrustRef trustRef = sec_trust_copy_ref(trust);
if (trustRef == NULL) {
return false;
}
if (verify_date != nil && SecTrustSetVerifyDate(trustRef, (__bridge CFDateRef)verify_date) != errSecSuccess) {
CFRelease(trustRef);
return false;
}
if (anchors.count > 0 || anchor_only) {
CFMutableArrayRef anchorArray = CFArrayCreateMutable(NULL, 0, &kCFTypeArrayCallBacks);
for (id certificate in anchors) {
CFArrayAppendValue(anchorArray, (__bridge const void *)certificate);
}
SecTrustSetAnchorCertificates(trustRef, anchorArray);
SecTrustSetAnchorCertificatesOnly(trustRef, anchor_only);
CFRelease(anchorArray);
}
CFErrorRef error = NULL;
result = SecTrustEvaluateWithError(trustRef, &error);
if (error != NULL) {
CFRelease(error);
}
CFRelease(trustRef);
return result;
}
static nw_connection_t box_apple_tls_create_connection(int connected_socket, nw_parameters_t parameters) {
static box_nw_connection_create_with_connected_socket_and_parameters_f create_fn;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
char name[] = "sretemarap_dna_tekcos_detcennoc_htiw_etaerc_noitcennoc_wn";
for (size_t i = 0, j = sizeof(name) - 2; i < j; i++, j--) {
char t = name[i];
name[i] = name[j];
name[j] = t;
}
create_fn = (box_nw_connection_create_with_connected_socket_and_parameters_f)dlsym(RTLD_DEFAULT, name);
});
if (create_fn == NULL) {
return nil;
}
return create_fn(connected_socket, parameters);
}
static bool box_apple_tls_state_copy(const box_apple_tls_state_t *source, box_apple_tls_state_t *destination) {
memset(destination, 0, sizeof(box_apple_tls_state_t));
destination->version = source->version;
destination->cipher_suite = source->cipher_suite;
if (source->alpn != NULL) {
destination->alpn = strdup(source->alpn);
if (destination->alpn == NULL) {
goto oom;
}
}
if (source->server_name != NULL) {
destination->server_name = strdup(source->server_name);
if (destination->server_name == NULL) {
goto oom;
}
}
if (source->peer_cert_chain_len > 0) {
destination->peer_cert_chain = malloc(source->peer_cert_chain_len);
if (destination->peer_cert_chain == NULL) {
goto oom;
}
memcpy(destination->peer_cert_chain, source->peer_cert_chain, source->peer_cert_chain_len);
destination->peer_cert_chain_len = source->peer_cert_chain_len;
}
return true;
oom:
box_apple_tls_state_reset(destination);
return false;
}
static bool box_apple_tls_state_load(nw_connection_t connection, box_apple_tls_state_t *state, char **error_out) {
box_apple_tls_state_reset(state);
if (connection == nil) {
box_set_error_message(error_out, "apple TLS: invalid client");
return false;
}
nw_protocol_definition_t tls_definition = nw_protocol_copy_tls_definition();
nw_protocol_metadata_t metadata = nw_connection_copy_protocol_metadata(connection, tls_definition);
if (metadata == NULL || !nw_protocol_metadata_is_tls(metadata)) {
box_set_error_message(error_out, "apple TLS: metadata unavailable");
return false;
}
sec_protocol_metadata_t sec_metadata = nw_tls_copy_sec_protocol_metadata(metadata);
if (sec_metadata == NULL) {
box_set_error_message(error_out, "apple TLS: metadata unavailable");
return false;
}
state->version = (uint16_t)sec_protocol_metadata_get_negotiated_tls_protocol_version(sec_metadata);
state->cipher_suite = (uint16_t)sec_protocol_metadata_get_negotiated_tls_ciphersuite(sec_metadata);
state->alpn = box_apple_tls_metadata_copy_negotiated_protocol(sec_metadata);
state->server_name = box_apple_tls_metadata_copy_server_name(sec_metadata);
NSMutableData *chain_data = [NSMutableData data];
sec_protocol_metadata_access_peer_certificate_chain(sec_metadata, ^(sec_certificate_t certificate) {
SecCertificateRef certificate_ref = sec_certificate_copy_ref(certificate);
if (certificate_ref == NULL) {
return;
}
CFDataRef certificate_data = SecCertificateCopyData(certificate_ref);
CFRelease(certificate_ref);
if (certificate_data == NULL) {
return;
}
uint32_t certificate_len = (uint32_t)CFDataGetLength(certificate_data);
uint32_t network_len = htonl(certificate_len);
[chain_data appendBytes:&network_len length:sizeof(network_len)];
[chain_data appendBytes:CFDataGetBytePtr(certificate_data) length:certificate_len];
CFRelease(certificate_data);
});
if (chain_data.length > 0) {
state->peer_cert_chain = malloc(chain_data.length);
if (state->peer_cert_chain == NULL) {
box_set_error_message(error_out, "apple TLS: out of memory");
box_apple_tls_state_reset(state);
return false;
}
memcpy(state->peer_cert_chain, chain_data.bytes, chain_data.length);
state->peer_cert_chain_len = chain_data.length;
}
return true;
}
box_apple_tls_client_t *box_apple_tls_client_create(
int connected_socket,
const char *server_name,
const char *alpn,
size_t alpn_len,
uint16_t min_version,
uint16_t max_version,
bool insecure,
const char *anchor_pem,
size_t anchor_pem_len,
bool anchor_only,
bool has_verify_time,
int64_t verify_time_unix_millis,
char **error_out
) {
box_apple_tls_client_t *client = calloc(1, sizeof(box_apple_tls_client_t));
if (client == NULL) {
close(connected_socket);
box_set_error_message(error_out, "apple TLS: out of memory");
return NULL;
}
client->queue = (__bridge_retained void *)dispatch_queue_create("sing-box.apple-private-tls", DISPATCH_QUEUE_SERIAL);
client->ready_semaphore = (__bridge_retained void *)dispatch_semaphore_create(0);
atomic_init(&client->ref_count, 1);
atomic_init(&client->ready, false);
atomic_init(&client->ready_done, false);
NSArray<NSString *> *alpnList = box_split_lines(alpn, alpn_len);
NSArray *anchors = box_parse_certificates_from_pem(anchor_pem, anchor_pem_len);
NSDate *verifyDate = nil;
if (has_verify_time) {
verifyDate = [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)verify_time_unix_millis / 1000.0];
}
nw_parameters_t parameters = nw_parameters_create_secure_tcp(^(nw_protocol_options_t tls_options) {
sec_protocol_options_t sec_options = nw_tls_copy_sec_protocol_options(tls_options);
if (min_version != 0) {
sec_protocol_options_set_min_tls_protocol_version(sec_options, (tls_protocol_version_t)min_version);
}
if (max_version != 0) {
sec_protocol_options_set_max_tls_protocol_version(sec_options, (tls_protocol_version_t)max_version);
}
if (server_name != NULL && server_name[0] != '\0') {
sec_protocol_options_set_tls_server_name(sec_options, server_name);
}
for (NSString *protocol in alpnList) {
sec_protocol_options_add_tls_application_protocol(sec_options, protocol.UTF8String);
}
sec_protocol_options_set_peer_authentication_required(sec_options, !insecure);
if (insecure) {
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
complete(true);
}, box_apple_tls_client_queue(client));
} else if (verifyDate != nil || anchors.count > 0 || anchor_only) {
sec_protocol_options_set_verify_block(sec_options, ^(sec_protocol_metadata_t metadata, sec_trust_t trust, sec_protocol_verify_complete_t complete) {
complete(box_evaluate_trust(trust, anchors, anchor_only, verifyDate));
}, box_apple_tls_client_queue(client));
}
}, NW_PARAMETERS_DEFAULT_CONFIGURATION);
nw_connection_t connection = box_apple_tls_create_connection(connected_socket, parameters);
if (connection == NULL) {
close(connected_socket);
if (client->ready_semaphore != NULL) {
CFBridgingRelease(client->ready_semaphore);
}
if (client->queue != NULL) {
CFBridgingRelease(client->queue);
}
free(client);
box_set_error_message(error_out, "apple TLS: failed to create connection");
return NULL;
}
client->connection = (__bridge_retained void *)connection;
atomic_fetch_add(&client->ref_count, 1);
nw_connection_set_state_changed_handler(connection, ^(nw_connection_state_t state, nw_error_t error) {
switch (state) {
case nw_connection_state_ready:
if (!atomic_load(&client->ready_done)) {
atomic_store(&client->ready, box_apple_tls_state_load(connection, &client->state, &client->ready_error));
atomic_store(&client->ready_done, true);
dispatch_semaphore_signal(box_apple_tls_ready_semaphore(client));
}
break;
case nw_connection_state_failed:
if (!atomic_load(&client->ready_done)) {
box_set_error_from_nw_error(&client->ready_error, error);
atomic_store(&client->ready_done, true);
dispatch_semaphore_signal(box_apple_tls_ready_semaphore(client));
}
break;
case nw_connection_state_cancelled:
if (!atomic_load(&client->ready_done)) {
box_set_error_from_nw_error(&client->ready_error, error);
atomic_store(&client->ready_done, true);
dispatch_semaphore_signal(box_apple_tls_ready_semaphore(client));
}
box_apple_tls_client_release(client);
break;
default:
break;
}
});
nw_connection_set_queue(connection, box_apple_tls_client_queue(client));
nw_connection_start(connection);
return client;
}
int box_apple_tls_client_wait_ready(box_apple_tls_client_t *client, int timeout_msec, char **error_out) {
dispatch_semaphore_t ready_semaphore = box_apple_tls_ready_semaphore(client);
if (ready_semaphore == nil) {
box_set_error_message(error_out, "apple TLS: invalid client");
return 0;
}
if (!atomic_load(&client->ready_done)) {
dispatch_time_t timeout = DISPATCH_TIME_FOREVER;
if (timeout_msec >= 0) {
timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC);
}
long wait_result = dispatch_semaphore_wait(ready_semaphore, timeout);
if (wait_result != 0) {
return -2;
}
}
if (atomic_load(&client->ready)) {
return 1;
}
if (client->ready_error != NULL) {
if (error_out != NULL) {
*error_out = client->ready_error;
client->ready_error = NULL;
} else {
free(client->ready_error);
client->ready_error = NULL;
}
} else {
box_set_error_message(error_out, "apple TLS: handshake failed");
}
return 0;
}
void box_apple_tls_client_cancel(box_apple_tls_client_t *client) {
if (client == NULL) {
return;
}
nw_connection_t connection = box_apple_tls_connection(client);
if (connection != nil) {
nw_connection_cancel(connection);
}
}
void box_apple_tls_client_free(box_apple_tls_client_t *client) {
if (client == NULL) {
return;
}
nw_connection_t connection = box_apple_tls_connection(client);
if (connection != nil) {
nw_connection_cancel(connection);
}
box_apple_tls_client_release(client);
}
ssize_t box_apple_tls_client_read(box_apple_tls_client_t *client, void *buffer, size_t buffer_len, int timeout_msec, bool *eof_out, char **error_out) {
nw_connection_t connection = box_apple_tls_connection(client);
if (connection == nil) {
box_set_error_message(error_out, "apple TLS: invalid client");
return -1;
}
dispatch_semaphore_t read_semaphore = dispatch_semaphore_create(0);
__block NSData *content_data = nil;
__block bool read_eof = false;
__block char *local_error = NULL;
nw_connection_receive(connection, 1, (uint32_t)buffer_len, ^(dispatch_data_t content, nw_content_context_t context, bool is_complete, nw_error_t error) {
if (content != NULL) {
const void *mapped = NULL;
size_t mapped_len = 0;
dispatch_data_t mapped_data = dispatch_data_create_map(content, &mapped, &mapped_len);
if (mapped != NULL && mapped_len > 0) {
content_data = [NSData dataWithBytes:mapped length:mapped_len];
}
(void)mapped_data;
}
if (error != NULL && content_data.length == 0) {
box_set_error_from_nw_error(&local_error, error);
}
if (is_complete && (context == NULL || nw_content_context_get_is_final(context))) {
read_eof = true;
}
dispatch_semaphore_signal(read_semaphore);
});
dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER;
if (timeout_msec >= 0) {
wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC);
}
long wait_result = dispatch_semaphore_wait(read_semaphore, wait_deadline);
if (wait_result != 0) {
nw_connection_cancel(connection);
dispatch_semaphore_wait(read_semaphore, DISPATCH_TIME_FOREVER);
if (local_error != NULL) {
free(local_error);
local_error = NULL;
}
return -2;
}
if (local_error != NULL) {
if (error_out != NULL) {
*error_out = local_error;
} else {
free(local_error);
}
return -1;
}
if (eof_out != NULL) {
*eof_out = read_eof;
}
if (content_data == nil || content_data.length == 0) {
return 0;
}
memcpy(buffer, content_data.bytes, content_data.length);
return (ssize_t)content_data.length;
}
ssize_t box_apple_tls_client_write(box_apple_tls_client_t *client, const void *buffer, size_t buffer_len, int timeout_msec, char **error_out) {
nw_connection_t connection = box_apple_tls_connection(client);
if (connection == nil) {
box_set_error_message(error_out, "apple TLS: invalid client");
return -1;
}
if (buffer_len == 0) {
return 0;
}
void *content_copy = malloc(buffer_len);
dispatch_queue_t queue = box_apple_tls_client_queue(client);
if (content_copy == NULL) {
free(content_copy);
box_set_error_message(error_out, "apple TLS: out of memory");
return -1;
}
if (queue == nil) {
free(content_copy);
box_set_error_message(error_out, "apple TLS: invalid client");
return -1;
}
memcpy(content_copy, buffer, buffer_len);
dispatch_data_t content = dispatch_data_create(content_copy, buffer_len, queue, ^{
free(content_copy);
});
dispatch_semaphore_t write_semaphore = dispatch_semaphore_create(0);
__block char *local_error = NULL;
nw_connection_send(connection, content, NW_CONNECTION_DEFAULT_STREAM_CONTEXT, false, ^(nw_error_t error) {
if (error != NULL) {
box_set_error_from_nw_error(&local_error, error);
}
dispatch_semaphore_signal(write_semaphore);
});
dispatch_time_t wait_deadline = DISPATCH_TIME_FOREVER;
if (timeout_msec >= 0) {
wait_deadline = dispatch_time(DISPATCH_TIME_NOW, (int64_t)timeout_msec * NSEC_PER_MSEC);
}
long wait_result = dispatch_semaphore_wait(write_semaphore, wait_deadline);
if (wait_result != 0) {
nw_connection_cancel(connection);
dispatch_semaphore_wait(write_semaphore, DISPATCH_TIME_FOREVER);
if (local_error != NULL) {
free(local_error);
local_error = NULL;
}
return -2;
}
if (local_error != NULL) {
if (error_out != NULL) {
*error_out = local_error;
} else {
free(local_error);
}
return -1;
}
return (ssize_t)buffer_len;
}
bool box_apple_tls_client_copy_state(box_apple_tls_client_t *client, box_apple_tls_state_t *state, char **error_out) {
dispatch_queue_t queue = box_apple_tls_client_queue(client);
if (queue == nil || state == NULL) {
box_set_error_message(error_out, "apple TLS: invalid client");
return false;
}
memset(state, 0, sizeof(box_apple_tls_state_t));
__block bool copied = false;
__block char *local_error = NULL;
dispatch_sync(queue, ^{
if (!atomic_load(&client->ready)) {
box_set_error_message(&local_error, "apple TLS: metadata unavailable");
return;
}
if (!box_apple_tls_state_copy(&client->state, state)) {
box_set_error_message(&local_error, "apple TLS: out of memory");
return;
}
copied = true;
});
if (copied) {
return true;
}
if (local_error != NULL) {
if (error_out != NULL) {
*error_out = local_error;
} else {
free(local_error);
}
}
box_apple_tls_state_reset(state);
return false;
}
void box_apple_tls_state_free(box_apple_tls_state_t *state) {
box_apple_tls_state_reset(state);
}

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