🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

mihomod

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mihomod - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+1
-3
bin/cli.js
#!/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';

{
"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"
}
}

@@ -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 @@

@@ -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.
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 @@ {

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 @@ }

@@ -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 };
}
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;
}
}

@@ -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?)' };
}

@@ -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 @@ }

@@ -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);
}