@miraj181/ipingyou 2.1.3 โ 2.1.5
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/README.md +32 -9
- package/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/lib/cleanup.js +2 -1
- package/src/lib/path-browser.js +5 -4
- package/src/lib/ssh.js +15 -2
- package/src/lib/tmux.js +13 -0
- package/src/modes/client.js +35 -35
- package/src/modes/host.js +94 -7
- package/src/server.js +14 -6
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ No firewalls to configure. No port forwarding. No plaintext leakage.
|
|
|
25
25
|
* ๐งญ **Approval Gate**: Require the Host to explicitly approve clients before they receive tunnel/key material.
|
|
26
26
|
* ๐ฆ **One-Time File Share**: Serve a single file/folder over SCP and revoke after use.
|
|
27
27
|
* ๐ **HTTP & TCP Exposure**: Share a local web app or any TCP service (DB/RDP/VNC) beyond SSH.
|
|
28
|
-
* ๐ **Shared Drop Folder**: Auto-prepared dropbox folder for safe file transfers (macOS friendly).
|
|
28
|
+
* ๐ **Shared Drop Folder**: Auto-prepared dropbox folder for safe file transfers (macOS friendly), removed on exit.
|
|
29
29
|
* ๐งพ **Live Session Logs**: Host/client/broker write ephemeral per-session logs with actions and request/response status, removed on exit.
|
|
30
30
|
|
|
31
31
|
---
|
|
@@ -60,22 +60,43 @@ securelink
|
|
|
60
60
|
|
|
61
61
|
## ๐ Zero-Knowledge Architecture
|
|
62
62
|
|
|
63
|
-
The public broker server exists solely to rendezvous connections. It is fundamentally a **"Dumb Pipe"**.
|
|
63
|
+
The public broker server exists solely to rendezvous connections and approvals. It is fundamentally a **"Dumb Pipe"**.
|
|
64
|
+
|
|
65
|
+
### Session Bootstrap & Data Path
|
|
64
66
|
|
|
65
67
|
```mermaid
|
|
66
68
|
graph LR
|
|
67
|
-
H[Host CLI] -->|AES-256-CBC Encrypted Payload| B((Broker Relay))
|
|
68
|
-
|
|
69
|
+
H[Host CLI] -->|AES-256-CBC Encrypted Session Payload| B((Broker Relay))
|
|
70
|
+
H -->|Host Auth Token for approvals and telemetry| B
|
|
71
|
+
B -->|Encrypted Session Payload| C[Client CLI]
|
|
69
72
|
C -->|Locally Decrypts Password| C
|
|
70
|
-
C -->|Direct Cloudflare SSH| H
|
|
73
|
+
C -->|Direct Cloudflare SSH/TCP| H
|
|
71
74
|
C -->|E2E AES-GCM WebSockets| H
|
|
72
75
|
```
|
|
73
76
|
|
|
74
|
-
1. **Host** starts up, spawns `cloudflared` tunnels for SSH and Chat,
|
|
75
|
-
2. **Host** encrypts the
|
|
77
|
+
1. **Host** starts up, spawns `cloudflared` tunnels for SSH/HTTP/TCP and Chat, then generates a random **AES-256 Session Password** plus a **host-only auth token**.
|
|
78
|
+
2. **Host** encrypts the session payload with the password and registers the ciphertext (plus the host token) with the Broker under a short UID.
|
|
76
79
|
3. **Client** runs `ipingyou connect`, enters the UID and Password.
|
|
77
|
-
4. **Client** fetches the ciphertext, decrypts it locally, and connects directly via SSH
|
|
78
|
-
5. On `Ctrl+C`, `tree-kill` initiates a graceful shutdown, revokes the UID from the broker, and
|
|
80
|
+
4. **Client** fetches the ciphertext, decrypts it locally, and connects directly via SSH or WebSockets.
|
|
81
|
+
5. On `Ctrl+C`, `tree-kill` initiates a graceful shutdown, revokes the UID from the broker, and removes session artifacts.
|
|
82
|
+
|
|
83
|
+
### Approval Gate Flow (Optional)
|
|
84
|
+
|
|
85
|
+
```mermaid
|
|
86
|
+
sequenceDiagram
|
|
87
|
+
participant C as Client CLI
|
|
88
|
+
participant B as Broker Relay
|
|
89
|
+
participant H as Host CLI
|
|
90
|
+
C->>B: approval-request encrypted metadata
|
|
91
|
+
H->>B: fetch approvals with x-host-token
|
|
92
|
+
H->>B: approve/deny with x-host-token
|
|
93
|
+
C->>B: poll approval status
|
|
94
|
+
B-->>C: approved/denied
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
1. **Client** submits encrypted approval metadata (username, host, intent) to the Broker.
|
|
98
|
+
2. **Host** lists and decides approvals using its host-only auth token; the Broker never shares this token with clients.
|
|
99
|
+
3. **Client** polls for approval status and proceeds only when approved.
|
|
79
100
|
|
|
80
101
|
---
|
|
81
102
|
|
|
@@ -111,6 +132,8 @@ These alerts (e.g., "AI-detected potential code anomaly", "Shell access", "Netwo
|
|
|
111
132
|
| `ipingyou service install` | ๐ป Installs Host mode as an always-on background daemon. |
|
|
112
133
|
| `ipingyou service stop` | Stops and removes the background daemon. |
|
|
113
134
|
| `ipingyou service status` | Shows background daemon status. |
|
|
135
|
+
| `ipingyou allowlist` | Manage the AI command allowlist (list/add/remove). |
|
|
136
|
+
| `ipingyou history` | View session event logs from `~/.ipingyou/logs`. |
|
|
114
137
|
|
|
115
138
|
---
|
|
116
139
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
27
|
|
|
28
28
|
import { detectOS, checkDependencies } from './lib/platform.js';
|
|
29
29
|
import { cleanupAll, installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
|
|
30
|
+
import { cleanupSessionLog } from './lib/session-log.js';
|
|
30
31
|
import { startHostMode } from './modes/host.js';
|
|
31
32
|
import { startClientMode } from './modes/client.js';
|
|
32
33
|
import { startAIMode } from './modes/ai.js';
|
|
@@ -122,6 +123,8 @@ function showRichHelp() {
|
|
|
122
123
|
console.log(` โข Cloudflare Tunnels punch through NAT/Firewalls securely.`);
|
|
123
124
|
console.log(` โข ${chalk.green('End-to-End Encryption')}: Tunnel URLs are AES-256 encrypted locally.`);
|
|
124
125
|
console.log(` โข The Broker never sees your plaintext URL, only ciphertext.`);
|
|
126
|
+
console.log(` โข ${chalk.green('Host Auth Token')}: Host-only token gates approvals & telemetry access.`);
|
|
127
|
+
console.log(` โข ${chalk.green('Approval Gate')}: Clients submit encrypted metadata; Host approves/denies.`);
|
|
125
128
|
console.log('');
|
|
126
129
|
|
|
127
130
|
console.log(chalk.bold.white(' ๐ฅ Advanced Features:'));
|
|
@@ -130,6 +133,8 @@ function showRichHelp() {
|
|
|
130
133
|
console.log(` โข ${chalk.green('E2E Chat Room')} : Real-time Web Crypto AES-GCM secure chat UI for Host & Clients.`);
|
|
131
134
|
console.log(` โข ${chalk.green('Daemonization')} : Run Host mode as a background service via PM2.`);
|
|
132
135
|
console.log(` โข ${chalk.green('Panic Kill-Switch')} : Instantly purge all processes, configurations, and traces.`);
|
|
136
|
+
console.log(` โข ${chalk.green('Shared Drop Folder')} : Session dropbox auto-removed on exit.`);
|
|
137
|
+
console.log(` โข ${chalk.green('Live Session Logs')} : Host/client/broker events written per session.`);
|
|
133
138
|
console.log('');
|
|
134
139
|
|
|
135
140
|
console.log(chalk.bold.white(' ๐ก Examples:'));
|
|
@@ -159,6 +164,7 @@ function fatal(context, err) {
|
|
|
159
164
|
stackLines.forEach(line => console.error(chalk.dim(` ${line.trim()}`)));
|
|
160
165
|
}
|
|
161
166
|
console.error('');
|
|
167
|
+
cleanupSessionLog();
|
|
162
168
|
cleanupAll().finally(() => process.exit(1));
|
|
163
169
|
}
|
|
164
170
|
|
package/src/lib/cleanup.js
CHANGED
|
@@ -14,6 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { execaCommand } from 'execa';
|
|
17
|
+
import { TMUX_SESSION_NAME, tmuxSocketArgs } from './tmux.js';
|
|
17
18
|
|
|
18
19
|
/** @type {Set<number>} โ Active child PIDs we manage */
|
|
19
20
|
const trackedPIDs = new Set();
|
|
@@ -191,7 +192,7 @@ export async function executePanicMode() {
|
|
|
191
192
|
} else {
|
|
192
193
|
await execaCommand('pkill -9 -f cloudflared', { reject: false });
|
|
193
194
|
await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
|
|
194
|
-
await execaCommand(
|
|
195
|
+
await execaCommand(`tmux ${tmuxSocketArgs().join(' ')} kill-session -t ${TMUX_SESSION_NAME}`, { reject: false });
|
|
195
196
|
}
|
|
196
197
|
} catch {}
|
|
197
198
|
|
package/src/lib/path-browser.js
CHANGED
|
@@ -109,11 +109,11 @@ export async function promptLocalPath(label, browserStart = process.cwd()) {
|
|
|
109
109
|
return expandLocalPath(localPath);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir) {
|
|
112
|
+
async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir, persistKnownHosts = true) {
|
|
113
113
|
const cdTarget = formatRemoteCd(remoteDir);
|
|
114
114
|
const cdCommand = cdTarget ? `cd ${cdTarget}` : 'cd';
|
|
115
115
|
const command = `${cdCommand} && printf '__SECURELINK_PWD__%s\\n' "$PWD" && ls -1Ap`;
|
|
116
|
-
const sshArgs = buildSshArgs(hostname, privateKeyPath);
|
|
116
|
+
const sshArgs = buildSshArgs(hostname, privateKeyPath, [], { persistKnownHosts });
|
|
117
117
|
sshArgs.push(`${username}@${hostname}`, command);
|
|
118
118
|
|
|
119
119
|
const result = await execa('ssh', sshArgs, {
|
|
@@ -146,7 +146,8 @@ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir
|
|
|
146
146
|
return { pwd, entries };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
export async function promptRemotePath(username, hostname, privateKeyPath, purpose, startDir = '~') {
|
|
149
|
+
export async function promptRemotePath(username, hostname, privateKeyPath, purpose, startDir = '~', options = {}) {
|
|
150
|
+
const { persistKnownHosts = true } = options;
|
|
150
151
|
const browseLabel = purpose === 'source'
|
|
151
152
|
? 'Browse host files interactively'
|
|
152
153
|
: 'Browse host folders interactively';
|
|
@@ -183,7 +184,7 @@ export async function promptRemotePath(username, hostname, privateKeyPath, purpo
|
|
|
183
184
|
while (true) {
|
|
184
185
|
let listing;
|
|
185
186
|
try {
|
|
186
|
-
listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir);
|
|
187
|
+
listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir, persistKnownHosts);
|
|
187
188
|
} catch (err) {
|
|
188
189
|
console.log(chalk.yellow(` โ ๏ธ Could not browse host files: ${err.message}`));
|
|
189
190
|
if (/Operation not permitted/i.test(err.stderr || err.message)) {
|
package/src/lib/ssh.js
CHANGED
|
@@ -34,11 +34,24 @@ export function getSshControlOptions(hostname) {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function
|
|
37
|
+
export function getKnownHostsOptions(persistKnownHosts = true) {
|
|
38
|
+
if (persistKnownHosts) {
|
|
39
|
+
return ['-o', 'StrictHostKeyChecking=accept-new'];
|
|
40
|
+
}
|
|
41
|
+
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
|
42
|
+
return [
|
|
43
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
44
|
+
'-o', `UserKnownHostsFile=${nullDevice}`,
|
|
45
|
+
'-o', 'LogLevel=ERROR',
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildSshArgs(hostname, privateKeyPath, extraOptions = [], options = {}) {
|
|
50
|
+
const { persistKnownHosts = true } = options;
|
|
38
51
|
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
39
52
|
const sshArgs = [
|
|
40
53
|
'-o', `ProxyCommand=${proxyCommand}`,
|
|
41
|
-
|
|
54
|
+
...getKnownHostsOptions(persistKnownHosts),
|
|
42
55
|
'-o', 'IdentitiesOnly=yes',
|
|
43
56
|
...getSshControlOptions(hostname),
|
|
44
57
|
...extraOptions,
|
package/src/lib/tmux.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const TMUX_SESSION_NAME = 'SecureLink_Session';
|
|
5
|
+
export const TMUX_SOCKET_PATH = path.join(os.tmpdir(), 'ipingyou-tmux.sock');
|
|
6
|
+
|
|
7
|
+
export function tmuxSocketArgs() {
|
|
8
|
+
return ['-S', TMUX_SOCKET_PATH];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function tmuxSocketCommand() {
|
|
12
|
+
return `tmux -S ${TMUX_SOCKET_PATH}`;
|
|
13
|
+
}
|
package/src/modes/client.js
CHANGED
|
@@ -24,7 +24,8 @@ import { getConfig, saveAlias } from '../lib/config.js';
|
|
|
24
24
|
import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
|
|
25
25
|
import { calculateChecksum } from '../lib/checksum.js';
|
|
26
26
|
import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
|
|
27
|
-
import { buildSshArgs, extractHostname, formatScpRemotePath, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
27
|
+
import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
28
|
+
import { TMUX_SESSION_NAME, tmuxSocketCommand } from '../lib/tmux.js';
|
|
28
29
|
import open from 'open';
|
|
29
30
|
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
30
31
|
|
|
@@ -68,7 +69,7 @@ async function writeEphemeralPrivateKey(privateKey) {
|
|
|
68
69
|
/**
|
|
69
70
|
* Start SSH connection through the Cloudflare tunnel.
|
|
70
71
|
*/
|
|
71
|
-
async function connectSSH(username, hostname, privateKeyPath) {
|
|
72
|
+
async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts = true) {
|
|
72
73
|
console.log('');
|
|
73
74
|
console.log(chalk.bold(' ๐ Establishing SSH Connection'));
|
|
74
75
|
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
@@ -84,10 +85,12 @@ async function connectSSH(username, hostname, privateKeyPath) {
|
|
|
84
85
|
const sshArgs = buildSshArgs(hostname, privateKeyPath, [
|
|
85
86
|
'-o', 'ServerAliveInterval=30',
|
|
86
87
|
'-o', 'ServerAliveCountMax=3'
|
|
87
|
-
]);
|
|
88
|
+
], { persistKnownHosts });
|
|
88
89
|
|
|
89
90
|
sshArgs.push(`${username}@${hostname}`);
|
|
90
|
-
|
|
91
|
+
const tmuxPrepare = `${tmuxSocketCommand()} has-session -t ${TMUX_SESSION_NAME} 2>/dev/null || ${tmuxSocketCommand()} new-session -d -s ${TMUX_SESSION_NAME}`;
|
|
92
|
+
const tmuxAttach = `${tmuxSocketCommand()} attach -t ${TMUX_SESSION_NAME}`;
|
|
93
|
+
sshArgs.push('-t', `${tmuxPrepare} && ${tmuxAttach} || exec $SHELL -l`);
|
|
91
94
|
|
|
92
95
|
const child = execa('ssh', sshArgs, {
|
|
93
96
|
stdio: 'inherit',
|
|
@@ -119,9 +122,9 @@ async function connectSSH(username, hostname, privateKeyPath) {
|
|
|
119
122
|
/**
|
|
120
123
|
* Perform an SCP file transfer through the Cloudflare tunnel.
|
|
121
124
|
*/
|
|
122
|
-
async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath) {
|
|
125
|
+
async function chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts = true) {
|
|
123
126
|
if (!sharedDropPath) {
|
|
124
|
-
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
|
|
127
|
+
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const { dropChoice } = await inquirer.prompt([{
|
|
@@ -145,7 +148,7 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
|
|
|
145
148
|
|
|
146
149
|
if (dropChoice === 'drop') return sharedDropPath;
|
|
147
150
|
if (dropChoice === 'drop_browse') {
|
|
148
|
-
return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath);
|
|
151
|
+
return promptRemotePath(username, hostname, privateKeyPath, 'source', sharedDropPath, { persistKnownHosts });
|
|
149
152
|
}
|
|
150
153
|
if (dropChoice === 'manual') {
|
|
151
154
|
const { remotePath } = await inquirer.prompt([{
|
|
@@ -157,10 +160,10 @@ async function chooseRemoteTransferPath(username, hostname, privateKeyPath, dire
|
|
|
157
160
|
return remotePath.trim();
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source');
|
|
163
|
+
return promptRemotePath(username, hostname, privateKeyPath, direction === 'upload' ? 'destination' : 'source', '~', { persistKnownHosts });
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null) {
|
|
166
|
+
async function performSCP(username, hostname, direction, privateKeyPath, sharedDropPath = null, persistKnownHosts = true) {
|
|
164
167
|
console.log('');
|
|
165
168
|
console.log(chalk.bold(` ๐ฆ SCP Transfer (${direction})`));
|
|
166
169
|
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
@@ -169,11 +172,11 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
|
|
|
169
172
|
let remotePath;
|
|
170
173
|
|
|
171
174
|
if (direction === 'upload') {
|
|
172
|
-
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
|
|
175
|
+
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
|
|
173
176
|
localPath = await promptLocalPath('client file/folder to upload');
|
|
174
177
|
} else {
|
|
175
178
|
localPath = await promptLocalPath('client destination');
|
|
176
|
-
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath);
|
|
179
|
+
remotePath = await chooseRemoteTransferPath(username, hostname, privateKeyPath, direction, sharedDropPath, persistKnownHosts);
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
await showConnectionTrace('Local', 'Remote SCP');
|
|
@@ -184,7 +187,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
|
|
|
184
187
|
const scpArgs = [
|
|
185
188
|
'-r', // recursive just in case
|
|
186
189
|
'-o', `ProxyCommand=${proxyCommand}`,
|
|
187
|
-
|
|
190
|
+
...getKnownHostsOptions(persistKnownHosts),
|
|
188
191
|
'-o', 'IdentitiesOnly=yes',
|
|
189
192
|
...getSshControlOptions(hostname)
|
|
190
193
|
];
|
|
@@ -230,13 +233,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
|
|
|
230
233
|
console.log(chalk.dim(' ๐ Verifying remote SHA-256 checksum...'));
|
|
231
234
|
try {
|
|
232
235
|
const remoteChecksumPath = joinRemotePath(remotePath, path.basename(localPath));
|
|
233
|
-
const sshArgs = [
|
|
234
|
-
'-o', `ProxyCommand=${proxyCommand}`,
|
|
235
|
-
'-o', 'StrictHostKeyChecking=accept-new',
|
|
236
|
-
'-o', 'IdentitiesOnly=yes',
|
|
237
|
-
...getSshControlOptions(hostname)
|
|
238
|
-
];
|
|
239
|
-
if (privateKeyPath) sshArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
236
|
+
const sshArgs = buildSshArgs(hostname, privateKeyPath, [], { persistKnownHosts });
|
|
240
237
|
sshArgs.push(`${username}@${hostname}`, `shasum -a 256 ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remoteChecksumPath)} 2>/dev/null || shasum -a 256 ${quoteRemoteShell(remotePath)} 2>/dev/null || sha256sum ${quoteRemoteShell(remotePath)}`);
|
|
241
238
|
|
|
242
239
|
const { stdout } = await execa('ssh', sshArgs, { reject: false });
|
|
@@ -268,13 +265,13 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
|
|
|
268
265
|
}
|
|
269
266
|
}
|
|
270
267
|
|
|
271
|
-
async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath) {
|
|
268
|
+
async function downloadSpecificRemotePath(username, hostname, privateKeyPath, remotePath, localPath, persistKnownHosts = true) {
|
|
272
269
|
await showConnectionTrace('Local', 'Remote SCP');
|
|
273
270
|
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
274
271
|
const scpArgs = [
|
|
275
272
|
'-r',
|
|
276
273
|
'-o', `ProxyCommand=${proxyCommand}`,
|
|
277
|
-
|
|
274
|
+
...getKnownHostsOptions(persistKnownHosts),
|
|
278
275
|
'-o', 'IdentitiesOnly=yes',
|
|
279
276
|
...getSshControlOptions(hostname),
|
|
280
277
|
];
|
|
@@ -343,6 +340,7 @@ export async function startClientMode(options = {}) {
|
|
|
343
340
|
let targetUid = null;
|
|
344
341
|
let targetPassword = null;
|
|
345
342
|
let targetUsername = null;
|
|
343
|
+
let persistKnownHosts = false;
|
|
346
344
|
|
|
347
345
|
if (aliasKeys.length > 0) {
|
|
348
346
|
const { useAlias } = await inquirer.prompt([
|
|
@@ -362,6 +360,7 @@ export async function startClientMode(options = {}) {
|
|
|
362
360
|
targetUid = aliasData.uid;
|
|
363
361
|
targetPassword = aliasData.password;
|
|
364
362
|
targetUsername = aliasData.username;
|
|
363
|
+
persistKnownHosts = true;
|
|
365
364
|
logSessionEvent('client_alias_selected', { alias: useAlias, uid: targetUid });
|
|
366
365
|
}
|
|
367
366
|
}
|
|
@@ -552,7 +551,7 @@ export async function startClientMode(options = {}) {
|
|
|
552
551
|
await pushTelemetry(BROKER_URL, targetUid, targetPassword, username, 'one-time-download');
|
|
553
552
|
logSessionEvent('client_one_time_download_start', { uid: targetUid, remotePath: payload.oneTimeSharePath });
|
|
554
553
|
|
|
555
|
-
const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest);
|
|
554
|
+
const success = await downloadSpecificRemotePath(username, hostname, privateKeyPath, payload.oneTimeSharePath, localDest, persistKnownHosts);
|
|
556
555
|
|
|
557
556
|
if (success) {
|
|
558
557
|
console.log(chalk.green(' โ
One-time file transfer completed successfully!'));
|
|
@@ -596,6 +595,7 @@ export async function startClientMode(options = {}) {
|
|
|
596
595
|
saveAlias(aliasName.trim(), { uid: targetUid, password: targetPassword, username });
|
|
597
596
|
console.log(chalk.green(` โ Saved as alias: ${chalk.bold(aliasName.trim())}\n`));
|
|
598
597
|
logSessionEvent('client_alias_saved', { alias: aliasName.trim(), uid: targetUid });
|
|
598
|
+
persistKnownHosts = true;
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
@@ -620,11 +620,11 @@ export async function startClientMode(options = {}) {
|
|
|
620
620
|
if (action === 'chat') {
|
|
621
621
|
await handleClientChat(targetUid, targetPassword, payload.chatUrl);
|
|
622
622
|
} else if (action === 'ssh') {
|
|
623
|
-
await connectSSH(username, hostname, privateKeyPath);
|
|
623
|
+
await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
|
|
624
624
|
} else if (action === 'reverse') {
|
|
625
|
-
await performReverseForward(username, hostname, privateKeyPath);
|
|
625
|
+
await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
|
|
626
626
|
} else {
|
|
627
|
-
await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath);
|
|
627
|
+
await performSCP(username, hostname, action, privateKeyPath, payload.sharedDropPath, persistKnownHosts);
|
|
628
628
|
}
|
|
629
629
|
|
|
630
630
|
console.log('');
|
|
@@ -638,7 +638,7 @@ export async function startClientMode(options = {}) {
|
|
|
638
638
|
]);
|
|
639
639
|
|
|
640
640
|
if (reconnect) {
|
|
641
|
-
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath);
|
|
641
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, payload.sharedDropPath, persistKnownHosts);
|
|
642
642
|
}
|
|
643
643
|
|
|
644
644
|
logSessionEvent('client_session_exit');
|
|
@@ -650,7 +650,7 @@ export async function startClientMode(options = {}) {
|
|
|
650
650
|
* params: { brokerUrl, uid, password, username, direction, localPath, remotePath }
|
|
651
651
|
*/
|
|
652
652
|
export async function performSCPNonInteractive(params = {}) {
|
|
653
|
-
const { brokerUrl, uid, password, username, direction, localPath, remotePath } = params;
|
|
653
|
+
const { brokerUrl, uid, password, username, direction, localPath, remotePath, persistKnownHosts = true } = params;
|
|
654
654
|
if (!brokerUrl || !uid || !password) throw new Error('Missing broker connection info');
|
|
655
655
|
|
|
656
656
|
const payload = await resolveUID(brokerUrl, uid, password);
|
|
@@ -662,7 +662,7 @@ export async function performSCPNonInteractive(params = {}) {
|
|
|
662
662
|
|
|
663
663
|
// Build scp args similar to performSCP
|
|
664
664
|
const proxyCommand = `cloudflared access tcp --hostname ${hostname}`;
|
|
665
|
-
const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`,
|
|
665
|
+
const scpArgs = ['-r', '-o', `ProxyCommand=${proxyCommand}`, ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
|
|
666
666
|
if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
667
667
|
|
|
668
668
|
const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
|
|
@@ -707,7 +707,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
|
|
|
707
707
|
}
|
|
708
708
|
}
|
|
709
709
|
|
|
710
|
-
async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null) {
|
|
710
|
+
async function handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath = null, persistKnownHosts = true) {
|
|
711
711
|
const { action } = await inquirer.prompt([
|
|
712
712
|
{
|
|
713
713
|
type: 'list',
|
|
@@ -732,11 +732,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
|
|
|
732
732
|
if (action === 'chat') {
|
|
733
733
|
await handleClientChat(targetUid, targetPassword, null);
|
|
734
734
|
} else if (action === 'ssh') {
|
|
735
|
-
await connectSSH(username, hostname, privateKeyPath);
|
|
735
|
+
await connectSSH(username, hostname, privateKeyPath, persistKnownHosts);
|
|
736
736
|
} else if (action === 'reverse') {
|
|
737
|
-
await performReverseForward(username, hostname, privateKeyPath);
|
|
737
|
+
await performReverseForward(username, hostname, privateKeyPath, persistKnownHosts);
|
|
738
738
|
} else {
|
|
739
|
-
await performSCP(username, hostname, action, privateKeyPath, sharedDropPath);
|
|
739
|
+
await performSCP(username, hostname, action, privateKeyPath, sharedDropPath, persistKnownHosts);
|
|
740
740
|
}
|
|
741
741
|
|
|
742
742
|
const { reconnect } = await inquirer.prompt([
|
|
@@ -749,11 +749,11 @@ async function handleSubsequentActions(username, hostname, privateKeyPath, targe
|
|
|
749
749
|
]);
|
|
750
750
|
|
|
751
751
|
if (reconnect) {
|
|
752
|
-
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath);
|
|
752
|
+
await handleSubsequentActions(username, hostname, privateKeyPath, targetUid, targetPassword, sharedDropPath, persistKnownHosts);
|
|
753
753
|
}
|
|
754
754
|
}
|
|
755
755
|
|
|
756
|
-
async function performReverseForward(username, hostname, privateKeyPath) {
|
|
756
|
+
async function performReverseForward(username, hostname, privateKeyPath, persistKnownHosts = true) {
|
|
757
757
|
console.log('');
|
|
758
758
|
console.log(chalk.bold.cyan(' ๐ Reverse Port Forwarding'));
|
|
759
759
|
console.log(chalk.dim(' โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ'));
|
|
@@ -782,7 +782,7 @@ async function performReverseForward(username, hostname, privateKeyPath) {
|
|
|
782
782
|
'-N',
|
|
783
783
|
'-R', portMap,
|
|
784
784
|
'-o', 'ExitOnForwardFailure=yes',
|
|
785
|
-
]);
|
|
785
|
+
], { persistKnownHosts });
|
|
786
786
|
sshArgs.push(`${username}@${hostname}`);
|
|
787
787
|
|
|
788
788
|
console.log('');
|
package/src/modes/host.js
CHANGED
|
@@ -29,6 +29,7 @@ import { startChatServer, openLocalChatUI } from '../lib/chat.js';
|
|
|
29
29
|
import { spawnTunnelSupervised } from '../lib/tunnel.js';
|
|
30
30
|
import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
|
|
31
31
|
import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
32
|
+
import { TMUX_SESSION_NAME, tmuxSocketArgs } from '../lib/tmux.js';
|
|
32
33
|
|
|
33
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
35
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -193,6 +194,23 @@ async function prepareSharedDropFolder(uid) {
|
|
|
193
194
|
return dropPath;
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
async function cleanupSharedDropFolder(dropPath, uid) {
|
|
198
|
+
if (!dropPath) return;
|
|
199
|
+
const expectedPath = path.join(os.homedir(), `ipingyou-dropbox-${uid}`);
|
|
200
|
+
if (path.resolve(dropPath) !== path.resolve(expectedPath)) {
|
|
201
|
+
console.log(chalk.yellow(' Skipping drop folder cleanup (unexpected path).'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const stat = await fs.promises.lstat(dropPath).catch(() => null);
|
|
206
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
207
|
+
console.log(chalk.dim(' Removing shared drop folder...'));
|
|
208
|
+
await fs.promises.rm(dropPath, { recursive: true, force: true });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.log(chalk.yellow(` Could not remove drop folder: ${err.message}`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
function showMacPrivacyPreflight(sharedDropPath) {
|
|
197
215
|
if (process.platform !== 'darwin') return;
|
|
198
216
|
|
|
@@ -257,12 +275,37 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
|
|
|
257
275
|
} catch { }
|
|
258
276
|
};
|
|
259
277
|
|
|
260
|
-
const iv = setInterval(push,
|
|
278
|
+
const iv = setInterval(push, 5000);
|
|
261
279
|
req.on('close', () => { clearInterval(iv); closed = true; });
|
|
262
280
|
// send initial payload
|
|
263
281
|
await push();
|
|
264
282
|
});
|
|
265
283
|
|
|
284
|
+
app.get('/api/clients', async (_req, res) => {
|
|
285
|
+
try {
|
|
286
|
+
const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
|
|
287
|
+
headers: sessionState.hostToken ? { 'x-host-token': sessionState.hostToken } : {}
|
|
288
|
+
});
|
|
289
|
+
if (!brokerRes.ok) {
|
|
290
|
+
const data = await brokerRes.json().catch(() => ({}));
|
|
291
|
+
return res.status(brokerRes.status).json({ error: data.error || 'Failed to fetch clients' });
|
|
292
|
+
}
|
|
293
|
+
const data = await brokerRes.json();
|
|
294
|
+
const clients = (data.clients || []).map((clientBlob) => {
|
|
295
|
+
try {
|
|
296
|
+
const decrypted = decrypt(clientBlob.iv, clientBlob.ciphertext, password, clientBlob.salt);
|
|
297
|
+
const t = JSON.parse(decrypted);
|
|
298
|
+
return { ...t, seenAt: clientBlob.seenAt || null };
|
|
299
|
+
} catch {
|
|
300
|
+
return { error: 'decrypt_failed', seenAt: clientBlob.seenAt || null };
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
res.json({ clients });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
res.status(500).json({ error: err.message });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
266
309
|
// CSRF Protection Middleware for all mutating endpoints
|
|
267
310
|
app.use((req, res, next) => {
|
|
268
311
|
if (req.method !== 'POST') return next();
|
|
@@ -387,6 +430,16 @@ function showToast(msg) {
|
|
|
387
430
|
t.classList.add('show');
|
|
388
431
|
setTimeout(() => t.classList.remove('show'), 2500);
|
|
389
432
|
}
|
|
433
|
+
|
|
434
|
+
function escapeHtml(value) {
|
|
435
|
+
return String(value || '').replace(/[&<>"']/g, (m) => ({
|
|
436
|
+
'&': '&',
|
|
437
|
+
'<': '<',
|
|
438
|
+
'>': '>',
|
|
439
|
+
'"': '"',
|
|
440
|
+
"'": ''',
|
|
441
|
+
})[m]);
|
|
442
|
+
}
|
|
390
443
|
|
|
391
444
|
function updateUptime() {
|
|
392
445
|
const diff = Math.floor((Date.now() - startedAt.getTime()) / 1000);
|
|
@@ -473,14 +526,47 @@ async function revokeSession() {
|
|
|
473
526
|
// Poll client telemetry (separate from SSE for simplicity)
|
|
474
527
|
async function pollClients() {
|
|
475
528
|
try {
|
|
476
|
-
const res = await fetch('/api/
|
|
529
|
+
const res = await fetch('/api/clients');
|
|
477
530
|
if (!res.ok) return;
|
|
478
|
-
|
|
531
|
+
const data = await res.json();
|
|
532
|
+
renderClients(data.clients || []);
|
|
479
533
|
} catch {}
|
|
480
534
|
}
|
|
535
|
+
|
|
536
|
+
function renderClients(clients) {
|
|
537
|
+
const container = document.getElementById('clients');
|
|
538
|
+
if (!clients || clients.length === 0) {
|
|
539
|
+
container.innerHTML = '<p class="empty">No clients connected yet</p>';
|
|
540
|
+
document.getElementById('client-count').textContent = '';
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
|
|
545
|
+
let html = '';
|
|
546
|
+
clients.forEach((c, idx) => {
|
|
547
|
+
if (c.error) {
|
|
548
|
+
html += '<div class="client-card"><strong>Client #' + (idx + 1) + '</strong> โ payload decryption failed</div>';
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
|
|
552
|
+
html += '<div class="client-card">'
|
|
553
|
+
+ '<strong>' + escapeHtml(c.username || 'Unknown') + '</strong>'
|
|
554
|
+
+ ' โ ' + escapeHtml(c.action || 'connected') + '<br>'
|
|
555
|
+
+ '<span class="meta">IP: ' + escapeHtml(c.ip || 'Unknown') + '</span><br>'
|
|
556
|
+
+ '<span class="meta">OS: ' + escapeHtml(c.os || 'Unknown') + '</span><br>'
|
|
557
|
+
+ '<span class="meta">CPU: ' + escapeHtml(c.cpu || 'Unknown') + '</span><br>'
|
|
558
|
+
+ '<span class="meta">RAM: ' + escapeHtml(c.ram || 'Unknown') + '</span><br>'
|
|
559
|
+
+ '<span class="meta">Time: ' + escapeHtml(when) + '</span>'
|
|
560
|
+
+ '</div>';
|
|
561
|
+
});
|
|
562
|
+
container.innerHTML = html;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
setInterval(pollClients, 5000);
|
|
566
|
+
pollClients();
|
|
481
567
|
</script>
|
|
482
568
|
</body></html>`);
|
|
483
|
-
|
|
569
|
+
});
|
|
484
570
|
|
|
485
571
|
return new Promise((resolve) => {
|
|
486
572
|
const server = app.listen(0, '127.0.0.1', async () => {
|
|
@@ -725,7 +811,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
725
811
|
|
|
726
812
|
try {
|
|
727
813
|
await execaCommand('tmux -V', { reject: true });
|
|
728
|
-
const sessionCheck = await execa('tmux', ['has-session', '-t',
|
|
814
|
+
const sessionCheck = await execa('tmux', [...tmuxSocketArgs(), 'has-session', '-t', TMUX_SESSION_NAME], { reject: false });
|
|
729
815
|
if (sessionCheck.exitCode !== 0) {
|
|
730
816
|
console.log(chalk.yellow(' โ ๏ธ No mirrored terminal session is active yet.'));
|
|
731
817
|
console.log(chalk.dim(' A client must choose "Connect via SSH" first. SCP-only clients do not create a tmux session.'));
|
|
@@ -733,7 +819,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
733
819
|
logSessionEvent('host_mirror_missing_session', {}, 'warn');
|
|
734
820
|
return waitForAction();
|
|
735
821
|
}
|
|
736
|
-
await
|
|
822
|
+
await execa('tmux', [...tmuxSocketArgs(), 'attach', '-t', TMUX_SESSION_NAME, '-r'], { stdio: 'inherit', reject: false });
|
|
737
823
|
logSessionEvent('host_mirror_attached');
|
|
738
824
|
} catch (err) {
|
|
739
825
|
console.log(chalk.yellow(' โ ๏ธ Could not attach to tmux.'));
|
|
@@ -798,7 +884,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
|
|
|
798
884
|
await execaCommand('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name = \'sshd.exe\'\\" | Where-Object { $_.CommandLine -match \'sshd:.*@\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"', { reject: false });
|
|
799
885
|
} else {
|
|
800
886
|
await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
|
|
801
|
-
await
|
|
887
|
+
await execa('tmux', [...tmuxSocketArgs(), 'kill-session', '-t', TMUX_SESSION_NAME], { reject: false });
|
|
802
888
|
}
|
|
803
889
|
spinner.succeed('All client SSH sessions terminated');
|
|
804
890
|
logSessionEvent('host_sessions_terminated');
|
|
@@ -953,6 +1039,7 @@ export async function startHostMode() {
|
|
|
953
1039
|
serviceConfig.sharedDropPath = await prepareSharedDropFolder(uid);
|
|
954
1040
|
console.log(chalk.green(` โ Shared drop folder ready: ${serviceConfig.sharedDropPath}`));
|
|
955
1041
|
showMacPrivacyPreflight(serviceConfig.sharedDropPath);
|
|
1042
|
+
addCleanupHook(() => cleanupSharedDropFolder(serviceConfig.sharedDropPath, uid));
|
|
956
1043
|
} catch (err) {
|
|
957
1044
|
console.log(chalk.yellow(` โ ๏ธ Could not prepare shared drop folder: ${err.message}`));
|
|
958
1045
|
}
|
package/src/server.js
CHANGED
|
@@ -68,7 +68,7 @@ app.use((req, res, next) => {
|
|
|
68
68
|
// Trust proxy is required if the server is behind a reverse proxy (like Render, Heroku, Cloudflare)
|
|
69
69
|
app.set('trust proxy', 1);
|
|
70
70
|
|
|
71
|
-
// General rate limiter for
|
|
71
|
+
// General rate limiter for public requests (100 reqs per 15 minutes per IP)
|
|
72
72
|
const generalLimiter = rateLimit({
|
|
73
73
|
windowMs: 15 * 60 * 1000,
|
|
74
74
|
max: 100,
|
|
@@ -76,7 +76,6 @@ const generalLimiter = rateLimit({
|
|
|
76
76
|
standardHeaders: true,
|
|
77
77
|
legacyHeaders: false,
|
|
78
78
|
});
|
|
79
|
-
app.use(generalLimiter);
|
|
80
79
|
|
|
81
80
|
// Stricter rate limiter for registration/revocation endpoints (e.g. 20 reqs per 15 mins)
|
|
82
81
|
const strictLimiter = rateLimit({
|
|
@@ -87,6 +86,15 @@ const strictLimiter = rateLimit({
|
|
|
87
86
|
legacyHeaders: false,
|
|
88
87
|
});
|
|
89
88
|
|
|
89
|
+
// Higher limiter for host-authenticated endpoints (dashboard polling, approvals, telemetry views)
|
|
90
|
+
const hostLimiter = rateLimit({
|
|
91
|
+
windowMs: 15 * 60 * 1000,
|
|
92
|
+
max: 600,
|
|
93
|
+
message: { error: 'Too many host requests. Please try again later.' },
|
|
94
|
+
standardHeaders: true,
|
|
95
|
+
legacyHeaders: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
90
98
|
// โโโ Active Defense & IP Blacklisting (IDS) โโโโโโโโโโโโโโโโโโ
|
|
91
99
|
const ipViolations = new Map(); // ip โ violation_count
|
|
92
100
|
const blacklistedIPs = new Set();
|
|
@@ -173,7 +181,7 @@ setInterval(pruneExpired, 5 * 60 * 1000);
|
|
|
173
181
|
// โโโ Routes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
174
182
|
|
|
175
183
|
// Health
|
|
176
|
-
app.get('/health', (_req, res) => {
|
|
184
|
+
app.get('/health', generalLimiter, (_req, res) => {
|
|
177
185
|
res.json({ status: 'ok', uptime: process.uptime(), activeUIDs: store.size });
|
|
178
186
|
});
|
|
179
187
|
|
|
@@ -253,7 +261,7 @@ app.post('/register', strictLimiter, (req, res) => {
|
|
|
253
261
|
* Returns the ENCRYPTED blob { iv, ciphertext } for a given UID.
|
|
254
262
|
* The client CLI decrypts it locally โ the broker never sees plaintext.
|
|
255
263
|
*/
|
|
256
|
-
app.get('/resolve/:uid', (req, res) => {
|
|
264
|
+
app.get('/resolve/:uid', generalLimiter, (req, res) => {
|
|
257
265
|
try {
|
|
258
266
|
const { uid } = req.params;
|
|
259
267
|
if (!isSafeParam(uid)) {
|
|
@@ -355,7 +363,7 @@ app.post('/approval-request/:uid', generalLimiter, (req, res) => {
|
|
|
355
363
|
});
|
|
356
364
|
|
|
357
365
|
// HOST-ONLY: List approval requests (requires host token)
|
|
358
|
-
app.get('/approval-requests/:uid',
|
|
366
|
+
app.get('/approval-requests/:uid', hostLimiter, (req, res) => {
|
|
359
367
|
if (!isSafeParam(req.params.uid)) return res.status(400).json({ error: 'Invalid UID' });
|
|
360
368
|
const entry = store.get(req.params.uid);
|
|
361
369
|
if (!entry) return res.status(404).json({ error: 'UID not found' });
|
|
@@ -434,7 +442,7 @@ app.post('/client-info/:uid', generalLimiter, (req, res) => {
|
|
|
434
442
|
* Host retrieves all securely encrypted client telemetry blobs.
|
|
435
443
|
*/
|
|
436
444
|
// HOST-ONLY: View client telemetry (requires host token)
|
|
437
|
-
app.get('/clients/:uid',
|
|
445
|
+
app.get('/clients/:uid', hostLimiter, (req, res) => {
|
|
438
446
|
try {
|
|
439
447
|
const { uid } = req.params;
|
|
440
448
|
if (!isSafeParam(uid)) return res.status(400).json({ error: 'Invalid UID format' });
|