@nabikaz/xping
Version:
VLESS connection ping tool using Xray with fragment support
811 lines (714 loc) • 30.9 kB
JavaScript
/**
* XPing - VLESS Connection Ping Tool
*
* A powerful command-line tool for testing VLESS proxy connections with
* advanced features like fragment support, real-time ping statistics,
* and automatic port management.
*
* @repository https://github.com/NabiKAZ/xping
* @author NabiKAZ <https://x.com/NabiKAZ>
* @license GPL-3.0
* @created 2025
*
* Copyright (C) 2025 NabiKAZ
* Licensed under GNU General Public License v3.0
* See: https://www.gnu.org/licenses/gpl-3.0.html
*/
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import chalk from 'chalk';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import net from 'net';
const execAsync = promisify(exec);
// Configuration constants
const XRAY_PATH = process.env.XPING_XRAY_PATH || 'xray';
const TARGET_URL = process.env.XPING_TARGET_URL || 'https://www.google.com/generate_204';
const FRAGMENT_PACKETS = process.env.XPING_FRAGMENT_PACKETS || "tlshello";
const FRAGMENT_LENGTH = process.env.XPING_FRAGMENT_LENGTH || "5-9";
const FRAGMENT_INTERVAL = process.env.XPING_FRAGMENT_INTERVAL || "1-2";
// Function to find a free port
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, (err) => {
if (err) {
reject(err);
} else {
const port = server.address().port;
server.close(() => {
resolve(port);
});
}
});
});
}
// Function to check if xray is available
async function checkXrayAvailable() {
try {
// Try the specified XRAY_PATH first
await execAsync(`"${XRAY_PATH}" version`);
return XRAY_PATH;
} catch (error) {
// Try system PATH as fallback
try {
await execAsync('xray version');
return 'xray';
} catch (e) {
return false;
}
}
}
// Function to parse vless URL
function parseVlessUrl(vlessUrl) {
try {
// Remove vless:// prefix
const urlWithoutProtocol = vlessUrl.replace('vless://', '');
// Split at @ to separate uuid and server info
const [uuid, serverPart] = urlWithoutProtocol.split('@');
// Split server part to get address, port and parameters
const [serverAndPort, paramsPart] = serverPart.split('?');
const [address, port] = serverAndPort.split(':');
// Parse parameters
const params = new URLSearchParams(paramsPart.split('#')[0]);
return {
uuid: uuid,
address: address,
port: parseInt(port),
encryption: params.get('encryption') || 'none',
security: params.get('security') || 'tls',
sni: params.get('sni') || '',
fp: params.get('fp') || '',
type: params.get('type') || 'ws',
host: params.get('host') || '',
path: params.get('path') || '/',
remark: decodeURIComponent(paramsPart.split('#')[1] || 'vless-config')
};
} catch (error) {
throw new Error(`Failed to parse vless URL: ${error.message}`);
}
}
// Function to generate xray config
function generateXrayConfig(vlessConfig, fragmentEnabled = false, proxyPort = 10801) {
const config = {
"log": {
"loglevel": "error"
},
"dns": {
"servers": [
"8.8.8.8",
"1.1.1.1"
]
},
"inbounds": [
{
"tag": "http-proxy",
"port": proxyPort,
"listen": "127.0.0.1",
"protocol": "http",
"settings": {
"auth": "noauth",
"allowTransparent": false
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": vlessConfig.address,
"port": vlessConfig.port,
"users": [
{
"id": vlessConfig.uuid,
"email": "ping@test.com",
"security": "auto",
"encryption": vlessConfig.encryption
}
]
}
]
},
"streamSettings": {
"network": vlessConfig.type,
"security": vlessConfig.security,
"tlsSettings": vlessConfig.security === "tls" ? {
"allowInsecure": false,
"serverName": vlessConfig.sni,
"fingerprint": vlessConfig.fp
} : undefined,
"wsSettings": vlessConfig.type === "ws" ? {
"path": vlessConfig.path,
"headers": {
"Host": vlessConfig.host
}
} : undefined,
"sockopt": fragmentEnabled ? {
"dialerProxy": "fragment"
} : undefined
},
"mux": {
"enabled": false,
"concurrency": 8
}
},
{
"tag": "direct",
"protocol": "freedom",
"settings": {
"domainStrategy": "AsIs",
"userLevel": 0
}
},
{
"tag": "fragment",
"protocol": "freedom",
"settings": fragmentEnabled ? {
"fragment": {
"packets": FRAGMENT_PACKETS,
"length": FRAGMENT_LENGTH,
"interval": FRAGMENT_INTERVAL
}
} : {}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"outboundTag": "proxy",
"domain": [""]
}
]
}
};
return config;
}
// Function to get current time string
function getCurrentTimeString() {
const now = new Date();
return now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Function to test connection with fetch through proxy
function testConnection(proxyPort, timeout = 10000) {
return new Promise(async (resolve, reject) => {
try {
// Use undici for proxy support
const { ProxyAgent, fetch } = await import('undici');
const proxyAgent = new ProxyAgent(`http://127.0.0.1:${proxyPort}`);
const response = await fetch(TARGET_URL, {
method: 'HEAD',
dispatcher: proxyAgent,
signal: AbortSignal.timeout(timeout)
});
resolve({
success: true,
statusCode: response.status,
output: `HTTP/${response.status}`
});
} catch (error) {
reject({
success: false,
error: error.message
});
}
});
}
// Function to detect input type and load config
function detectInputTypeAndLoadConfig(input) {
// Check if input is a vless URL
if (input.startsWith('vless://')) {
return {
type: 'vless',
config: parseVlessUrl(input)
};
}
// Check if input is a file path
const filePath = resolve(input);
if (existsSync(filePath)) {
try {
const fileContent = readFileSync(filePath, 'utf8');
const xrayConfig = JSON.parse(fileContent);
// Extract connection info from xray config for display
const outbound = xrayConfig.outbounds?.find(o => o.protocol === 'vless' || o.protocol === 'vmess' || o.protocol === 'trojan');
if (!outbound) {
throw new Error('No valid proxy outbound found in config file');
}
const connectionInfo = extractConnectionInfo(outbound);
return {
type: 'config',
config: connectionInfo
};
} catch (error) {
throw new Error(`Failed to read config file: ${error.message}`);
}
}
throw new Error('Input must be either a vless:// URL or a valid xray config file path');
}
// Function to extract connection info from xray outbound for display
function extractConnectionInfo(outbound) {
const vnext = outbound.settings?.vnext?.[0];
const streamSettings = outbound.streamSettings || {};
return {
address: vnext?.address || 'unknown',
port: vnext?.port || 'unknown',
protocol: outbound.protocol || 'unknown',
security: streamSettings.security || 'none',
network: streamSettings.network || 'tcp',
remark: outbound.tag || 'config-file'
};
}
// Function to detect fragment settings in config file
function detectFragmentInConfig(xrayConfig) {
// Check if any outbound has fragment settings
const fragmentOutbounds = xrayConfig.outbounds?.filter(outbound =>
outbound.settings?.fragment ||
(outbound.protocol === 'freedom' && outbound.settings?.fragment)
);
if (fragmentOutbounds && fragmentOutbounds.length > 0) {
const fragmentConfig = fragmentOutbounds[0].settings.fragment;
return {
enabled: true,
packets: fragmentConfig.packets || 'unknown',
length: fragmentConfig.length || 'unknown',
interval: fragmentConfig.interval || 'unknown'
};
}
// Check if main proxy outbound uses fragment via dialerProxy
const proxyOutbound = xrayConfig.outbounds?.find(o =>
o.protocol === 'vless' || o.protocol === 'vmess' || o.protocol === 'trojan'
);
if (proxyOutbound?.streamSettings?.sockopt?.dialerProxy) {
const dialerProxyTag = proxyOutbound.streamSettings.sockopt.dialerProxy;
const dialerOutbound = xrayConfig.outbounds?.find(o => o.tag === dialerProxyTag);
if (dialerOutbound?.settings?.fragment) {
const fragmentConfig = dialerOutbound.settings.fragment;
return {
enabled: true,
packets: fragmentConfig.packets || 'unknown',
length: fragmentConfig.length || 'unknown',
interval: fragmentConfig.interval || 'unknown'
};
}
}
return { enabled: false };
}
// Function to validate xray config
async function validateXrayConfig(xrayPath, configData) {
try {
const cmd = xrayPath === 'xray' ? 'xray' : `"${xrayPath}"`;
// If configData is a file path (string without JSON structure)
if (typeof configData === 'string' && !configData.trim().startsWith('{')) {
// Use file path method for existing files
const { stdout, stderr } = await execAsync(`${cmd} -test -config "${configData}"`);
return { valid: true, message: 'Config is valid' };
}
// Use stdin for config validation
const xrayProcess = spawn(cmd === 'xray' ? 'xray' : xrayPath, ['-test', '-config', 'stdin:'], {
stdio: ['pipe', 'pipe', 'pipe']
});
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
xrayProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
xrayProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
xrayProcess.on('close', (code) => {
if (code === 0) {
resolve({ valid: true, message: 'Config is valid' });
} else {
const errorMessage = stderr.trim() || stdout.trim() || 'Invalid config format';
resolve({ valid: false, message: errorMessage });
}
});
// Send config via stdin
if (typeof configData === 'string') {
xrayProcess.stdin.write(configData);
} else {
xrayProcess.stdin.write(JSON.stringify(configData, null, 2));
}
xrayProcess.stdin.end();
});
} catch (error) {
return {
valid: false,
message: error.stderr?.trim() || error.stdout?.trim() || error.message || 'Invalid config format'
};
}
}
// Main function
async function main() {
const argv = yargs(hideBin(process.argv))
.scriptName('xping')
.usage('\nVLESS connection ping tool using Xray with fragment support\nProject: https://github.com/NabiKAZ/xping\n\nUsage: $0 <input> [options]')
.updateStrings({
'Positionals:': 'Arguments:'
})
.fail((msg, err, yargs) => {
if (err) {
console.error(chalk.red(`❌ Error: ${err.message}`));
} else {
console.error(yargs.help());
console.error('');
console.error(chalk.red(`❌ ${msg}`));
}
process.exit(1);
})
.command('$0 <input>', 'VLESS URL or xray config file path to test', (yargs) => {
yargs.positional('input', {
describe: 'VLESS URL or xray config file path to test',
type: 'string'
});
})
.option('f', {
alias: 'fragment',
describe: 'Enable fragment mode',
type: 'boolean',
default: false
})
.option('d', {
alias: 'delay',
describe: 'Delay between pings in milliseconds',
type: 'number',
default: 1000
})
.option('t', {
alias: 'timeout',
describe: 'Connection timeout in milliseconds',
type: 'number',
default: 10000
})
.option('c', {
alias: 'count',
describe: 'Number of pings to send (default: infinite)',
type: 'number'
})
.version('1.0.0')
.alias('v', 'version')
.help('h')
.alias('h', 'help')
.epilogue(`Examples:
$ xping "vless://uuid@server:port?security=tls&type=ws&path=/..."
$ xping config.json --fragment --count 10
$ xping "vless://..." --delay 500 --timeout 10000 --count 5
$ xping config.json -c 3 -d 2000 -t 5000
Environment Variables:
XPING_XRAY_PATH Path to xray binary (default: xray)
XPING_TARGET_URL Target URL for testing (default: https://www.google.com/generate_204)
XPING_FRAGMENT_PACKETS Fragment packets type (default: tlshello)
XPING_FRAGMENT_LENGTH Fragment length range (default: 5-9)
XPING_FRAGMENT_INTERVAL Fragment interval range (default: 1-2)
Note:
- When using a config file, it will be processed in memory with a free port (original file unchanged)
- Fragment mode is only applied to vless URLs, not config files`)
.wrap(null)
.parseSync();
const input = argv.input;
const fragmentEnabled = argv.fragment;
const delay = argv.delay;
const timeout = argv.timeout;
const pingCount = argv.count || null; // null means infinite
try {
console.log('');
// Check if xray is available
const xrayPath = await checkXrayAvailable();
if (!xrayPath) {
console.log(chalk.red('❌ Xray not found!'));
console.log(chalk.yellow(`💡 Searched for: ${XRAY_PATH} (from XPING_XRAY_PATH env var or default)`));
console.log(chalk.blue('📥 Download: https://github.com/XTLS/Xray-core/releases'));
process.exit(1);
}
// Find a free port for proxy
let proxyPort = await findFreePort();
// Detect input type and load config
const inputData = detectInputTypeAndLoadConfig(input);
const connectionInfo = inputData.config;
let xrayConfig;
let configFilePath = null;
let configFragmentInfo = null;
if (inputData.type === 'vless') {
xrayConfig = generateXrayConfig(connectionInfo, fragmentEnabled, proxyPort);
// Validate generated config via stdin
const validation = await validateXrayConfig(xrayPath, xrayConfig);
if (!validation.valid) {
console.log(chalk.red(`❌ Generated config validation failed:`));
console.log(chalk.red(validation.message));
process.exit(1);
}
console.log(chalk.green('✅ Generated config validation passed'));
} else {
// Load and modify existing config file to use our free port
configFilePath = resolve(input);
// Read and parse the config file
const fileContent = readFileSync(configFilePath, 'utf8');
const fullXrayConfig = JSON.parse(fileContent);
configFragmentInfo = detectFragmentInConfig(fullXrayConfig);
// Modify the config to use our free port instead of the original port
if (fullXrayConfig.inbounds) {
fullXrayConfig.inbounds.forEach(inbound => {
if (inbound.protocol === 'http' || inbound.protocol === 'mixed' || inbound.protocol === 'socks') {
inbound.port = proxyPort;
inbound.listen = "127.0.0.1"; // Ensure it's localhost only
}
});
}
// We'll use the modified config via stdin instead of file path
xrayConfig = fullXrayConfig;
configFilePath = null; // Don't use file path, use stdin instead
// Validate the modified config
const validation = await validateXrayConfig(xrayPath, xrayConfig);
if (!validation.valid) {
console.log(chalk.red(`❌ Config validation failed:`));
console.log(chalk.red(validation.message));
process.exit(1);
}
console.log(chalk.green('✅ Config validation passed'));
}
// Show config information
console.log(chalk.cyanBright(`📍 ${connectionInfo.address}:${connectionInfo.port}`) + chalk.gray(' | ') + chalk.white(connectionInfo.remark));
if (inputData.type === 'vless') {
console.log(chalk.cyanBright(`🔒 ${connectionInfo.security}`) + chalk.gray(' | Type: ') + chalk.yellow(connectionInfo.type) + chalk.gray(' | Host: ') + chalk.yellow(connectionInfo.host) + chalk.gray(' | SNI: ') + chalk.yellow(connectionInfo.sni) + chalk.gray(' | Path: ') + chalk.yellow(connectionInfo.path));
} else {
console.log(chalk.cyanBright(`🔒 ${connectionInfo.security}`) + chalk.gray(' | Type: ') + chalk.yellow(connectionInfo.network) + chalk.gray(' | Protocol: ') + chalk.yellow(connectionInfo.protocol));
}
// Show fragment info
let fragmentInfo;
if (inputData.type === 'vless') {
fragmentInfo = fragmentEnabled ? {
enabled: true,
packets: FRAGMENT_PACKETS,
length: FRAGMENT_LENGTH,
interval: FRAGMENT_INTERVAL
} : { enabled: false };
} else {
fragmentInfo = configFragmentInfo || { enabled: false };
}
if (fragmentInfo.enabled) {
console.log(chalk.cyanBright(`📝 Fragment: `) + chalk.gray('packets: ') + chalk.yellow(fragmentInfo.packets) + chalk.gray(', length: ') + chalk.yellow(fragmentInfo.length) + chalk.gray(', interval: ') + chalk.yellow(fragmentInfo.interval));
} else {
console.log(chalk.cyanBright(`📝 Fragment: `) + chalk.gray('disabled'));
}
console.log('');
// Start xray process with config via stdin
const xrayArgs = ['-config', 'stdin:'];
xrayProcess = spawn(xrayPath === true ? 'xray' : xrayPath, xrayArgs, {
stdio: 'pipe'
});
// Handle xray process errors
xrayProcess.on('error', (error) => {
console.log(chalk.red(`❌ Failed to start xray: ${error.message}`));
process.exit(1);
});
// Send config to xray via stdin (both vless and modified config file)
if (xrayConfig) {
xrayProcess.stdin.write(JSON.stringify(xrayConfig, null, 2));
xrayProcess.stdin.end();
}
let xrayStarted = false;
let xrayError = false;
let xrayErrorMessages = [];
let xrayOutputMessages = [];
let processExited = false;
xrayProcess.stdout.on('data', (data) => {
const output = data.toString();
xrayOutputMessages.push(output.trim());
// Check for various signs that xray has started successfully
if (output.includes('started') ||
output.includes('listening') ||
output.includes('A unified platform for anti-censorship') ||
(output.includes('Xray') && output.includes('windows/amd64'))) {
xrayStarted = true;
}
});
xrayProcess.stderr.on('data', (data) => {
const error = data.toString().trim();
if (error) {
xrayErrorMessages.push(error);
// Also immediately show critical errors
if (error.includes('Failed to start') ||
error.includes('failed to listen') ||
error.includes('bind:') ||
error.includes('address already in use') ||
error.includes('permission denied') ||
error.includes('invalid config') ||
error.includes('parse') ||
error.includes('cannot') ||
error.includes('error')) {
console.log(chalk.red(`❌ Xray error: ${error}`));
}
xrayError = true;
}
});
xrayProcess.on('exit', (code) => {
processExited = true;
if (code !== 0 && code !== null) {
console.log(chalk.red(`❌ Xray exited with code: ${code}`));
if (xrayErrorMessages.length > 0) {
console.log(chalk.red('📝 Error details:'));
xrayErrorMessages.forEach(msg => {
console.log(chalk.red(` ${msg}`));
});
} else if (xrayOutputMessages.length > 0) {
console.log(chalk.red('📋 Xray output messages:'));
xrayOutputMessages.forEach(msg => {
if (msg.trim()) {
console.log(chalk.red(`${msg}`));
}
});
} else {
console.log(chalk.red('📝 No specific error message available'));
}
process.exit(1);
}
});
// Wait for xray to start or fail with better timing
let attempts = 0;
const maxAttempts = 6; // 3 seconds total, enough time for startup
while (attempts < maxAttempts && !xrayStarted && !processExited) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
// Check if we have errors during waiting
if (xrayError && xrayErrorMessages.length > 0) {
break;
}
}
// Check final status
if (processExited || xrayError) {
if (!processExited) {
// Process is still running but has errors
console.log(chalk.red('❌ Xray startup errors detected:'));
xrayErrorMessages.forEach(msg => {
console.log(chalk.red(` ${msg}`));
});
xrayProcess.kill();
}
process.exit(1);
}
if (!xrayStarted) {
console.log(chalk.red('❌ Xray failed to start within timeout period'));
if (xrayErrorMessages.length > 0) {
console.log(chalk.red('📝 Error messages received:'));
xrayErrorMessages.forEach(msg => {
console.log(chalk.red(` ${msg}`));
});
} else if (xrayOutputMessages.length > 0) {
console.log(chalk.yellow('📋 Output messages received:'));
xrayOutputMessages.forEach(msg => {
if (msg.trim()) {
console.log(chalk.yellow(` ${msg}`));
}
});
} else {
console.log(chalk.red('📝 No output received from Xray during startup'));
}
xrayProcess.kill();
process.exit(1);
}
let currentPing = 0;
// Ping loop - infinite or limited by count
while (pingCount === null || currentPing < pingCount) {
currentPing++;
try {
// Show testing message and get result in one line
process.stdout.write(chalk.gray(`🌐 [${getCurrentTimeString()}] Testing connection to ${TARGET_URL}...`));
const startTime = Date.now();
const result = await testConnection(proxyPort, timeout);
const endTime = Date.now();
const responseTime = endTime - startTime;
// Clear the testing line and show result
process.stdout.write('\r\x1b[K'); // Clear current line
// Update statistics only after successful test
pingStats.count = currentPing;
pingStats.success++;
pingStats.total += responseTime;
pingStats.min = Math.min(pingStats.min, responseTime);
pingStats.max = Math.max(pingStats.max, responseTime);
// Calculate success rate
const successRate = Math.round((pingStats.success / pingStats.count) * 100);
// Format output with more info
console.log(chalk.gray(`[${getCurrentTimeString()}]`) + chalk.green(` ✅ ${connectionInfo.address}:${connectionInfo.port} responded in `) + chalk.bold.whiteBright(`${responseTime}ms`));
} catch (error) {
// Clear the testing line and show timeout
process.stdout.write('\r\x1b[K'); // Clear current line
// Update statistics only after failed test
pingStats.count = currentPing;
const successRate = Math.round((pingStats.success / pingStats.count) * 100);
// Extract error reason
let errorReason = 'Request timeout';
if (error.error) {
if (error.error.includes('fetch failed')) errorReason = 'Connection failed';
else if (error.error.includes('timeout')) errorReason = 'Request timeout';
else if (error.error.includes('ECONNREFUSED')) errorReason = 'Connection refused';
else if (error.error.includes('ENOTFOUND')) errorReason = 'Host not found';
else errorReason = 'Network error';
}
console.log(chalk.gray(`[${getCurrentTimeString()}]`) + chalk.red(` ❌ ${errorReason}`));
}
// Wait before next ping (but not after the last ping)
if (pingCount === null || currentPing < pingCount) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Show final statistics if count was specified
if (pingCount !== null) {
showPingStatistics();
}
// Clean up
xrayProcess.kill();
} catch (error) {
console.log(chalk.red(`❌ Error: ${error.message}`));
process.exit(1);
}
}
// Handle process termination
let xrayProcess = null;
let pingStats = { count: 0, success: 0, total: 0, min: Infinity, max: 0 };
function showPingStatistics() {
console.log(chalk.blue('\n📊 === Statistics ==='));
console.log(chalk.white(`📦 Sent: `) + chalk.cyan(pingStats.count) + chalk.white(` | Received: `) + chalk.green(pingStats.success) + chalk.white(` | Lost: `) + chalk.red(pingStats.count - pingStats.success) + chalk.yellow(` (${Math.round(((pingStats.count - pingStats.success) / pingStats.count) * 100) || 0}% loss)`));
if (pingStats.success > 0) {
const avgTime = Math.round(pingStats.total / pingStats.success);
console.log(chalk.white(`⏱️ Min: `) + chalk.green(`${pingStats.min}ms`) + chalk.white(` | Max: `) + chalk.red(`${pingStats.max}ms`) + chalk.white(` | Avg: `) + chalk.yellow(`${avgTime}ms`));
}
console.log(chalk.magenta('🏁 Completed!'));
}
process.on('SIGINT', () => {
// Clear any pending testing message
process.stdout.write('\r\x1b[K');
console.log(chalk.yellow('\n^C'));
showPingStatistics();
if (xrayProcess) {
xrayProcess.kill();
}
process.exit(0);
});
process.on('SIGTERM', () => {
// Clear any pending testing message
process.stdout.write('\r\x1b[K');
console.log(chalk.yellow('\nProcess terminated'));
showPingStatistics();
if (xrayProcess) {
xrayProcess.kill();
}
process.exit(0);
});
// Run the main function
main().catch(error => {
console.error(chalk.red('💥 Unexpected error:'), error.message);
process.exit(1);
});