@miraj181/ipingyou 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/package.json +62 -0
- package/src/cli.js +241 -0
- package/src/lib/animations.js +226 -0
- package/src/lib/cleanup.js +131 -0
- package/src/lib/crypto.js +53 -0
- package/src/lib/platform.js +179 -0
- package/src/lib/uid.js +23 -0
- package/src/modes/client.js +322 -0
- package/src/modes/host.js +317 -0
- package/src/server.js +144 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* Graceful Cleanup & Process Killer
|
|
4
|
+
* ============================================================
|
|
5
|
+
* Tracks all spawned child processes (cloudflared, ssh, etc.)
|
|
6
|
+
* and kills them on SIGINT/exit using tree-kill to ensure
|
|
7
|
+
* no orphan processes linger.
|
|
8
|
+
* ============================================================
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import treeKill from 'tree-kill';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
|
|
14
|
+
/** @type {Set<number>} โ Active child PIDs we manage */
|
|
15
|
+
const trackedPIDs = new Set();
|
|
16
|
+
|
|
17
|
+
/** @type {string|null} โ UID to revoke on shutdown */
|
|
18
|
+
let _revokeUID = null;
|
|
19
|
+
|
|
20
|
+
/** @type {string|null} โ Broker URL for revocation */
|
|
21
|
+
let _brokerUrl = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register a spawned child PID for tracking.
|
|
25
|
+
* @param {number} pid
|
|
26
|
+
*/
|
|
27
|
+
export function trackPID(pid) {
|
|
28
|
+
if (pid) trackedPIDs.add(pid);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Unregister a PID (after it exits naturally).
|
|
33
|
+
* @param {number} pid
|
|
34
|
+
*/
|
|
35
|
+
export function untrackPID(pid) {
|
|
36
|
+
trackedPIDs.delete(pid);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set UID + broker URL for automatic revocation on shutdown.
|
|
41
|
+
* @param {string} uid
|
|
42
|
+
* @param {string} brokerUrl
|
|
43
|
+
*/
|
|
44
|
+
export function setRevokeOnExit(uid, brokerUrl) {
|
|
45
|
+
_revokeUID = uid;
|
|
46
|
+
_brokerUrl = brokerUrl;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Kill a single PID tree.
|
|
51
|
+
* @param {number} pid
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
function killPID(pid) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
treeKill(pid, 'SIGTERM', (err) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
// Force kill if SIGTERM fails
|
|
59
|
+
treeKill(pid, 'SIGKILL', () => resolve());
|
|
60
|
+
} else {
|
|
61
|
+
resolve();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Kill all tracked PIDs and revoke UID from broker.
|
|
69
|
+
*/
|
|
70
|
+
export async function cleanupAll() {
|
|
71
|
+
console.log('');
|
|
72
|
+
console.log(chalk.yellow(' ๐งน Cleaning up...'));
|
|
73
|
+
|
|
74
|
+
// Kill all tracked processes
|
|
75
|
+
const kills = [];
|
|
76
|
+
for (const pid of trackedPIDs) {
|
|
77
|
+
console.log(chalk.dim(` Killing PID ${pid}...`));
|
|
78
|
+
kills.push(killPID(pid));
|
|
79
|
+
}
|
|
80
|
+
await Promise.allSettled(kills);
|
|
81
|
+
trackedPIDs.clear();
|
|
82
|
+
|
|
83
|
+
// Revoke UID from broker
|
|
84
|
+
if (_revokeUID && _brokerUrl) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${_brokerUrl}/revoke/${_revokeUID}`, { method: 'DELETE' });
|
|
87
|
+
if (res.ok) {
|
|
88
|
+
console.log(chalk.dim(` Revoked UID ${_revokeUID} from broker`));
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Best-effort โ broker might be down
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(chalk.green(' โ
Cleanup complete. Goodbye!'));
|
|
96
|
+
console.log('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Install SIGINT/SIGTERM handlers for graceful shutdown.
|
|
101
|
+
*/
|
|
102
|
+
export function installShutdownHandlers() {
|
|
103
|
+
let shuttingDown = false;
|
|
104
|
+
|
|
105
|
+
const handler = async (signal) => {
|
|
106
|
+
if (shuttingDown) return; // Prevent double-cleanup
|
|
107
|
+
shuttingDown = true;
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(chalk.yellow(` โก Received ${signal}`));
|
|
110
|
+
await cleanupAll();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
process.on('SIGINT', () => handler('SIGINT'));
|
|
115
|
+
process.on('SIGTERM', () => handler('SIGTERM'));
|
|
116
|
+
|
|
117
|
+
// Also handle uncaught exceptions gracefully
|
|
118
|
+
process.on('uncaughtException', async (err) => {
|
|
119
|
+
console.error(chalk.red(` ๐ฅ Uncaught exception: ${err.message}`));
|
|
120
|
+
await cleanupAll();
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get count of tracked PIDs.
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
export function getTrackedCount() {
|
|
130
|
+
return trackedPIDs.size;
|
|
131
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* AES-256-CBC Encryption Utilities
|
|
4
|
+
* ============================================================
|
|
5
|
+
* Shared crypto module used by both broker and CLI.
|
|
6
|
+
* All sensitive data is encrypted before transit/storage.
|
|
7
|
+
* ============================================================
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_HEX_KEY = 'b374a26d71590483815c467a99623e1b7db95f269c2889279a32c4530fc4159f';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the encryption key as a Buffer.
|
|
16
|
+
* Reads from env var SECRET_KEY, falls back to the default dev key.
|
|
17
|
+
*/
|
|
18
|
+
export function getKey() {
|
|
19
|
+
const hex = process.env.SECRET_KEY || DEFAULT_HEX_KEY;
|
|
20
|
+
return Buffer.from(hex, 'hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Encrypt a plaintext string with AES-256-CBC.
|
|
25
|
+
* @param {string} plaintext
|
|
26
|
+
* @returns {{ iv: string, ciphertext: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function encrypt(plaintext) {
|
|
29
|
+
const key = getKey();
|
|
30
|
+
const iv = crypto.randomBytes(16);
|
|
31
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
32
|
+
let enc = cipher.update(plaintext, 'utf8', 'base64');
|
|
33
|
+
enc += cipher.final('base64');
|
|
34
|
+
return {
|
|
35
|
+
iv: iv.toString('hex'),
|
|
36
|
+
ciphertext: enc,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decrypt a ciphertext with AES-256-CBC.
|
|
42
|
+
* @param {string} ivHex โ 32-char hex IV
|
|
43
|
+
* @param {string} cipherBase64
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function decrypt(ivHex, cipherBase64) {
|
|
47
|
+
const key = getKey();
|
|
48
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
49
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
50
|
+
let dec = decipher.update(cipherBase64, 'base64', 'utf8');
|
|
51
|
+
dec += decipher.final('utf8');
|
|
52
|
+
return dec;
|
|
53
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* Platform Detection & Dependency Checker
|
|
4
|
+
* ============================================================
|
|
5
|
+
* Detects OS, checks for ssh/cloudflared, and provides
|
|
6
|
+
* automated installation guidance per platform.
|
|
7
|
+
* ============================================================
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execaCommand } from 'execa';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import ora from 'ora';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect the current operating system.
|
|
17
|
+
* @returns {{ platform: string, isLinux: boolean, isMac: boolean, isWindows: boolean, distro: string|null }}
|
|
18
|
+
*/
|
|
19
|
+
export function detectOS() {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
const result = {
|
|
22
|
+
platform,
|
|
23
|
+
isLinux: platform === 'linux',
|
|
24
|
+
isMac: platform === 'darwin',
|
|
25
|
+
isWindows: platform === 'win32',
|
|
26
|
+
distro: null,
|
|
27
|
+
arch: os.arch(),
|
|
28
|
+
hostname: os.hostname(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect Linux distribution family.
|
|
36
|
+
* @returns {Promise<string>} 'debian' | 'arch' | 'fedora' | 'unknown'
|
|
37
|
+
*/
|
|
38
|
+
export async function detectLinuxDistro() {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execaCommand('cat /etc/os-release', { reject: false });
|
|
41
|
+
const lower = stdout.toLowerCase();
|
|
42
|
+
if (lower.includes('ubuntu') || lower.includes('debian') || lower.includes('kali') || lower.includes('mint')) {
|
|
43
|
+
return 'debian';
|
|
44
|
+
}
|
|
45
|
+
if (lower.includes('arch') || lower.includes('manjaro')) {
|
|
46
|
+
return 'arch';
|
|
47
|
+
}
|
|
48
|
+
if (lower.includes('fedora') || lower.includes('centos') || lower.includes('rhel')) {
|
|
49
|
+
return 'fedora';
|
|
50
|
+
}
|
|
51
|
+
} catch { /* ignore */ }
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a command exists on PATH.
|
|
57
|
+
* @param {string} cmd
|
|
58
|
+
* @returns {Promise<boolean>}
|
|
59
|
+
*/
|
|
60
|
+
export async function commandExists(cmd) {
|
|
61
|
+
try {
|
|
62
|
+
const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
|
|
63
|
+
await execaCommand(checkCmd, { reject: true });
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if sudo is available (Linux/macOS).
|
|
72
|
+
* @returns {Promise<boolean>}
|
|
73
|
+
*/
|
|
74
|
+
export async function hasSudo() {
|
|
75
|
+
if (process.platform === 'win32') return false;
|
|
76
|
+
return commandExists('sudo');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Install a dependency on the current platform.
|
|
81
|
+
* @param {string} pkg โ package name (e.g. 'openssh-server', 'cloudflared')
|
|
82
|
+
* @param {'debian'|'arch'|'fedora'|'mac'|'windows'} distro
|
|
83
|
+
*/
|
|
84
|
+
export async function installDependency(pkg, distro) {
|
|
85
|
+
const spinner = ora(`Installing ${chalk.cyan(pkg)}...`).start();
|
|
86
|
+
|
|
87
|
+
const commands = {
|
|
88
|
+
debian: `sudo apt-get install -y ${pkg}`,
|
|
89
|
+
arch: `sudo pacman -S --noconfirm ${pkg}`,
|
|
90
|
+
fedora: `sudo dnf install -y ${pkg}`,
|
|
91
|
+
mac: `brew install ${pkg}`,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const cmd = commands[distro];
|
|
95
|
+
if (!cmd) {
|
|
96
|
+
spinner.fail(`No auto-install command for ${distro}. Please install ${pkg} manually.`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await execaCommand(cmd, { stdio: 'inherit' });
|
|
102
|
+
spinner.succeed(`${chalk.green(pkg)} installed successfully`);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
spinner.fail(`Failed to install ${pkg}: ${err.message}`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run full dependency check for ssh and cloudflared.
|
|
112
|
+
* @returns {Promise<{ ssh: boolean, cloudflared: boolean }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function checkDependencies() {
|
|
115
|
+
const osInfo = detectOS();
|
|
116
|
+
const results = { ssh: false, cloudflared: false };
|
|
117
|
+
|
|
118
|
+
console.log('');
|
|
119
|
+
console.log(chalk.bold(' ๐ Dependency Check'));
|
|
120
|
+
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
121
|
+
|
|
122
|
+
// Check SSH
|
|
123
|
+
const sshCmd = osInfo.isWindows ? 'ssh' : 'ssh';
|
|
124
|
+
results.ssh = await commandExists(sshCmd);
|
|
125
|
+
console.log(` ${results.ssh ? chalk.green('โ') : chalk.red('โ')} ssh ${results.ssh ? chalk.dim('found') : chalk.red('missing')}`);
|
|
126
|
+
|
|
127
|
+
// Check cloudflared
|
|
128
|
+
results.cloudflared = await commandExists('cloudflared');
|
|
129
|
+
console.log(` ${results.cloudflared ? chalk.green('โ') : chalk.red('โ')} cloudflared ${results.cloudflared ? chalk.dim('found') : chalk.red('missing')}`);
|
|
130
|
+
|
|
131
|
+
console.log('');
|
|
132
|
+
|
|
133
|
+
// Auto-install logic for missing deps
|
|
134
|
+
if (!results.ssh || !results.cloudflared) {
|
|
135
|
+
if (osInfo.isLinux) {
|
|
136
|
+
const distro = await detectLinuxDistro();
|
|
137
|
+
const canSudo = await hasSudo();
|
|
138
|
+
|
|
139
|
+
if (canSudo) {
|
|
140
|
+
console.log(chalk.yellow(' โก Attempting automatic installation...'));
|
|
141
|
+
console.log('');
|
|
142
|
+
|
|
143
|
+
if (!results.ssh) {
|
|
144
|
+
const sshPkg = distro === 'arch' ? 'openssh' : 'openssh-server';
|
|
145
|
+
results.ssh = await installDependency(sshPkg, distro);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!results.cloudflared) {
|
|
149
|
+
// cloudflared isn't always in default repos
|
|
150
|
+
console.log(chalk.yellow(' โน๏ธ cloudflared must be installed manually:'));
|
|
151
|
+
console.log(chalk.dim(' https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
console.log(chalk.yellow(' โ ๏ธ sudo not available โ cannot auto-install.'));
|
|
155
|
+
console.log(chalk.dim(' Install ssh and cloudflared manually.'));
|
|
156
|
+
}
|
|
157
|
+
} else if (osInfo.isMac) {
|
|
158
|
+
if (!results.cloudflared) {
|
|
159
|
+
console.log(chalk.yellow(' โน๏ธ Install cloudflared via Homebrew:'));
|
|
160
|
+
console.log(chalk.cyan(' brew install cloudflared'));
|
|
161
|
+
}
|
|
162
|
+
if (!results.ssh) {
|
|
163
|
+
console.log(chalk.dim(' โน๏ธ macOS ships with SSH by default. Enable it in:'));
|
|
164
|
+
console.log(chalk.dim(' System Preferences โ Sharing โ Remote Login'));
|
|
165
|
+
}
|
|
166
|
+
} else if (osInfo.isWindows) {
|
|
167
|
+
console.log(chalk.yellow(' โ ๏ธ Windows detected โ manual install required:'));
|
|
168
|
+
if (!results.ssh) {
|
|
169
|
+
console.log(chalk.cyan(' winget install Microsoft.OpenSSH.Client'));
|
|
170
|
+
console.log(chalk.dim(' Or enable via: Settings โ Apps โ Optional Features โ OpenSSH'));
|
|
171
|
+
}
|
|
172
|
+
if (!results.cloudflared) {
|
|
173
|
+
console.log(chalk.cyan(' winget install Cloudflare.cloudflared'));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return results;
|
|
179
|
+
}
|
package/src/lib/uid.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* Session UID Generator
|
|
4
|
+
* ============================================================
|
|
5
|
+
* Generates cryptographically random 8-character UIDs.
|
|
6
|
+
* NOT based on hardware/MAC โ purely random per-session,
|
|
7
|
+
* so the "door" closes permanently when the session ends.
|
|
8
|
+
* ============================================================
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { customAlphabet } from 'nanoid';
|
|
12
|
+
|
|
13
|
+
// Use lowercase alphanumeric only โ easy to share verbally
|
|
14
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
15
|
+
const generate = customAlphabet(alphabet, 8);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a random 8-character session UID.
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function generateUID() {
|
|
22
|
+
return generate();
|
|
23
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* Client Mode โ "Access a Remote Machine"
|
|
4
|
+
* ============================================================
|
|
5
|
+
* 1. Prompt for the remote host's UID
|
|
6
|
+
* 2. Resolve UID โ ENCRYPTED blob from the Broker
|
|
7
|
+
* 3. DECRYPT tunnel URL locally using shared key
|
|
8
|
+
* 4. Execute SSH/SCP through the Cloudflare tunnel proxy
|
|
9
|
+
*
|
|
10
|
+
* Security: The broker only returns { iv, ciphertext }.
|
|
11
|
+
* Decryption happens ONLY on this machine.
|
|
12
|
+
* ============================================================
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execa } from 'execa';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import inquirer from 'inquirer';
|
|
18
|
+
import { decrypt } from '../lib/crypto.js';
|
|
19
|
+
import { trackPID, untrackPID } from '../lib/cleanup.js';
|
|
20
|
+
import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, animatedSteps, simulateTransferProgress } from '../lib/animations.js';
|
|
21
|
+
|
|
22
|
+
const BROKER_URL = process.env.BROKER_URL || 'http://localhost:4000';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a UID to a tunnel URL via the broker.
|
|
26
|
+
* The broker returns an encrypted blob; we decrypt locally.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} uid
|
|
29
|
+
* @returns {Promise<string|null>} The decrypted tunnel URL, or null on failure
|
|
30
|
+
*/
|
|
31
|
+
async function resolveUID(uid) {
|
|
32
|
+
const spinner = createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`${BROKER_URL}/resolve/${uid}`);
|
|
36
|
+
|
|
37
|
+
if (res.status === 404) {
|
|
38
|
+
spinner.fail('UID not found โ the host may not be online or the session expired');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (res.status === 410) {
|
|
42
|
+
spinner.fail('UID has expired โ ask the host for a new session');
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const data = await res.json().catch(() => ({}));
|
|
47
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
|
|
52
|
+
if (!data.iv || !data.ciphertext) {
|
|
53
|
+
spinner.fail('Broker returned invalid response โ missing encrypted data');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
spinner.text = `Decrypting tunnel URL locally...`;
|
|
58
|
+
|
|
59
|
+
// Simulate decryption delay for effect
|
|
60
|
+
await new Promise(r => setTimeout(r, 600));
|
|
61
|
+
|
|
62
|
+
// Decrypt locally
|
|
63
|
+
let tunnelUrl;
|
|
64
|
+
try {
|
|
65
|
+
tunnelUrl = decrypt(data.iv, data.ciphertext);
|
|
66
|
+
} catch (decryptErr) {
|
|
67
|
+
spinner.fail('Decryption failed โ SECRET_KEY mismatch');
|
|
68
|
+
console.error(chalk.red(' โ Error: Could not decrypt tunnel URL'));
|
|
69
|
+
console.log(chalk.dim(' Make sure your SECRET_KEY matches the host\'s key.'));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!tunnelUrl.startsWith('https://')) {
|
|
74
|
+
spinner.fail('Decrypted data is not a valid tunnel URL');
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
spinner.succeed(`Resolved: ${chalk.dim(tunnelUrl)} ${chalk.green('[decrypted locally]')}`);
|
|
79
|
+
return tunnelUrl;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
spinner.fail(`Broker lookup failed: ${err.message}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractHostname(url) {
|
|
87
|
+
try {
|
|
88
|
+
return new URL(url).hostname;
|
|
89
|
+
} catch {
|
|
90
|
+
return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function promptUsername() {
|
|
95
|
+
const { username } = await inquirer.prompt([
|
|
96
|
+
{
|
|
97
|
+
type: 'input',
|
|
98
|
+
name: 'username',
|
|
99
|
+
message: 'SSH username on the remote machine:',
|
|
100
|
+
default: process.env.USER || process.env.USERNAME || 'root',
|
|
101
|
+
validate: (v) => v.trim().length > 0 || 'Username is required',
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
return username.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Start SSH connection through the Cloudflare tunnel.
|
|
109
|
+
*/
|
|
110
|
+
async function connectSSH(username, hostname) {
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(chalk.bold(' ๐ Establishing SSH Connection'));
|
|
113
|
+
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
114
|
+
|
|
115
|
+
await showConnectionTrace('Local', 'Remote SSH');
|
|
116
|
+
|
|
117
|
+
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const spinner = createSpinner('Handshaking...', sshSpinner).start();
|
|
121
|
+
await new Promise(r => setTimeout(r, 800));
|
|
122
|
+
spinner.succeed('Connection established! Handing over to terminal...');
|
|
123
|
+
console.log('');
|
|
124
|
+
|
|
125
|
+
const child = execa('ssh', [
|
|
126
|
+
'-o', `ProxyCommand=${proxyCommand}`,
|
|
127
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
128
|
+
'-o', 'ServerAliveInterval=30',
|
|
129
|
+
'-o', 'ServerAliveCountMax=3',
|
|
130
|
+
`${username}@${hostname}`,
|
|
131
|
+
], {
|
|
132
|
+
stdio: 'inherit',
|
|
133
|
+
reject: false,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
trackPID(child.pid);
|
|
137
|
+
const result = await child;
|
|
138
|
+
untrackPID(child.pid);
|
|
139
|
+
|
|
140
|
+
if (result.exitCode === 0) {
|
|
141
|
+
console.log('');
|
|
142
|
+
console.log(chalk.green(' โ
SSH session ended cleanly'));
|
|
143
|
+
} else if (result.exitCode === 255) {
|
|
144
|
+
console.log('');
|
|
145
|
+
console.error(chalk.red(' โ SSH connection failed (exit code 255)'));
|
|
146
|
+
} else {
|
|
147
|
+
console.log('');
|
|
148
|
+
console.error(chalk.red(` โ SSH exited with code ${result.exitCode}`));
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(chalk.red(` โ SSH error: ${err.message}`));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Perform an SCP file transfer through the Cloudflare tunnel.
|
|
157
|
+
*/
|
|
158
|
+
async function performSCP(username, hostname, direction) {
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(chalk.bold(` ๐ฆ SCP Transfer (${direction})`));
|
|
161
|
+
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
162
|
+
|
|
163
|
+
const { localPath, remotePath } = await inquirer.prompt([
|
|
164
|
+
{
|
|
165
|
+
type: 'input',
|
|
166
|
+
name: 'localPath',
|
|
167
|
+
message: `Local file/folder path:`,
|
|
168
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'input',
|
|
172
|
+
name: 'remotePath',
|
|
173
|
+
message: `Remote path (relative to ${username}'s home or absolute):`,
|
|
174
|
+
validate: v => v.trim().length > 0 || 'Required',
|
|
175
|
+
}
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
await showConnectionTrace('Local', 'Remote SCP');
|
|
179
|
+
|
|
180
|
+
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
181
|
+
|
|
182
|
+
// Construct SCP args
|
|
183
|
+
const scpArgs = [
|
|
184
|
+
'-r', // recursive just in case
|
|
185
|
+
'-o', `ProxyCommand=${proxyCommand}`,
|
|
186
|
+
'-o', 'StrictHostKeyChecking=accept-new'
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
if (direction === 'upload') {
|
|
190
|
+
scpArgs.push(localPath, `${username}@${hostname}:${remotePath}`);
|
|
191
|
+
} else {
|
|
192
|
+
scpArgs.push(`${username}@${hostname}:${remotePath}`, localPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const transferSpinner = createSpinner(`Transferring via SCP...`, fileTransferSpinner).start();
|
|
197
|
+
|
|
198
|
+
const child = execa('scp', scpArgs, {
|
|
199
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
200
|
+
reject: false,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
trackPID(child.pid);
|
|
204
|
+
const result = await child;
|
|
205
|
+
untrackPID(child.pid);
|
|
206
|
+
|
|
207
|
+
transferSpinner.stop();
|
|
208
|
+
|
|
209
|
+
if (result.exitCode === 0) {
|
|
210
|
+
await simulateTransferProgress(direction === 'upload' ? localPath : remotePath, direction, 1500);
|
|
211
|
+
console.log(chalk.green(` โ
Transfer completed successfully!`));
|
|
212
|
+
} else {
|
|
213
|
+
console.error(chalk.red(' โ SCP transfer failed'));
|
|
214
|
+
if (result.stderr) console.error(chalk.dim(` ${result.stderr.trim()}`));
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.error(chalk.red(` โ SCP error: ${err.message}`));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Main Client Mode entry point.
|
|
223
|
+
*/
|
|
224
|
+
export async function startClientMode() {
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log(chalk.bold.cyan(' ๐ CLIENT MODE โ Access a Remote Machine'));
|
|
227
|
+
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
228
|
+
console.log('');
|
|
229
|
+
|
|
230
|
+
const answer = await inquirer.prompt([
|
|
231
|
+
{
|
|
232
|
+
type: 'input',
|
|
233
|
+
name: 'uid',
|
|
234
|
+
message: 'Enter the remote host\'s UID:',
|
|
235
|
+
validate: (v) => {
|
|
236
|
+
const trimmed = v.trim();
|
|
237
|
+
if (trimmed.length < 6 || trimmed.length > 16) return 'UID must be 6-16 characters';
|
|
238
|
+
if (!/^[a-z0-9]+$/.test(trimmed)) return 'UID should be lowercase alphanumeric';
|
|
239
|
+
return true;
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
const uid = answer.uid.trim();
|
|
244
|
+
|
|
245
|
+
const tunnelUrl = await resolveUID(uid);
|
|
246
|
+
if (!tunnelUrl) {
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const username = await promptUsername();
|
|
251
|
+
const hostname = extractHostname(tunnelUrl);
|
|
252
|
+
|
|
253
|
+
const { action } = await inquirer.prompt([
|
|
254
|
+
{
|
|
255
|
+
type: 'list',
|
|
256
|
+
name: 'action',
|
|
257
|
+
message: 'What would you like to do?',
|
|
258
|
+
choices: [
|
|
259
|
+
{ name: '๐ฅ๏ธ Connect via SSH (Interactive Shell)', value: 'ssh' },
|
|
260
|
+
{ name: '๐ค Upload file/folder via SCP', value: 'upload' },
|
|
261
|
+
{ name: '๐ฅ Download file/folder via SCP', value: 'download' }
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
if (action === 'ssh') {
|
|
267
|
+
await connectSSH(username, hostname);
|
|
268
|
+
} else {
|
|
269
|
+
await performSCP(username, hostname, action);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log('');
|
|
273
|
+
const { reconnect } = await inquirer.prompt([
|
|
274
|
+
{
|
|
275
|
+
type: 'confirm',
|
|
276
|
+
name: 'reconnect',
|
|
277
|
+
message: 'Perform another action with the same host?',
|
|
278
|
+
default: false,
|
|
279
|
+
},
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
if (reconnect) {
|
|
283
|
+
await handleSubsequentActions(username, hostname);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function handleSubsequentActions(username, hostname) {
|
|
288
|
+
const { action } = await inquirer.prompt([
|
|
289
|
+
{
|
|
290
|
+
type: 'list',
|
|
291
|
+
name: 'action',
|
|
292
|
+
message: 'What would you like to do next?',
|
|
293
|
+
choices: [
|
|
294
|
+
{ name: '๐ฅ๏ธ Connect via SSH', value: 'ssh' },
|
|
295
|
+
{ name: '๐ค Upload file/folder via SCP', value: 'upload' },
|
|
296
|
+
{ name: '๐ฅ Download file/folder via SCP', value: 'download' },
|
|
297
|
+
{ name: 'โ Exit', value: 'exit' }
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
]);
|
|
301
|
+
|
|
302
|
+
if (action === 'exit') return;
|
|
303
|
+
|
|
304
|
+
if (action === 'ssh') {
|
|
305
|
+
await connectSSH(username, hostname);
|
|
306
|
+
} else {
|
|
307
|
+
await performSCP(username, hostname, action);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { reconnect } = await inquirer.prompt([
|
|
311
|
+
{
|
|
312
|
+
type: 'confirm',
|
|
313
|
+
name: 'reconnect',
|
|
314
|
+
message: 'Perform another action?',
|
|
315
|
+
default: false,
|
|
316
|
+
},
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
if (reconnect) {
|
|
320
|
+
await handleSubsequentActions(username, hostname);
|
|
321
|
+
}
|
|
322
|
+
}
|