ghostterm 1.3.0 → 2.0.1
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/CHANGELOG.md +20 -101
- package/README.md +25 -267
- package/bin/ghostterm-p2p.js +312 -0
- package/lib/auth.js +152 -0
- package/lib/pty-manager.js +336 -0
- package/lib/webrtc-peer.js +193 -0
- package/package.json +23 -19
- package/bin/ghostterm.js +0 -726
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Prevent crash from node-pty conpty_console_list_agent on Windows background processes
|
|
5
|
+
process.on('uncaughtException', (err) => {
|
|
6
|
+
if (err.message && err.message.includes('AttachConsole')) {
|
|
7
|
+
// Ignore — this is a known node-pty issue in background mode
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
console.error('Uncaught error:', err.message);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const WebSocket = require('ws');
|
|
14
|
+
const qrcode = require('qrcode-terminal');
|
|
15
|
+
const { CompanionWebRTC } = require('../lib/webrtc-peer');
|
|
16
|
+
const { PtyManager } = require('../lib/pty-manager');
|
|
17
|
+
const { getToken, saveToken } = require('../lib/auth');
|
|
18
|
+
|
|
19
|
+
// --- Configuration ---
|
|
20
|
+
const DEFAULT_SIGNALING = 'wss://ghostterm-p2p-signal.fly.dev';
|
|
21
|
+
const SITE_URL = 'https://ghostterm.pages.dev';
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const signalingUrl = getArg('--signal', '-s') || DEFAULT_SIGNALING;
|
|
25
|
+
const siteUrl = getArg('--site') || SITE_URL;
|
|
26
|
+
const helpRequested = args.includes('--help') || args.includes('-h');
|
|
27
|
+
|
|
28
|
+
function getArg(long, short) {
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
if (args[i] === long || (short && args[i] === short)) {
|
|
31
|
+
return args[i + 1];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (helpRequested) {
|
|
38
|
+
console.log(`
|
|
39
|
+
GhostTerm P2P - Control your terminal from your phone via WebRTC
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
npx ghostterm-p2p [options]
|
|
43
|
+
|
|
44
|
+
Options:
|
|
45
|
+
-s, --signal <url> Signaling server URL (default: ${DEFAULT_SIGNALING})
|
|
46
|
+
--site <url> Mobile site URL (default: ${SITE_URL})
|
|
47
|
+
-h, --help Show this help
|
|
48
|
+
|
|
49
|
+
How it works:
|
|
50
|
+
1. This tool connects to the signaling server and gets a 6-digit pair code
|
|
51
|
+
2. Open the mobile site on your phone and enter the code
|
|
52
|
+
3. A direct P2P WebRTC connection is established
|
|
53
|
+
4. All terminal data flows directly between your phone and PC (encrypted)
|
|
54
|
+
5. The signaling server never sees your terminal data
|
|
55
|
+
`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- State ---
|
|
60
|
+
let ws = null;
|
|
61
|
+
let webrtc = null;
|
|
62
|
+
let ptyManager = null;
|
|
63
|
+
let pairCode = null;
|
|
64
|
+
let reconnecting = false;
|
|
65
|
+
let authToken = null; // Google or long token
|
|
66
|
+
|
|
67
|
+
// --- Main ---
|
|
68
|
+
|
|
69
|
+
async function start() {
|
|
70
|
+
console.log('\n GhostTerm P2P');
|
|
71
|
+
console.log(' =============');
|
|
72
|
+
|
|
73
|
+
// Try to get auth token (optional — won't block if no login)
|
|
74
|
+
try {
|
|
75
|
+
authToken = await getToken();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.log(' Skipping login (anonymous mode)');
|
|
78
|
+
authToken = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(' Connecting to signaling server...');
|
|
82
|
+
connectSignaling();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function connectSignaling() {
|
|
86
|
+
const url = `${signalingUrl}?role=companion`;
|
|
87
|
+
ws = new WebSocket(url);
|
|
88
|
+
|
|
89
|
+
ws.on('open', () => {
|
|
90
|
+
console.log(' Connected to signaling server.');
|
|
91
|
+
reconnecting = false;
|
|
92
|
+
resetReconnectDelay();
|
|
93
|
+
// Send auth token if available
|
|
94
|
+
if (authToken) {
|
|
95
|
+
ws.send(JSON.stringify({ type: 'auth', token: authToken }));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
ws.on('message', (raw) => {
|
|
100
|
+
let msg;
|
|
101
|
+
try {
|
|
102
|
+
msg = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
handleSignalingMessage(msg);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
ws.on('close', () => {
|
|
110
|
+
if (!reconnecting) {
|
|
111
|
+
console.log('\n Signaling server disconnected.');
|
|
112
|
+
}
|
|
113
|
+
scheduleReconnect();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
ws.on('error', (err) => {
|
|
117
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
|
|
118
|
+
console.error(` Cannot reach signaling server at ${signalingUrl}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleSignalingMessage(msg) {
|
|
124
|
+
switch (msg.type) {
|
|
125
|
+
case 'pair_code':
|
|
126
|
+
pairCode = msg.code;
|
|
127
|
+
displayPairCode(msg.code);
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case 'mobile_connected':
|
|
131
|
+
console.log('\n Mobile device paired! Establishing P2P connection...');
|
|
132
|
+
initiateWebRTC();
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case 'webrtc_answer':
|
|
136
|
+
case 'webrtc_ice':
|
|
137
|
+
if (webrtc) {
|
|
138
|
+
webrtc.handleSignaling(msg);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case 'mobile_disconnected':
|
|
143
|
+
// Ignore if P2P is active — mobile closed signaling WS after P2P established (expected)
|
|
144
|
+
if (webrtc && webrtc.isActive) {
|
|
145
|
+
// P2P still alive, signaling disconnect is normal
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
console.log('\n Mobile disconnected.');
|
|
149
|
+
cleanupWebRTC();
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case 'pair_expired':
|
|
153
|
+
console.log('\n Pair code expired. Reconnecting for a new code...');
|
|
154
|
+
ws.close();
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case 'auth_ok':
|
|
158
|
+
console.log(` Authenticated as: ${msg.email}`);
|
|
159
|
+
// Save long token for future sessions
|
|
160
|
+
if (msg.longToken) {
|
|
161
|
+
saveToken(null, msg.longToken);
|
|
162
|
+
authToken = msg.longToken;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case 'auth_error':
|
|
167
|
+
console.error(` Auth failed: ${msg.message}`);
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'error':
|
|
171
|
+
console.error(` Server error: ${msg.message}`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function displayPairCode(code) {
|
|
177
|
+
const mobileUrl = `${siteUrl}?code=${code}`;
|
|
178
|
+
|
|
179
|
+
console.log('\n +--------------------------+');
|
|
180
|
+
console.log(` | Pair Code: ${code} |`);
|
|
181
|
+
console.log(' +--------------------------+');
|
|
182
|
+
console.log(`\n Or scan QR code with your phone:\n`);
|
|
183
|
+
|
|
184
|
+
qrcode.generate(mobileUrl, { small: true }, (qr) => {
|
|
185
|
+
// Indent each line
|
|
186
|
+
const lines = qr.split('\n').map(l => ' ' + l).join('\n');
|
|
187
|
+
console.log(lines);
|
|
188
|
+
console.log(`\n Mobile URL: ${mobileUrl}`);
|
|
189
|
+
console.log('\n Waiting for phone to connect...\n');
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function initiateWebRTC() {
|
|
194
|
+
cleanupWebRTC();
|
|
195
|
+
|
|
196
|
+
webrtc = new CompanionWebRTC();
|
|
197
|
+
ptyManager = new PtyManager();
|
|
198
|
+
|
|
199
|
+
// Wire PTY output to WebRTC
|
|
200
|
+
ptyManager.on('send', (msg) => {
|
|
201
|
+
if (webrtc && webrtc.isActive) {
|
|
202
|
+
webrtc.send(msg);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Wire WebRTC messages to PTY
|
|
207
|
+
webrtc.on('message', (msg) => {
|
|
208
|
+
if (msg.type === 'shutdown') {
|
|
209
|
+
console.log('\n Shutdown requested from mobile. Bye!');
|
|
210
|
+
cleanupWebRTC();
|
|
211
|
+
if (ws) ws.close();
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
if (msg.type === 'resize') {
|
|
215
|
+
console.log(` [resize] cols=${msg.cols} rows=${msg.rows} sid=${msg.sessionId || 'attached'}`);
|
|
216
|
+
}
|
|
217
|
+
if (ptyManager) {
|
|
218
|
+
const responses = ptyManager.handleMessage(msg);
|
|
219
|
+
for (const resp of responses) {
|
|
220
|
+
webrtc.send(resp);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
webrtc.on('open', () => {
|
|
226
|
+
console.log(' P2P connection established! Terminal is now accessible from your phone.');
|
|
227
|
+
console.log(' All data flows directly between your devices (encrypted).');
|
|
228
|
+
console.log(' Press Ctrl+C to stop.\n');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
webrtc.on('close', () => {
|
|
232
|
+
console.log('\n P2P connection closed.');
|
|
233
|
+
cleanupPty();
|
|
234
|
+
scheduleReconnect();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
webrtc.on('timeout', () => {
|
|
238
|
+
console.log('\n P2P heartbeat timeout. Connection lost.');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
webrtc.on('error', (err) => {
|
|
242
|
+
console.error(` WebRTC error: ${err}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Initiate the connection (sends offer via signaling)
|
|
246
|
+
webrtc.initiate((msg) => {
|
|
247
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
248
|
+
ws.send(JSON.stringify(msg));
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function cleanupWebRTC() {
|
|
254
|
+
if (webrtc) {
|
|
255
|
+
webrtc.removeAllListeners();
|
|
256
|
+
webrtc.close();
|
|
257
|
+
webrtc = null;
|
|
258
|
+
}
|
|
259
|
+
cleanupPty();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanupPty() {
|
|
263
|
+
if (ptyManager) {
|
|
264
|
+
ptyManager.removeAllListeners();
|
|
265
|
+
ptyManager.destroy();
|
|
266
|
+
ptyManager = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let reconnectTimer = null;
|
|
271
|
+
let reconnectDelay = 1000; // start at 1s
|
|
272
|
+
const RECONNECT_MAX = 30000; // cap at 30s
|
|
273
|
+
|
|
274
|
+
function scheduleReconnect() {
|
|
275
|
+
if (reconnectTimer) return;
|
|
276
|
+
reconnecting = true;
|
|
277
|
+
const delay = reconnectDelay;
|
|
278
|
+
console.log(` Reconnecting in ${(delay / 1000).toFixed(0)}s...`);
|
|
279
|
+
reconnectTimer = setTimeout(() => {
|
|
280
|
+
reconnectTimer = null;
|
|
281
|
+
connectSignaling();
|
|
282
|
+
}, delay);
|
|
283
|
+
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped)
|
|
284
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resetReconnectDelay() {
|
|
288
|
+
reconnectDelay = 1000;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Graceful shutdown ---
|
|
292
|
+
|
|
293
|
+
process.on('SIGINT', () => {
|
|
294
|
+
console.log('\n Shutting down...');
|
|
295
|
+
cleanupWebRTC();
|
|
296
|
+
if (ws) {
|
|
297
|
+
ws.close();
|
|
298
|
+
}
|
|
299
|
+
if (reconnectTimer) {
|
|
300
|
+
clearTimeout(reconnectTimer);
|
|
301
|
+
}
|
|
302
|
+
process.exit(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
process.on('SIGTERM', () => {
|
|
306
|
+
cleanupWebRTC();
|
|
307
|
+
if (ws) ws.close();
|
|
308
|
+
process.exit(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// --- Go ---
|
|
312
|
+
start();
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { execFile } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const GOOGLE_CLIENT_ID = '967114885808-9lrt6j05ip65t88sm1rq0foetcu52kdv.apps.googleusercontent.com';
|
|
10
|
+
const CRED_DIR = path.join(os.homedir(), '.ghostterm');
|
|
11
|
+
const CRED_FILE = path.join(CRED_DIR, 'credentials.json');
|
|
12
|
+
|
|
13
|
+
function loadToken() {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(CRED_FILE)) {
|
|
16
|
+
const cred = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8'));
|
|
17
|
+
if (cred.id_token) {
|
|
18
|
+
try {
|
|
19
|
+
const parts = cred.id_token.split('.');
|
|
20
|
+
if (parts.length >= 2) {
|
|
21
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
22
|
+
if (payload.exp && payload.exp * 1000 > Date.now()) {
|
|
23
|
+
return cred.id_token;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
// Long token format (base64.hex) — try it anyway
|
|
28
|
+
if (cred.id_token.split('.').length === 2) {
|
|
29
|
+
return cred.id_token;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (cred.long_token) {
|
|
33
|
+
return cred.long_token;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveToken(idToken, longToken) {
|
|
41
|
+
if (!fs.existsSync(CRED_DIR)) {
|
|
42
|
+
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
const cred = {};
|
|
45
|
+
if (idToken) cred.id_token = idToken;
|
|
46
|
+
if (longToken) cred.long_token = longToken;
|
|
47
|
+
fs.writeFileSync(CRED_FILE, JSON.stringify(cred, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function openBrowser(url) {
|
|
51
|
+
if (os.platform() === 'win32') {
|
|
52
|
+
execFile('cmd', ['/c', 'start', '', url]);
|
|
53
|
+
} else if (os.platform() === 'darwin') {
|
|
54
|
+
execFile('open', [url]);
|
|
55
|
+
} else {
|
|
56
|
+
execFile('xdg-open', [url]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startLoginFlow() {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const loginPage = `<!DOCTYPE html>
|
|
63
|
+
<html><head>
|
|
64
|
+
<meta charset="UTF-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
66
|
+
<title>GhostTerm P2P - Sign In</title>
|
|
67
|
+
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
68
|
+
<style>
|
|
69
|
+
body { font-family: -apple-system, system-ui, sans-serif; background: #0f0f1a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
70
|
+
.container { text-align: center; }
|
|
71
|
+
h1 { color: #8b5cf6; margin-bottom: 8px; }
|
|
72
|
+
p { color: #94a3b8; margin-bottom: 24px; }
|
|
73
|
+
#status { margin-top: 20px; color: #10b981; display: none; font-size: 18px; }
|
|
74
|
+
</style>
|
|
75
|
+
</head><body>
|
|
76
|
+
<div class="container">
|
|
77
|
+
<h1>GhostTerm P2P</h1>
|
|
78
|
+
<p>Sign in with Google to link your companion</p>
|
|
79
|
+
<div id="g_id_onload"
|
|
80
|
+
data-client_id="${GOOGLE_CLIENT_ID}"
|
|
81
|
+
data-callback="onSignIn"
|
|
82
|
+
data-auto_select="true">
|
|
83
|
+
</div>
|
|
84
|
+
<div class="g_id_signin" data-type="standard" data-size="large" data-theme="filled_black" data-text="signin_with" data-width="300"></div>
|
|
85
|
+
<div id="status">Login successful! You can close this tab.</div>
|
|
86
|
+
</div>
|
|
87
|
+
<script>
|
|
88
|
+
function onSignIn(response) {
|
|
89
|
+
fetch('/callback', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ id_token: response.credential })
|
|
93
|
+
}).then(() => {
|
|
94
|
+
document.getElementById('status').style.display = 'block';
|
|
95
|
+
document.querySelector('.g_id_signin').style.display = 'none';
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
</body></html>`;
|
|
100
|
+
|
|
101
|
+
const loginServer = http.createServer((req, res) => {
|
|
102
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
103
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
104
|
+
res.end(loginPage);
|
|
105
|
+
} else if (req.method === 'POST' && req.url === '/callback') {
|
|
106
|
+
let body = '';
|
|
107
|
+
req.on('data', chunk => body += chunk);
|
|
108
|
+
req.on('end', () => {
|
|
109
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
110
|
+
res.end('{"ok":true}');
|
|
111
|
+
try {
|
|
112
|
+
const { id_token } = JSON.parse(body);
|
|
113
|
+
saveToken(id_token);
|
|
114
|
+
console.log(' Login successful!');
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
loginServer.close();
|
|
117
|
+
resolve(id_token);
|
|
118
|
+
}, 500);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
reject(e);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
res.writeHead(404);
|
|
125
|
+
res.end();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
loginServer.listen(3000, () => {
|
|
130
|
+
console.log(' Opening browser for Google login...');
|
|
131
|
+
console.log(' If browser does not open, go to: http://localhost:3000');
|
|
132
|
+
openBrowser('http://localhost:3000');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
loginServer.close();
|
|
137
|
+
reject(new Error('Login timeout (2 min)'));
|
|
138
|
+
}, 120000);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function getToken() {
|
|
143
|
+
let token = loadToken();
|
|
144
|
+
if (token) {
|
|
145
|
+
console.log(' Using cached credential');
|
|
146
|
+
return token;
|
|
147
|
+
}
|
|
148
|
+
console.log(' No saved login found. Starting Google Sign-In...');
|
|
149
|
+
return startLoginFlow();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { getToken, saveToken, loadToken };
|