+1
-3
| #!/usr/bin/env node | ||
| import { resolve } from 'path'; | ||
| import { install } from '../src/install.js'; | ||
| import { configure, addNode } from '../src/configure.js'; | ||
| import { startMihomo, stopMihomo } from '../src/service.js'; | ||
| import { status, listNodes } from '../src/api.js'; | ||
| import { status, listNodes, switchNode } from '../src/api.js'; | ||
| import { watch } from '../src/watchdog.js'; | ||
| import { switchNode } from '../src/api.js'; | ||
| import { loadConfig } from '../src/config.js'; | ||
@@ -10,0 +8,0 @@ import { output, error } from '../src/logger.js'; |
+21
-3
| { | ||
| "name": "mihomod", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "Network proxy manager and health watchdog for mihomo. Install, configure, monitor, and auto-switch proxy nodes.", | ||
@@ -13,3 +13,11 @@ "type": "module", | ||
| }, | ||
| "keywords": ["mihomo", "proxy", "watchdog", "clash", "vpn", "openclaw", "claw-use"], | ||
| "keywords": [ | ||
| "mihomo", | ||
| "proxy", | ||
| "watchdog", | ||
| "clash", | ||
| "vpn", | ||
| "openclaw", | ||
| "claw-use" | ||
| ], | ||
| "author": "4ier", | ||
@@ -24,3 +32,13 @@ "license": "MIT", | ||
| }, | ||
| "files": ["bin/", "src/", "templates/", "SKILL.md", "README.md", "LICENSE"] | ||
| "files": [ | ||
| "bin/", | ||
| "src/", | ||
| "templates/", | ||
| "SKILL.md", | ||
| "README.md", | ||
| "LICENSE" | ||
| ], | ||
| "dependencies": { | ||
| "yaml": "^2.8.3" | ||
| } | ||
| } |
+8
-0
@@ -13,2 +13,3 @@ # claw-use-mihomo | ||
| - 🔄 **Auto-switch** to best available node on failure | ||
| - 🛡️ **Safe config writes** — atomic write + YAML validation + backup | ||
| - 🖥️ **Cross-platform** Linux, macOS, Windows | ||
@@ -77,2 +78,9 @@ | ||
| ## Safety | ||
| - **Atomic config writes**: new config is written to a temp file, validated (YAML parse + structure check), then renamed into place. Old config is always backed up to `.bak`. | ||
| - **All network calls have timeouts**: API 5s, subscriptions 30s, binary downloads 120s. | ||
| - **Subscription size capped** at 10MB. | ||
| - **Graceful shutdown**: watchdog handles SIGTERM/SIGINT cleanly. | ||
| ## Agent-Friendly | ||
@@ -79,0 +87,0 @@ |
+13
-1
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: claw-use-mihomo | ||
| description: Manage mihomo proxy - install, configure from subscriptions, monitor health, auto-switch nodes. Supports vmess/ss/trojan/vless protocols. | ||
| --- | ||
| # claw-use-mihomo | ||
@@ -31,2 +36,3 @@ | ||
| ``` | ||
| Config is validated (YAML parse + structure check) before writing. Old config is backed up to `.bak`. | ||
@@ -68,3 +74,3 @@ ### Add single node | ||
| ``` | ||
| Monitors endpoints, auto-switches on failure. Outputs JSON events to stdout. | ||
| Monitors endpoints, auto-switches on failure. Outputs JSON events to stdout. Handles SIGTERM/SIGINT gracefully. | ||
@@ -75,4 +81,10 @@ ## Config | ||
| ## Safety | ||
| - Config writes are **atomic**: write to `.tmp` → validate YAML + structure → rename (old config backed up to `.bak`) | ||
| - Subscription content is validated before writing — malformed YAML is rejected | ||
| - All network calls have timeouts (API: 5s, subscriptions: 30s, downloads: 120s) | ||
| - Subscription downloads capped at 10MB | ||
| ## All output is JSON | ||
| All commands output structured JSON (human-readable on TTY). | ||
| Exit codes: 0=success, 1=error, 2=config error, 3=network error. |
+18
-9
| import { log } from './logger.js'; | ||
| const API_TIMEOUT_MS = 5000; | ||
| function apiUrl(config, path) { | ||
@@ -13,7 +15,14 @@ return `${config.mihomo.api}${path}`; | ||
| function fetchWithTimeout(url, options = {}) { | ||
| return fetch(url, { | ||
| signal: AbortSignal.timeout(API_TIMEOUT_MS), | ||
| ...options, | ||
| }); | ||
| } | ||
| export async function status(config) { | ||
| try { | ||
| const [proxyRes, versionRes] = await Promise.all([ | ||
| fetch(apiUrl(config, `/proxies/${encodeURIComponent(config.selector)}`), { headers: headers(config) }), | ||
| fetch(apiUrl(config, '/version'), { headers: headers(config) }) | ||
| fetchWithTimeout(apiUrl(config, `/proxies/${encodeURIComponent(config.selector)}`), { headers: headers(config) }), | ||
| fetchWithTimeout(apiUrl(config, '/version'), { headers: headers(config) }) | ||
| ]); | ||
@@ -26,7 +35,8 @@ | ||
| const allRes = await fetch(apiUrl(config, '/proxies'), { headers: headers(config) }); | ||
| const allRes = await fetchWithTimeout(apiUrl(config, '/proxies'), { headers: headers(config) }); | ||
| const all = await allRes.json(); | ||
| const skipTypes = new Set(['Selector', 'URLTest', 'Fallback', 'Direct', 'Reject', 'Compatible', 'Pass']); | ||
| const proxies = Object.values(all.proxies || {}); | ||
| const alive = proxies.filter(p => p.alive && !['Selector','URLTest','Fallback','Direct','Reject','Compatible','Pass'].includes(p.type)).length; | ||
| const total = proxies.filter(p => !['Selector','URLTest','Fallback','Direct','Reject','Compatible','Pass'].includes(p.type)).length; | ||
| const alive = proxies.filter(p => p.alive && !skipTypes.has(p.type)).length; | ||
| const total = proxies.filter(p => !skipTypes.has(p.type)).length; | ||
@@ -54,6 +64,6 @@ const hist = proxy.history || []; | ||
| export async function listNodes(config) { | ||
| const res = await fetch(apiUrl(config, '/proxies'), { headers: headers(config) }); | ||
| const res = await fetchWithTimeout(apiUrl(config, '/proxies'), { headers: headers(config) }); | ||
| if (!res.ok) throw new Error(`API error: ${res.status}`); | ||
| const data = await res.json(); | ||
| const skipTypes = new Set(['Selector','URLTest','Fallback','Direct','Reject','Compatible','Pass']); | ||
| const skipTypes = new Set(['Selector', 'URLTest', 'Fallback', 'Direct', 'Reject', 'Compatible', 'Pass']); | ||
@@ -79,3 +89,2 @@ return Object.entries(data.proxies || {}) | ||
| if (!nodeName) { | ||
| // Auto-select best | ||
| const nodes = await listNodes(config); | ||
@@ -100,3 +109,3 @@ const priorities = config.watchdog.nodePriority || []; | ||
| const res = await fetch( | ||
| const res = await fetchWithTimeout( | ||
| apiUrl(config, `/proxies/${encodeURIComponent(config.selector)}`), | ||
@@ -103,0 +112,0 @@ { |
+29
-5
| import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { dirname, join } from 'path'; | ||
| import { homedir } from 'os'; | ||
@@ -28,12 +28,36 @@ | ||
| function deepMerge(target, source) { | ||
| const result = { ...target }; | ||
| for (const key of Object.keys(source)) { | ||
| if ( | ||
| source[key] !== null && | ||
| typeof source[key] === 'object' && | ||
| !Array.isArray(source[key]) && | ||
| typeof target[key] === 'object' && | ||
| !Array.isArray(target[key]) && | ||
| target[key] !== null | ||
| ) { | ||
| result[key] = deepMerge(target[key], source[key]); | ||
| } else { | ||
| result[key] = source[key]; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| export function loadConfig(overridePath) { | ||
| const configPath = overridePath || DEFAULT_CONFIG_FILE; | ||
| if (!existsSync(configPath)) { | ||
| // Create default config | ||
| mkdirSync(DEFAULT_CONFIG_DIR, { recursive: true }); | ||
| writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2)); | ||
| return DEFAULT_CONFIG; | ||
| return structuredClone(DEFAULT_CONFIG); | ||
| } | ||
| const raw = readFileSync(configPath, 'utf8'); | ||
| return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } catch (e) { | ||
| throw new Error(`Failed to parse config ${configPath}: ${e.message}`); | ||
| } | ||
| return deepMerge(DEFAULT_CONFIG, parsed); | ||
| } | ||
@@ -43,3 +67,3 @@ | ||
| const configPath = overridePath || DEFAULT_CONFIG_FILE; | ||
| mkdirSync(join(configPath, '..'), { recursive: true }); | ||
| mkdirSync(dirname(configPath), { recursive: true }); | ||
| writeFileSync(configPath, JSON.stringify(config, null, 2)); | ||
@@ -46,0 +70,0 @@ } |
+190
-100
@@ -1,64 +0,183 @@ | ||
| import { readFileSync, writeFileSync, existsSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { getConfigDir } from './platform.js'; | ||
| import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'; | ||
| import { spawnSync } from 'child_process'; | ||
| import { dirname, join } from 'path'; | ||
| import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; | ||
| import { findBinary, getConfigDir } from './platform.js'; | ||
| import { parseProxyUrl, fetchSubscription } from './subscribe.js'; | ||
| import { log } from './logger.js'; | ||
| const CONFIG_TEMPLATE = `mixed-port: 7890 | ||
| allow-lan: false | ||
| bind-address: '*' | ||
| mode: rule | ||
| log-level: info | ||
| external-controller: 0.0.0.0:9090 | ||
| unified-delay: true | ||
| tcp-concurrent: true | ||
| ipv6: true | ||
| const CONFIG_TEMPLATE = { | ||
| 'mixed-port': 7890, | ||
| 'allow-lan': false, | ||
| 'bind-address': '*', | ||
| mode: 'rule', | ||
| 'log-level': 'info', | ||
| 'external-controller': '0.0.0.0:9090', | ||
| 'unified-delay': true, | ||
| 'tcp-concurrent': true, | ||
| ipv6: true, | ||
| dns: { | ||
| enable: true, | ||
| ipv6: true, | ||
| 'enhanced-mode': 'fake-ip', | ||
| 'fake-ip-range': '198.18.0.1/16', | ||
| 'default-nameserver': ['223.5.5.5', '119.29.29.29'], | ||
| nameserver: ['223.5.5.5', '119.29.29.29'], | ||
| fallback: ['https://cloudflare-dns.com/dns-query', 'https://dns.google/dns-query'], | ||
| 'fallback-filter': { | ||
| geoip: true, | ||
| 'geoip-code': 'CN' | ||
| } | ||
| }, | ||
| tun: { | ||
| enable: true, | ||
| stack: 'system', | ||
| 'dns-hijack': ['any:53'], | ||
| 'auto-route': true, | ||
| 'auto-detect-interface': true | ||
| } | ||
| }; | ||
| dns: | ||
| enable: true | ||
| ipv6: true | ||
| enhanced-mode: fake-ip | ||
| fake-ip-range: 198.18.0.1/16 | ||
| default-nameserver: | ||
| - 223.5.5.5 | ||
| - 119.29.29.29 | ||
| nameserver: | ||
| - 223.5.5.5 | ||
| - 119.29.29.29 | ||
| fallback: | ||
| - https://cloudflare-dns.com/dns-query | ||
| - https://dns.google/dns-query | ||
| fallback-filter: | ||
| geoip: true | ||
| geoip-code: CN | ||
| function isPlainObject(value) { | ||
| return Boolean(value) && typeof value === 'object' && !Array.isArray(value); | ||
| } | ||
| tun: | ||
| enable: true | ||
| stack: system | ||
| dns-hijack: | ||
| - any:53 | ||
| auto-route: true | ||
| auto-detect-interface: true | ||
| function validateProxy(proxy, index, source) { | ||
| if (!isPlainObject(proxy)) { | ||
| throw new Error(`${source}: proxy #${index + 1} must be an object`); | ||
| } | ||
| proxies: [] | ||
| for (const key of ['name', 'type', 'server', 'port']) { | ||
| if (proxy[key] === undefined || proxy[key] === null || proxy[key] === '') { | ||
| throw new Error(`${source}: proxy #${index + 1} is missing '${key}'`); | ||
| } | ||
| } | ||
| proxy-groups: | ||
| - name: 🚀节点选择 | ||
| type: select | ||
| proxies: | ||
| - ♻️自动选择 | ||
| - DIRECT | ||
| if (!Number.isFinite(Number(proxy.port)) || Number(proxy.port) <= 0) { | ||
| throw new Error(`${source}: proxy #${index + 1} has invalid 'port'`); | ||
| } | ||
| } | ||
| - name: ♻️自动选择 | ||
| type: url-test | ||
| url: https://www.gstatic.com/generate_204 | ||
| interval: 300 | ||
| tolerance: 50 | ||
| proxies: [] | ||
| function validateClashConfig(doc, source) { | ||
| if (!isPlainObject(doc)) { | ||
| throw new Error(`${source}: config must be a YAML object`); | ||
| } | ||
| rules: | ||
| - GEOIP,cn,DIRECT | ||
| - MATCH,🚀节点选择 | ||
| `; | ||
| if (!Array.isArray(doc.proxies) || doc.proxies.length === 0) { | ||
| throw new Error(`${source}: 'proxies' must be a non-empty array`); | ||
| } | ||
| if (!Array.isArray(doc['proxy-groups']) || doc['proxy-groups'].length === 0) { | ||
| throw new Error(`${source}: 'proxy-groups' must be a non-empty array`); | ||
| } | ||
| if (!Array.isArray(doc.rules) || doc.rules.length === 0) { | ||
| throw new Error(`${source}: 'rules' must be a non-empty array`); | ||
| } | ||
| doc.proxies.forEach((proxy, index) => validateProxy(proxy, index, source)); | ||
| return doc; | ||
| } | ||
| function parseAndValidateYaml(content, source) { | ||
| let parsed; | ||
| try { | ||
| parsed = parseYaml(content); | ||
| } catch (error) { | ||
| throw new Error(`${source}: invalid YAML - ${error.message}`); | ||
| } | ||
| return validateClashConfig(parsed, source); | ||
| } | ||
| function buildConfigFromProxies(proxies) { | ||
| const proxyNames = proxies.map(proxy => proxy.name); | ||
| return { | ||
| ...CONFIG_TEMPLATE, | ||
| proxies, | ||
| 'proxy-groups': [ | ||
| { | ||
| name: '🚀节点选择', | ||
| type: 'select', | ||
| proxies: ['♻️自动选择', 'DIRECT', ...proxyNames] | ||
| }, | ||
| { | ||
| name: '♻️自动选择', | ||
| type: 'url-test', | ||
| url: 'https://www.gstatic.com/generate_204', | ||
| interval: 300, | ||
| tolerance: 50, | ||
| proxies: proxyNames | ||
| } | ||
| ], | ||
| rules: ['GEOIP,cn,DIRECT', 'MATCH,🚀节点选择'] | ||
| }; | ||
| } | ||
| function ensureGroupIncludesProxy(group, proxyName) { | ||
| if (!Array.isArray(group.proxies)) group.proxies = []; | ||
| if (!group.proxies.includes(proxyName)) { | ||
| group.proxies.push(proxyName); | ||
| } | ||
| } | ||
| function runOptionalMihomoValidation(tempPath, config) { | ||
| const binaryPath = config?.mihomo?.binaryPath || findBinary(); | ||
| if (!binaryPath) { | ||
| log('Skipping optional mihomo -t validation: binary not found'); | ||
| return; | ||
| } | ||
| const result = spawnSync(binaryPath, ['-t', '-d', dirname(tempPath), '-f', tempPath], { | ||
| encoding: 'utf8', | ||
| stdio: ['ignore', 'pipe', 'pipe'] | ||
| }); | ||
| if (result.error) { | ||
| log(`Optional mihomo -t validation failed to start: ${result.error.message}`); | ||
| return; | ||
| } | ||
| if (result.status !== 0) { | ||
| const message = (result.stderr || result.stdout || '').trim() || 'unknown error'; | ||
| log(`Optional mihomo -t validation failed (non-blocking): ${message}`); | ||
| } | ||
| } | ||
| function atomicWriteConfig(configPath, yamlContent, config) { | ||
| mkdirSync(dirname(configPath), { recursive: true }); | ||
| const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`; | ||
| const backupPath = `${configPath}.bak`; | ||
| writeFileSync(tempPath, yamlContent, 'utf8'); | ||
| const validatedConfig = parseAndValidateYaml(readFileSync(tempPath, 'utf8'), tempPath); | ||
| runOptionalMihomoValidation(tempPath, config); | ||
| try { | ||
| if (existsSync(backupPath)) { | ||
| unlinkSync(backupPath); | ||
| } | ||
| if (existsSync(configPath)) { | ||
| renameSync(configPath, backupPath); | ||
| } | ||
| renameSync(tempPath, configPath); | ||
| return validatedConfig; | ||
| } catch (error) { | ||
| if (!existsSync(configPath) && existsSync(backupPath)) { | ||
| try { | ||
| renameSync(backupPath, configPath); | ||
| } catch {} | ||
| } | ||
| throw error; | ||
| } finally { | ||
| if (existsSync(tempPath)) { | ||
| unlinkSync(tempPath); | ||
| } | ||
| } | ||
| } | ||
| export async function configure(subscriptionUrl, config) { | ||
@@ -74,45 +193,15 @@ const configDir = getConfigDir(); | ||
| if (sub.format === 'clash') { | ||
| // Already in clash format, use directly | ||
| yamlContent = sub.raw; | ||
| log('Using subscription config directly (clash format)'); | ||
| const clashConfig = sub.config || parseAndValidateYaml(sub.raw, 'subscription'); | ||
| yamlContent = stringifyYaml(clashConfig); | ||
| log('Validated clash-format subscription'); | ||
| } else { | ||
| // Build from template + parsed proxies | ||
| const proxyNames = sub.proxies.map(p => p.name); | ||
| let content = CONFIG_TEMPLATE; | ||
| // Simple YAML manipulation (avoid heavy deps) | ||
| const proxyLines = sub.proxies.map(p => { | ||
| const entries = Object.entries(p).map(([k, v]) => { | ||
| if (typeof v === 'object') return ` ${k}: ${JSON.stringify(v)}`; | ||
| if (typeof v === 'boolean') return ` ${k}: ${v}`; | ||
| if (typeof v === 'number') return ` ${k}: ${v}`; | ||
| return ` ${k}: "${v}"`; | ||
| }); | ||
| return ` - ${entries.join('\n ')}`; | ||
| }); | ||
| // Inject proxies | ||
| content = content.replace('proxies: []', 'proxies:\n' + sub.proxies.map(p => | ||
| ' - ' + JSON.stringify(p).replace(/,"/g, ', "') | ||
| ).join('\n')); | ||
| // Inject proxy names into groups | ||
| const namesList = proxyNames.map(n => ` - ${n}`).join('\n'); | ||
| content = content.replace( | ||
| " - name: 🚀节点选择\n type: select\n proxies:\n - ♻️自动选择\n - DIRECT", | ||
| ` - name: 🚀节点选择\n type: select\n proxies:\n - ♻️自动选择\n - DIRECT\n${namesList}` | ||
| ); | ||
| content = content.replace( | ||
| " - name: ♻️自动选择\n type: url-test\n url: https://www.gstatic.com/generate_204\n interval: 300\n tolerance: 50\n proxies: []", | ||
| ` - name: ♻️自动选择\n type: url-test\n url: https://www.gstatic.com/generate_204\n interval: 300\n tolerance: 50\n proxies:\n${namesList}` | ||
| ); | ||
| yamlContent = content; | ||
| const builtConfig = validateClashConfig(buildConfigFromProxies(sub.proxies), 'generated config'); | ||
| yamlContent = stringifyYaml(builtConfig); | ||
| } | ||
| writeFileSync(configPath, yamlContent); | ||
| const writtenConfig = atomicWriteConfig(configPath, yamlContent, config); | ||
| log(`Config written to ${configPath}`); | ||
| const nodeCount = (yamlContent.match(/- name:/g) || []).length; | ||
| const groupCount = (yamlContent.match(/type: (select|url-test|fallback)/g) || []).length; | ||
| const nodeCount = writtenConfig.proxies.length; | ||
| const groupCount = writtenConfig['proxy-groups'].length; | ||
@@ -132,16 +221,17 @@ return { configured: true, nodes: nodeCount, groups: groupCount, path: configPath }; | ||
| let content = readFileSync(configPath, 'utf8'); | ||
| const content = readFileSync(configPath, 'utf8'); | ||
| const parsedConfig = parseAndValidateYaml(content, configPath); | ||
| // Append proxy to proxies list | ||
| const proxyYaml = ' - ' + JSON.stringify(proxy).replace(/,"/g, ', "'); | ||
| content = content.replace(/(proxies:\n)/, `$1${proxyYaml}\n`); | ||
| parsedConfig.proxies.push(proxy); | ||
| // Add to selector groups | ||
| const nameEntry = ` - ${proxy.name}`; | ||
| content = content.replace(/(🚀节点选择[\s\S]*?proxies:\n)/,`$1${nameEntry}\n`); | ||
| content = content.replace(/(♻️自动选择[\s\S]*?proxies:\n)/, `$1${nameEntry}\n`); | ||
| const targetGroups = new Set([config?.selector, '🚀节点选择', '♻️自动选择'].filter(Boolean)); | ||
| for (const group of parsedConfig['proxy-groups']) { | ||
| if (targetGroups.has(group.name)) { | ||
| ensureGroupIncludesProxy(group, proxy.name); | ||
| } | ||
| } | ||
| writeFileSync(configPath, content); | ||
| atomicWriteConfig(configPath, stringifyYaml(parsedConfig), config); | ||
| return { added: true, name: proxy.name, type: proxy.type, server: proxy.server, port: proxy.port }; | ||
| } |
+61
-31
| import { getPlatform, getBinaryPath, getConfigDir } from './platform.js'; | ||
| import { mkdirSync, chmodSync, existsSync, createWriteStream, writeFileSync } from 'fs'; | ||
| import { mkdirSync, chmodSync, existsSync, writeFileSync, statSync } from 'fs'; | ||
| import { join, dirname } from 'path'; | ||
| import { execSync } from 'child_process'; | ||
| import { spawnSync } from 'child_process'; | ||
| import { mkdtempSync } from 'fs'; | ||
| import { tmpdir } from 'os'; | ||
| import { log } from './logger.js'; | ||
@@ -9,5 +11,6 @@ import { homedir } from 'os'; | ||
| const RELEASES_API = 'https://api.github.com/repos/MetaCubeX/mihomo/releases/latest'; | ||
| const DOWNLOAD_TIMEOUT_MS = 120000; | ||
| async function getLatestRelease() { | ||
| const res = await fetch(RELEASES_API); | ||
| const res = await fetch(RELEASES_API, { signal: AbortSignal.timeout(30000) }); | ||
| if (!res.ok) throw new Error(`Failed to fetch releases: ${res.status}`); | ||
@@ -28,6 +31,6 @@ return res.json(); | ||
| async function download(url, dest) { | ||
| const res = await fetch(url); | ||
| const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); | ||
| if (!res.ok) throw new Error(`Download failed: ${res.status}`); | ||
| const buffer = Buffer.from(await res.arrayBuffer()); | ||
| const { writeFileSync } = await import('fs'); | ||
| if (buffer.length === 0) throw new Error('Downloaded file is empty'); | ||
| writeFileSync(dest, buffer); | ||
@@ -43,3 +46,2 @@ } | ||
| // Fetch latest release | ||
| log('Fetching latest mihomo release...'); | ||
@@ -52,20 +54,34 @@ const release = await getLatestRelease(); | ||
| // Download | ||
| const tmpFile = join('/tmp', asset.name); | ||
| // Use unique temp directory | ||
| const tmpDir = mkdtempSync(join(tmpdir(), 'mihomod-')); | ||
| const tmpFile = join(tmpDir, asset.name); | ||
| await download(asset.browser_download_url, tmpFile); | ||
| // Verify download size matches | ||
| const downloadedSize = statSync(tmpFile).size; | ||
| if (asset.size && Math.abs(downloadedSize - asset.size) > 1024) { | ||
| throw new Error(`Download size mismatch: expected ~${asset.size}, got ${downloadedSize}`); | ||
| } | ||
| // Extract | ||
| mkdirSync(dirname(binPath), { recursive: true }); | ||
| if (tmpFile.endsWith('.gz')) { | ||
| execSync(`gunzip -f "${tmpFile}"`, { stdio: 'pipe' }); | ||
| const gunzipResult = spawnSync('gunzip', ['-f', tmpFile], { stdio: 'pipe' }); | ||
| if (gunzipResult.status !== 0) throw new Error(`gunzip failed: ${(gunzipResult.stderr || '').toString()}`); | ||
| const extracted = tmpFile.replace('.gz', ''); | ||
| execSync(`mv "${extracted}" "${binPath}"`, { stdio: 'pipe' }); | ||
| const mvResult = spawnSync('mv', [extracted, binPath], { stdio: 'pipe' }); | ||
| if (mvResult.status !== 0) throw new Error(`mv failed: ${(mvResult.stderr || '').toString()}`); | ||
| chmodSync(binPath, 0o755); | ||
| } else if (tmpFile.endsWith('.zip')) { | ||
| execSync(`unzip -o "${tmpFile}" -d "${dirname(binPath)}"`, { stdio: 'pipe' }); | ||
| // Rename extracted binary | ||
| const unzipResult = spawnSync('unzip', ['-o', tmpFile, '-d', dirname(binPath)], { stdio: 'pipe' }); | ||
| if (unzipResult.status !== 0) throw new Error(`unzip failed: ${(unzipResult.stderr || '').toString()}`); | ||
| const extracted = join(dirname(binPath), asset.name.replace('.zip', '')); | ||
| if (existsSync(extracted)) execSync(`mv "${extracted}" "${binPath}"`, { stdio: 'pipe' }); | ||
| if (existsSync(extracted)) { | ||
| spawnSync('mv', [extracted, binPath], { stdio: 'pipe' }); | ||
| } | ||
| } | ||
| // Clean up temp dir | ||
| spawnSync('rm', ['-rf', tmpDir], { stdio: 'pipe' }); | ||
| // Create config dir | ||
@@ -84,3 +100,4 @@ mkdirSync(configDir, { recursive: true }); | ||
| try { | ||
| const ver = execSync(`"${binPath}" -v`, { encoding: 'utf8' }).trim(); | ||
| const result = spawnSync(binPath, ['-v'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); | ||
| const ver = (result.stdout || '').trim(); | ||
| log(`Installed: ${ver}`); | ||
@@ -94,4 +111,3 @@ return { installed: true, version: ver, path: binPath, configDir, service: serviceInstalled }; | ||
| function installSystemdService(binPath, configDir) { | ||
| try { | ||
| const unit = `[Unit] | ||
| const unit = `[Unit] | ||
| Description=mihomo Daemon | ||
@@ -111,18 +127,28 @@ After=network-online.target | ||
| `; | ||
| try { | ||
| // Try system-level service with sudo | ||
| const servicePath = '/etc/systemd/system/mihomo.service'; | ||
| try { | ||
| execSync(`echo '${unit}' | sudo tee ${servicePath}`, { stdio: 'pipe' }); | ||
| execSync('sudo systemctl daemon-reload', { stdio: 'pipe' }); | ||
| execSync('sudo systemctl enable mihomo', { stdio: 'pipe' }); | ||
| const result = spawnSync('sudo', ['tee', servicePath], { | ||
| input: unit, | ||
| stdio: ['pipe', 'pipe', 'pipe'] | ||
| }); | ||
| if (result.status === 0) { | ||
| spawnSync('sudo', ['systemctl', 'daemon-reload'], { stdio: 'pipe' }); | ||
| spawnSync('sudo', ['systemctl', 'enable', 'mihomo'], { stdio: 'pipe' }); | ||
| return true; | ||
| } catch { | ||
| // No sudo, try user service | ||
| const userDir = join(homedir(), '.config', 'systemd', 'user'); | ||
| mkdirSync(userDir, { recursive: true }); | ||
| writeFileSync(join(userDir, 'mihomo.service'), unit); | ||
| execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); | ||
| execSync('systemctl --user enable mihomo', { stdio: 'pipe' }); | ||
| return true; | ||
| } | ||
| } catch { return false; } | ||
| } catch {} | ||
| try { | ||
| // Fallback to user service | ||
| const userDir = join(homedir(), '.config', 'systemd', 'user'); | ||
| mkdirSync(userDir, { recursive: true }); | ||
| writeFileSync(join(userDir, 'mihomo.service'), unit); | ||
| spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' }); | ||
| spawnSync('systemctl', ['--user', 'enable', 'mihomo'], { stdio: 'pipe' }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
@@ -147,6 +173,10 @@ | ||
| </plist>`; | ||
| const plistPath = join(homedir(), 'Library', 'LaunchAgents', 'com.mihomo.daemon.plist'); | ||
| const agentsDir = join(homedir(), 'Library', 'LaunchAgents'); | ||
| mkdirSync(agentsDir, { recursive: true }); | ||
| const plistPath = join(agentsDir, 'com.mihomo.daemon.plist'); | ||
| writeFileSync(plistPath, plist); | ||
| return true; | ||
| } catch { return false; } | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
+72
-34
@@ -1,11 +0,33 @@ | ||
| import { execSync, spawn } from 'child_process'; | ||
| import { spawnSync, spawn } from 'child_process'; | ||
| import { platform } from 'os'; | ||
| import { existsSync } from 'fs'; | ||
| import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; | ||
| import { join, dirname } from 'path'; | ||
| import { getBinaryPath, getConfigDir } from './platform.js'; | ||
| import { log } from './logger.js'; | ||
| import { homedir } from 'os'; | ||
| const PID_FILE = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'mihomod', 'mihomo.pid'); | ||
| function savePid(pid) { | ||
| mkdirSync(dirname(PID_FILE), { recursive: true }); | ||
| writeFileSync(PID_FILE, String(pid)); | ||
| } | ||
| function loadPid() { | ||
| try { | ||
| const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim()); | ||
| if (!Number.isFinite(pid) || pid <= 0) return null; | ||
| // Check if process is alive | ||
| try { process.kill(pid, 0); return pid; } catch { return null; } | ||
| } catch { return null; } | ||
| } | ||
| function clearPid() { | ||
| try { writeFileSync(PID_FILE, ''); } catch {} | ||
| } | ||
| export async function startMihomo(config) { | ||
| const os = platform(); | ||
| const binPath = config?.mihomo?.binaryPath || getBinaryPath(); | ||
| const configDir = config?.mihomo?.configPath ? config.mihomo.configPath.replace(/\/[^/]+$/, '') : getConfigDir(); | ||
| const configDir = config?.mihomo?.configPath ? dirname(config.mihomo.configPath) : getConfigDir(); | ||
@@ -17,22 +39,25 @@ if (!existsSync(binPath)) { | ||
| if (os === 'linux') { | ||
| try { | ||
| execSync('systemctl is-active mihomo', { stdio: 'pipe' }); | ||
| // Check if already running via systemd | ||
| const isActive = spawnSync('systemctl', ['is-active', 'mihomo'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); | ||
| if (isActive.stdout?.trim() === 'active') { | ||
| return { started: true, method: 'systemd', note: 'already running' }; | ||
| } catch {} | ||
| try { | ||
| execSync('sudo systemctl start mihomo', { stdio: 'pipe' }); | ||
| return { started: true, method: 'systemd' }; | ||
| } catch { | ||
| try { | ||
| execSync('systemctl --user start mihomo', { stdio: 'pipe' }); | ||
| return { started: true, method: 'systemd-user' }; | ||
| } catch {} | ||
| } | ||
| // Try system systemd | ||
| const sysResult = spawnSync('sudo', ['systemctl', 'start', 'mihomo'], { stdio: 'pipe' }); | ||
| if (sysResult.status === 0) return { started: true, method: 'systemd' }; | ||
| // Try user systemd | ||
| const userActive = spawnSync('systemctl', ['--user', 'is-active', 'mihomo'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); | ||
| if (userActive.stdout?.trim() === 'active') { | ||
| return { started: true, method: 'systemd-user', note: 'already running' }; | ||
| } | ||
| const userResult = spawnSync('systemctl', ['--user', 'start', 'mihomo'], { stdio: 'pipe' }); | ||
| if (userResult.status === 0) return { started: true, method: 'systemd-user' }; | ||
| } | ||
| if (os === 'darwin') { | ||
| try { | ||
| execSync('launchctl load ~/Library/LaunchAgents/com.mihomo.daemon.plist', { stdio: 'pipe' }); | ||
| return { started: true, method: 'launchd' }; | ||
| } catch {} | ||
| const result = spawnSync('launchctl', ['load', join(homedir(), 'Library', 'LaunchAgents', 'com.mihomo.daemon.plist')], { stdio: 'pipe' }); | ||
| if (result.status === 0) return { started: true, method: 'launchd' }; | ||
| } | ||
@@ -46,3 +71,11 @@ | ||
| child.unref(); | ||
| savePid(child.pid); | ||
| // Wait a moment and verify it's still running | ||
| await new Promise(r => setTimeout(r, 2000)); | ||
| const stillAlive = loadPid(); | ||
| if (!stillAlive) { | ||
| throw new Error('mihomo started but exited immediately. Check config with: mihomo -t -d ' + configDir); | ||
| } | ||
| return { started: true, method: 'direct', pid: child.pid }; | ||
@@ -55,27 +88,32 @@ } | ||
| if (os === 'linux') { | ||
| try { | ||
| execSync('sudo systemctl stop mihomo', { stdio: 'pipe' }); | ||
| return { stopped: true, method: 'systemd' }; | ||
| } catch { | ||
| try { | ||
| execSync('systemctl --user stop mihomo', { stdio: 'pipe' }); | ||
| return { stopped: true, method: 'systemd-user' }; | ||
| } catch {} | ||
| } | ||
| const sysResult = spawnSync('sudo', ['systemctl', 'stop', 'mihomo'], { stdio: 'pipe' }); | ||
| if (sysResult.status === 0) return { stopped: true, method: 'systemd' }; | ||
| const userResult = spawnSync('systemctl', ['--user', 'stop', 'mihomo'], { stdio: 'pipe' }); | ||
| if (userResult.status === 0) return { stopped: true, method: 'systemd-user' }; | ||
| } | ||
| if (os === 'darwin') { | ||
| const result = spawnSync('launchctl', ['unload', join(homedir(), 'Library', 'LaunchAgents', 'com.mihomo.daemon.plist')], { stdio: 'pipe' }); | ||
| if (result.status === 0) return { stopped: true, method: 'launchd' }; | ||
| } | ||
| // Try saved PID | ||
| const pid = loadPid(); | ||
| if (pid) { | ||
| try { | ||
| execSync('launchctl unload ~/Library/LaunchAgents/com.mihomo.daemon.plist', { stdio: 'pipe' }); | ||
| return { stopped: true, method: 'launchd' }; | ||
| process.kill(pid, 'SIGTERM'); | ||
| clearPid(); | ||
| return { stopped: true, method: 'pid', pid }; | ||
| } catch {} | ||
| } | ||
| // Fallback: kill process | ||
| try { | ||
| execSync('pkill -f mihomo', { stdio: 'pipe' }); | ||
| // Last resort: pkill with exact binary name | ||
| const result = spawnSync('pkill', ['-x', 'mihomo'], { stdio: 'pipe' }); | ||
| if (result.status === 0) { | ||
| clearPid(); | ||
| return { stopped: true, method: 'pkill' }; | ||
| } catch { | ||
| return { stopped: false, error: 'Could not stop mihomo' }; | ||
| } | ||
| return { stopped: false, error: 'Could not stop mihomo (not running?)' }; | ||
| } |
+141
-23
@@ -5,2 +5,95 @@ /** | ||
| import { parse as parseYaml } from 'yaml'; | ||
| const SUBSCRIPTION_TIMEOUT_MS = 30000; | ||
| const MAX_SUBSCRIPTION_BYTES = 10 * 1024 * 1024; | ||
| function normalizeBase64(value) { | ||
| const normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, ''); | ||
| const padding = normalized.length % 4; | ||
| return padding ? normalized + '='.repeat(4 - padding) : normalized; | ||
| } | ||
| function decodeBase64(value) { | ||
| return Buffer.from(normalizeBase64(value), 'base64').toString('utf8'); | ||
| } | ||
| function splitAtFirst(value, delimiter) { | ||
| const index = value.indexOf(delimiter); | ||
| if (index === -1) return [value, '']; | ||
| return [value.slice(0, index), value.slice(index + delimiter.length)]; | ||
| } | ||
| function splitAtLast(value, delimiter) { | ||
| const index = value.lastIndexOf(delimiter); | ||
| if (index === -1) return [value, '']; | ||
| return [value.slice(0, index), value.slice(index + delimiter.length)]; | ||
| } | ||
| function parseHostPort(value) { | ||
| if (!value) throw new Error('Invalid SS host: missing server'); | ||
| if (value.startsWith('[')) { | ||
| const closing = value.indexOf(']'); | ||
| const portStart = value.lastIndexOf(':'); | ||
| if (closing === -1 || portStart <= closing) { | ||
| throw new Error('Invalid SS host: missing port'); | ||
| } | ||
| return { | ||
| server: value.slice(1, closing), | ||
| port: value.slice(portStart + 1) | ||
| }; | ||
| } | ||
| const portStart = value.lastIndexOf(':'); | ||
| if (portStart === -1) throw new Error('Invalid SS host: missing port'); | ||
| return { | ||
| server: value.slice(0, portStart), | ||
| port: value.slice(portStart + 1) | ||
| }; | ||
| } | ||
| function parseMethodPassword(value) { | ||
| const separator = value.indexOf(':'); | ||
| if (separator === -1) { | ||
| throw new Error('Invalid SS credentials'); | ||
| } | ||
| return { | ||
| method: value.slice(0, separator), | ||
| password: value.slice(separator + 1) | ||
| }; | ||
| } | ||
| async function readResponseText(response, maxBytes) { | ||
| const contentLength = Number(response.headers.get('content-length') || 0); | ||
| if (contentLength > maxBytes) { | ||
| throw new Error(`Subscription too large: ${contentLength} bytes exceeds ${maxBytes} bytes`); | ||
| } | ||
| if (!response.body) { | ||
| return response.text(); | ||
| } | ||
| const reader = response.body.getReader(); | ||
| const chunks = []; | ||
| let total = 0; | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| total += value.byteLength; | ||
| if (total > maxBytes) { | ||
| reader.releaseLock(); | ||
| throw new Error(`Subscription too large: exceeds ${maxBytes} bytes`); | ||
| } | ||
| chunks.push(Buffer.from(value)); | ||
| } | ||
| return Buffer.concat(chunks).toString('utf8'); | ||
| } | ||
| export function parseProxyUrl(url) { | ||
@@ -16,3 +109,3 @@ if (url.startsWith('vmess://')) return parseVmess(url); | ||
| const b64 = url.replace('vmess://', ''); | ||
| const data = JSON.parse(Buffer.from(b64, 'base64').toString()); | ||
| const data = JSON.parse(decodeBase64(b64)); | ||
| return { | ||
@@ -42,18 +135,28 @@ name: data.ps || `vmess-${data.add}`, | ||
| const cleaned = url.replace('ss://', ''); | ||
| const [main, fragment] = cleaned.split('#'); | ||
| const [mainWithQuery, fragment] = splitAtFirst(cleaned, '#'); | ||
| const [main] = splitAtFirst(mainWithQuery, '?'); | ||
| const name = fragment ? decodeURIComponent(fragment) : undefined; | ||
| let method, password, server, port; | ||
| let method; | ||
| let password; | ||
| let server; | ||
| let port; | ||
| if (main.includes('@')) { | ||
| const [userinfo, hostport] = main.split('@'); | ||
| const decoded = Buffer.from(userinfo, 'base64').toString(); | ||
| [method, password] = decoded.split(':'); | ||
| [server, port] = hostport.split(':'); | ||
| const [userinfoEncoded, hostport] = splitAtLast(main, '@'); | ||
| const decodedUserinfo = (() => { | ||
| try { | ||
| return decodeBase64(userinfoEncoded); | ||
| } catch { | ||
| return decodeURIComponent(userinfoEncoded); | ||
| } | ||
| })(); | ||
| ({ method, password } = parseMethodPassword(decodedUserinfo)); | ||
| ({ server, port } = parseHostPort(hostport)); | ||
| } else { | ||
| const decoded = Buffer.from(main, 'base64').toString(); | ||
| const match = decoded.match(/^(.+?):(.+?)@(.+?):(\d+)$/); | ||
| if (match) { | ||
| [, method, password, server, port] = match; | ||
| } | ||
| const decoded = decodeBase64(main); | ||
| const [userinfo, hostport] = splitAtLast(decoded, '@'); | ||
| ({ method, password } = parseMethodPassword(userinfo)); | ||
| ({ server, port } = parseHostPort(hostport)); | ||
| } | ||
@@ -109,23 +212,38 @@ | ||
| export async function fetchSubscription(url) { | ||
| const res = await fetch(url); | ||
| const res = await fetch(url, { signal: AbortSignal.timeout(SUBSCRIPTION_TIMEOUT_MS) }); | ||
| if (!res.ok) throw new Error(`Subscription fetch failed: ${res.status}`); | ||
| const text = await res.text(); | ||
| const text = await readResponseText(res, MAX_SUBSCRIPTION_BYTES); | ||
| // Try YAML (clash/mihomo format) | ||
| if (text.includes('proxies:')) { | ||
| return { format: 'clash', raw: text }; | ||
| } | ||
| try { | ||
| const parsed = parseYaml(text); | ||
| if (parsed && Array.isArray(parsed.proxies) && parsed.proxies.length > 0) { | ||
| return { format: 'clash', raw: text, config: parsed }; | ||
| } | ||
| } catch {} | ||
| const trimmed = text.trim(); | ||
| // Try base64 encoded list | ||
| try { | ||
| const decoded = Buffer.from(text.trim(), 'base64').toString(); | ||
| const lines = decoded.split('\n').filter(l => l.trim()); | ||
| const proxies = lines.map(l => parseProxyUrl(l.trim())).filter(Boolean); | ||
| return { format: 'base64', proxies }; | ||
| const decoded = decodeBase64(trimmed); | ||
| const lines = decoded | ||
| .split('\n') | ||
| .map(line => line.trim()) | ||
| .filter(line => line.match(/^(vmess|ss|trojan|vless):\/\//)); | ||
| if (lines.length > 0) { | ||
| const proxies = lines.map(line => parseProxyUrl(line)); | ||
| return { format: 'base64', proxies }; | ||
| } | ||
| } catch {} | ||
| // Try line-by-line URLs | ||
| const lines = text.split('\n').filter(l => l.trim().match(/^(vmess|ss|trojan|vless):\/\//)); | ||
| const lines = text | ||
| .split('\n') | ||
| .map(line => line.trim()) | ||
| .filter(line => line.match(/^(vmess|ss|trojan|vless):\/\//)); | ||
| if (lines.length) { | ||
| const proxies = lines.map(l => parseProxyUrl(l.trim())).filter(Boolean); | ||
| const proxies = lines.map(line => parseProxyUrl(line)); | ||
| return { format: 'urls', proxies }; | ||
@@ -132,0 +250,0 @@ } |
+24
-6
@@ -8,7 +8,18 @@ import { testEndpoints, switchNode, status } from './api.js'; | ||
| let lastSwitch = 0; | ||
| let running = true; | ||
| function shutdown(signal) { | ||
| log(`Received ${signal}, shutting down watchdog`); | ||
| console.log(JSON.stringify({ event: 'shutdown', signal })); | ||
| running = false; | ||
| } | ||
| process.on('SIGTERM', () => shutdown('SIGTERM')); | ||
| process.on('SIGINT', () => shutdown('SIGINT')); | ||
| const current = await status(config).catch(() => ({ node: 'unknown' })); | ||
| log(`Watchdog started: interval=${checkInterval}s threshold=${failThreshold} node=${current.node}`); | ||
| console.log(JSON.stringify({ event: 'started', node: current.node, interval: checkInterval, threshold: failThreshold })); | ||
| while (true) { | ||
| while (running) { | ||
| const result = await testEndpoints(config); | ||
@@ -33,5 +44,5 @@ | ||
| try { | ||
| const result = await switchNode(config); | ||
| log(`Switched: ${result.from} -> ${result.to}`); | ||
| console.log(JSON.stringify({ event: 'switch', ...result })); | ||
| const switchResult = await switchNode(config); | ||
| log(`Switched: ${switchResult.from} -> ${switchResult.to}`); | ||
| console.log(JSON.stringify({ event: 'switch', ...switchResult })); | ||
| } catch (e) { | ||
@@ -42,3 +53,3 @@ log(`Switch failed: ${e.message}`); | ||
| } else { | ||
| log(`Cooldown active, skipping switch`); | ||
| log('Cooldown active, skipping switch'); | ||
| } | ||
@@ -49,4 +60,11 @@ failCount = 0; | ||
| await new Promise(r => setTimeout(r, checkInterval * 1000)); | ||
| // Interruptible sleep | ||
| await new Promise(r => { | ||
| const timer = setTimeout(r, checkInterval * 1000); | ||
| if (!running) { clearTimeout(timer); r(); } | ||
| }); | ||
| } | ||
| log('Watchdog stopped'); | ||
| process.exit(0); | ||
| } |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
45673
33.1%1070
33.25%97
8.99%9
-25%1
Infinity%11
22.22%+ Added
+ Added