Update On Wed Apr 15 21:30:54 CEST 2026

This commit is contained in:
github-action[bot]
2026-04-15 21:30:54 +02:00
parent 878c0ddfbf
commit f44b2dcb12
338 changed files with 23877 additions and 9252 deletions
+1
View File
@@ -1330,3 +1330,4 @@ Update On Sat Apr 11 20:57:57 CEST 2026
Update On Sun Apr 12 21:03:12 CEST 2026
Update On Mon Apr 13 21:25:44 CEST 2026
Update On Tue Apr 14 21:27:36 CEST 2026
Update On Wed Apr 15 21:30:45 CEST 2026
+10 -2
View File
@@ -453,10 +453,18 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) {
"host": pluginInfo.Get("obfs-host"),
}
} else if strings.Contains(pluginName, "v2ray-plugin") {
mode := pluginInfo.Get("mode")
if mode == "" {
mode = pluginInfo.Get("obfs")
}
host := pluginInfo.Get("host")
if host == "" {
host = pluginInfo.Get("obfs-host")
}
ss["plugin"] = "v2ray-plugin"
ss["plugin-opts"] = map[string]any{
"mode": pluginInfo.Get("mode"),
"host": pluginInfo.Get("host"),
"mode": mode,
"host": host,
"path": pluginInfo.Get("path"),
"tls": strings.Contains(plugin, "tls"),
}
@@ -13,6 +13,7 @@ import (
"strconv"
"sync"
"testing"
"time"
N "github.com/metacubex/mihomo/common/net"
"github.com/metacubex/mihomo/common/pool"
@@ -185,6 +186,11 @@ func NewHttpTestTunnel() *TestTunnel {
DialContext: func(context.Context, string, string) (net.Conn, error) {
return instance, nil
},
// from http.DefaultTransport
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// for our self-signed cert
TLSClientConfig: tlsClientConfig.Clone(),
// open http2
@@ -192,6 +198,7 @@ func NewHttpTestTunnel() *TestTunnel {
}
client := http.Client{
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
+3 -3
View File
@@ -63,7 +63,7 @@ jobs:
workspaces: 'backend'
save-if: ${{ github.event_name == 'push' }}
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
@@ -155,7 +155,7 @@ jobs:
workspaces: 'backend'
save-if: ${{ github.event_name == 'push' }}
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
@@ -247,7 +247,7 @@ jobs:
workspaces: 'backend'
save-if: ${{ github.event_name == 'push' }}
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
with:
node-version: '24'
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
@@ -63,7 +63,7 @@ jobs:
with:
node-version: '24'
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
with:
node-version: 24
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
with:
deno-version: v2.x
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
@@ -94,7 +94,7 @@ jobs:
with:
node-version: 24
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
with:
node-version: 24
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
node-version: '24'
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 24
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
+2 -2
View File
@@ -7530,9 +7530,9 @@ checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
[[package]]
name = "redb"
version = "3.1.3"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a"
checksum = "67f7f231ea7b1172b7ac00ccf96b1250f0fb5a16d5585836aa4ebc997df7cbde"
dependencies = [
"libc",
]
+1 -1
View File
@@ -144,7 +144,7 @@ notify-debouncer-full = "0.7.0"
notify = "8.0.0"
# Database
redb = "3.0.0"
redb = "4.0.0"
# Logging & Tracing
log = "0.4.20"
File diff suppressed because one or more lines are too long
@@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
},
{
"description": "An empty permission you can use to modify the global scope.",
"description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string",
"const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope."
"markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
},
{
"description": "This scope permits access to all files and list content of top level directories in the application folders.",
@@ -2439,10 +2439,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -2486,12 +2486,24 @@
"const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string",
@@ -2558,12 +2570,24 @@
"const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string",
@@ -5703,10 +5727,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
},
{
"description": "An empty permission you can use to modify the global scope.",
"description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string",
"const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope."
"markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
},
{
"description": "This scope permits access to all files and list content of top level directories in the application folders.",
@@ -1400,10 +1400,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
},
{
"description": "An empty permission you can use to modify the global scope.",
"description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string",
"const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope."
"markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
},
{
"description": "This scope permits access to all files and list content of top level directories in the application folders.",
@@ -2439,10 +2439,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -2486,12 +2486,24 @@
"const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string",
@@ -2558,12 +2570,24 @@
"const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string",
@@ -5703,10 +5727,10 @@
"markdownDescription": "This enables all index or metadata related commands without any pre-configured accessible paths."
},
{
"description": "An empty permission you can use to modify the global scope.",
"description": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n",
"type": "string",
"const": "fs:scope",
"markdownDescription": "An empty permission you can use to modify the global scope."
"markdownDescription": "An empty permission you can use to modify the global scope.\n\n## Example\n\n```json\n{\n \"identifier\": \"read-documents\",\n \"windows\": [\"main\"],\n \"permissions\": [\n \"fs:allow-read\",\n {\n \"identifier\": \"fs:scope\",\n \"allow\": [\n \"$APPDATA/documents/**/*\"\n ],\n \"deny\": [\n \"$APPDATA/documents/secret.txt\"\n ]\n }\n ]\n}\n```\n"
},
{
"description": "This scope permits access to all files and list content of top level directories in the application folders.",
@@ -45,6 +45,59 @@ const createPersistedState = (key: string, defaultValue: boolean) => {
return { getStoredValue, setStoredValue }
}
const MAX_REASONABLE_MEMORY_BYTES = 16 * 1024 ** 4 // 16 TB
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
const parsed =
typeof value === 'number'
? value
: typeof value === 'string'
? Number(value)
: NaN
if (!Number.isFinite(parsed) || parsed < 0) {
return null
}
return parsed
}
const normalizeClashMemory = (raw: unknown): ClashMemory | null => {
if (typeof raw !== 'object' || raw === null) {
return null
}
const data = raw as Record<string, unknown>
let inuse = toNonNegativeFiniteNumber(data.inuse)
const oslimit = toNonNegativeFiniteNumber(data.oslimit) ?? 0
if (inuse === null) {
return null
}
// Keep memory values in bytes and normalize obvious unit mismatches.
if (oslimit > 0 && inuse > oslimit * 2) {
if (inuse / 8 <= oslimit * 2) {
inuse /= 8
}
while (inuse > oslimit * 2 && inuse % 1024 === 0) {
inuse /= 1024
}
if (inuse > oslimit * 2) {
inuse = oslimit
}
} else if (oslimit <= 0 && inuse > MAX_REASONABLE_MEMORY_BYTES) {
return null
}
return {
inuse: Math.trunc(inuse),
oslimit: Math.trunc(oslimit),
}
}
const ClashWSContext = createContext<{
recordLogs: boolean
setRecordLogs: (value: boolean) => void
@@ -142,7 +195,12 @@ export const ClashWSProvider = ({ children }: PropsWithChildren) => {
return
}
const data = JSON.parse(memoryWS.latestMessage?.data) as ClashMemory
const rawData = JSON.parse(memoryWS.latestMessage?.data ?? 'null')
const data = normalizeClashMemory(rawData)
if (!data) {
return
}
const currentData = queryClient.getQueryData([
CLASH_MEMORY_QUERY_KEY,
@@ -9,6 +9,14 @@
@tailwind utilities;
/* do not use layer */
:where(code, kbd, pre, samp) {
font-family: var(--font-mono);
font-feature-settings:
'liga' 0,
'calt' 0;
}
@theme {
--font-mono:
'Cascadia Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco,
@@ -36,6 +36,8 @@ import { ProfileDialog } from './profile-dialog'
import { GlobalUpdatePendingContext } from './provider'
import { ClashProfile } from './utils'
const clampPercentage = (value: number) => Math.min(100, Math.max(0, value))
export interface ProfileItemProps {
item: ProfileQueryResultItem
selected?: boolean
@@ -75,11 +77,13 @@ export const ProfileItem = memo(function ProfileItem({
if ('extra' in item && item.extra) {
const { download, upload, total: t } = item.extra
total = t
total = t ?? 0
used = download + upload
used = (download ?? 0) + (upload ?? 0)
progress = (used / (total || 1)) * 100
if (total > 0) {
progress = clampPercentage((used / total) * 100)
}
}
return { progress, total, used }
@@ -2,6 +2,8 @@ import parseTraffic from '@/utils/parse-traffic'
import { LinearProgress, Tooltip } from '@mui/material'
import { ProxiesProviderProps } from './proxies-provider'
const clampPercentage = (value: number) => Math.min(100, Math.max(0, value))
export const ProxiesProviderTraffic = ({ provider }: ProxiesProviderProps) => {
const calc = () => {
let progress = 0
@@ -15,7 +17,9 @@ export const ProxiesProviderTraffic = ({ provider }: ProxiesProviderProps) => {
used = (download ?? 0) + (upload ?? 0)
progress = (used / (total ?? 0)) * 100
if (total > 0) {
progress = clampPercentage((used / total) * 100)
}
}
return { progress, total, used }
@@ -0,0 +1,55 @@
import { ComponentProps } from 'react'
import useWindowMaximized from '@/hooks/use-window-maximized'
import { cn } from '@nyanpasu/ui'
import WindowControl from './window-control'
import WindowHeader from './window-header'
export function DefaultHeader({
className,
children,
...props
}: ComponentProps<'div'>) {
return (
<WindowHeader
className={cn('items-center justify-between px-3', className)}
data-slot="app-header"
{...props}
>
<div className="flex items-center gap-2" data-tauri-drag-region>
{children}
</div>
<WindowControl />
</WindowHeader>
)
}
export function MacOSHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<WindowHeader
className={cn('items-center justify-center px-3', className)}
data-slot="app-header-macos"
{...props}
/>
)
}
export function MacOSHeaderLeft({
className,
...props
}: ComponentProps<'div'>) {
const { isMaximized } = useWindowMaximized()
return (
<div
className={cn(
'absolute left-22 hidden items-center md:flex',
isMaximized ? 'left-2' : 'left-22',
className,
)}
data-slot="app-header-macos-left"
data-tauri-drag-region
{...props}
/>
)
}
@@ -1,7 +1,8 @@
import { ComponentProps } from 'react'
import { DefaultHeader, MacOSHeader } from '@/components/window/system-titlebar'
import WindowControl from '@/components/window/window-control'
import WindowHeader from '@/components/window/window-header'
import WindowTitle from '@/components/window/window-title'
import { isMacOS } from '@/consts'
const APP_NAME = 'Clash Nyanpasu - Editor'
@@ -20,20 +21,18 @@ const Title = () => {
}
export default function Header({
beforeClose,
}: {
className,
...props
}: ComponentProps<'div'> & {
beforeClose?: ComponentProps<typeof WindowControl>['beforeClose']
}) {
return (
<WindowHeader
className="shrink-0 items-center justify-between px-3"
data-slot="window-control"
>
<div className="flex items-center gap-2" data-tauri-drag-region>
<Title />
</div>
<WindowControl hiddenAlwaysOnTop beforeClose={beforeClose} />
</WindowHeader>
return isMacOS ? (
<MacOSHeader className={className} {...props}>
<Title />
</MacOSHeader>
) : (
<DefaultHeader className={className} {...props}>
<Title />
</DefaultHeader>
)
}
@@ -1,10 +1,11 @@
import { ComponentProps } from 'react'
import WindowControl from '@/components/window/window-control'
import WindowHeader from '@/components/window/window-header'
import {
DefaultHeader,
MacOSHeader,
MacOSHeaderLeft,
} from '@/components/window/system-titlebar'
import WindowTitle from '@/components/window/window-title'
import { isMacOS } from '@/consts'
import useWindowMaximized from '@/hooks/use-window-maximized'
import { cn } from '@nyanpasu/ui'
import HeaderMenu from './header-menu'
const APP_NAME = 'Clash Nyanpasu'
@@ -23,51 +24,19 @@ const Title = () => {
)
}
export function DefaultHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<WindowHeader
className={cn('items-center justify-between px-3', className)}
data-slot="app-header"
{...props}
>
<div className="flex items-center gap-2" data-tauri-drag-region>
<Title />
<HeaderMenu className="hidden md:flex" />
</div>
<WindowControl />
</WindowHeader>
)
}
export function MacOSHeader({ className, ...props }: ComponentProps<'div'>) {
const { isMaximized } = useWindowMaximized()
return (
<WindowHeader
className={cn('items-center justify-center px-3', className)}
data-slot="app-header"
{...props}
>
<div
className={cn(
'absolute left-22 hidden items-center md:flex',
isMaximized ? 'left-2' : 'left-22',
)}
data-tauri-drag-region
>
<HeaderMenu />
</div>
<Title />
</WindowHeader>
)
}
export default function Header({ className, ...props }: ComponentProps<'div'>) {
return isMacOS ? (
<MacOSHeader className={className} {...props} />
<MacOSHeader className={className} {...props}>
<MacOSHeaderLeft>
<HeaderMenu />
</MacOSHeaderLeft>
<Title />
</MacOSHeader>
) : (
<DefaultHeader className={className} {...props} />
<DefaultHeader className={className} {...props}>
<Title />
<HeaderMenu className="hidden md:flex" />
</DefaultHeader>
)
}
@@ -23,6 +23,8 @@ import {
} from '@nyanpasu/interface'
import UpdateOptionEditor from './update-option-editor'
const clampPercentage = (value: number) => Math.min(100, Math.max(0, value))
export const SubscriptionCard = ({ profile }: { profile: RemoteProfile }) => {
const { update } = useProfile()
@@ -38,11 +40,13 @@ export const SubscriptionCard = ({ profile }: { profile: RemoteProfile }) => {
) {
const { download, upload, total: t } = profile.extra
total = t
total = t ?? 0
used = download + upload
used = (download ?? 0) + (upload ?? 0)
progress = (used / (total || 1)) * 100
if (total > 0) {
progress = clampPercentage((used / total) * 100)
}
}
return { progress, total, used }
@@ -1,6 +1,8 @@
import { useMemo } from 'react'
import { ClashProviderProxies } from '@nyanpasu/interface'
const clampPercentage = (value: number) => Math.min(100, Math.max(0, value))
export const useProxiesSubscription = (data: ClashProviderProxies) => {
return useMemo(() => {
let progress = 0
@@ -25,7 +27,9 @@ export const useProxiesSubscription = (data: ClashProviderProxies) => {
used = download + upload
progress = (used / (total || 1)) * 100
if (total > 0) {
progress = clampPercentage((used / total) * 100)
}
}
return {
+3 -3
View File
@@ -2,10 +2,10 @@
"manifest_version": 1,
"latest": {
"mihomo": "v1.19.23",
"mihomo_alpha": "Not Found",
"mihomo_alpha": "alpha-839bb39",
"clash_rs": "v0.9.7",
"clash_premium": "2023-09-05-gdcc8d87",
"clash_rs_alpha": "0.9.7-alpha+sha.1b5afe6"
"clash_rs_alpha": "0.9.7-alpha+sha.dd693bf"
},
"arch_template": {
"mihomo": {
@@ -69,5 +69,5 @@
"linux-armv7hf": "clash-rs-armv7-unknown-linux-gnueabihf"
}
},
"updated_at": "2026-04-13T22:29:12.908Z"
"updated_at": "2026-04-14T22:29:27.706Z"
}
+6 -6
View File
@@ -612,8 +612,8 @@ importers:
specifier: 2.26.22
version: 2.26.22
undici:
specifier: 7.24.7
version: 7.24.7
specifier: 8.1.0
version: 8.1.0
yargs:
specifier: 18.0.0
version: 18.0.0
@@ -7761,9 +7761,9 @@ packages:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
undici@7.24.7:
resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==}
engines: {node: '>=20.18.1'}
undici@8.1.0:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
@@ -15612,7 +15612,7 @@ snapshots:
dependencies:
'@fastify/busboy': 2.1.1
undici@7.24.7: {}
undici@8.1.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
+1 -1
View File
@@ -24,7 +24,7 @@
"picocolors": "1.1.1",
"tar": "7.5.13",
"telegram": "2.26.22",
"undici": "7.24.7",
"undici": "8.1.0",
"yargs": "18.0.0"
}
}
+4 -7
View File
@@ -113,10 +113,7 @@ jobs:
rm -rf ./output/{clash,dat,mrs,nginx,srs,surge,text}
- name: Upload files to GitHub release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file_glob: true
file: ./output/*
release_name: ${{ env.RELEASE_NAME }}
tag: ${{ env.TAG_NAME }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create ${{ env.TAG_NAME }} --title "${{ env.RELEASE_NAME }}" ./output/*
@@ -0,0 +1,241 @@
From b61fcc3c7f95734e14741d8787fc9eb69b8437d4 Mon Sep 17 00:00:00 2001
From: Daniel Golle <daniel@makrotopia.org>
Date: Sat, 11 Apr 2026 22:47:15 +0100
Subject: [PATCH 1/2] PM / devfreq: mtk-cci: use devres for resource management
in probe
Convert mtk_ccifreq_probe() to use devm-managed resource cleanup
instead of manual goto-based error handling.
This pattern (devm_add_action_or_reset with a regulator disable
callback) is well-established in the kernel, used by drivers such as
ads7846, max6639 and others.
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
---
drivers/devfreq/mtk-cci-devfreq.c | 150 +++++++++++++++---------------
1 file changed, 73 insertions(+), 77 deletions(-)
--- a/drivers/devfreq/mtk-cci-devfreq.c
+++ b/drivers/devfreq/mtk-cci-devfreq.c
@@ -246,6 +246,21 @@ static struct devfreq_dev_profile mtk_cc
.target = mtk_ccifreq_target,
};
+static void mtk_ccifreq_regulator_disable(void *data)
+{
+ regulator_disable(data);
+}
+
+static void mtk_ccifreq_clk_disable_unprepare(void *data)
+{
+ clk_disable_unprepare(data);
+}
+
+static void mtk_ccifreq_opp_of_remove_table(void *data)
+{
+ dev_pm_opp_of_remove_table(data);
+}
+
static int mtk_ccifreq_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
@@ -266,44 +281,47 @@ static int mtk_ccifreq_probe(struct plat
platform_set_drvdata(pdev, drv);
drv->cci_clk = devm_clk_get(dev, "cci");
- if (IS_ERR(drv->cci_clk)) {
- ret = PTR_ERR(drv->cci_clk);
- return dev_err_probe(dev, ret, "failed to get cci clk\n");
- }
+ if (IS_ERR(drv->cci_clk))
+ return dev_err_probe(dev, PTR_ERR(drv->cci_clk),
+ "failed to get cci clk\n");
drv->inter_clk = devm_clk_get(dev, "intermediate");
- if (IS_ERR(drv->inter_clk)) {
- ret = PTR_ERR(drv->inter_clk);
- return dev_err_probe(dev, ret,
+ if (IS_ERR(drv->inter_clk))
+ return dev_err_probe(dev, PTR_ERR(drv->inter_clk),
"failed to get intermediate clk\n");
- }
drv->proc_reg = devm_regulator_get_optional(dev, "proc");
- if (IS_ERR(drv->proc_reg)) {
- ret = PTR_ERR(drv->proc_reg);
- return dev_err_probe(dev, ret,
+ if (IS_ERR(drv->proc_reg))
+ return dev_err_probe(dev, PTR_ERR(drv->proc_reg),
"failed to get proc regulator\n");
- }
ret = regulator_enable(drv->proc_reg);
- if (ret) {
- dev_err(dev, "failed to enable proc regulator\n");
+ if (ret)
+ return dev_err_probe(dev, ret,
+ "failed to enable proc regulator\n");
+
+ ret = devm_add_action_or_reset(dev, mtk_ccifreq_regulator_disable,
+ drv->proc_reg);
+ if (ret)
return ret;
- }
drv->sram_reg = devm_regulator_get_optional(dev, "sram");
if (IS_ERR(drv->sram_reg)) {
- ret = PTR_ERR(drv->sram_reg);
- if (ret == -EPROBE_DEFER)
- goto out_free_resources;
+ if (PTR_ERR(drv->sram_reg) == -EPROBE_DEFER)
+ return -EPROBE_DEFER;
drv->sram_reg = NULL;
} else {
ret = regulator_enable(drv->sram_reg);
- if (ret) {
- dev_err(dev, "failed to enable sram regulator\n");
- goto out_free_resources;
- }
+ if (ret)
+ return dev_err_probe(dev, ret,
+ "failed to enable sram regulator\n");
+
+ ret = devm_add_action_or_reset(dev,
+ mtk_ccifreq_regulator_disable,
+ drv->sram_reg);
+ if (ret)
+ return ret;
}
/*
@@ -317,31 +335,36 @@ static int mtk_ccifreq_probe(struct plat
ret = clk_prepare_enable(drv->cci_clk);
if (ret)
- goto out_free_resources;
+ return ret;
+
+ ret = devm_add_action_or_reset(dev, mtk_ccifreq_clk_disable_unprepare,
+ drv->cci_clk);
+ if (ret)
+ return ret;
ret = dev_pm_opp_of_add_table(dev);
- if (ret) {
- dev_err(dev, "failed to add opp table: %d\n", ret);
- goto out_disable_cci_clk;
- }
+ if (ret)
+ return dev_err_probe(dev, ret, "failed to add opp table\n");
+
+ ret = devm_add_action_or_reset(dev, mtk_ccifreq_opp_of_remove_table,
+ dev);
+ if (ret)
+ return ret;
rate = clk_get_rate(drv->inter_clk);
opp = dev_pm_opp_find_freq_ceil(dev, &rate);
- if (IS_ERR(opp)) {
- ret = PTR_ERR(opp);
- dev_err(dev, "failed to get intermediate opp: %d\n", ret);
- goto out_remove_opp_table;
- }
+ if (IS_ERR(opp))
+ return dev_err_probe(dev, PTR_ERR(opp),
+ "failed to get intermediate opp\n");
+
drv->inter_voltage = dev_pm_opp_get_voltage(opp);
dev_pm_opp_put(opp);
rate = U32_MAX;
opp = dev_pm_opp_find_freq_floor(drv->dev, &rate);
- if (IS_ERR(opp)) {
- dev_err(dev, "failed to get opp\n");
- ret = PTR_ERR(opp);
- goto out_remove_opp_table;
- }
+ if (IS_ERR(opp))
+ return dev_err_probe(dev, PTR_ERR(opp),
+ "failed to get opp\n");
opp_volt = dev_pm_opp_get_voltage(opp);
dev_pm_opp_put(opp);
@@ -349,63 +372,36 @@ static int mtk_ccifreq_probe(struct plat
if (ret) {
dev_err(dev, "failed to scale to highest voltage %lu in proc_reg\n",
opp_volt);
- goto out_remove_opp_table;
+ return ret;
}
passive_data = devm_kzalloc(dev, sizeof(*passive_data), GFP_KERNEL);
- if (!passive_data) {
- ret = -ENOMEM;
- goto out_remove_opp_table;
- }
+ if (!passive_data)
+ return -ENOMEM;
passive_data->parent_type = CPUFREQ_PARENT_DEV;
drv->devfreq = devm_devfreq_add_device(dev, &mtk_ccifreq_profile,
DEVFREQ_GOV_PASSIVE,
passive_data);
- if (IS_ERR(drv->devfreq)) {
- ret = -EPROBE_DEFER;
- dev_err(dev, "failed to add devfreq device: %ld\n",
- PTR_ERR(drv->devfreq));
- goto out_remove_opp_table;
- }
+ if (IS_ERR(drv->devfreq))
+ return dev_err_probe(dev, -EPROBE_DEFER,
+ "failed to add devfreq device: %ld\n",
+ PTR_ERR(drv->devfreq));
drv->opp_nb.notifier_call = mtk_ccifreq_opp_notifier;
ret = dev_pm_opp_register_notifier(dev, &drv->opp_nb);
- if (ret) {
- dev_err(dev, "failed to register opp notifier: %d\n", ret);
- goto out_remove_opp_table;
- }
- return 0;
-
-out_remove_opp_table:
- dev_pm_opp_of_remove_table(dev);
-
-out_disable_cci_clk:
- clk_disable_unprepare(drv->cci_clk);
-
-out_free_resources:
- if (regulator_is_enabled(drv->proc_reg))
- regulator_disable(drv->proc_reg);
- if (!IS_ERR_OR_NULL(drv->sram_reg) &&
- regulator_is_enabled(drv->sram_reg))
- regulator_disable(drv->sram_reg);
+ if (ret)
+ return dev_err_probe(dev, ret,
+ "failed to register opp notifier\n");
- return ret;
+ return 0;
}
static void mtk_ccifreq_remove(struct platform_device *pdev)
{
- struct device *dev = &pdev->dev;
- struct mtk_ccifreq_drv *drv;
-
- drv = platform_get_drvdata(pdev);
+ struct mtk_ccifreq_drv *drv = platform_get_drvdata(pdev);
- dev_pm_opp_unregister_notifier(dev, &drv->opp_nb);
- dev_pm_opp_of_remove_table(dev);
- clk_disable_unprepare(drv->cci_clk);
- regulator_disable(drv->proc_reg);
- if (drv->sram_reg)
- regulator_disable(drv->sram_reg);
+ dev_pm_opp_unregister_notifier(&pdev->dev, &drv->opp_nb);
}
static const struct mtk_ccifreq_platform_data mt8183_platform_data = {
@@ -0,0 +1,53 @@
From b19968e432fe2ebe3af4bc9190923edb70b6aeee Mon Sep 17 00:00:00 2001
From: Daniel Golle <daniel@makrotopia.org>
Date: Mon, 13 Apr 2026 15:22:05 +0100
Subject: [PATCH 2/2] PM / devfreq: mtk-cci: check cpufreq availability early
Check spufreq availablility early at probe so -EPROBE_DEFER is emitted
before touching any regulators.
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
---
drivers/devfreq/mtk-cci-devfreq.c | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
--- a/drivers/devfreq/mtk-cci-devfreq.c
+++ b/drivers/devfreq/mtk-cci-devfreq.c
@@ -4,6 +4,7 @@
*/
#include <linux/clk.h>
+#include <linux/cpufreq.h>
#include <linux/devfreq.h>
#include <linux/minmax.h>
#include <linux/module.h>
@@ -263,13 +264,28 @@ static void mtk_ccifreq_opp_of_remove_ta
static int mtk_ccifreq_probe(struct platform_device *pdev)
{
+ struct devfreq_passive_data *passive_data;
struct device *dev = &pdev->dev;
+ struct cpufreq_policy *policy;
struct mtk_ccifreq_drv *drv;
- struct devfreq_passive_data *passive_data;
struct dev_pm_opp *opp;
unsigned long rate, opp_volt;
int ret;
+ /*
+ * Check if cpufreq is available before touching any regulators.
+ * The passive devfreq governor needs cpufreq as its parent and
+ * will return -EPROBE_DEFER if it is not yet registered. If we
+ * enable the proc regulator (CPU core power) before this check,
+ * the subsequent probe failure causes devres to disable that
+ * regulator, potentially cutting CPU core voltage and hanging
+ * the system.
+ */
+ policy = cpufreq_cpu_get(0);
+ if (!policy)
+ return -EPROBE_DEFER;
+ cpufreq_cpu_put(policy);
+
drv = devm_kzalloc(dev, sizeof(*drv), GFP_KERNEL);
if (!drv)
return -ENOMEM;
+10 -2
View File
@@ -453,10 +453,18 @@ func ConvertsV2Ray(buf []byte) ([]map[string]any, error) {
"host": pluginInfo.Get("obfs-host"),
}
} else if strings.Contains(pluginName, "v2ray-plugin") {
mode := pluginInfo.Get("mode")
if mode == "" {
mode = pluginInfo.Get("obfs")
}
host := pluginInfo.Get("host")
if host == "" {
host = pluginInfo.Get("obfs-host")
}
ss["plugin"] = "v2ray-plugin"
ss["plugin-opts"] = map[string]any{
"mode": pluginInfo.Get("mode"),
"host": pluginInfo.Get("host"),
"mode": mode,
"host": host,
"path": pluginInfo.Get("path"),
"tls": strings.Contains(plugin, "tls"),
}
+7
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"sync"
"testing"
"time"
N "github.com/metacubex/mihomo/common/net"
"github.com/metacubex/mihomo/common/pool"
@@ -185,6 +186,11 @@ func NewHttpTestTunnel() *TestTunnel {
DialContext: func(context.Context, string, string) (net.Conn, error) {
return instance, nil
},
// from http.DefaultTransport
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// for our self-signed cert
TLSClientConfig: tlsClientConfig.Clone(),
// open http2
@@ -192,6 +198,7 @@ func NewHttpTestTunnel() *TestTunnel {
}
client := http.Client{
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
+2 -2
View File
@@ -8,12 +8,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ddns-go
PKG_VERSION:=6.16.6
PKG_VERSION:=6.16.7
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/jeessy2/ddns-go/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=60505ad420882c5f64b45d449e8cd9f37e5cc4189558609b0f14aeca71ab0bda
PKG_HASH:=3f30d2aba480b20605951b6bd7e21dd059a2b9804f270ba448aefaa63e4e4158
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
+1 -1
View File
@@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.4.6
PKG_VERSION:=26.4.15
PKG_RELEASE:=1
PKG_PO_VERSION:=$(PKG_VERSION)
@@ -533,12 +533,12 @@ o = s:taboption("DNS", Flag, "dns_redirect", translate("DNS Redirect"), translat
o.default = "1"
o.rmempty = false
local use_nft = m:get("@global_forwarding[0]", "use_nft") == "1"
local set_title = api.i18n.translate(use_nft and "Clear NFTSET on Reboot" or "Clear IPSET on Reboot")
local prefer_nft = m:get("@global_forwarding[0]", "prefer_nft") == "1"
local set_title = api.i18n.translate(prefer_nft and "Clear NFTSET on Reboot" or "Clear IPSET on Reboot")
o = s:taboption("DNS", Flag, "flush_set_on_reboot", set_title, translate("Clear IPSET/NFTSET on service reboot. This may increase reboot time."))
o.default = "0"
set_title = api.i18n.translate(use_nft and "Clear NFTSET" or "Clear IPSET")
set_title = api.i18n.translate(prefer_nft and "Clear NFTSET" or "Clear IPSET")
o = s:taboption("DNS", DummyValue, "clear_ipset", set_title, translate("Try this feature if the rule modification does not take effect."))
o.rawhtml = true
function o.cfgvalue(self, section)
@@ -513,33 +513,6 @@ o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_domain"), translate("Camouflage Domain"), translate("Use it together with the DNS disguised type. You can fill in any domain."))
o:depends({ [_n("mkcp_guise")] = "dns" })
o = s:option(Value, _n("mkcp_mtu"), translate("KCP MTU"))
o.default = "1350"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_tti"), translate("KCP TTI"))
o.default = "20"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_uplinkCapacity"), translate("KCP uplinkCapacity"))
o.default = "5"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_downlinkCapacity"), translate("KCP downlinkCapacity"))
o.default = "20"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Flag, _n("mkcp_congestion"), translate("KCP Congestion"))
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_readBufferSize"), translate("KCP readBufferSize"))
o.default = "1"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_writeBufferSize"), translate("KCP writeBufferSize"))
o.default = "1"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_seed"), translate("KCP Seed"))
o:depends({ [_n("transport")] = "mkcp" })
@@ -652,6 +625,14 @@ end
-- [[ User-Agent ]]--
o = s:option(Value, _n("user_agent"), translate("User-Agent"))
o.default = ""
o:value("", translate("default"))
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36", "chrome")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "firefox")
o:value("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", "safari")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70", "edge")
o:value("Go-http-client/1.1", "golang")
o:value("curl/7.68.0", "curl")
o:depends({ [_n("tcp_guise")] = "http" })
o:depends({ [_n("transport")] = "ws" })
o:depends({ [_n("transport")] = "httpupgrade" })
@@ -639,6 +639,14 @@ o:depends({ [_n("grpc_health_check")] = true })
-- [[ User-Agent ]]--
o = s:option(Value, _n("user_agent"), translate("User-Agent"))
o.default = ""
o:value("", translate("default"))
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36", "chrome")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "firefox")
o:value("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", "safari")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70", "edge")
o:value("Go-http-client/1.1", "golang")
o:value("curl/7.68.0", "curl")
o:depends({ [_n("tcp_guise")] = "http" })
o:depends({ [_n("transport")] = "http" })
o:depends({ [_n("transport")] = "ws" })
@@ -352,33 +352,6 @@ o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_domain"), translate("Camouflage Domain"), translate("Use it together with the DNS disguised type. You can fill in any domain."))
o:depends({ [_n("mkcp_guise")] = "dns" })
o = s:option(Value, _n("mkcp_mtu"), translate("KCP MTU"))
o.default = "1350"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_tti"), translate("KCP TTI"))
o.default = "20"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_uplinkCapacity"), translate("KCP uplinkCapacity"))
o.default = "5"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_downlinkCapacity"), translate("KCP downlinkCapacity"))
o.default = "20"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Flag, _n("mkcp_congestion"), translate("KCP Congestion"))
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_readBufferSize"), translate("KCP readBufferSize"))
o.default = "1"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_writeBufferSize"), translate("KCP writeBufferSize"))
o.default = "1"
o:depends({ [_n("transport")] = "mkcp" })
o = s:option(Value, _n("mkcp_seed"), translate("KCP Seed"))
o:depends({ [_n("transport")] = "mkcp" })
@@ -193,13 +193,12 @@ function gen_outbound(flag, node, tag, proxy_table)
}
} or nil,
kcpSettings = (node.transport == "mkcp") and {
mtu = tonumber(node.mkcp_mtu),
tti = tonumber(node.mkcp_tti),
uplinkCapacity = tonumber(node.mkcp_uplinkCapacity),
downlinkCapacity = tonumber(node.mkcp_downlinkCapacity),
congestion = (node.mkcp_congestion == "1") and true or false,
readBufferSize = tonumber(node.mkcp_readBufferSize),
writeBufferSize = tonumber(node.mkcp_writeBufferSize)
mtu = 1350,
tti = 50,
uplinkCapacity = 12,
downlinkCapacity = 100,
CwndMultiplier = 1,
MaxSendingWindow = 2 * 1024 * 1024
} or nil,
wsSettings = (node.transport == "ws") and {
path = node.ws_path or "/",
@@ -655,13 +654,12 @@ function gen_config_server(node)
}
} or nil,
kcpSettings = (node.transport == "mkcp") and {
mtu = tonumber(node.mkcp_mtu),
tti = tonumber(node.mkcp_tti),
uplinkCapacity = tonumber(node.mkcp_uplinkCapacity),
downlinkCapacity = tonumber(node.mkcp_downlinkCapacity),
congestion = (node.mkcp_congestion == "1") and true or false,
readBufferSize = tonumber(node.mkcp_readBufferSize),
writeBufferSize = tonumber(node.mkcp_writeBufferSize)
mtu = 1350,
tti = 50,
uplinkCapacity = 12,
downlinkCapacity = 100,
CwndMultiplier = 1,
MaxSendingWindow = 2 * 1024 * 1024
} or nil,
wsSettings = (node.transport == "ws") and {
host = node.ws_host or nil,
@@ -581,12 +581,6 @@ local function processData(szType, content, add_mode, group)
if info.net == 'kcp' or info.net == 'mkcp' then
info.net = "mkcp"
result.mkcp_guise = info.type
result.mkcp_mtu = 1350
result.mkcp_tti = 50
result.mkcp_uplinkCapacity = 5
result.mkcp_downlinkCapacity = 20
result.mkcp_readBufferSize = 2
result.mkcp_writeBufferSize = 2
result.mkcp_seed = info.seed
end
if info.net == 'quic' then
@@ -839,12 +833,6 @@ local function processData(szType, content, add_mode, group)
if params.type == 'kcp' or params.type == 'mkcp' then
result.transport = "mkcp"
result.mkcp_guise = params.headerType or "none"
result.mkcp_mtu = 1350
result.mkcp_tti = 50
result.mkcp_uplinkCapacity = 5
result.mkcp_downlinkCapacity = 20
result.mkcp_readBufferSize = 2
result.mkcp_writeBufferSize = 2
result.mkcp_seed = params.seed
end
if params.type == 'quic' then
@@ -1057,12 +1045,6 @@ local function processData(szType, content, add_mode, group)
if params.type == 'kcp' or params.type == 'mkcp' then
result.transport = "mkcp"
result.mkcp_guise = params.headerType or "none"
result.mkcp_mtu = 1350
result.mkcp_tti = 50
result.mkcp_uplinkCapacity = 5
result.mkcp_downlinkCapacity = 20
result.mkcp_readBufferSize = 2
result.mkcp_writeBufferSize = 2
result.mkcp_seed = params.seed
end
if params.type == 'quic' then
@@ -1205,12 +1187,6 @@ local function processData(szType, content, add_mode, group)
if params.type == 'kcp' or params.type == 'mkcp' then
result.transport = "mkcp"
result.mkcp_guise = params.headerType or "none"
result.mkcp_mtu = 1350
result.mkcp_tti = 50
result.mkcp_uplinkCapacity = 5
result.mkcp_downlinkCapacity = 20
result.mkcp_readBufferSize = 2
result.mkcp_writeBufferSize = 2
result.mkcp_seed = params.seed
end
if params.type == 'quic' then
+12 -12
View File
@@ -140,7 +140,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -151,7 +151,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -883,7 +883,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -996,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2245,7 +2245,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2620,7 +2620,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2897,7 +2897,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2956,7 +2956,7 @@ dependencies = [
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3457,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3603,7 +3603,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3950,9 +3950,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tun"
version = "0.8.6"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87dce40a7bfb165d8eb8e96f7463230f3d91b56aae21e0f1e56db60161962e1a"
checksum = "0ebb3e56bb60c1e6650c9317997862ab05864c358add3cfaa34b855ffae583d0"
dependencies = [
"bytes",
"cfg-if",
+55
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" ./...
-1
View File
@@ -19,7 +19,6 @@ linters:
enable:
- govet
- ineffassign
- paralleltest
- staticcheck
settings:
staticcheck:
+1 -1
View File
@@ -52,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
+43
View File
@@ -0,0 +1,43 @@
package adapter
import (
"context"
"net/http"
"sync"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/logger"
)
type HTTPTransport interface {
http.RoundTripper
CloseIdleConnections()
Reset()
}
type HTTPClientManager interface {
ResolveTransport(ctx context.Context, logger logger.ContextLogger, options option.HTTPClientOptions) (HTTPTransport, error)
DefaultTransport() HTTPTransport
ResetNetwork()
}
type HTTPStartContext struct {
access sync.Mutex
transports []HTTPTransport
}
func NewHTTPStartContext() *HTTPStartContext {
return &HTTPStartContext{}
}
func (c *HTTPStartContext) Register(transport HTTPTransport) {
c.access.Lock()
defer c.access.Unlock()
c.transports = append(c.transports, transport)
}
func (c *HTTPStartContext) Close() {
for _, transport := range c.transports {
transport.CloseIdleConnections()
}
}
-49
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"
@@ -77,46 +71,3 @@ type RuleSetMetadata struct {
ContainsIPCIDRRule bool
ContainsDNSQueryTypeRule 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()
}
}
+32 -3
View File
@@ -16,12 +16,14 @@ import (
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/certificate"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/httpclient"
"github.com/sagernet/sing-box/common/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/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"
@@ -50,6 +52,7 @@ type Box struct {
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
httpClientService adapter.LifecycleService
internalService []adapter.LifecycleService
done chan struct{}
}
@@ -169,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 ||
@@ -181,8 +185,6 @@ 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)
@@ -209,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)
@@ -368,6 +374,12 @@ func New(options Options) (*Box, error) {
&option.LocalDNSServerOptions{},
)
})
httpClientManager.Initialize(func() (*httpclient.ManagedTransport, error) {
deprecated.Report(ctx, deprecated.OptionImplicitDefaultHTTPClient)
var httpClientOptions option.HTTPClientOptions
httpClientOptions.DefaultOutbound = true
return httpclient.NewTransport(ctx, logFactory.NewLogger("httpclient"), "", httpClientOptions)
})
if platformInterface != nil {
err = platformInterface.Initialize(networkManager)
if err != nil {
@@ -428,6 +440,7 @@ func New(options Options) (*Box, error) {
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
httpClientService: httpClientService,
createdAt: createdAt,
logFactory: logFactory,
logger: logFactory.Logger(),
@@ -490,7 +503,15 @@ func (s *Box) preStart() error {
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.network, s.connection, s.router, s.dnsRouter)
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
}
@@ -567,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()
@@ -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, ","))
@@ -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)
}
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
+25 -2
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
}
+19 -5
View File
@@ -19,6 +19,7 @@ type DirectDialer interface {
type DetourDialer struct {
outboundManager adapter.OutboundManager
detour string
defaultOutbound bool
legacyDNSDialer bool
dialer N.Dialer
initOnce sync.Once
@@ -33,6 +34,13 @@ func NewDetour(outboundManager adapter.OutboundManager, detour string, legacyDNS
}
}
func NewDefaultOutboundDetour(outboundManager adapter.OutboundManager) N.Dialer {
return &DetourDialer{
outboundManager: outboundManager,
defaultOutbound: true,
}
}
func InitializeDetour(dialer N.Dialer) error {
detourDialer, isDetour := common.Cast[*DetourDialer](dialer)
if !isDetour {
@@ -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.legacyDNSDialer {
if directDialer, isDirect := dialer.(DirectDialer); isDirect {
if directDialer.IsEmpty() {
d.initErr = E.New("detour to an empty direct outbound makes no sense")
+9 -1
View File
@@ -25,6 +25,7 @@ type Options struct {
NewDialer bool
LegacyDNSDialer 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)
} 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
@@ -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))
}
@@ -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
);
@@ -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);
}
@@ -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)
}
}
@@ -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")
}
+130
View File
@@ -0,0 +1,130 @@
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,
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
}
+14
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
}
+86
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()
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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")
}
@@ -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()
}
+175
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
}
+115
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)
}
+221
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())
}
}
@@ -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))
}
}
@@ -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);
@@ -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);
}
@@ -0,0 +1,453 @@
//go:build darwin && cgo
package tls
import (
"context"
stdtls "crypto/tls"
"errors"
"net"
"os"
"testing"
"time"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/json/badoption"
"github.com/sagernet/sing/common/logger"
)
const appleTLSTestTimeout = 5 * time.Second
const (
appleTLSSuccessHandshakeLoops = 20
appleTLSFailureRecoveryLoops = 10
)
type appleTLSServerResult struct {
state stdtls.ConnectionState
err error
}
func TestAppleClientHandshakeAppliesALPNAndVersion(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
for index := 0; index < appleTLSSuccessHandshakeLoops; index++ {
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
MaxVersion: stdtls.VersionTLS12,
NextProtos: []string{"h2"},
})
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.2",
ALPN: badoption.Listable[string]{"h2"},
Certificate: badoption.Listable[string]{serverCertificatePEM},
})
if err != nil {
t.Fatalf("iteration %d: %v", index, err)
}
clientState := clientConn.ConnectionState()
if clientState.Version != stdtls.VersionTLS12 {
_ = clientConn.Close()
t.Fatalf("iteration %d: unexpected negotiated version: %x", index, clientState.Version)
}
if clientState.NegotiatedProtocol != "h2" {
_ = clientConn.Close()
t.Fatalf("iteration %d: unexpected negotiated protocol: %q", index, clientState.NegotiatedProtocol)
}
_ = clientConn.Close()
result := <-serverResult
if result.err != nil {
t.Fatalf("iteration %d: %v", index, result.err)
}
if result.state.Version != stdtls.VersionTLS12 {
t.Fatalf("iteration %d: server negotiated unexpected version: %x", index, result.state.Version)
}
if result.state.NegotiatedProtocol != "h2" {
t.Fatalf("iteration %d: server negotiated unexpected protocol: %q", index, result.state.NegotiatedProtocol)
}
}
}
func TestAppleClientHandshakeRejectsVersionMismatch(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS13,
MaxVersion: stdtls.VersionTLS13,
})
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MaxVersion: "1.2",
Certificate: badoption.Listable[string]{serverCertificatePEM},
})
if err == nil {
clientConn.Close()
t.Fatal("expected version mismatch handshake to fail")
}
if result := <-serverResult; result.err == nil {
t.Fatal("expected server handshake to fail on version mismatch")
}
}
func TestAppleClientHandshakeRejectsServerNameMismatch(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
serverResult, serverAddress := startAppleTLSTestServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
})
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "example.com",
Certificate: badoption.Listable[string]{serverCertificatePEM},
})
if err == nil {
clientConn.Close()
t.Fatal("expected server name mismatch handshake to fail")
}
if result := <-serverResult; result.err == nil {
t.Fatal("expected server handshake to fail on server name mismatch")
}
}
func TestAppleClientHandshakeRecoversAfterFailure(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
testCases := []struct {
name string
serverConfig *stdtls.Config
clientOptions option.OutboundTLSOptions
}{
{
name: "version mismatch",
serverConfig: &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS13,
MaxVersion: stdtls.VersionTLS13,
},
clientOptions: option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MaxVersion: "1.2",
Certificate: badoption.Listable[string]{serverCertificatePEM},
},
},
{
name: "server name mismatch",
serverConfig: &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
},
clientOptions: option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "example.com",
Certificate: badoption.Listable[string]{serverCertificatePEM},
},
},
}
successClientOptions := option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.2",
ALPN: badoption.Listable[string]{"h2"},
Certificate: badoption.Listable[string]{serverCertificatePEM},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for index := 0; index < appleTLSFailureRecoveryLoops; index++ {
failedResult, failedAddress := startAppleTLSTestServer(t, testCase.serverConfig)
failedConn, err := newAppleTestClientConn(t, failedAddress, testCase.clientOptions)
if err == nil {
_ = failedConn.Close()
t.Fatalf("iteration %d: expected handshake failure", index)
}
if result := <-failedResult; result.err == nil {
t.Fatalf("iteration %d: expected server handshake failure", index)
}
successResult, successAddress := startAppleTLSTestServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
MaxVersion: stdtls.VersionTLS12,
NextProtos: []string{"h2"},
})
successConn, err := newAppleTestClientConn(t, successAddress, successClientOptions)
if err != nil {
t.Fatalf("iteration %d: follow-up handshake failed: %v", index, err)
}
clientState := successConn.ConnectionState()
if clientState.NegotiatedProtocol != "h2" {
_ = successConn.Close()
t.Fatalf("iteration %d: unexpected negotiated protocol after failure: %q", index, clientState.NegotiatedProtocol)
}
_ = successConn.Close()
result := <-successResult
if result.err != nil {
t.Fatalf("iteration %d: follow-up server handshake failed: %v", index, result.err)
}
if result.state.NegotiatedProtocol != "h2" {
t.Fatalf("iteration %d: follow-up server negotiated unexpected protocol: %q", index, result.state.NegotiatedProtocol)
}
}
})
}
}
func TestAppleClientReadDeadline(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
serverDone, serverAddress := startAppleTLSSilentServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
MaxVersion: stdtls.VersionTLS12,
})
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.2",
Certificate: badoption.Listable[string]{serverCertificatePEM},
})
if err != nil {
t.Fatal(err)
}
defer clientConn.Close()
defer close(serverDone)
err = clientConn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
readDone := make(chan error, 1)
buffer := make([]byte, 64)
go func() {
_, readErr := clientConn.Read(buffer)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if !errors.Is(readErr, os.ErrDeadlineExceeded) {
t.Fatalf("expected os.ErrDeadlineExceeded, got %v", readErr)
}
case <-time.After(2 * time.Second):
t.Fatal("Read did not return within 2s after deadline")
}
_, err = clientConn.Read(buffer)
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("sticky deadline: expected os.ErrDeadlineExceeded, got %v", err)
}
}
func TestAppleClientSetDeadlineClearsPreExpiredSticky(t *testing.T) {
serverCertificate, serverCertificatePEM := newAppleTestCertificate(t, "localhost")
serverDone, serverAddress := startAppleTLSSilentServer(t, &stdtls.Config{
Certificates: []stdtls.Certificate{serverCertificate},
MinVersion: stdtls.VersionTLS12,
MaxVersion: stdtls.VersionTLS12,
})
clientConn, err := newAppleTestClientConn(t, serverAddress, option.OutboundTLSOptions{
Enabled: true,
Engine: "apple",
ServerName: "localhost",
MinVersion: "1.2",
MaxVersion: "1.2",
Certificate: badoption.Listable[string]{serverCertificatePEM},
})
if err != nil {
t.Fatal(err)
}
defer clientConn.Close()
defer close(serverDone)
err = clientConn.SetReadDeadline(time.Now().Add(-time.Second))
if err != nil {
t.Fatalf("SetReadDeadline past: %v", err)
}
// Pre-expired deadline trips sticky flag without cancelling nw_connection
// (prepareReadTimeout short-circuits before the C read is issued).
buffer := make([]byte, 64)
_, err = clientConn.Read(buffer)
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("pre-expired: expected os.ErrDeadlineExceeded, got %v", err)
}
err = clientConn.SetReadDeadline(time.Time{})
if err != nil {
t.Fatalf("SetReadDeadline zero: %v", err)
}
newDeadline := 300 * time.Millisecond
err = clientConn.SetReadDeadline(time.Now().Add(newDeadline))
if err != nil {
t.Fatalf("SetReadDeadline future: %v", err)
}
readStart := time.Now()
_, err = clientConn.Read(buffer)
readElapsed := time.Since(readStart)
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("after clear: expected os.ErrDeadlineExceeded, got %v", err)
}
if readElapsed < newDeadline-50*time.Millisecond {
t.Fatalf("sticky flag was not cleared: Read returned after %v, expected ~%v", readElapsed, newDeadline)
}
}
func startAppleTLSSilentServer(t *testing.T, tlsConfig *stdtls.Config) (chan<- struct{}, string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
listener.Close()
})
if tcpListener, isTCP := listener.(*net.TCPListener); isTCP {
err = tcpListener.SetDeadline(time.Now().Add(appleTLSTestTimeout))
if err != nil {
t.Fatal(err)
}
}
done := make(chan struct{})
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
handshakeErr := conn.SetDeadline(time.Now().Add(appleTLSTestTimeout))
if handshakeErr != nil {
return
}
tlsConn := stdtls.Server(conn, tlsConfig)
defer tlsConn.Close()
handshakeErr = tlsConn.Handshake()
if handshakeErr != nil {
return
}
handshakeErr = conn.SetDeadline(time.Time{})
if handshakeErr != nil {
return
}
<-done
}()
return done, listener.Addr().String()
}
func newAppleTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) {
t.Helper()
privateKeyPEM, certificatePEM, err := 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 startAppleTLSTestServer(t *testing.T, tlsConfig *stdtls.Config) (<-chan appleTLSServerResult, string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
listener.Close()
})
if tcpListener, isTCP := listener.(*net.TCPListener); isTCP {
err = tcpListener.SetDeadline(time.Now().Add(appleTLSTestTimeout))
if err != nil {
t.Fatal(err)
}
}
result := make(chan appleTLSServerResult, 1)
go func() {
defer close(result)
conn, err := listener.Accept()
if err != nil {
result <- appleTLSServerResult{err: err}
return
}
defer conn.Close()
err = conn.SetDeadline(time.Now().Add(appleTLSTestTimeout))
if err != nil {
result <- appleTLSServerResult{err: err}
return
}
tlsConn := stdtls.Server(conn, tlsConfig)
defer tlsConn.Close()
err = tlsConn.Handshake()
if err != nil {
result <- appleTLSServerResult{err: err}
return
}
result <- appleTLSServerResult{state: tlsConn.ConnectionState()}
}()
return result, listener.Addr().String()
}
func newAppleTestClientConn(t *testing.T, serverAddress string, options option.OutboundTLSOptions) (Conn, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), appleTLSTestTimeout)
t.Cleanup(cancel)
clientConfig, err := NewClientWithOptions(ClientOptions{
Context: ctx,
Logger: logger.NOP(),
ServerAddress: "",
Options: options,
})
if err != nil {
return nil, err
}
conn, err := net.DialTimeout("tcp", serverAddress, appleTLSTestTimeout)
if err != nil {
return nil, err
}
tlsConn, err := ClientHandshake(ctx, conn, clientConfig)
if err != nil {
conn.Close()
return nil, err
}
return tlsConn, nil
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !darwin || !cgo
package tls
import (
"context"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
)
func newAppleClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
return nil, E.New("Apple TLS engine is not available on non-Apple platforms")
}
+53 -12
View File
@@ -8,14 +8,49 @@ import (
"os"
"github.com/sagernet/sing-box/common/badtls"
"github.com/sagernet/sing-box/common/tlsspoof"
C "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"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
)
var errMissingServerName = E.New("missing server_name or insecure=true")
func parseTLSSpoofOptions(serverName string, options option.OutboundTLSOptions) (string, tlsspoof.Method, error) {
if options.Spoof == "" {
if options.SpoofMethod != "" {
return "", 0, E.New("`spoof_method` requires `spoof`")
}
return "", 0, nil
}
if !tlsspoof.PlatformSupported {
return "", 0, E.New("`spoof` is not supported on this platform")
}
if options.DisableSNI || serverName == "" {
return "", 0, E.New("`spoof` requires TLS ClientHello with SNI")
}
method, err := tlsspoof.ParseMethod(options.SpoofMethod)
if err != nil {
return "", 0, err
}
return options.Spoof, method, nil
}
func applyTLSSpoof(conn net.Conn, spoof string, method tlsspoof.Method) (net.Conn, error) {
if spoof == "" {
return conn, nil
}
spoofer, err := tlsspoof.NewSpoofer(conn, method)
if err != nil {
return nil, err
}
return tlsspoof.NewConn(conn, spoofer, spoof), nil
}
func NewDialerFromOptions(ctx context.Context, logger logger.ContextLogger, dialer N.Dialer, serverAddress string, options option.OutboundTLSOptions) (N.Dialer, error) {
if !options.Enabled {
return dialer, nil
@@ -42,11 +77,12 @@ func NewClient(ctx context.Context, logger logger.ContextLogger, serverAddress s
}
type ClientOptions struct {
Context context.Context
Logger logger.ContextLogger
ServerAddress string
Options option.OutboundTLSOptions
KTLSCompatible bool
Context context.Context
Logger logger.ContextLogger
ServerAddress string
Options option.OutboundTLSOptions
AllowEmptyServerName bool
KTLSCompatible bool
}
func NewClientWithOptions(options ClientOptions) (Config, error) {
@@ -61,17 +97,22 @@ func NewClientWithOptions(options ClientOptions) (Config, error) {
if options.Options.KernelRx {
options.Logger.Warn("enabling kTLS RX will definitely reduce performance, please checkout https://sing-box.sagernet.org/configuration/shared/tls/#kernel_rx")
}
if options.Options.Reality != nil && options.Options.Reality.Enabled {
return NewRealityClient(options.Context, options.Logger, options.ServerAddress, options.Options)
} else if options.Options.UTLS != nil && options.Options.UTLS.Enabled {
return NewUTLSClient(options.Context, options.Logger, options.ServerAddress, options.Options)
switch options.Options.Engine {
case C.TLSEngineDefault, "go":
case C.TLSEngineApple:
return newAppleClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName)
default:
return nil, E.New("unknown tls engine: ", options.Options.Engine)
}
return NewSTDClient(options.Context, options.Logger, options.ServerAddress, options.Options)
if options.Options.Reality != nil && options.Options.Reality.Enabled {
return newRealityClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName)
} else if options.Options.UTLS != nil && options.Options.UTLS.Enabled {
return newUTLSClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName)
}
return newSTDClient(options.Context, options.Logger, options.ServerAddress, options.Options, options.AllowEmptyServerName)
}
func ClientHandshake(ctx context.Context, conn net.Conn, config Config) (Conn, error) {
ctx, cancel := context.WithTimeout(ctx, C.TCPTimeout)
defer cancel()
tlsConn, err := aTLS.ClientHandshake(ctx, conn, config)
if err != nil {
return nil, err
+16 -1
View File
@@ -52,11 +52,18 @@ type RealityClientConfig struct {
}
func NewRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
return newRealityClient(ctx, logger, serverAddress, options, false)
}
func newRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
if options.UTLS == nil || !options.UTLS.Enabled {
return nil, E.New("uTLS is required by reality client")
}
if options.Spoof != "" || options.SpoofMethod != "" {
return nil, E.New("spoof is unsupported in reality")
}
uClient, err := NewUTLSClient(ctx, logger, serverAddress, options)
uClient, err := newUTLSClient(ctx, logger, serverAddress, options, allowEmptyServerName)
if err != nil {
return nil, err
}
@@ -108,6 +115,14 @@ func (e *RealityClientConfig) SetNextProtos(nextProto []string) {
e.uClient.SetNextProtos(nextProto)
}
func (e *RealityClientConfig) HandshakeTimeout() time.Duration {
return e.uClient.HandshakeTimeout()
}
func (e *RealityClientConfig) SetHandshakeTimeout(timeout time.Duration) {
e.uClient.SetHandshakeTimeout(timeout)
}
func (e *RealityClientConfig) STDConfig() (*STDConfig, error) {
return nil, E.New("unsupported usage for reality")
}
+22 -3
View File
@@ -26,7 +26,8 @@ import (
var _ ServerConfigCompat = (*RealityServerConfig)(nil)
type RealityServerConfig struct {
config *utls.RealityConfig
config *utls.RealityConfig
handshakeTimeout time.Duration
}
func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) {
@@ -130,7 +131,16 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt
if options.ECH != nil && options.ECH.Enabled {
return nil, E.New("Reality is conflict with ECH")
}
var config ServerConfig = &RealityServerConfig{&tlsConfig}
var handshakeTimeout time.Duration
if options.HandshakeTimeout > 0 {
handshakeTimeout = options.HandshakeTimeout.Build()
} else {
handshakeTimeout = C.TCPTimeout
}
var config ServerConfig = &RealityServerConfig{
config: &tlsConfig,
handshakeTimeout: handshakeTimeout,
}
if options.KernelTx || options.KernelRx {
if !C.IsLinux {
return nil, E.New("kTLS is only supported on Linux")
@@ -161,6 +171,14 @@ func (c *RealityServerConfig) SetNextProtos(nextProto []string) {
c.config.NextProtos = nextProto
}
func (c *RealityServerConfig) HandshakeTimeout() time.Duration {
return c.handshakeTimeout
}
func (c *RealityServerConfig) SetHandshakeTimeout(timeout time.Duration) {
c.handshakeTimeout = timeout
}
func (c *RealityServerConfig) STDConfig() (*tls.Config, error) {
return nil, E.New("unsupported usage for reality")
}
@@ -191,7 +209,8 @@ func (c *RealityServerConfig) ServerHandshake(ctx context.Context, conn net.Conn
func (c *RealityServerConfig) Clone() Config {
return &RealityServerConfig{
config: c.config.Clone(),
config: c.config.Clone(),
handshakeTimeout: c.handshakeTimeout,
}
}
+5 -2
View File
@@ -46,8 +46,11 @@ func NewServerWithOptions(options ServerOptions) (ServerConfig, error) {
}
func ServerHandshake(ctx context.Context, conn net.Conn, config ServerConfig) (Conn, error) {
ctx, cancel := context.WithTimeout(ctx, C.TCPTimeout)
defer cancel()
if config.HandshakeTimeout() == 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, C.TCPTimeout)
defer cancel()
}
tlsConn, err := aTLS.ServerHandshake(ctx, conn, config)
if err != nil {
return nil, err
+92 -25
View File
@@ -14,6 +14,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
@@ -24,16 +25,32 @@ import (
type STDClientConfig struct {
ctx context.Context
config *tls.Config
serverName string
disableSNI bool
verifyServerName bool
handshakeTimeout time.Duration
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
spoof string
spoofMethod tlsspoof.Method
}
func (c *STDClientConfig) ServerName() string {
return c.config.ServerName
return c.serverName
}
func (c *STDClientConfig) SetServerName(serverName string) {
c.serverName = serverName
if c.disableSNI {
c.config.ServerName = ""
if c.verifyServerName {
c.config.VerifyConnection = verifyConnection(c.config.RootCAs, c.config.Time, serverName)
} else {
c.config.VerifyConnection = nil
}
return
}
c.config.ServerName = serverName
}
@@ -45,6 +62,14 @@ func (c *STDClientConfig) SetNextProtos(nextProto []string) {
c.config.NextProtos = nextProto
}
func (c *STDClientConfig) HandshakeTimeout() time.Duration {
return c.handshakeTimeout
}
func (c *STDClientConfig) SetHandshakeTimeout(timeout time.Duration) {
c.handshakeTimeout = timeout
}
func (c *STDClientConfig) STDConfig() (*STDConfig, error) {
return c.config, nil
}
@@ -53,17 +78,29 @@ func (c *STDClientConfig) Client(conn net.Conn) (Conn, error) {
if c.recordFragment {
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
}
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
if err != nil {
return nil, err
}
return tls.Client(conn, c.config), nil
}
func (c *STDClientConfig) Clone() Config {
return &STDClientConfig{
cloned := &STDClientConfig{
ctx: c.ctx,
config: c.config.Clone(),
serverName: c.serverName,
disableSNI: c.disableSNI,
verifyServerName: c.verifyServerName,
handshakeTimeout: c.handshakeTimeout,
fragment: c.fragment,
fragmentFallbackDelay: c.fragmentFallbackDelay,
recordFragment: c.recordFragment,
spoof: c.spoof,
spoofMethod: c.spoofMethod,
}
cloned.SetServerName(cloned.serverName)
return cloned
}
func (c *STDClientConfig) ECHConfigList() []byte {
@@ -75,41 +112,27 @@ func (c *STDClientConfig) SetECHConfigList(EncryptedClientHelloConfigList []byte
}
func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
return newSTDClient(ctx, logger, serverAddress, options, false)
}
func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
var serverName string
if options.ServerName != "" {
serverName = options.ServerName
} else if serverAddress != "" {
serverName = serverAddress
}
if serverName == "" && !options.Insecure {
return nil, E.New("missing server_name or insecure=true")
if serverName == "" && !options.Insecure && !allowEmptyServerName {
return nil, errMissingServerName
}
var tlsConfig tls.Config
tlsConfig.Time = ntp.TimeFuncFromContext(ctx)
tlsConfig.RootCAs = adapter.RootPoolFromContext(ctx)
if !options.DisableSNI {
tlsConfig.ServerName = serverName
}
if options.Insecure {
tlsConfig.InsecureSkipVerify = options.Insecure
} else if options.DisableSNI {
tlsConfig.InsecureSkipVerify = true
tlsConfig.VerifyConnection = func(state tls.ConnectionState) error {
verifyOptions := x509.VerifyOptions{
Roots: tlsConfig.RootCAs,
DNSName: serverName,
Intermediates: x509.NewCertPool(),
}
for _, cert := range state.PeerCertificates[1:] {
verifyOptions.Intermediates.AddCert(cert)
}
if tlsConfig.Time != nil {
verifyOptions.CurrentTime = tlsConfig.Time()
}
_, err := state.PeerCertificates[0].Verify(verifyOptions)
return err
}
}
if len(options.CertificatePublicKeySHA256) > 0 {
if len(options.Certificate) > 0 || options.CertificatePath != "" {
@@ -117,7 +140,7 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
}
tlsConfig.InsecureSkipVerify = true
tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
return VerifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts)
}
}
if len(options.ALPN) > 0 {
@@ -198,7 +221,30 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
} else if len(clientCertificate) > 0 || len(clientKey) > 0 {
return nil, E.New("client certificate and client key must be provided together")
}
var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}
var handshakeTimeout time.Duration
if options.HandshakeTimeout > 0 {
handshakeTimeout = options.HandshakeTimeout.Build()
} else {
handshakeTimeout = C.TCPTimeout
}
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
if err != nil {
return nil, err
}
var config Config = &STDClientConfig{
ctx: ctx,
config: &tlsConfig,
serverName: serverName,
disableSNI: options.DisableSNI,
verifyServerName: options.DisableSNI && !options.Insecure,
handshakeTimeout: handshakeTimeout,
fragment: options.Fragment,
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
recordFragment: options.RecordFragment,
spoof: spoof,
spoofMethod: spoofMethod,
}
config.SetServerName(serverName)
if options.ECH != nil && options.ECH.Enabled {
var err error
config, err = parseECHClientConfig(ctx, config.(ECHCapableConfig), options)
@@ -220,7 +266,28 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
return config, nil
}
func verifyPublicKeySHA256(knownHashValues [][]byte, rawCerts [][]byte, timeFunc func() time.Time) error {
func verifyConnection(rootCAs *x509.CertPool, timeFunc func() time.Time, serverName string) func(state tls.ConnectionState) error {
return func(state tls.ConnectionState) error {
if serverName == "" {
return errMissingServerName
}
verifyOptions := x509.VerifyOptions{
Roots: rootCAs,
DNSName: serverName,
Intermediates: x509.NewCertPool(),
}
for _, cert := range state.PeerCertificates[1:] {
verifyOptions.Intermediates.AddCert(cert)
}
if timeFunc != nil {
verifyOptions.CurrentTime = timeFunc()
}
_, err := state.PeerCertificates[0].Verify(verifyOptions)
return err
}
}
func VerifyPublicKeySHA256(knownHashValues [][]byte, rawCerts [][]byte) error {
leafCertificate, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return E.Cause(err, "failed to parse leaf certificate")
+23 -2
View File
@@ -92,6 +92,7 @@ func getACMENextProtos(provider adapter.CertificateProvider) []string {
type STDServerConfig struct {
access sync.RWMutex
config *tls.Config
handshakeTimeout time.Duration
logger log.Logger
certificateProvider managedCertificateProvider
acmeService adapter.SimpleLifecycle
@@ -139,6 +140,18 @@ func (c *STDServerConfig) SetNextProtos(nextProto []string) {
c.config = config
}
func (c *STDServerConfig) HandshakeTimeout() time.Duration {
c.access.RLock()
defer c.access.RUnlock()
return c.handshakeTimeout
}
func (c *STDServerConfig) SetHandshakeTimeout(timeout time.Duration) {
c.access.Lock()
defer c.access.Unlock()
c.handshakeTimeout = timeout
}
func (c *STDServerConfig) hasACMEALPN() bool {
if c.acmeService != nil {
return true
@@ -165,7 +178,8 @@ func (c *STDServerConfig) Server(conn net.Conn) (Conn, error) {
func (c *STDServerConfig) Clone() Config {
return &STDServerConfig{
config: c.config.Clone(),
config: c.config.Clone(),
handshakeTimeout: c.handshakeTimeout,
}
}
@@ -458,7 +472,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
tlsConfig.ClientAuth = tls.RequestClientCert
}
tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
return verifyPublicKeySHA256(options.ClientCertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
return VerifyPublicKeySHA256(options.ClientCertificatePublicKeySHA256, rawCerts)
}
} else {
return nil, E.New("missing client_certificate, client_certificate_path or client_certificate_public_key_sha256 for client authentication")
@@ -471,8 +485,15 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
return nil, err
}
}
var handshakeTimeout time.Duration
if options.HandshakeTimeout > 0 {
handshakeTimeout = options.HandshakeTimeout.Build()
} else {
handshakeTimeout = C.TCPTimeout
}
serverConfig := &STDServerConfig{
config: tlsConfig,
handshakeTimeout: handshakeTimeout,
logger: logger,
certificateProvider: certificateProvider,
acmeService: acmeService,
+77 -11
View File
@@ -14,6 +14,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/tlsfragment"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
@@ -28,17 +29,33 @@ import (
type UTLSClientConfig struct {
ctx context.Context
config *utls.Config
serverName string
disableSNI bool
verifyServerName bool
handshakeTimeout time.Duration
id utls.ClientHelloID
fragment bool
fragmentFallbackDelay time.Duration
recordFragment bool
spoof string
spoofMethod tlsspoof.Method
}
func (c *UTLSClientConfig) ServerName() string {
return c.config.ServerName
return c.serverName
}
func (c *UTLSClientConfig) SetServerName(serverName string) {
c.serverName = serverName
if c.disableSNI {
c.config.ServerName = ""
if c.verifyServerName {
c.config.InsecureServerNameToVerify = serverName
} else {
c.config.InsecureServerNameToVerify = ""
}
return
}
c.config.ServerName = serverName
}
@@ -53,6 +70,14 @@ func (c *UTLSClientConfig) SetNextProtos(nextProto []string) {
c.config.NextProtos = nextProto
}
func (c *UTLSClientConfig) HandshakeTimeout() time.Duration {
return c.handshakeTimeout
}
func (c *UTLSClientConfig) SetHandshakeTimeout(timeout time.Duration) {
c.handshakeTimeout = timeout
}
func (c *UTLSClientConfig) STDConfig() (*STDConfig, error) {
return nil, E.New("unsupported usage for uTLS")
}
@@ -61,6 +86,10 @@ func (c *UTLSClientConfig) Client(conn net.Conn) (Conn, error) {
if c.recordFragment {
conn = tf.NewConn(conn, c.ctx, c.fragment, c.recordFragment, c.fragmentFallbackDelay)
}
conn, err := applyTLSSpoof(conn, c.spoof, c.spoofMethod)
if err != nil {
return nil, err
}
return &utlsALPNWrapper{utlsConnWrapper{utls.UClient(conn, c.config.Clone(), c.id)}, c.config.NextProtos}, nil
}
@@ -69,9 +98,22 @@ func (c *UTLSClientConfig) SetSessionIDGenerator(generator func(clientHello []by
}
func (c *UTLSClientConfig) Clone() Config {
return &UTLSClientConfig{
c.ctx, c.config.Clone(), c.id, c.fragment, c.fragmentFallbackDelay, c.recordFragment,
cloned := &UTLSClientConfig{
ctx: c.ctx,
config: c.config.Clone(),
serverName: c.serverName,
disableSNI: c.disableSNI,
verifyServerName: c.verifyServerName,
handshakeTimeout: c.handshakeTimeout,
id: c.id,
fragment: c.fragment,
fragmentFallbackDelay: c.fragmentFallbackDelay,
recordFragment: c.recordFragment,
spoof: c.spoof,
spoofMethod: c.spoofMethod,
}
cloned.SetServerName(cloned.serverName)
return cloned
}
func (c *UTLSClientConfig) ECHConfigList() []byte {
@@ -143,29 +185,29 @@ func (c *utlsALPNWrapper) HandshakeContext(ctx context.Context) error {
}
func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
return newUTLSClient(ctx, logger, serverAddress, options, false)
}
func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
var serverName string
if options.ServerName != "" {
serverName = options.ServerName
} else if serverAddress != "" {
serverName = serverAddress
}
if serverName == "" && !options.Insecure {
return nil, E.New("missing server_name or insecure=true")
if serverName == "" && !options.Insecure && !allowEmptyServerName {
return nil, errMissingServerName
}
var tlsConfig utls.Config
tlsConfig.Time = ntp.TimeFuncFromContext(ctx)
tlsConfig.RootCAs = adapter.RootPoolFromContext(ctx)
if !options.DisableSNI {
tlsConfig.ServerName = serverName
}
if options.Insecure {
tlsConfig.InsecureSkipVerify = options.Insecure
} else if options.DisableSNI {
if options.Reality != nil && options.Reality.Enabled {
return nil, E.New("disable_sni is unsupported in reality")
}
tlsConfig.InsecureServerNameToVerify = serverName
}
if len(options.CertificatePublicKeySHA256) > 0 {
if len(options.Certificate) > 0 || options.CertificatePath != "" {
@@ -173,7 +215,7 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
}
tlsConfig.InsecureSkipVerify = true
tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time)
return VerifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts)
}
}
if len(options.ALPN) > 0 {
@@ -251,11 +293,35 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
} else if len(clientCertificate) > 0 || len(clientKey) > 0 {
return nil, E.New("client certificate and client key must be provided together")
}
var handshakeTimeout time.Duration
if options.HandshakeTimeout > 0 {
handshakeTimeout = options.HandshakeTimeout.Build()
} else {
handshakeTimeout = C.TCPTimeout
}
spoof, spoofMethod, err := parseTLSSpoofOptions(serverName, options)
if err != nil {
return nil, err
}
id, err := uTLSClientHelloID(options.UTLS.Fingerprint)
if err != nil {
return nil, err
}
var config Config = &UTLSClientConfig{ctx, &tlsConfig, id, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}
var config Config = &UTLSClientConfig{
ctx: ctx,
config: &tlsConfig,
serverName: serverName,
disableSNI: options.DisableSNI,
verifyServerName: options.DisableSNI && !options.Insecure,
handshakeTimeout: handshakeTimeout,
id: id,
fragment: options.Fragment,
fragmentFallbackDelay: time.Duration(options.FragmentFallbackDelay),
recordFragment: options.RecordFragment,
spoof: spoof,
spoofMethod: spoofMethod,
}
config.SetServerName(serverName)
if options.ECH != nil && options.ECH.Enabled {
if options.Reality != nil && options.Reality.Enabled {
return nil, E.New("Reality is conflict with ECH")
+8
View File
@@ -12,10 +12,18 @@ import (
)
func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
return newUTLSClient(ctx, logger, serverAddress, options, false)
}
func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
return nil, E.New(`uTLS is not included in this build, rebuild with -tags with_utls`)
}
func NewRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions) (Config, error) {
return newRealityClient(ctx, logger, serverAddress, options, false)
}
func newRealityClient(ctx context.Context, logger logger.ContextLogger, serverAddress string, options option.OutboundTLSOptions, allowEmptyServerName bool) (Config, error) {
return nil, E.New(`uTLS, which is required by reality is not included in this build, rebuild with -tags with_utls`)
}
+6 -3
View File
@@ -23,9 +23,10 @@ const (
)
type MyServerName struct {
Index int
Length int
ServerName string
Index int
Length int
ServerName string
ExtensionsListLengthIndex int
}
func IndexTLSServerName(payload []byte) *MyServerName {
@@ -41,6 +42,7 @@ func IndexTLSServerName(payload []byte) *MyServerName {
return nil
}
serverName.Index += recordLayerHeaderLen
serverName.ExtensionsListLengthIndex += recordLayerHeaderLen
return serverName
}
@@ -82,6 +84,7 @@ func indexTLSServerNameFromHandshake(handshake []byte) *MyServerName {
return nil
}
serverName.Index += currentIndex
serverName.ExtensionsListLengthIndex = currentIndex
return serverName
}
+86
View File
@@ -0,0 +1,86 @@
package tlsspoof
import (
"encoding/binary"
tf "github.com/sagernet/sing-box/common/tlsfragment"
E "github.com/sagernet/sing/common/exceptions"
)
const (
recordLengthOffset = 3
handshakeLengthOffset = 6
)
// server_name extension layout (RFC 6066 §3). Offsets are relative to the
// SNI host name (index returned by the parser):
//
// ... uint16 extension_type = 0x0000 (host_name - 9)
// ... uint16 extension_data_length (host_name - 7)
// ... uint16 server_name_list_length (host_name - 5)
// ... uint8 name_type = host_name (host_name - 3)
// ... uint16 host_name_length (host_name - 2)
// sni host_name (host_name)
const (
extensionDataLengthOffsetFromSNI = -7
listLengthOffsetFromSNI = -5
hostNameLengthOffsetFromSNI = -2
)
func rewriteSNI(record []byte, fakeSNI string) ([]byte, error) {
if len(fakeSNI) > 0xFFFF {
return nil, E.New("fake SNI too long: ", len(fakeSNI), " bytes")
}
serverName := tf.IndexTLSServerName(record)
if serverName == nil {
return nil, E.New("not a ClientHello with SNI")
}
delta := len(fakeSNI) - serverName.Length
out := make([]byte, len(record)+delta)
copy(out, record[:serverName.Index])
copy(out[serverName.Index:], fakeSNI)
copy(out[serverName.Index+len(fakeSNI):], record[serverName.Index+serverName.Length:])
err := patchUint16(out, recordLengthOffset, delta)
if err != nil {
return nil, E.Cause(err, "patch record length")
}
err = patchUint24(out, handshakeLengthOffset, delta)
if err != nil {
return nil, E.Cause(err, "patch handshake length")
}
for _, off := range []int{
serverName.ExtensionsListLengthIndex,
serverName.Index + extensionDataLengthOffsetFromSNI,
serverName.Index + listLengthOffsetFromSNI,
serverName.Index + hostNameLengthOffsetFromSNI,
} {
err = patchUint16(out, off, delta)
if err != nil {
return nil, E.Cause(err, "patch length at offset ", off)
}
}
return out, nil
}
func patchUint16(data []byte, offset, delta int) error {
patched := int(binary.BigEndian.Uint16(data[offset:])) + delta
if patched < 0 || patched > 0xFFFF {
return E.New("uint16 out of range: ", patched)
}
binary.BigEndian.PutUint16(data[offset:], uint16(patched))
return nil
}
func patchUint24(data []byte, offset, delta int) error {
original := int(data[offset])<<16 | int(data[offset+1])<<8 | int(data[offset+2])
patched := original + delta
if patched < 0 || patched > 0xFFFFFF {
return E.New("uint24 out of range: ", patched)
}
data[offset] = byte(patched >> 16)
data[offset+1] = byte(patched >> 8)
data[offset+2] = byte(patched)
return nil
}
@@ -0,0 +1,79 @@
package tlsspoof
import (
"encoding/binary"
"encoding/hex"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
// realClientHello is a captured Chrome ClientHello for github.com,
// reused from common/tlsfragment/index_test.go.
const realClientHello = "16030105f8010005f403036e35de7389a679c54029cf452611f2211c70d9ac3897271de589ab6155f8e4ab20637d225f1ef969ad87ed78bfb9d171300bcb1703b6f314ccefb964f79b7d0961002a0a0a130213031301c02cc02bcca9c030c02fcca8c00ac009c014c013009d009c0035002fc008c012000a01000581baba00000000000f000d00000a6769746875622e636f6d00170000ff01000100000a000e000c3a3a11ec001d001700180019000b000201000010000e000c02683208687474702f312e31000500050100000000000d00160014040308040401050308050805050108060601020100120000003304ef04ed3a3a00010011ec04c0aeb2250c092a3463161cccb29d9183331a424964248579507ed23a180b0ceab2a5f5d9ce41547e497a89055471ea572867ba3a1fc3c9e45025274a20f60c6b60e62476b6afed0403af59ab83660ef4112ae20386a602010d0a5d454c0ed34c84ed4423e750213e6a2baab1bf9c4367a6007ab40a33d95220c2dcaa44f257024a5626b545db0510f4311b1a60714154909c6a61fdfca011fb2626d657aeb6070bf078508babe3b584555013e34acc56198ed4663742b3155a664a9901794c4586820a7dc162c01827291f3792e1237f801a8d1ef096013c181c4a58d2f6859ba75022d18cc4418bd4f351d5c18f83a58857d05af860c4b9ac018a5b63f17184e591532c6bc2cf2215d4a282c8a8a4f6f7aee110422c8bc9ebd3b1d609c568523aaae555db320e6c269473d87af38c256cbb9febc20aea6380c32a8916f7a373c8b1e37554e3260bf6621f6b804ee80b3c516b1d01985bf4c603b6daa9a5991de6a7a29f3a7122b8afb843a7660110fce62b43c615f5bcc2db688ba012649c0952b0a2c031e732d2b454c6b2968683cb8d244be2c9a7fa163222979eaf92722b92b862d81a3d94450c2b60c318421ebb4307c42d1f0473592a5c30e42039cc68cda9721e61aa63f49def17c15221680ed444896340133bbee67556f56b9f9d78a4df715f926a12add0cc9c862e46ea8b7316ae468282c18601b2771c9c9322f982228cf93effaacd3f80cbd12bce5fc36f56e2a3caf91e578a5fae00c9b23a8ed1a66764f4433c3628a70b8f0a6196adc60a4cb4226f07ba4c6b363fe9065563bfc1347452946386bab488686e837ab979c64f9047417fca635fe1bb4f074f256cc8af837c7b455e280426547755af90a61640169ef180aea3a77e662bb6dac1b6c3696027129b1a5edf495314e9c7f4b6110e16378ec893fa24642330a40aba1a85326101acb97c620fd8d71389e69eaed7bdb01bbe1fd428d66191150c7b2cd1ad4257391676a82ba8ce07fb2667c3b289f159003a7c7bc31d361b7b7f49a802961739d950dfcc0fa1c7abce5abdd2245101da391151490862028110465950b9e9c03d08a90998ab83267838d2e74a0593bc81f74cdf734519a05b351c0e5488c68dd810e6e9142ccc1e2f4a7f464297eb340e27acc6b9d64e12e38cce8492b3d939140b5a9e149a75597f10a23874c84323a07cdd657274378f887c85c4259b9c04cd33ba58ed630ef2a744f8e19dd34843dff331d2a6be7e2332c599289cd248a611c73d7481cd4a9bd43449a3836f14b2af18a1739e17999e4c67e85cc5bcecabb14185e5bcaff3c96098f03dc5aba819f29587758f49f940585354a2a780830528d68ccd166920dadcaa25cab5fc1907272a826aba3f08bc6b88757776812ecb6c7cec69a223ec0a13a7b62a2349a0f63ed7a27a3b15ba21d71fe6864ec6e089ae17cadd433fa3138f7ee24353c11365818f8fc34f43a05542d18efaac24bfccc1f748a0cc1a67ad379468b76fd34973dba785f5c91d618333cd810fe0700d1bbc8422029782628070a624c52c5309a4a64d625b11f8033ab28df34a1add297517fcc06b92b6817b3c5144438cf260867c57bde68c8c4b82e6a135ef676a52fbae5708002a404e6189a60e2836de565ad1b29e3819e5ed49f6810bcb28e1bd6de57306f94b79d9dae1cc4624d2a068499beef81cd5fe4b76dcbfff2a2008001d002001976128c6d5a934533f28b9914d2480aab2a8c1ab03d212529ce8b27640a716002d00020101002b000706caca03040303001b00030200015a5a000100"
func decodeClientHello(t *testing.T) []byte {
t.Helper()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
return payload
}
func assertConsistent(t *testing.T, payload []byte, expectedSNI string) {
t.Helper()
serverName := tf.IndexTLSServerName(payload)
require.NotNil(t, serverName, "parser should find SNI in rewritten payload")
require.Equal(t, expectedSNI, serverName.ServerName)
require.Equal(t, expectedSNI, string(payload[serverName.Index:serverName.Index+serverName.Length]))
// Record length must equal len(payload) - 5.
recordLen := binary.BigEndian.Uint16(payload[3:5])
require.Equal(t, len(payload)-5, int(recordLen), "record length must equal payload - 5")
// Handshake length must equal len(payload) - 5 - 4.
handshakeLen := int(payload[6])<<16 | int(payload[7])<<8 | int(payload[8])
require.Equal(t, len(payload)-5-4, handshakeLen, "handshake length must equal payload - 9")
}
func TestRewriteSNI_ShorterReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "a.io")
require.NoError(t, err)
require.Len(t, out, len(payload)-6) // original "github.com" is 10 bytes, "a.io" is 4 bytes.
assertConsistent(t, out, "a.io")
}
func TestRewriteSNI_SameLengthReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "example.co")
require.NoError(t, err)
require.Len(t, out, len(payload))
assertConsistent(t, out, "example.co")
}
func TestRewriteSNI_LongerReplacement(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
out, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.Len(t, out, len(payload)+5) // "letsencrypt.org" is 15, original 10, delta 5.
assertConsistent(t, out, "letsencrypt.org")
}
func TestRewriteSNI_NoSNIReturnsError(t *testing.T) {
t.Parallel()
// Truncated payload — not a valid ClientHello.
_, err := rewriteSNI([]byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01}, "x.com")
require.Error(t, err)
}
func TestRewriteSNI_DoesNotMutateInput(t *testing.T) {
t.Parallel()
payload := decodeClientHello(t)
original := append([]byte(nil), payload...)
_, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.Equal(t, original, payload, "input payload must not be mutated")
}
+126
View File
@@ -0,0 +1,126 @@
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
tf "github.com/sagernet/sing-box/common/tlsfragment"
"github.com/stretchr/testify/require"
)
type fakeSpoofer struct {
injected [][]byte
err error
}
func (f *fakeSpoofer) Inject(payload []byte) error {
if f.err != nil {
return f.err
}
f.injected = append(f.injected, append([]byte(nil), payload...))
return nil
}
func (f *fakeSpoofer) Close() error {
return nil
}
func readAll(t *testing.T, conn net.Conn) []byte {
t.Helper()
data, err := io.ReadAll(conn)
require.NoError(t, err)
return data
}
func TestConn_Write_InjectsThenForwards(t *testing.T) {
t.Parallel()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, payload, forwarded, "underlying conn must receive the real ClientHello unchanged")
require.Len(t, spoofer.injected, 1)
injected := spoofer.injected[0]
serverName := tf.IndexTLSServerName(injected)
require.NotNil(t, serverName, "injected payload must parse as ClientHello")
require.Equal(t, "letsencrypt.org", serverName.ServerName)
}
func TestConn_Write_SecondWriteDoesNotInject(t *testing.T) {
t.Parallel()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
client, server := net.Pipe()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
serverRead := make(chan []byte, 1)
go func() {
serverRead <- readAll(t, server)
}()
_, err = wrapped.Write(payload)
require.NoError(t, err)
_, err = wrapped.Write([]byte("second"))
require.NoError(t, err)
require.NoError(t, wrapped.Close())
forwarded := <-serverRead
require.Equal(t, append(append([]byte(nil), payload...), []byte("second")...), forwarded)
require.Len(t, spoofer.injected, 1)
}
func TestConn_Write_NonClientHelloReturnsError(t *testing.T) {
t.Parallel()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
spoofer := &fakeSpoofer{}
wrapped := NewConn(client, spoofer, "letsencrypt.org")
_, err := wrapped.Write([]byte("not a ClientHello"))
require.Error(t, err)
require.Empty(t, spoofer.injected)
}
func TestParseMethod(t *testing.T) {
t.Parallel()
cases := map[string]struct {
want Method
ok bool
}{
"": {MethodWrongSequence, true},
"wrong-sequence": {MethodWrongSequence, true},
"wrong-checksum": {MethodWrongChecksum, true},
"nonsense": {0, false},
}
for input, expected := range cases {
m, err := ParseMethod(input)
if !expected.ok {
require.Error(t, err, "input=%q", input)
continue
}
require.NoError(t, err, "input=%q", input)
require.Equal(t, expected.want, m, "input=%q", input)
}
}
+29
View File
@@ -0,0 +1,29 @@
package tlsspoof
import (
"net"
"net/netip"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
)
// The returned addresses are v4-unmapped and share the same family.
func tcpEndpoints(conn net.Conn) (*net.TCPConn, netip.AddrPort, netip.AddrPort, error) {
tcpConn, isTCP := common.Cast[*net.TCPConn](conn)
if !isTCP {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: underlying conn is not *net.TCPConn")
}
local := M.AddrPortFromNet(tcpConn.LocalAddr())
remote := M.AddrPortFromNet(tcpConn.RemoteAddr())
if !local.IsValid() || !remote.IsValid() {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: invalid conn address")
}
local = netip.AddrPortFrom(local.Addr().Unmap(), local.Port())
remote = netip.AddrPortFrom(remote.Addr().Unmap(), remote.Port())
if local.Addr().Is4() != remote.Addr().Is4() {
return nil, netip.AddrPort{}, netip.AddrPort{}, E.New("tls_spoof: local/remote address family mismatch")
}
return tcpConn, local, remote, nil
}
@@ -0,0 +1,5 @@
//go:build darwin
package tlsspoof
const loopbackInterface = "lo0"
@@ -0,0 +1,5 @@
//go:build linux
package tlsspoof
const loopbackInterface = "lo"
@@ -0,0 +1,112 @@
//go:build linux || darwin
package tlsspoof
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func requireRoot(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Fatal("integration test requires root")
}
}
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
"-s", "4096", fmt.Sprintf("tcp and port %d", port))
cmd.Cancel = func() error {
return cmd.Process.Signal(os.Interrupt)
}
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
stderr, err := cmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Signal(os.Interrupt)
_ = cmd.Wait()
})
ready := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "listening on") {
close(ready)
io.Copy(io.Discard, stderr)
return
}
}
}()
select {
case <-ready:
case <-time.After(2 * time.Second):
t.Fatal("tcpdump did not attach within 2s")
}
var found atomic.Bool
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if strings.Contains(scanner.Text(), needle) {
found.Store(true)
}
}
}()
do()
time.Sleep(200 * time.Millisecond)
_ = cmd.Process.Signal(os.Interrupt)
<-readerDone
return found.Load()
}
func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
accepted := make(chan net.Conn, 1)
go func() {
c, err := listener.Accept()
if err == nil {
accepted <- c
}
close(accepted)
}()
addr := listener.Addr().(*net.TCPAddr)
client, err = net.Dial("tcp4", addr.String())
require.NoError(t, err)
server := <-accepted
require.NotNil(t, server)
go io.Copy(io.Discard, server)
t.Cleanup(func() {
client.Close()
server.Close()
listener.Close()
})
return client, uint16(addr.Port)
}
@@ -0,0 +1,100 @@
//go:build linux || darwin
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestIntegrationSpoofer_WrongChecksum(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := NewSpoofer(client, MethodWrongChecksum)
require.NoError(t, err)
defer spoofer.Close()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
func TestIntegrationSpoofer_WrongSequence(t *testing.T) {
requireRoot(t)
client, serverPort := dialLocalEchoServer(t)
spoofer, err := NewSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
defer spoofer.Close()
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
require.NoError(t, spoofer.Inject(fake))
}, 3*time.Second)
require.True(t, captured, "injected fake ClientHello must be observable on loopback")
}
// Loopback bypasses TCP checksum validation, so wrong-sequence is used instead.
func TestIntegrationConn_InjectsThenForwardsRealCH(t *testing.T) {
requireRoot(t)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
serverReceived := make(chan []byte, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
addr := listener.Addr().(*net.TCPAddr)
serverPort := uint16(addr.Port)
client, err := net.Dial("tcp4", addr.String())
require.NoError(t, err)
t.Cleanup(func() {
client.Close()
listener.Close()
})
spoofer, err := NewSpoofer(client, MethodWrongSequence)
require.NoError(t, err)
wrapped := NewConn(client, spoofer, "letsencrypt.org")
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
captured := tcpdumpObserver(t, loopbackInterface, serverPort, "letsencrypt.org", func() {
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
}, 3*time.Second)
require.True(t, captured, "fake ClientHello with letsencrypt.org SNI must be on the wire")
_ = wrapped.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got, "server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)")
case <-time.After(2 * time.Second):
t.Fatal("echo server did not receive real ClientHello")
}
}
@@ -0,0 +1,139 @@
//go:build windows && (amd64 || 386)
package tlsspoof
import (
"encoding/hex"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newSpoofer(t *testing.T, conn net.Conn, method Method) Spoofer {
t.Helper()
spoofer, err := NewSpoofer(conn, method)
require.NoError(t, err)
return spoofer
}
// Basic lifecycle: opening a spoofer against a live TCP conn installs
// the driver, spawns run(), then shuts down cleanly without ever
// injecting. Exercises the close path that cancels an in-flight Recv.
func TestIntegrationSpooferOpenClose(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
accepted := make(chan net.Conn, 1)
go func() {
c, _ := listener.Accept()
accepted <- c
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
server := <-accepted
t.Cleanup(func() {
if server != nil {
server.Close()
}
})
spoofer := newSpoofer(t, client, MethodWrongSequence)
require.NoError(t, spoofer.Close())
}
// End-to-end: Conn.Write injects a fake ClientHello with a rewritten
// SNI, then forwards the real ClientHello. With wrong-sequence, the
// fake lands before the connection's send-next sequence — the peer TCP
// stack treats it as already-received and only surfaces the real bytes
// to the echo server.
func TestIntegrationConnInjectsThenForwardsRealCH(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
serverReceived := make(chan []byte, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
spoofer := newSpoofer(t, client, MethodWrongSequence)
wrapped := NewConn(client, spoofer, "letsencrypt.org")
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
n, err := wrapped.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
_ = wrapped.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got,
"server must receive real ClientHello unchanged (wrong-sequence fake must be dropped)")
case <-time.After(5 * time.Second):
t.Fatal("echo server did not receive real ClientHello within 5s")
}
}
// Inject before any kernel payload: stages the fake, then Write flushes
// the real CH. Same terminal expectation as the Conn variant but via the
// Spoofer primitive directly.
func TestIntegrationSpooferInjectThenWrite(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { listener.Close() })
serverReceived := make(chan []byte, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
got, _ := io.ReadAll(conn)
serverReceived <- got
}()
client, err := net.Dial("tcp4", listener.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { client.Close() })
spoofer := newSpoofer(t, client, MethodWrongSequence)
t.Cleanup(func() { spoofer.Close() })
payload, err := hex.DecodeString(realClientHello)
require.NoError(t, err)
fake, err := rewriteSNI(payload, "letsencrypt.org")
require.NoError(t, err)
require.NoError(t, spoofer.Inject(fake))
n, err := client.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
_ = client.Close()
select {
case got := <-serverReceived:
require.Equal(t, payload, got)
case <-time.After(5 * time.Second):
t.Fatal("echo server did not receive real ClientHello within 5s")
}
}
+100
View File
@@ -0,0 +1,100 @@
package tlsspoof
import (
"net/netip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
E "github.com/sagernet/sing/common/exceptions"
)
const (
defaultTTL uint8 = 64
defaultWindowSize uint16 = 0xFFFF
tcpHeaderLen = header.TCPMinimumSize
)
func buildTCPSegment(
src netip.AddrPort,
dst netip.AddrPort,
seqNum uint32,
ackNum uint32,
payload []byte,
corruptChecksum bool,
) []byte {
if src.Addr().Is4() != dst.Addr().Is4() {
panic("tlsspoof: mixed IPv4/IPv6 address family")
}
var (
frame []byte
ipHeaderLen int
)
if src.Addr().Is4() {
ipHeaderLen = header.IPv4MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
ip := header.IPv4(frame[:ipHeaderLen])
ip.Encode(&header.IPv4Fields{
TotalLength: uint16(len(frame)),
ID: 0,
TTL: defaultTTL,
Protocol: uint8(header.TCPProtocolNumber),
SrcAddr: src.Addr(),
DstAddr: dst.Addr(),
})
ip.SetChecksum(^ip.CalculateChecksum())
} else {
ipHeaderLen = header.IPv6MinimumSize
frame = make([]byte, ipHeaderLen+tcpHeaderLen+len(payload))
ip := header.IPv6(frame[:ipHeaderLen])
ip.Encode(&header.IPv6Fields{
PayloadLength: uint16(tcpHeaderLen + len(payload)),
TransportProtocol: header.TCPProtocolNumber,
HopLimit: defaultTTL,
SrcAddr: src.Addr(),
DstAddr: dst.Addr(),
})
}
encodeTCP(frame, ipHeaderLen, src, dst, seqNum, ackNum, payload, corruptChecksum)
return frame
}
func encodeTCP(frame []byte, ipHeaderLen int, src, dst netip.AddrPort, seqNum, ackNum uint32, payload []byte, corruptChecksum bool) {
tcp := header.TCP(frame[ipHeaderLen:])
copy(frame[ipHeaderLen+tcpHeaderLen:], payload)
tcp.Encode(&header.TCPFields{
SrcPort: src.Port(),
DstPort: dst.Port(),
SeqNum: seqNum,
AckNum: ackNum,
DataOffset: tcpHeaderLen,
Flags: header.TCPFlagAck | header.TCPFlagPsh,
WindowSize: defaultWindowSize,
})
applyTCPChecksum(tcp, src.Addr(), dst.Addr(), payload, corruptChecksum)
}
func buildSpoofFrame(method Method, src, dst netip.AddrPort, sendNext, receiveNext uint32, payload []byte) ([]byte, error) {
var sequence uint32
corrupt := false
switch method {
case MethodWrongSequence:
sequence = sendNext - uint32(len(payload))
case MethodWrongChecksum:
sequence = sendNext
corrupt = true
default:
return nil, E.New("tls_spoof: unknown method ", method)
}
return buildTCPSegment(src, dst, sequence, receiveNext, payload, corrupt), nil
}
func applyTCPChecksum(tcp header.TCP, srcAddr, dstAddr netip.Addr, payload []byte, corrupt bool) {
tcpLen := tcpHeaderLen + len(payload)
pseudo := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcAddr.AsSlice(), dstAddr.AsSlice(), uint16(tcpLen))
payloadChecksum := checksum.Checksum(payload, 0)
tcpChecksum := ^tcp.CalculateChecksum(checksum.Combine(pseudo, payloadChecksum))
if corrupt {
tcpChecksum ^= 0xFFFF
}
tcp.SetChecksum(tcpChecksum)
}
+77
View File
@@ -0,0 +1,77 @@
package tlsspoof
import (
"net/netip"
"testing"
"github.com/sagernet/sing-tun/gtcpip"
"github.com/sagernet/sing-tun/gtcpip/checksum"
"github.com/sagernet/sing-tun/gtcpip/header"
"github.com/stretchr/testify/require"
)
func TestBuildTCPSegment_IPv4_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, false)
ip := header.IPv4(frame[:header.IPv4MinimumSize])
require.True(t, ip.IsChecksumValid())
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_IPv4_CorruptChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("1.2.3.4:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 100_000, 200_000, payload, true)
tcp := header.TCP(frame[header.IPv4MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.False(t, tcp.IsChecksumValid(
tcpip.AddrFrom4(src.Addr().As4()),
tcpip.AddrFrom4(dst.Addr().As4()),
payloadChecksum,
uint16(len(payload)),
))
// IP checksum must still be valid so the router forwards the packet.
require.True(t, header.IPv4(frame[:header.IPv4MinimumSize]).IsChecksumValid())
}
func TestBuildTCPSegment_IPv6_ValidChecksum(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("[fe80::1]:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
payload := []byte("fake-client-hello")
frame := buildTCPSegment(src, dst, 0xDEADBEEF, 0x12345678, payload, false)
tcp := header.TCP(frame[header.IPv6MinimumSize:])
payloadChecksum := checksum.Checksum(payload, 0)
require.True(t, tcp.IsChecksumValid(
tcpip.AddrFrom16(src.Addr().As16()),
tcpip.AddrFrom16(dst.Addr().As16()),
payloadChecksum,
uint16(len(payload)),
))
}
func TestBuildTCPSegment_MixedFamilyPanics(t *testing.T) {
t.Parallel()
src := netip.MustParseAddrPort("10.0.0.1:54321")
dst := netip.MustParseAddrPort("[2606:4700::1]:443")
require.Panics(t, func() {
buildTCPSegment(src, dst, 0, 0, nil, false)
})
}

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