@miraj181/ipingyou 1.0.1 β 2.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/README.md +36 -96
- package/package.json +11 -4
- package/src/cli.js +84 -4
- package/src/lib/chat.js +333 -0
- package/src/lib/cleanup.js +78 -0
- package/src/lib/config.js +41 -0
- package/src/lib/crypto.js +23 -14
- package/src/lib/platform.js +415 -53
- package/src/modes/client.js +451 -58
- package/src/modes/host.js +416 -119
- package/src/server.js +0 -143
package/README.md
CHANGED
|
@@ -1,62 +1,53 @@
|
|
|
1
1
|
# π iPingYou β SecureLink CLI
|
|
2
2
|
|
|
3
|
-
Secure peer-to-peer remote access via SSH & Cloudflare Tunnels. A Node.js CLI tool that lets two machines establish an encrypted SSH connection through Cloudflare's network,
|
|
3
|
+
Secure peer-to-peer remote access via SSH & Cloudflare Tunnels. A zero-configuration Node.js CLI tool that lets two machines establish an encrypted SSH connection through Cloudflare's network, instantly.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
Host Machine
|
|
9
|
-
ββββββββββββββββ POST
|
|
10
|
-
β cloudflared βββ/register
|
|
11
|
-
β tunnel :22 β {uid,url}
|
|
12
|
-
β β β
|
|
13
|
-
β SSH daemon
|
|
14
|
-
β β cloudflared
|
|
15
|
-
ββββββββββββββββ TCP proxy
|
|
8
|
+
Host Machine Client Machine
|
|
9
|
+
ββββββββββββββββ POST GET ββββββββββββββββ
|
|
10
|
+
β cloudflared βββ/registerβββΆ[Broker Server]ββ/resolveβββ ipingyou β
|
|
11
|
+
β tunnel :22 β {uid,url} (Render Hosted) /:uid β connect β
|
|
12
|
+
β β AES-256-CBC β β
|
|
13
|
+
β SSH daemon βββββββββββββββ ββββββββββββββ SSH via β
|
|
14
|
+
β β cloudflared ProxyCmd β ProxyCmd β
|
|
15
|
+
ββββββββββββββββ TCP proxy ββββββββββββββββ
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
### Flow
|
|
19
|
-
1. **Host** starts `ipingyou host` β generates an 8-char UID, spins up `cloudflared` tunnel, registers with broker
|
|
20
|
-
2. **Host** shares UID with the client
|
|
21
|
-
3. **Client** runs `ipingyou connect` β enters UID, broker resolves it to tunnel URL β SSH connects via cloudflared proxy
|
|
22
|
-
4. On `Ctrl+C`, all spawned processes are killed via `tree-kill` and the UID is revoked from the broker
|
|
19
|
+
1. **Host** starts `ipingyou host` β generates an 8-char UID, spins up `cloudflared` tunnel, and registers with the broker.
|
|
20
|
+
2. **Host** shares the UID with the client.
|
|
21
|
+
3. **Client** runs `ipingyou connect` β enters UID, broker resolves it to tunnel URL β SSH connects via cloudflared proxy.
|
|
22
|
+
4. On `Ctrl+C`, all spawned processes are killed via `tree-kill` and the UID is automatically revoked from the broker.
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## Usage
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
You do not need to clone the repository or configure any `.env` files. `iPingYou` is purely published on npm and handles the backend API automatically!
|
|
27
|
+
|
|
28
|
+
### The "On-the-Fly" Way (Recommended)
|
|
29
|
+
Run it anywhere using `npx` (make sure you aren't inside the project source code folder):
|
|
27
30
|
|
|
28
31
|
```bash
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
npm install
|
|
32
|
-
npm run broker
|
|
33
|
-
# Broker: http://localhost:4000
|
|
34
|
-
```
|
|
32
|
+
# Start the interactive wizard
|
|
33
|
+
npx @miraj181/ipingyou
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
# Or instantly start as a Host
|
|
36
|
+
npx @miraj181/ipingyou host
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
npx ipingyou
|
|
40
|
-
# or interactively:
|
|
41
|
-
npx ipingyou
|
|
38
|
+
# Or instantly connect as a Client
|
|
39
|
+
npx @miraj181/ipingyou connect
|
|
42
40
|
```
|
|
43
41
|
|
|
44
|
-
###
|
|
42
|
+
### Global Install
|
|
43
|
+
If you use the tool frequently, install it globally:
|
|
45
44
|
|
|
46
45
|
```bash
|
|
47
|
-
|
|
48
|
-
# or interactively:
|
|
49
|
-
npx ipingyou
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## CLI Commands
|
|
46
|
+
npm install -g @miraj181/ipingyou
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
ipingyou
|
|
56
|
-
|
|
57
|
-
ipingyou connect Start as client β access a remote machine
|
|
58
|
-
ipingyou broker Start the central broker server
|
|
59
|
-
ipingyou broker -p 5000 Start broker on custom port
|
|
48
|
+
# Run as native commands:
|
|
49
|
+
ipingyou
|
|
50
|
+
securelink
|
|
60
51
|
```
|
|
61
52
|
|
|
62
53
|
## Prerequisites
|
|
@@ -67,66 +58,15 @@ ipingyou broker -p 5000 Start broker on custom port
|
|
|
67
58
|
| `ssh` | β
| Ships with macOS/Linux; `winget install Microsoft.OpenSSH.Client` on Windows |
|
|
68
59
|
| `cloudflared` | β
| `brew install cloudflared` / [download](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) |
|
|
69
60
|
|
|
70
|
-
The CLI auto-detects your OS and will attempt to
|
|
71
|
-
|
|
72
|
-
## Project Structure
|
|
73
|
-
|
|
74
|
-
```
|
|
75
|
-
ipingyou/
|
|
76
|
-
βββ src/
|
|
77
|
-
β βββ cli.js β Main CLI entry (npx-ready, shebang)
|
|
78
|
-
β βββ server.js β Central Broker (Express.js)
|
|
79
|
-
β βββ lib/
|
|
80
|
-
β β βββ cleanup.js β Graceful shutdown + tree-kill
|
|
81
|
-
β β βββ crypto.js β AES-256-CBC encrypt/decrypt
|
|
82
|
-
β β βββ platform.js β OS detection + dependency check
|
|
83
|
-
β β βββ uid.js β Random 8-char UID generator (nanoid)
|
|
84
|
-
β βββ modes/
|
|
85
|
-
β βββ host.js β Host mode logic
|
|
86
|
-
β βββ client.js β Client mode logic
|
|
87
|
-
βββ .env.example
|
|
88
|
-
βββ .gitignore
|
|
89
|
-
βββ package.json
|
|
90
|
-
βββ render.yaml β One-click Render deploy
|
|
91
|
-
βββ README.md
|
|
92
|
-
```
|
|
61
|
+
*The CLI auto-detects your OS and will attempt to guide you on how to install missing dependencies!*
|
|
93
62
|
|
|
94
63
|
## Security
|
|
95
64
|
|
|
96
|
-
- **
|
|
97
|
-
- **
|
|
98
|
-
- **
|
|
99
|
-
- **Auto-revoke** β On `Ctrl+C`, UID is deleted from broker before exit
|
|
100
|
-
- **
|
|
101
|
-
- **No persistence** β Broker uses in-memory Map only; server restart = clean slate
|
|
102
|
-
|
|
103
|
-
## Deploy Broker to Render (Free)
|
|
104
|
-
|
|
105
|
-
1. Push this repo to GitHub
|
|
106
|
-
2. Go to [render.com/new](https://render.com/new)
|
|
107
|
-
3. Click **"New Web Service"** β connect your repo
|
|
108
|
-
4. Render auto-detects `render.yaml` β click **Deploy**
|
|
109
|
-
5. Set `SECRET_KEY` in Render Dashboard β Environment
|
|
110
|
-
6. Set `BROKER_URL` in your local `.env` to your Render URL
|
|
111
|
-
|
|
112
|
-
## Environment Variables
|
|
113
|
-
|
|
114
|
-
| Variable | Default | Description |
|
|
115
|
-
|----------|---------|-------------|
|
|
116
|
-
| `SECRET_KEY` | Dev key | AES-256 hex key (64 chars) |
|
|
117
|
-
| `BROKER_URL` | `http://localhost:4000` | Broker endpoint for CLI |
|
|
118
|
-
| `BROKER_PORT` | `4000` | Port for broker server |
|
|
119
|
-
|
|
120
|
-
## Differences from remote-penitrator
|
|
121
|
-
|
|
122
|
-
| Aspect | remote-penitrator | iPingYou |
|
|
123
|
-
|--------|-------------------|----------|
|
|
124
|
-
| Interface | Shell scripts + web dashboard | Interactive Node.js CLI |
|
|
125
|
-
| Direction | One-way (runner β C2) | Peer-to-peer (host β client) |
|
|
126
|
-
| UID System | Hardcoded PC IDs | Random per-session UIDs |
|
|
127
|
-
| Process Mgmt | Manual | tree-kill auto-cleanup |
|
|
128
|
-
| Install | Copy scripts | `npx ipingyou` |
|
|
129
|
-
| Modes | Reporter only | Host + Client + Broker |
|
|
65
|
+
- **Zero-Knowledge Architecture** β The broker never sees the plaintext Cloudflare URL. It is strictly used as an encrypted key-value store.
|
|
66
|
+
- **AES-256-CBC** β Tunnel URLs are encrypted locally before being transmitted to the broker.
|
|
67
|
+
- **Random UIDs** β 8-char nanoid, not hardware-based. When the session dies, the door is locked forever.
|
|
68
|
+
- **Auto-revoke** β On `Ctrl+C`, UID is immediately deleted from the broker before exit.
|
|
69
|
+
- **No Persistence** β Broker uses an in-memory Map only with strict 1-hour TTLs.
|
|
130
70
|
|
|
131
71
|
## License
|
|
132
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraj181/ipingyou",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "SecureLink-CLI β Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"securelink": "src/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"src/",
|
|
11
|
+
"src/cli.js",
|
|
12
|
+
"src/lib/",
|
|
13
|
+
"src/modes/",
|
|
12
14
|
"README.md",
|
|
13
15
|
"LICENSE"
|
|
14
16
|
],
|
|
@@ -43,10 +45,15 @@
|
|
|
43
45
|
"chalk": "^5.3.0",
|
|
44
46
|
"commander": "^12.1.0",
|
|
45
47
|
"execa": "^9.5.2",
|
|
48
|
+
"express": "^5.2.1",
|
|
49
|
+
"express-rate-limit": "^8.5.2",
|
|
50
|
+
"helmet": "^8.1.0",
|
|
46
51
|
"inquirer": "^12.3.2",
|
|
47
52
|
"nanoid": "^5.0.9",
|
|
53
|
+
"open": "^11.0.0",
|
|
48
54
|
"ora": "^8.1.1",
|
|
49
|
-
"tree-kill": "^1.2.2"
|
|
55
|
+
"tree-kill": "^1.2.2",
|
|
56
|
+
"ws": "^8.20.1"
|
|
50
57
|
},
|
|
51
58
|
"devDependencies": {
|
|
52
59
|
"nodemon": "^3.1.4"
|
|
@@ -55,4 +62,4 @@
|
|
|
55
62
|
"node": ">=18.0.0"
|
|
56
63
|
},
|
|
57
64
|
"type": "module"
|
|
58
|
-
}
|
|
65
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -23,9 +23,14 @@ import inquirer from 'inquirer';
|
|
|
23
23
|
import chalk from 'chalk';
|
|
24
24
|
|
|
25
25
|
import { detectOS, checkDependencies } from './lib/platform.js';
|
|
26
|
-
import { installShutdownHandlers } from './lib/cleanup.js';
|
|
26
|
+
import { installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
|
|
27
27
|
import { startHostMode } from './modes/host.js';
|
|
28
28
|
import { startClientMode } from './modes/client.js';
|
|
29
|
+
import { execa } from 'execa';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
34
|
|
|
30
35
|
// βββ ASCII Banner ββββββββββββββββββββββββββββββββββββββββββββ
|
|
31
36
|
function showBanner() {
|
|
@@ -67,10 +72,20 @@ function showRichHelp() {
|
|
|
67
72
|
console.log(` β’ The Broker never sees your plaintext URL, only ciphertext.`);
|
|
68
73
|
console.log('');
|
|
69
74
|
|
|
75
|
+
console.log(chalk.bold.white(' π₯ Advanced Features:'));
|
|
76
|
+
console.log(` β’ ${chalk.green('Terminal Mirroring')} : Host can spectate connected SSH clients in real-time.`);
|
|
77
|
+
console.log(` β’ ${chalk.green('Reverse Forwarding')} : Clients can expose their local localhost ports back to the Host.`);
|
|
78
|
+
console.log(` β’ ${chalk.green('E2E Chat Room')} : Real-time Web Crypto AES-GCM secure chat UI for Host & Clients.`);
|
|
79
|
+
console.log(` β’ ${chalk.green('Daemonization')} : Run Host mode as a background service via PM2.`);
|
|
80
|
+
console.log(` β’ ${chalk.green('Panic Kill-Switch')} : Instantly purge all processes, configurations, and traces.`);
|
|
81
|
+
console.log('');
|
|
82
|
+
|
|
70
83
|
console.log(chalk.bold.white(' π‘ Examples:'));
|
|
71
|
-
console.log(` $ npx ipingyou
|
|
72
|
-
console.log(` $ npx ipingyou host
|
|
73
|
-
console.log(` $ npx ipingyou connect
|
|
84
|
+
console.log(` $ npx ipingyou ${chalk.dim('# Interactive wizard (Recommended)')}`);
|
|
85
|
+
console.log(` $ npx ipingyou host ${chalk.dim('# Quick start as Host')}`);
|
|
86
|
+
console.log(` $ npx ipingyou connect ${chalk.dim('# Quick start as Client')}`);
|
|
87
|
+
console.log(` $ npx ipingyou panic ${chalk.dim('# Self-destruct and wipe memory/traces')}`);
|
|
88
|
+
console.log(` $ npx ipingyou service install ${chalk.dim('# Install Host mode as a background daemon')}`);
|
|
74
89
|
console.log('');
|
|
75
90
|
}
|
|
76
91
|
|
|
@@ -91,6 +106,16 @@ function fatal(context, err) {
|
|
|
91
106
|
process.exit(1);
|
|
92
107
|
}
|
|
93
108
|
|
|
109
|
+
// βββ Process-Level Error Catching ββββββββββββββββββββββββββββ
|
|
110
|
+
process.on('uncaughtException', (err) => {
|
|
111
|
+
fatal('uncaughtException', err);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
process.on('unhandledRejection', (reason) => {
|
|
115
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
116
|
+
fatal('unhandledRejection', err);
|
|
117
|
+
});
|
|
118
|
+
|
|
94
119
|
// βββ Interactive Mode Selection ββββββββββββββββββββββββββββββ
|
|
95
120
|
async function interactiveMode() {
|
|
96
121
|
showBanner();
|
|
@@ -158,6 +183,7 @@ program
|
|
|
158
183
|
.name('ipingyou')
|
|
159
184
|
.description('SecureLink-CLI β Secure P2P remote access via SSH & Cloudflare Tunnels')
|
|
160
185
|
.version('1.0.0')
|
|
186
|
+
.option('-b, --broker <url>', 'Override the central broker URL')
|
|
161
187
|
.addHelpText('beforeAll', () => {
|
|
162
188
|
showBanner();
|
|
163
189
|
showRichHelp();
|
|
@@ -169,6 +195,9 @@ program
|
|
|
169
195
|
.description('Start host mode β allow remote access to this machine')
|
|
170
196
|
.action(async () => {
|
|
171
197
|
try {
|
|
198
|
+
const opts = program.opts();
|
|
199
|
+
if (opts.broker) process.env.BROKER_URL = opts.broker;
|
|
200
|
+
|
|
172
201
|
showBanner();
|
|
173
202
|
showSystemInfo();
|
|
174
203
|
installShutdownHandlers();
|
|
@@ -185,6 +214,9 @@ program
|
|
|
185
214
|
.option('-u, --uid <uid>', 'The remote host UID')
|
|
186
215
|
.action(async () => {
|
|
187
216
|
try {
|
|
217
|
+
const opts = program.opts();
|
|
218
|
+
if (opts.broker) process.env.BROKER_URL = opts.broker;
|
|
219
|
+
|
|
188
220
|
showBanner();
|
|
189
221
|
showSystemInfo();
|
|
190
222
|
installShutdownHandlers();
|
|
@@ -195,9 +227,57 @@ program
|
|
|
195
227
|
}
|
|
196
228
|
});
|
|
197
229
|
|
|
230
|
+
program
|
|
231
|
+
.command('panic')
|
|
232
|
+
.description('π¨ Self-destruct mode: wipe all configs, kill tunnels, and remove traces')
|
|
233
|
+
.action(async () => {
|
|
234
|
+
try {
|
|
235
|
+
showBanner();
|
|
236
|
+
await executePanicMode();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
fatal('panic', err);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
program
|
|
243
|
+
.command('service <action>')
|
|
244
|
+
.description('π» Manage background daemon (actions: install, stop, status)')
|
|
245
|
+
.action(async (action) => {
|
|
246
|
+
try {
|
|
247
|
+
showBanner();
|
|
248
|
+
console.log(chalk.bold.cyan(' π» Background Service Manager'));
|
|
249
|
+
console.log(chalk.dim(' ββββββββββββββββββββββββββββββββββββββ'));
|
|
250
|
+
|
|
251
|
+
const { execaCommand } = await import('execa');
|
|
252
|
+
|
|
253
|
+
if (action === 'install') {
|
|
254
|
+
console.log(chalk.dim(' Installing PM2 globally and starting host...'));
|
|
255
|
+
await execaCommand('npm install -g pm2', { stdio: 'inherit' });
|
|
256
|
+
await execaCommand('pm2 start ipingyou --name "ipingyou-host" -- host', { stdio: 'inherit' });
|
|
257
|
+
await execaCommand('pm2 save', { stdio: 'inherit' });
|
|
258
|
+
await execaCommand('pm2 startup', { stdio: 'inherit' });
|
|
259
|
+
console.log(chalk.green('\n β
Service installed and running in the background.'));
|
|
260
|
+
} else if (action === 'stop') {
|
|
261
|
+
await execaCommand('pm2 stop ipingyou-host', { stdio: 'inherit' });
|
|
262
|
+
await execaCommand('pm2 delete ipingyou-host', { stdio: 'inherit' });
|
|
263
|
+
await execaCommand('pm2 save', { stdio: 'inherit' });
|
|
264
|
+
console.log(chalk.green('\n β
Service stopped and removed.'));
|
|
265
|
+
} else if (action === 'status') {
|
|
266
|
+
await execaCommand('pm2 status ipingyou-host', { stdio: 'inherit' });
|
|
267
|
+
} else {
|
|
268
|
+
console.log(chalk.red(` β Unknown action: ${action}. Use install, stop, or status.`));
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
fatal('service', err);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
198
275
|
// βββ Default: interactive mode ββββββββββββββββββββββββββββββ
|
|
199
276
|
program.action(async () => {
|
|
200
277
|
try {
|
|
278
|
+
const opts = program.opts();
|
|
279
|
+
if (opts.broker) process.env.BROKER_URL = opts.broker;
|
|
280
|
+
|
|
201
281
|
installShutdownHandlers();
|
|
202
282
|
await interactiveMode();
|
|
203
283
|
} catch (err) {
|
package/src/lib/chat.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
const HTML_CONTENT = `
|
|
7
|
+
<!DOCTYPE html>
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="UTF-8">
|
|
11
|
+
<title>iPingYou β Secure Chat Room</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root {
|
|
14
|
+
--bg: #0f172a;
|
|
15
|
+
--bg-panel: #1e293b;
|
|
16
|
+
--text: #f8fafc;
|
|
17
|
+
--primary: #38bdf8;
|
|
18
|
+
--accent: #818cf8;
|
|
19
|
+
--danger: #ef4444;
|
|
20
|
+
--border: #334155;
|
|
21
|
+
}
|
|
22
|
+
body {
|
|
23
|
+
margin: 0; padding: 0; font-family: 'Inter', system-ui, sans-serif;
|
|
24
|
+
background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column;
|
|
25
|
+
}
|
|
26
|
+
header {
|
|
27
|
+
background: var(--bg-panel); padding: 1rem 2rem; border-bottom: 1px solid var(--border);
|
|
28
|
+
display: flex; justify-content: space-between; align-items: center; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
29
|
+
}
|
|
30
|
+
h1 { margin: 0; font-size: 1.25rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
|
|
31
|
+
.badge { background: var(--primary); color: #000; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.8rem; font-weight: bold; }
|
|
32
|
+
.leave-btn {
|
|
33
|
+
background: var(--danger); color: white; border: none; padding: 0.5rem 1rem; border-radius: 0.5rem;
|
|
34
|
+
font-weight: bold; cursor: pointer; transition: opacity 0.2s;
|
|
35
|
+
}
|
|
36
|
+
.leave-btn:hover { opacity: 0.8; }
|
|
37
|
+
main {
|
|
38
|
+
flex: 1; display: flex; overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
.sidebar {
|
|
41
|
+
width: 250px; background: var(--bg-panel); border-right: 1px solid var(--border);
|
|
42
|
+
padding: 1rem; overflow-y: auto;
|
|
43
|
+
}
|
|
44
|
+
.chat-area {
|
|
45
|
+
flex: 1; display: flex; flex-direction: column; background: var(--bg);
|
|
46
|
+
}
|
|
47
|
+
.messages {
|
|
48
|
+
flex: 1; padding: 1.5rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem;
|
|
49
|
+
}
|
|
50
|
+
.message { max-width: 70%; padding: 0.8rem 1rem; border-radius: 1rem; line-height: 1.4; animation: popIn 0.3s ease-out; }
|
|
51
|
+
.message.system { max-width: 100%; align-self: center; background: transparent; color: #94a3b8; font-size: 0.9rem; font-style: italic; text-align: center; }
|
|
52
|
+
.message.self { align-self: flex-end; background: var(--primary); color: #000; border-bottom-right-radius: 0.25rem; }
|
|
53
|
+
.message.other { align-self: flex-start; background: var(--bg-panel); border-bottom-left-radius: 0.25rem; }
|
|
54
|
+
.message-header { font-size: 0.75rem; margin-bottom: 0.25rem; opacity: 0.8; }
|
|
55
|
+
.input-area {
|
|
56
|
+
padding: 1rem; background: var(--bg-panel); border-top: 1px solid var(--border); display: flex; gap: 1rem;
|
|
57
|
+
}
|
|
58
|
+
input[type="text"] {
|
|
59
|
+
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
|
60
|
+
padding: 0.75rem 1rem; border-radius: 0.5rem; outline: none; transition: border-color 0.2s;
|
|
61
|
+
}
|
|
62
|
+
input[type="text"]:focus { border-color: var(--primary); }
|
|
63
|
+
button.send {
|
|
64
|
+
background: var(--accent); color: white; border: none; padding: 0 1.5rem; border-radius: 0.5rem;
|
|
65
|
+
font-weight: bold; cursor: pointer; transition: transform 0.1s, opacity 0.2s;
|
|
66
|
+
}
|
|
67
|
+
button.send:active { transform: scale(0.95); }
|
|
68
|
+
@keyframes popIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
69
|
+
|
|
70
|
+
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
71
|
+
.user-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 0.5rem; background: var(--bg); }
|
|
72
|
+
.user-item::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; }
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<header>
|
|
77
|
+
<h1>π¬ SecureLink Chat <span class="badge" id="conn-count">0 connected</span></h1>
|
|
78
|
+
<button class="leave-btn" id="leave-btn">Leave Room</button>
|
|
79
|
+
</header>
|
|
80
|
+
<main>
|
|
81
|
+
<div class="sidebar">
|
|
82
|
+
<h3 style="margin-top:0; font-size: 0.9rem; color: #94a3b8; text-transform: uppercase;">Participants</h3>
|
|
83
|
+
<ul class="user-list" id="users"></ul>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="chat-area">
|
|
86
|
+
<div class="messages" id="msgs"></div>
|
|
87
|
+
<form class="input-area" id="chat-form">
|
|
88
|
+
<input type="text" id="msg-input" placeholder="Type a secure message..." autocomplete="off" disabled>
|
|
89
|
+
<button type="submit" class="send" id="send-btn" disabled>Send</button>
|
|
90
|
+
</form>
|
|
91
|
+
</div>
|
|
92
|
+
</main>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
const isHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
96
|
+
let username = isHost ? 'Host' : prompt('Enter your name for the chat:', 'Client_' + Math.floor(Math.random()*1000));
|
|
97
|
+
if (!username) username = 'Anonymous';
|
|
98
|
+
|
|
99
|
+
const sessionPassword = window.location.hash.substring(1);
|
|
100
|
+
if (!sessionPassword) {
|
|
101
|
+
document.body.innerHTML = '<h2 style="text-align:center; margin-top:20vh; color:red;">Fatal: Missing session password in URL hash. Cannot decrypt E2E chat.</h2>';
|
|
102
|
+
throw new Error("Missing password");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
106
|
+
const ws = new WebSocket(wsProtocol + '//' + window.location.host);
|
|
107
|
+
|
|
108
|
+
const msgs = document.getElementById('msgs');
|
|
109
|
+
const form = document.getElementById('chat-form');
|
|
110
|
+
const input = document.getElementById('msg-input');
|
|
111
|
+
const sendBtn = document.getElementById('send-btn');
|
|
112
|
+
const leaveBtn = document.getElementById('leave-btn');
|
|
113
|
+
const usersList = document.getElementById('users');
|
|
114
|
+
const connCount = document.getElementById('conn-count');
|
|
115
|
+
|
|
116
|
+
// βββ Web Crypto E2E AES-GCM ββββββββββββββββββββββββββββββββββ
|
|
117
|
+
const enc = new TextEncoder();
|
|
118
|
+
const dec = new TextDecoder();
|
|
119
|
+
|
|
120
|
+
function buf2hex(buffer) { return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); }
|
|
121
|
+
function hex2buf(hexString) { return new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); }
|
|
122
|
+
|
|
123
|
+
async function deriveKey(password, saltBuffer) {
|
|
124
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
125
|
+
"raw", enc.encode(password), {name: "PBKDF2"}, false, ["deriveBits", "deriveKey"]
|
|
126
|
+
);
|
|
127
|
+
return crypto.subtle.deriveKey(
|
|
128
|
+
{ name: "PBKDF2", salt: saltBuffer, iterations: 100000, hash: "SHA-256" },
|
|
129
|
+
keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function encryptPayload(obj) {
|
|
134
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
135
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
136
|
+
const key = await deriveKey(sessionPassword, salt);
|
|
137
|
+
const ciphertextBuffer = await crypto.subtle.encrypt(
|
|
138
|
+
{ name: "AES-GCM", iv: iv }, key, enc.encode(JSON.stringify(obj))
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
salt: buf2hex(salt),
|
|
142
|
+
iv: buf2hex(iv),
|
|
143
|
+
ciphertext: buf2hex(ciphertextBuffer)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function decryptPayload(encObj) {
|
|
148
|
+
try {
|
|
149
|
+
const salt = hex2buf(encObj.salt);
|
|
150
|
+
const iv = hex2buf(encObj.iv);
|
|
151
|
+
const ciphertext = hex2buf(encObj.ciphertext);
|
|
152
|
+
const key = await deriveKey(sessionPassword, salt);
|
|
153
|
+
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
154
|
+
{ name: "AES-GCM", iv: iv }, key, ciphertext
|
|
155
|
+
);
|
|
156
|
+
return JSON.parse(dec.decode(decryptedBuffer));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// βββ UI Logic ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
163
|
+
function appendMessage(msg) {
|
|
164
|
+
const div = document.createElement('div');
|
|
165
|
+
if (msg.type === 'system') {
|
|
166
|
+
div.className = 'message system';
|
|
167
|
+
div.textContent = msg.text;
|
|
168
|
+
} else {
|
|
169
|
+
div.className = 'message ' + (msg.sender === username ? 'self' : 'other');
|
|
170
|
+
div.innerHTML = '<div class="message-header">' + msg.sender + ' β’ ' + msg.time + '</div><div>' + msg.text + '</div>';
|
|
171
|
+
}
|
|
172
|
+
msgs.appendChild(div);
|
|
173
|
+
msgs.scrollTop = msgs.scrollHeight;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function updateUsers(users) {
|
|
177
|
+
usersList.innerHTML = '';
|
|
178
|
+
users.forEach(u => {
|
|
179
|
+
const li = document.createElement('li');
|
|
180
|
+
li.className = 'user-item';
|
|
181
|
+
li.textContent = u;
|
|
182
|
+
usersList.appendChild(li);
|
|
183
|
+
});
|
|
184
|
+
connCount.textContent = users.length + ' connected';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
ws.onopen = async () => {
|
|
188
|
+
input.disabled = false;
|
|
189
|
+
sendBtn.disabled = false;
|
|
190
|
+
input.focus();
|
|
191
|
+
|
|
192
|
+
const encPayload = await encryptPayload({ type: 'join', sender: username });
|
|
193
|
+
ws.send(JSON.stringify({ type: 'e2e', payload: encPayload }));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
ws.onmessage = async (event) => {
|
|
197
|
+
const data = JSON.parse(event.data);
|
|
198
|
+
if (data.type === 'state') {
|
|
199
|
+
updateUsers(data.users);
|
|
200
|
+
} else if (data.type === 'close') {
|
|
201
|
+
appendMessage({ type: 'system', text: 'The Host has closed the chat room. You may leave now.' });
|
|
202
|
+
ws.close();
|
|
203
|
+
input.disabled = true;
|
|
204
|
+
sendBtn.disabled = true;
|
|
205
|
+
} else if (data.type === 'e2e') {
|
|
206
|
+
// Decrypt the incoming E2E message
|
|
207
|
+
const decrypted = await decryptPayload(data.payload);
|
|
208
|
+
if (decrypted) {
|
|
209
|
+
if (decrypted.type === 'join') {
|
|
210
|
+
appendMessage({ type: 'system', text: \`\${decrypted.sender} has joined the chat (E2E Encrypted)\` });
|
|
211
|
+
} else if (decrypted.type === 'chat') {
|
|
212
|
+
appendMessage(decrypted);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
appendMessage(data);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
ws.onclose = () => {
|
|
221
|
+
appendMessage({ type: 'system', text: 'Connection closed.' });
|
|
222
|
+
input.disabled = true;
|
|
223
|
+
sendBtn.disabled = true;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
form.onsubmit = async (e) => {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
const text = input.value.trim();
|
|
229
|
+
if (!text) return;
|
|
230
|
+
|
|
231
|
+
const msgObj = { type: 'chat', sender: username, text: text, time: new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) };
|
|
232
|
+
const encPayload = await encryptPayload(msgObj);
|
|
233
|
+
|
|
234
|
+
ws.send(JSON.stringify({ type: 'e2e', payload: encPayload }));
|
|
235
|
+
input.value = '';
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
leaveBtn.onclick = () => {
|
|
239
|
+
if (isHost) {
|
|
240
|
+
if(confirm('Are you sure you want to close the chat room for everyone?')) {
|
|
241
|
+
ws.send(JSON.stringify({ type: 'host_close' }));
|
|
242
|
+
window.close();
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
ws.close();
|
|
246
|
+
window.close();
|
|
247
|
+
document.body.innerHTML = '<h2 style="text-align:center; margin-top:20vh;">You have left the chat. You can close this tab.</h2>';
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
</script>
|
|
251
|
+
</body>
|
|
252
|
+
</html>
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
export async function startChatServer(onClose) {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
const server = http.createServer((req, res) => {
|
|
258
|
+
if (req.url === '/') {
|
|
259
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
260
|
+
res.end(HTML_CONTENT);
|
|
261
|
+
} else {
|
|
262
|
+
res.writeHead(404);
|
|
263
|
+
res.end('Not found');
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const wss = new WebSocketServer({ server });
|
|
268
|
+
const clients = new Map(); // ws -> username
|
|
269
|
+
|
|
270
|
+
function broadcastState() {
|
|
271
|
+
const users = Array.from(clients.values());
|
|
272
|
+
const payload = JSON.stringify({ type: 'state', users });
|
|
273
|
+
for (const client of wss.clients) {
|
|
274
|
+
if (client.readyState === 1) client.send(payload);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function broadcastMsg(msg) {
|
|
279
|
+
const payload = JSON.stringify(msg);
|
|
280
|
+
for (const client of wss.clients) {
|
|
281
|
+
if (client.readyState === 1) client.send(payload);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
wss.on('connection', (ws) => {
|
|
286
|
+
ws.on('message', (message) => {
|
|
287
|
+
try {
|
|
288
|
+
const data = JSON.parse(message);
|
|
289
|
+
|
|
290
|
+
if (data.type === 'e2e') {
|
|
291
|
+
// E2E messages just get forwarded to all clients
|
|
292
|
+
broadcastMsg(data);
|
|
293
|
+
} else if (data.type === 'join_event') {
|
|
294
|
+
// For updating participant count dynamically if we wanted to extract sender,
|
|
295
|
+
// but since E2E hides sender, we rely on connection count
|
|
296
|
+
} else if (data.type === 'host_close') {
|
|
297
|
+
broadcastMsg({ type: 'close' });
|
|
298
|
+
server.close();
|
|
299
|
+
if (onClose) onClose();
|
|
300
|
+
}
|
|
301
|
+
} catch (e) {
|
|
302
|
+
// ignore invalid parse
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
ws.on('close', () => {
|
|
307
|
+
// Since we can't read the encrypted username, we just update state
|
|
308
|
+
clients.delete(ws);
|
|
309
|
+
broadcastState();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// When connection opens, just assign a generic ID to count them
|
|
314
|
+
wss.on('connection', (ws) => {
|
|
315
|
+
clients.set(ws, `User_${Math.floor(Math.random()*1000)}`);
|
|
316
|
+
broadcastState();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
server.listen(0, '127.0.0.1', () => {
|
|
320
|
+
const port = server.address().port;
|
|
321
|
+
resolve({ port, server });
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function openLocalChatUI(port, password) {
|
|
327
|
+
try {
|
|
328
|
+
const chatUrl = `http://localhost:${port}#${password}`;
|
|
329
|
+
await open(chatUrl);
|
|
330
|
+
} catch {
|
|
331
|
+
console.log(chalk.dim(` Unable to auto-open browser. Visit http://localhost:${port}#${password}`));
|
|
332
|
+
}
|
|
333
|
+
}
|