clash-kit 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +30 -21
- package/LICENSE.md +8 -8
- package/README.md +133 -124
- package/bin/dns-helper +0 -0
- package/bin/index.js +96 -85
- package/default.yaml +46 -46
- package/index.js +204 -218
- package/lib/api.js +178 -116
- package/lib/commands/init.js +127 -127
- package/lib/commands/proxy.js +73 -73
- package/lib/commands/restart.js +10 -0
- package/lib/commands/start.js +30 -30
- package/lib/commands/status.js +105 -85
- package/lib/commands/stop.js +67 -30
- package/lib/commands/sub.js +81 -81
- package/lib/commands/sysproxy.js +60 -24
- package/lib/commands/test.js +70 -70
- package/lib/commands/tun.js +123 -95
- package/lib/kernel.js +197 -178
- package/lib/port.js +115 -115
- package/lib/subscription.js +128 -125
- package/lib/sysnet.js +73 -52
- package/lib/sysproxy.js +133 -145
- package/lib/tun.js +150 -126
- package/package.json +50 -48
package/lib/subscription.js
CHANGED
|
@@ -1,125 +1,128 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
import axios from 'axios'
|
|
4
|
-
import { fileURLToPath } from 'url'
|
|
5
|
-
import ora from 'ora'
|
|
6
|
-
import
|
|
7
|
-
import YAML from 'yaml'
|
|
8
|
-
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
-
const __dirname = path.dirname(__filename)
|
|
11
|
-
|
|
12
|
-
const PROFILES_DIR = path.join(__dirname, '../profiles')
|
|
13
|
-
const CONFIG_PATH = path.join(__dirname, '../config.yaml')
|
|
14
|
-
const CURRENT_PROFILE_PATH = path.join(__dirname, '../.current_profile')
|
|
15
|
-
|
|
16
|
-
// 确保 profiles 目录存在
|
|
17
|
-
if (!fs.existsSync(PROFILES_DIR)) {
|
|
18
|
-
fs.mkdirSync(PROFILES_DIR)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function downloadSubscription(url, name) {
|
|
22
|
-
const spinner = ora(`正在下载订阅 ${name}...`).start()
|
|
23
|
-
try {
|
|
24
|
-
const res = await axios.get(url, {
|
|
25
|
-
responseType: 'text',
|
|
26
|
-
headers: {
|
|
27
|
-
'User-Agent': 'Clash/1.0.0', // 伪装成 Clash 客户端,通常能直接获取 YAML 格式
|
|
28
|
-
},
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
let content = res.data
|
|
32
|
-
|
|
33
|
-
// 尝试解析 YAML,如果不是对象或者看起来不像配置,尝试 Base64 解码
|
|
34
|
-
let isConfig = false
|
|
35
|
-
try {
|
|
36
|
-
const parsed = YAML.parse(content)
|
|
37
|
-
if (parsed && typeof parsed === 'object' && (parsed.proxies || parsed.Proxy || parsed.port)) {
|
|
38
|
-
isConfig = true
|
|
39
|
-
}
|
|
40
|
-
} catch (e) {
|
|
41
|
-
console.warn('订阅服务商返回的不是有效 YAML,尝试 Base64 解码...')
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (!isConfig) {
|
|
45
|
-
try {
|
|
46
|
-
// 尝试 Base64 解码
|
|
47
|
-
const decoded = Buffer.from(content, 'base64').toString('utf-8')
|
|
48
|
-
// 再次检查解码后是否为有效 YAML 配置
|
|
49
|
-
const parsedDecoded = YAML.parse(decoded)
|
|
50
|
-
if (
|
|
51
|
-
parsedDecoded &&
|
|
52
|
-
typeof parsedDecoded === 'object' &&
|
|
53
|
-
(parsedDecoded.proxies || parsedDecoded.Proxy || parsedDecoded.port)
|
|
54
|
-
) {
|
|
55
|
-
content = decoded
|
|
56
|
-
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
console.warn('Base64 解码失败,保留原始内容')
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const filePath = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
63
|
-
fs.writeFileSync(filePath, content)
|
|
64
|
-
spinner.succeed(`订阅 ${name} 下载成功`)
|
|
65
|
-
return filePath
|
|
66
|
-
} catch (err) {
|
|
67
|
-
spinner.fail(`下载订阅失败: ${err.message}`)
|
|
68
|
-
throw new Error(`下载订阅失败: ${err.message}`)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function listProfiles() {
|
|
73
|
-
return fs
|
|
74
|
-
.readdirSync(PROFILES_DIR)
|
|
75
|
-
.filter(f => f.endsWith('.yaml'))
|
|
76
|
-
.map(f => f.replace('.yaml', ''))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function getCurrentProfile() {
|
|
80
|
-
if (fs.existsSync(CURRENT_PROFILE_PATH)) {
|
|
81
|
-
return fs.readFileSync(CURRENT_PROFILE_PATH, 'utf8').trim()
|
|
82
|
-
}
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function useProfile(name) {
|
|
87
|
-
const source = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
88
|
-
if (!fs.existsSync(source)) throw new Error(`配置文件 ${name} 不存在`)
|
|
89
|
-
|
|
90
|
-
const spinner = ora(`正在切换到配置 ${name}...`).start()
|
|
91
|
-
|
|
92
|
-
// 读取订阅配置
|
|
93
|
-
const subscriptionConfig = YAML.parse(fs.readFileSync(source, 'utf8'))
|
|
94
|
-
|
|
95
|
-
// 读取现有配置(如果存在)
|
|
96
|
-
let existingConfig = {}
|
|
97
|
-
if (fs.existsSync(CONFIG_PATH)) {
|
|
98
|
-
existingConfig = YAML.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// 合并配置:保留用户自定义字段,更新订阅字段
|
|
102
|
-
const mergedConfig = {
|
|
103
|
-
...subscriptionConfig,
|
|
104
|
-
port: existingConfig['port'],
|
|
105
|
-
'bind-address': existingConfig['bind-address'],
|
|
106
|
-
'socks-port': existingConfig['socks-port'],
|
|
107
|
-
'allow-lan': existingConfig['allow-lan'],
|
|
108
|
-
// 其他需要保留的自定义字段...
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 写入合并后的配置
|
|
112
|
-
fs.writeFileSync(CONFIG_PATH, YAML.stringify(mergedConfig))
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import axios from 'axios'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import ora from 'ora'
|
|
6
|
+
import * as api from './api.js'
|
|
7
|
+
import YAML from 'yaml'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = path.dirname(__filename)
|
|
11
|
+
|
|
12
|
+
export const PROFILES_DIR = path.join(__dirname, '../profiles')
|
|
13
|
+
export const CONFIG_PATH = path.join(__dirname, '../config.yaml')
|
|
14
|
+
export const CURRENT_PROFILE_PATH = path.join(__dirname, '../.current_profile')
|
|
15
|
+
|
|
16
|
+
// 确保 profiles 目录存在
|
|
17
|
+
if (!fs.existsSync(PROFILES_DIR)) {
|
|
18
|
+
fs.mkdirSync(PROFILES_DIR)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function downloadSubscription(url, name) {
|
|
22
|
+
const spinner = ora(`正在下载订阅 ${name}...`).start()
|
|
23
|
+
try {
|
|
24
|
+
const res = await axios.get(url, {
|
|
25
|
+
responseType: 'text',
|
|
26
|
+
headers: {
|
|
27
|
+
'User-Agent': 'Clash/1.0.0', // 伪装成 Clash 客户端,通常能直接获取 YAML 格式
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
let content = res.data
|
|
32
|
+
|
|
33
|
+
// 尝试解析 YAML,如果不是对象或者看起来不像配置,尝试 Base64 解码
|
|
34
|
+
let isConfig = false
|
|
35
|
+
try {
|
|
36
|
+
const parsed = YAML.parse(content)
|
|
37
|
+
if (parsed && typeof parsed === 'object' && (parsed.proxies || parsed.Proxy || parsed.port)) {
|
|
38
|
+
isConfig = true
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.warn('订阅服务商返回的不是有效 YAML,尝试 Base64 解码...')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!isConfig) {
|
|
45
|
+
try {
|
|
46
|
+
// 尝试 Base64 解码
|
|
47
|
+
const decoded = Buffer.from(content, 'base64').toString('utf-8')
|
|
48
|
+
// 再次检查解码后是否为有效 YAML 配置
|
|
49
|
+
const parsedDecoded = YAML.parse(decoded)
|
|
50
|
+
if (
|
|
51
|
+
parsedDecoded &&
|
|
52
|
+
typeof parsedDecoded === 'object' &&
|
|
53
|
+
(parsedDecoded.proxies || parsedDecoded.Proxy || parsedDecoded.port)
|
|
54
|
+
) {
|
|
55
|
+
content = decoded
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn('Base64 解码失败,保留原始内容')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filePath = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
63
|
+
fs.writeFileSync(filePath, content)
|
|
64
|
+
spinner.succeed(`订阅 ${name} 下载成功`)
|
|
65
|
+
return filePath
|
|
66
|
+
} catch (err) {
|
|
67
|
+
spinner.fail(`下载订阅失败: ${err.message}`)
|
|
68
|
+
throw new Error(`下载订阅失败: ${err.message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function listProfiles() {
|
|
73
|
+
return fs
|
|
74
|
+
.readdirSync(PROFILES_DIR)
|
|
75
|
+
.filter(f => f.endsWith('.yaml'))
|
|
76
|
+
.map(f => f.replace('.yaml', ''))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getCurrentProfile() {
|
|
80
|
+
if (fs.existsSync(CURRENT_PROFILE_PATH)) {
|
|
81
|
+
return fs.readFileSync(CURRENT_PROFILE_PATH, 'utf8').trim()
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function useProfile(name) {
|
|
87
|
+
const source = path.join(PROFILES_DIR, `${name}.yaml`)
|
|
88
|
+
if (!fs.existsSync(source)) throw new Error(`配置文件 ${name} 不存在`)
|
|
89
|
+
|
|
90
|
+
const spinner = ora(`正在切换到配置 ${name}...`).start()
|
|
91
|
+
|
|
92
|
+
// 读取订阅配置
|
|
93
|
+
const subscriptionConfig = YAML.parse(fs.readFileSync(source, 'utf8'))
|
|
94
|
+
|
|
95
|
+
// 读取现有配置(如果存在)
|
|
96
|
+
let existingConfig = {}
|
|
97
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
98
|
+
existingConfig = YAML.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 合并配置:保留用户自定义字段,更新订阅字段
|
|
102
|
+
const mergedConfig = {
|
|
103
|
+
...subscriptionConfig,
|
|
104
|
+
port: existingConfig['port'],
|
|
105
|
+
'bind-address': existingConfig['bind-address'],
|
|
106
|
+
'socks-port': existingConfig['socks-port'],
|
|
107
|
+
'allow-lan': existingConfig['allow-lan'],
|
|
108
|
+
// 其他需要保留的自定义字段...
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 写入合并后的配置
|
|
112
|
+
fs.writeFileSync(CONFIG_PATH, YAML.stringify(mergedConfig))
|
|
113
|
+
|
|
114
|
+
// 记录当前使用的配置文件
|
|
115
|
+
fs.writeFileSync(CURRENT_PROFILE_PATH, name)
|
|
116
|
+
|
|
117
|
+
// 尝试热重载
|
|
118
|
+
if (await api.isClashRunning()) {
|
|
119
|
+
try {
|
|
120
|
+
await api.reloadConfig(CONFIG_PATH)
|
|
121
|
+
spinner.succeed('Clash 配置已切换并热重载生效')
|
|
122
|
+
} catch (err) {
|
|
123
|
+
spinner.warn(`配置已切换,但热重载失败: ${err.message}`)
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
spinner.succeed('配置已切换(Clash 未运行,将在下次启动时生效)')
|
|
127
|
+
}
|
|
128
|
+
}
|
package/lib/sysnet.js
CHANGED
|
@@ -1,52 +1,73 @@
|
|
|
1
|
-
import { execSync } from 'child_process'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
7
|
+
const __dirname = path.dirname(__filename)
|
|
8
|
+
const HELPER_BIN = path.join(__dirname, '../bin/dns-helper')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 获取 macOS 当前主要的网络服务名称 (Wi-Fi, Ethernet 等)
|
|
12
|
+
*/
|
|
13
|
+
function getMainNetworkService() {
|
|
14
|
+
try {
|
|
15
|
+
// 这是一个简单的方法,通常第一行是默认路由接口
|
|
16
|
+
// networksetup -listnetworkserviceorder
|
|
17
|
+
// 更精准的方法是 route get default 但输出解析较复杂
|
|
18
|
+
// 这里我们先假设最常见的几种情况
|
|
19
|
+
const output = execSync('networksetup -listallnetworkservices', { encoding: 'utf-8' })
|
|
20
|
+
const services = output.split('\n').filter(s => s && !s.includes('An asterisk'))
|
|
21
|
+
|
|
22
|
+
// 优先尝试 Wi-Fi 和 Ethernet
|
|
23
|
+
const wifi = services.find(s => s === 'Wi-Fi')
|
|
24
|
+
const ethernet = services.find(s => s.includes('Ethernet') || s.includes('LAN'))
|
|
25
|
+
|
|
26
|
+
// 简单粗暴:优先返回 Wi-Fi,其次 Ethernet,最后第一个
|
|
27
|
+
// TODO: 更严谨的做法是检测哪个接口有 IP 且是 default route
|
|
28
|
+
return wifi || ethernet || services[0]
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error('获取网络服务失败:', e)
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 设置系统 DNS
|
|
37
|
+
* @param {string[]} servers DNS 服务器列表
|
|
38
|
+
*/
|
|
39
|
+
export function setDNS(servers) {
|
|
40
|
+
if (process.platform !== 'darwin') {
|
|
41
|
+
return { success: true, message: 'Platform is not macOS, skipping DNS change.' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const service = getMainNetworkService()
|
|
45
|
+
if (!service) {
|
|
46
|
+
return { success: false, error: 'Could not determine main network service.' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 检查是否存在 helper
|
|
50
|
+
let useHelper = false
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(HELPER_BIN)) {
|
|
53
|
+
const stats = fs.statSync(HELPER_BIN)
|
|
54
|
+
if (stats.uid === 0 && (stats.mode & 0o4000)) {
|
|
55
|
+
useHelper = true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const serversArg = servers.length > 0 ? servers.join(' ') : 'Empty'
|
|
63
|
+
const command = useHelper
|
|
64
|
+
? `"${HELPER_BIN}" "${service}" ${serversArg}`
|
|
65
|
+
: `sudo networksetup -setdnsservers "${service}" ${serversArg}`
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
execSync(command, { stdio: 'pipe' }) // Use 'pipe' to suppress output
|
|
69
|
+
return { success: true }
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { success: false, error: e.message }
|
|
72
|
+
}
|
|
73
|
+
}
|