webtun 1.4.2 → 1.4.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 +44 -30
- package/bin/webtun.js +16 -22
- package/package.json +7 -2
- package/postinstall.js +148 -61
- package/public/index.html +267 -18
- package/server.js +349 -136
package/server.js
CHANGED
|
@@ -8,13 +8,15 @@ try {
|
|
|
8
8
|
const idx = trimmed.indexOf('=');
|
|
9
9
|
if (idx === -1) return;
|
|
10
10
|
const key = trimmed.slice(0, idx).trim();
|
|
11
|
-
|
|
11
|
+
let val = trimmed.slice(idx + 1).trim();
|
|
12
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
13
|
+
val = val.slice(1, -1);
|
|
14
|
+
}
|
|
12
15
|
if (!(key in process.env)) process.env[key] = val;
|
|
13
16
|
});
|
|
14
17
|
} catch {}
|
|
15
18
|
|
|
16
19
|
const express = require('express');
|
|
17
|
-
const http = require('http');
|
|
18
20
|
const WebSocket = require('ws');
|
|
19
21
|
let pty;
|
|
20
22
|
try {
|
|
@@ -33,8 +35,10 @@ try {
|
|
|
33
35
|
console.error(' npm install -g webtun');
|
|
34
36
|
console.error('');
|
|
35
37
|
console.error(' Option 3 — If building from source, install build tools first:');
|
|
36
|
-
console.error(' Linux:
|
|
37
|
-
console.error(' macOS:
|
|
38
|
+
console.error(' Linux: sudo apt-get install -y python3 make g++');
|
|
39
|
+
console.error(' macOS: xcode-select --install');
|
|
40
|
+
console.error(' Windows: install "Desktop development with C++" (Visual Studio Build Tools)');
|
|
41
|
+
console.error(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
|
|
38
42
|
console.error('');
|
|
39
43
|
process.exit(1);
|
|
40
44
|
}
|
|
@@ -44,6 +48,10 @@ const path = require('path');
|
|
|
44
48
|
const os = require('os');
|
|
45
49
|
const crypto = require('crypto');
|
|
46
50
|
const { execSync, execFileSync, spawn } = require('child_process');
|
|
51
|
+
const archiver = require('archiver');
|
|
52
|
+
const yauzl = require('yauzl');
|
|
53
|
+
const https = require('https');
|
|
54
|
+
const http = require('http');
|
|
47
55
|
|
|
48
56
|
// MIME type lookup without mime-types dependency
|
|
49
57
|
const MIME_MAP = {
|
|
@@ -109,7 +117,10 @@ const wss = new WebSocket.Server({ server, path: '/ws' });
|
|
|
109
117
|
|
|
110
118
|
const PORT = process.env.PORT || 3000;
|
|
111
119
|
const PIN = process.env.PIN || '';
|
|
112
|
-
|
|
120
|
+
// On Windows, ignore SHELL env from Git Bash/MSYS2/WSL — prefer PowerShell
|
|
121
|
+
const SHELL = (os.platform() === 'win32' && !process.env.WEBTUN_SHELL)
|
|
122
|
+
? 'powershell.exe'
|
|
123
|
+
: (process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh')));
|
|
113
124
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
114
125
|
const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
|
|
115
126
|
|
|
@@ -127,7 +138,9 @@ app.use((req, res, next) => {
|
|
|
127
138
|
});
|
|
128
139
|
|
|
129
140
|
// Trust proxy for proper IP detection behind reverse proxy
|
|
130
|
-
|
|
141
|
+
// 'loopback' only trusts 127.0.0.1/::1 — safe default for direct connections
|
|
142
|
+
// Set TRUST_PROXY=true in .env if behind a reverse proxy
|
|
143
|
+
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : 'loopback');
|
|
131
144
|
|
|
132
145
|
function constantTimeEqual(a, b) {
|
|
133
146
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
@@ -147,12 +160,16 @@ app.get('/api/auth/required', (req, res) => {
|
|
|
147
160
|
res.json({ required: !!PIN });
|
|
148
161
|
});
|
|
149
162
|
|
|
163
|
+
app.get('/api/version', (req, res) => {
|
|
164
|
+
res.json({ version: require('./package.json').version });
|
|
165
|
+
});
|
|
166
|
+
|
|
150
167
|
app.post('/api/auth', authRateLimiter, (req, res) => {
|
|
151
168
|
const { pin } = req.body;
|
|
152
169
|
if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
|
|
153
170
|
res.json({ success: true, token: PIN || 'open' });
|
|
154
171
|
} else {
|
|
155
|
-
res.status(401).json({ error: '
|
|
172
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
156
173
|
}
|
|
157
174
|
});
|
|
158
175
|
|
|
@@ -177,6 +194,194 @@ function realPath(targetPath) {
|
|
|
177
194
|
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
// Case-aware path containment (Windows paths are case-insensitive).
|
|
198
|
+
function pathContained(parent, child) {
|
|
199
|
+
let p = path.resolve(parent);
|
|
200
|
+
let c = path.resolve(child);
|
|
201
|
+
if (os.platform() === 'win32') {
|
|
202
|
+
p = p.replace(/\\/g, '/').toLowerCase();
|
|
203
|
+
c = c.replace(/\\/g, '/').toLowerCase();
|
|
204
|
+
if (!p.endsWith('/')) p += '/';
|
|
205
|
+
return c === p.slice(0, -1) || c.startsWith(p);
|
|
206
|
+
}
|
|
207
|
+
return c === p || c.startsWith(p + path.sep);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function renameWithFallback(src, dst) {
|
|
211
|
+
try {
|
|
212
|
+
await fsPromises.rename(src, dst);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e.code === 'EXDEV') {
|
|
215
|
+
await fsPromises.cp(src, dst, { recursive: true, force: true });
|
|
216
|
+
await fsPromises.rm(src, { recursive: true, force: true });
|
|
217
|
+
} else {
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function dirSize(dir) {
|
|
224
|
+
let total = 0;
|
|
225
|
+
async function walk(d) {
|
|
226
|
+
let entries;
|
|
227
|
+
try { entries = await fsPromises.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
228
|
+
await Promise.all(entries.map(async e => {
|
|
229
|
+
const full = path.join(d, e.name);
|
|
230
|
+
try {
|
|
231
|
+
if (e.isDirectory()) await walk(full);
|
|
232
|
+
else if (e.isFile()) {
|
|
233
|
+
const st = await fsPromises.stat(full);
|
|
234
|
+
total += st.size;
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
await walk(dir);
|
|
240
|
+
return total;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function createZipArchive(entries, zipPath) {
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const output = fs.createWriteStream(zipPath);
|
|
246
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
247
|
+
output.on('close', () => resolve());
|
|
248
|
+
output.on('error', reject);
|
|
249
|
+
archive.on('error', reject);
|
|
250
|
+
archive.pipe(output);
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
try {
|
|
253
|
+
const st = fs.statSync(entry.fullPath);
|
|
254
|
+
if (st.isDirectory()) archive.directory(entry.fullPath, entry.nameInZip);
|
|
255
|
+
else archive.file(entry.fullPath, { name: entry.nameInZip });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return reject(e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
archive.finalize();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function streamZipDirectory(dirPath, res) {
|
|
265
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
266
|
+
archive.on('error', err => {
|
|
267
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
268
|
+
else res.end();
|
|
269
|
+
});
|
|
270
|
+
archive.pipe(res);
|
|
271
|
+
archive.directory(dirPath, path.basename(dirPath));
|
|
272
|
+
archive.finalize();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function extractZip(zipPath, destDir) {
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
|
278
|
+
if (err) return reject(err);
|
|
279
|
+
zipfile.readEntry();
|
|
280
|
+
zipfile.on('entry', entry => {
|
|
281
|
+
const entryName = entry.fileName.replace(/\\/g, '/');
|
|
282
|
+
const entryPath = path.normalize(entryName);
|
|
283
|
+
if (entryPath.startsWith('..') || path.isAbsolute(entryPath) || entryName.includes('\0')) {
|
|
284
|
+
return reject(new Error('Invalid zip entry: ' + entry.fileName));
|
|
285
|
+
}
|
|
286
|
+
const target = path.join(destDir, entryPath);
|
|
287
|
+
if (!pathContained(destDir, target)) {
|
|
288
|
+
return reject(new Error('Zip entry escapes destination directory'));
|
|
289
|
+
}
|
|
290
|
+
if (/\/$/.test(entryName)) {
|
|
291
|
+
fs.mkdirSync(target, { recursive: true });
|
|
292
|
+
zipfile.readEntry();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
296
|
+
zipfile.openReadStream(entry, (err2, readStream) => {
|
|
297
|
+
if (err2) return reject(err2);
|
|
298
|
+
const writeStream = fs.createWriteStream(target);
|
|
299
|
+
readStream.on('error', reject);
|
|
300
|
+
writeStream.on('error', reject);
|
|
301
|
+
writeStream.on('close', () => zipfile.readEntry());
|
|
302
|
+
readStream.pipe(writeStream);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
zipfile.on('end', () => resolve());
|
|
306
|
+
zipfile.on('error', reject);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findCloudflared() {
|
|
312
|
+
const isWin = os.platform() === 'win32';
|
|
313
|
+
const name = isWin ? 'cloudflared.exe' : 'cloudflared';
|
|
314
|
+
const candidates = [
|
|
315
|
+
path.join(__dirname, name),
|
|
316
|
+
path.join(process.cwd(), name),
|
|
317
|
+
];
|
|
318
|
+
if (isWin) {
|
|
319
|
+
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
320
|
+
candidates.push(path.join(localApp, 'cloudflared', name));
|
|
321
|
+
const pf = process.env.ProgramW6432 || process.env.ProgramFiles;
|
|
322
|
+
if (pf) candidates.push(path.join(pf, 'cloudflared', name));
|
|
323
|
+
} else {
|
|
324
|
+
candidates.push(path.join(os.homedir(), '.local', 'bin', name));
|
|
325
|
+
candidates.push('/usr/local/bin/' + name);
|
|
326
|
+
candidates.push('/usr/bin/' + name);
|
|
327
|
+
}
|
|
328
|
+
for (const c of candidates) {
|
|
329
|
+
try { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch {}
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const cmd = isWin ? 'where cloudflared' : 'command -v cloudflared';
|
|
333
|
+
const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\r?\n/)[0];
|
|
334
|
+
if (out && fs.existsSync(out)) return out;
|
|
335
|
+
} catch {}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function killPid(pid, signal = 'SIGTERM') {
|
|
340
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return;
|
|
341
|
+
try {
|
|
342
|
+
if (os.platform() === 'win32') {
|
|
343
|
+
try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch {}
|
|
344
|
+
// Fallback: also kill any remaining child processes via WMIC
|
|
345
|
+
try {
|
|
346
|
+
const out = execSync(`wmic process where "ParentProcessId=${pid}" get ProcessId /format:list`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
347
|
+
const childPids = out.split('\n').filter(l => l.startsWith('ProcessId=')).map(l => parseInt(l.split('=')[1])).filter(Boolean);
|
|
348
|
+
for (const cp of childPids) { try { execSync(`taskkill /PID ${cp} /F`, { stdio: 'ignore' }); } catch {} }
|
|
349
|
+
} catch {}
|
|
350
|
+
} else {
|
|
351
|
+
process.kill(pid, signal);
|
|
352
|
+
}
|
|
353
|
+
} catch {}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildSessionEnv() {
|
|
357
|
+
if (os.platform() === 'win32') {
|
|
358
|
+
const env = { ...process.env };
|
|
359
|
+
env.TERM = env.TERM || 'xterm-256color';
|
|
360
|
+
env.COLORTERM = env.COLORTERM || 'truecolor';
|
|
361
|
+
if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE.replace(/\\/g, '/');
|
|
362
|
+
if (!env.USER && env.USERNAME) env.USER = env.USERNAME;
|
|
363
|
+
env.SHELL = SHELL;
|
|
364
|
+
// Prefer Path (Windows) over PATH if both set
|
|
365
|
+
if (env.Path && !env.PATH) env.PATH = env.Path;
|
|
366
|
+
return env;
|
|
367
|
+
}
|
|
368
|
+
const safe = {
|
|
369
|
+
TERM: 'xterm-256color',
|
|
370
|
+
COLORTERM: 'truecolor',
|
|
371
|
+
HOME: process.env.HOME || '',
|
|
372
|
+
USER: process.env.USER || '',
|
|
373
|
+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
|
374
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
375
|
+
SHELL
|
|
376
|
+
};
|
|
377
|
+
if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
|
|
378
|
+
// Preserve common terminal/locale vars when present
|
|
379
|
+
for (const k of ['LC_ALL', 'LC_CTYPE', 'TERM_PROGRAM', 'COLORFGBG']) {
|
|
380
|
+
if (process.env[k]) safe[k] = process.env[k];
|
|
381
|
+
}
|
|
382
|
+
return safe;
|
|
383
|
+
}
|
|
384
|
+
|
|
180
385
|
async function safeStat(p) {
|
|
181
386
|
try { return await fsPromises.stat(p); } catch { return null; }
|
|
182
387
|
}
|
|
@@ -284,7 +489,7 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
|
|
|
284
489
|
}
|
|
285
490
|
const oldPath = realPath(req.body.oldPath);
|
|
286
491
|
const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
|
|
287
|
-
await
|
|
492
|
+
await renameWithFallback(oldPath, newPath);
|
|
288
493
|
res.json({ success: true, newPath });
|
|
289
494
|
} catch (e) {
|
|
290
495
|
res.status(500).json({ error: e.message });
|
|
@@ -333,7 +538,7 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
|
|
|
333
538
|
}
|
|
334
539
|
await fsPromises.rm(src, { recursive: true, force: true });
|
|
335
540
|
} else {
|
|
336
|
-
await
|
|
541
|
+
await renameWithFallback(src, dst);
|
|
337
542
|
}
|
|
338
543
|
} else {
|
|
339
544
|
if (isDir) {
|
|
@@ -421,7 +626,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
421
626
|
return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
|
|
422
627
|
}
|
|
423
628
|
const p = realPath(req.body.path);
|
|
424
|
-
|
|
629
|
+
await fsPromises.stat(p);
|
|
425
630
|
const baseName = path.basename(p);
|
|
426
631
|
let zipName = baseName + '.zip';
|
|
427
632
|
let zipPath = path.join(path.dirname(p), zipName);
|
|
@@ -432,13 +637,7 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
|
|
|
432
637
|
zipPath = path.join(path.dirname(p), zipName);
|
|
433
638
|
counter++;
|
|
434
639
|
}
|
|
435
|
-
|
|
436
|
-
const zipTarget = path.basename(zipPath);
|
|
437
|
-
if (st.isDirectory()) {
|
|
438
|
-
execSync(`zip -r -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
439
|
-
} else {
|
|
440
|
-
execSync(`zip -6 "${zipTarget}" "${baseName}"`, { cwd: zipDir, stdio: 'pipe' });
|
|
441
|
-
}
|
|
640
|
+
await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
|
|
442
641
|
res.json({ success: true, name: zipName });
|
|
443
642
|
} catch (e) {
|
|
444
643
|
res.status(500).json({ error: e.message });
|
|
@@ -456,23 +655,8 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
|
|
|
456
655
|
if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
|
|
457
656
|
const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
|
|
458
657
|
await fsPromises.mkdir(destDir, { recursive: true });
|
|
459
|
-
// Security: scan zip entries before extraction
|
|
460
|
-
const unzipList = execSync(`unzip -l "${p}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
461
|
-
for (const line of unzipList.split('\n')) {
|
|
462
|
-
const match = line.match(/^\s*\S+\s+\S+\s+(.+)$/);
|
|
463
|
-
if (match) {
|
|
464
|
-
const entryPath = path.normalize(match[1]);
|
|
465
|
-
if (entryPath.startsWith('..') || path.isAbsolute(entryPath)) {
|
|
466
|
-
return res.status(400).json({ error: 'Invalid zip entry: ' + match[1] });
|
|
467
|
-
}
|
|
468
|
-
const target = path.join(destDir, entryPath);
|
|
469
|
-
if (!target.startsWith(destDir + path.sep) && target !== destDir) {
|
|
470
|
-
return res.status(400).json({ error: 'Zip entry escapes destination directory' });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
658
|
try {
|
|
475
|
-
|
|
659
|
+
await extractZip(p, destDir);
|
|
476
660
|
} catch (e) {
|
|
477
661
|
return res.status(500).json({ error: e.message });
|
|
478
662
|
}
|
|
@@ -537,13 +721,7 @@ app.get('/api/files/download', checkPin, async (req, res) => {
|
|
|
537
721
|
if (st.isDirectory()) {
|
|
538
722
|
res.setHeader('Content-Type', 'application/zip');
|
|
539
723
|
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
|
|
540
|
-
|
|
541
|
-
const zipProc = spawn('zip', ['-r', '-6', '-', path.basename(p)], { cwd: path.dirname(p), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
542
|
-
zipProc.stdout.pipe(res);
|
|
543
|
-
zipProc.on('error', err => {
|
|
544
|
-
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
545
|
-
});
|
|
546
|
-
zipProc.on('close', code => { if (code !== 0 && !res.headersSent) res.status(500).json({ error: 'zip failed' }); });
|
|
724
|
+
streamZipDirectory(p, res);
|
|
547
725
|
return;
|
|
548
726
|
} else {
|
|
549
727
|
const mimeType = mimeLookup(p);
|
|
@@ -573,7 +751,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
|
|
|
573
751
|
try {
|
|
574
752
|
const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
|
|
575
753
|
const finalDest = path.join(destDir, safeName);
|
|
576
|
-
if (!
|
|
754
|
+
if (!pathContained(destDir, finalDest)) {
|
|
577
755
|
return cb(new Error('Invalid upload destination'));
|
|
578
756
|
}
|
|
579
757
|
const subPath = path.dirname(finalDest);
|
|
@@ -583,7 +761,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
|
|
|
583
761
|
cb(err);
|
|
584
762
|
}
|
|
585
763
|
},
|
|
586
|
-
filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[
|
|
764
|
+
filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[^\w\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF_.\-]/g, '_'))
|
|
587
765
|
});
|
|
588
766
|
const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
|
|
589
767
|
upload(req, res, err => {
|
|
@@ -615,10 +793,22 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
|
|
|
615
793
|
isSocket: st.isSocket(), isFIFO: st.isFIFO(),
|
|
616
794
|
};
|
|
617
795
|
try {
|
|
618
|
-
|
|
796
|
+
if (os.platform() === 'win32') {
|
|
797
|
+
stat.owner = String(st.uid);
|
|
798
|
+
} else {
|
|
799
|
+
stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
800
|
+
}
|
|
619
801
|
} catch { stat.owner = String(st.uid); }
|
|
620
802
|
try {
|
|
621
|
-
|
|
803
|
+
if (os.platform() === 'win32') {
|
|
804
|
+
stat.group = String(st.gid);
|
|
805
|
+
} else if (os.platform() === 'darwin') {
|
|
806
|
+
const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
807
|
+
const m = dscl.match(/RecordName:\s*(.+)/);
|
|
808
|
+
stat.group = m ? m[1].trim() : String(st.gid);
|
|
809
|
+
} else {
|
|
810
|
+
stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
|
|
811
|
+
}
|
|
622
812
|
} catch { stat.group = String(st.gid); }
|
|
623
813
|
try {
|
|
624
814
|
const symlink = lst && lst.isSymbolicLink() ? await fsPromises.readlink(p) : null;
|
|
@@ -641,8 +831,7 @@ app.get('/api/files/size', checkPin, async (req, res) => {
|
|
|
641
831
|
if (!st.isDirectory()) {
|
|
642
832
|
return res.json({ path: p, size: st.size, isDir: false });
|
|
643
833
|
}
|
|
644
|
-
const
|
|
645
|
-
const size = parseInt(out.split('\t')[0], 10);
|
|
834
|
+
const size = await dirSize(p);
|
|
646
835
|
res.json({ path: p, size, isDir: true });
|
|
647
836
|
} catch (e) {
|
|
648
837
|
res.status(500).json({ error: e.message });
|
|
@@ -710,7 +899,8 @@ app.post('/api/files/chmod', checkPin, async (req, res) => {
|
|
|
710
899
|
if (!/^[0-7]{3,4}$/.test(req.body.mode)) return res.status(400).json({ error: 'mode must be a 3-4 digit octal number (e.g. 755, 644, 1777)' });
|
|
711
900
|
const mode = parseInt(req.body.mode, 8);
|
|
712
901
|
await fsPromises.chmod(p, mode);
|
|
713
|
-
|
|
902
|
+
const warning = os.platform() === 'win32' ? 'chmod has no effect on Windows' : undefined;
|
|
903
|
+
res.json({ success: true, mode: req.body.mode, ...(warning && { warning }) });
|
|
714
904
|
} catch (e) {
|
|
715
905
|
res.status(500).json({ error: e.message });
|
|
716
906
|
}
|
|
@@ -812,23 +1002,18 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
|
|
|
812
1002
|
}
|
|
813
1003
|
let dest = realPath(req.body.destination);
|
|
814
1004
|
const resolved = req.body.sources.map(s => realPath(s));
|
|
815
|
-
// Prevent zipping workspace root
|
|
816
1005
|
// Auto-rename if destination exists
|
|
817
1006
|
let counter = 1;
|
|
818
1007
|
const ext = '.zip';
|
|
819
1008
|
const origDest = dest;
|
|
820
1009
|
while (true) {
|
|
821
1010
|
try { await fsPromises.access(dest); } catch { break; }
|
|
822
|
-
dest = origDest.replace(/(\.zip)
|
|
1011
|
+
dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
|
|
823
1012
|
counter++;
|
|
824
1013
|
}
|
|
825
|
-
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
const rel = path.relative(zipDir, s);
|
|
829
|
-
return `"${rel}"`;
|
|
830
|
-
});
|
|
831
|
-
execSync(`zip -r -6 "${zipTarget}" ${zipArgs.join(' ')}`, { cwd: zipDir, stdio: 'pipe' });
|
|
1014
|
+
await fsPromises.mkdir(path.dirname(dest), { recursive: true });
|
|
1015
|
+
const entries = resolved.map(s => ({ fullPath: s, nameInZip: path.basename(s) }));
|
|
1016
|
+
await createZipArchive(entries, dest);
|
|
832
1017
|
res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
|
|
833
1018
|
} catch (e) {
|
|
834
1019
|
res.status(500).json({ error: e.message });
|
|
@@ -913,10 +1098,21 @@ app.get('/api/system/network', checkPin, async (req, res) => {
|
|
|
913
1098
|
gateway = route.split('\n')[0].trim() || null;
|
|
914
1099
|
const dnsOut = execFileSync('powershell.exe', ['-Command', '(Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
915
1100
|
dns = dnsOut.split('\n').filter(Boolean);
|
|
1101
|
+
} else if (os.platform() === 'darwin') {
|
|
1102
|
+
const route = execFileSync('sh', ['-c', "route -n get default 2>/dev/null | awk '/gateway:/{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1103
|
+
gateway = route || null;
|
|
1104
|
+
const resolv = execFileSync('sh', ['-c', "scutil --dns 2>/dev/null | awk '/nameserver\[0\]/{print $3}' | head -3"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1105
|
+
dns = resolv.split('\n').filter(Boolean);
|
|
916
1106
|
} else {
|
|
917
1107
|
const route = execFileSync('sh', ['-c', "ip route | grep default | head -1 | awk '{print $3}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
918
1108
|
gateway = route || null;
|
|
919
|
-
|
|
1109
|
+
let resolv = '';
|
|
1110
|
+
try {
|
|
1111
|
+
resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1112
|
+
} catch {}
|
|
1113
|
+
if (!resolv) {
|
|
1114
|
+
try { resolv = execFileSync('sh', ['-c', "resolvectl status 2>/dev/null | awk '/DNS Servers/{found=1; next} /^$/{found=0} found{print $1}' | head -3"], { encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
|
|
1115
|
+
}
|
|
920
1116
|
dns = resolv.split('\n').filter(Boolean);
|
|
921
1117
|
}
|
|
922
1118
|
} catch {}
|
|
@@ -927,6 +1123,15 @@ app.get('/api/system/network', checkPin, async (req, res) => {
|
|
|
927
1123
|
const m = l.match(/:(\d+)\s+/);
|
|
928
1124
|
return m ? { port: parseInt(m[1]), process: l.split(/\s+/).pop() } : null;
|
|
929
1125
|
}).filter(Boolean);
|
|
1126
|
+
} else if (os.platform() === 'darwin') {
|
|
1127
|
+
const out = execFileSync('lsof', ['-i', '-P', '-n', '-sTCP:LISTEN'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1128
|
+
const lines = out.split('\n').slice(1).filter(Boolean);
|
|
1129
|
+
listenPorts = lines.map(l => {
|
|
1130
|
+
const parts = l.split(/\s+/);
|
|
1131
|
+
const addr = parts[8] || '';
|
|
1132
|
+
const m = addr.match(/:(\d+)$/);
|
|
1133
|
+
return m ? { port: parseInt(m[1]), address: addr, process: parts[0] || '' } : null;
|
|
1134
|
+
}).filter(Boolean);
|
|
930
1135
|
} else {
|
|
931
1136
|
const out = execFileSync('sh', ['-c', "ss -tlnp 2>/dev/null | tail -n+2"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
932
1137
|
listenPorts = out.split('\n').filter(Boolean).map(l => {
|
|
@@ -1091,11 +1296,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1091
1296
|
}
|
|
1092
1297
|
const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
1093
1298
|
|
|
1094
|
-
const sessionEnv = (
|
|
1095
|
-
const safe = { TERM: 'xterm-256color', COLORTERM: 'truecolor', HOME: process.env.HOME || '', USER: process.env.USER || '', PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', LANG: process.env.LANG || 'C.UTF-8', SHELL: SHELL };
|
|
1096
|
-
if (process.env.NODE_ENV) safe.NODE_ENV = process.env.NODE_ENV;
|
|
1097
|
-
return safe;
|
|
1098
|
-
})();
|
|
1299
|
+
const sessionEnv = buildSessionEnv();
|
|
1099
1300
|
|
|
1100
1301
|
const send = (type, payload) => {
|
|
1101
1302
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -1128,7 +1329,8 @@ wss.on('connection', (ws, req) => {
|
|
|
1128
1329
|
});
|
|
1129
1330
|
}
|
|
1130
1331
|
} else {
|
|
1131
|
-
|
|
1332
|
+
const shellArgs = os.platform() === 'win32' ? ['-NoLogo'] : ['-l'];
|
|
1333
|
+
proc = pty.spawn(SHELL, shellArgs, {
|
|
1132
1334
|
name: 'xterm-256color', cols, rows, cwd,
|
|
1133
1335
|
env: sessionEnv
|
|
1134
1336
|
});
|
|
@@ -1139,9 +1341,29 @@ wss.on('connection', (ws, req) => {
|
|
|
1139
1341
|
return;
|
|
1140
1342
|
}
|
|
1141
1343
|
|
|
1142
|
-
|
|
1344
|
+
// Back-pressure: pause PTY output when WebSocket send buffer is full
|
|
1345
|
+
let paused = false;
|
|
1346
|
+
const HIGH_WATER = 4 * 1024 * 1024; // 4MB — pause PTY above this
|
|
1347
|
+
const LOW_WATER = 1 * 1024 * 1024; // 1MB — resume PTY below this
|
|
1348
|
+
|
|
1349
|
+
proc.onData(data => {
|
|
1350
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1351
|
+
send(0x00, data);
|
|
1352
|
+
// If WebSocket buffer is backing up, pause PTY to prevent OOM
|
|
1353
|
+
if (!paused && ws.bufferedAmount > HIGH_WATER) {
|
|
1354
|
+
try { proc.pause(); paused = true; } catch (_) {}
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
// Drain check: resume PTY when buffer drops
|
|
1359
|
+
const drainCheck = setInterval(() => {
|
|
1360
|
+
if (paused && ws.bufferedAmount < LOW_WATER) {
|
|
1361
|
+
try { proc.resume(); paused = false; } catch (_) {}
|
|
1362
|
+
}
|
|
1363
|
+
}, 50);
|
|
1143
1364
|
|
|
1144
1365
|
proc.onExit(() => {
|
|
1366
|
+
clearInterval(drainCheck);
|
|
1145
1367
|
if (!TMUX || !sessionId) send(0x01, Buffer.from([0]));
|
|
1146
1368
|
ws.close();
|
|
1147
1369
|
});
|
|
@@ -1160,8 +1382,8 @@ wss.on('connection', (ws, req) => {
|
|
|
1160
1382
|
if (buf.length < 1) return;
|
|
1161
1383
|
const type = buf[0];
|
|
1162
1384
|
if (type === 0x00) {
|
|
1163
|
-
// Limit input to
|
|
1164
|
-
const payload = buf.slice(1, Math.min(buf.length,
|
|
1385
|
+
// Limit input to 1MB per message
|
|
1386
|
+
const payload = buf.slice(1, Math.min(buf.length, 1048577));
|
|
1165
1387
|
proc.write(payload.toString('utf8'));
|
|
1166
1388
|
} else if (type === 0x01 && buf.length >= 5) {
|
|
1167
1389
|
const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
|
|
@@ -1177,6 +1399,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1177
1399
|
|
|
1178
1400
|
const cleanup = () => {
|
|
1179
1401
|
clearInterval(pingInterval);
|
|
1402
|
+
clearInterval(drainCheck);
|
|
1180
1403
|
try { proc.kill(); } catch {}
|
|
1181
1404
|
};
|
|
1182
1405
|
ws.on('close', cleanup);
|
|
@@ -1294,6 +1517,15 @@ app.get('/api/system', checkPin, async (req, res) => {
|
|
|
1294
1517
|
mem: p.mem || '',
|
|
1295
1518
|
cmd: p.cmd || ''
|
|
1296
1519
|
}));
|
|
1520
|
+
} else if (os.platform() === 'darwin') {
|
|
1521
|
+
const psOut = await spawnRead('ps', ['-axo', 'pid,user,%cpu,%mem,command', '-r']);
|
|
1522
|
+
const lines = psOut.trim().split('\n').slice(1, 16);
|
|
1523
|
+
for (const line of lines) {
|
|
1524
|
+
const m = line.match(/^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)/);
|
|
1525
|
+
if (m) {
|
|
1526
|
+
processes.push({ user: m[2], pid: m[1], cpu: m[3], mem: m[4], cmd: m[5] });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1297
1529
|
} else {
|
|
1298
1530
|
const psOut = await spawnRead('ps', ['-eo', 'pid,user,%cpu,%mem,cmd', '--no-headers', '--sort=-%cpu']);
|
|
1299
1531
|
const lines = psOut.trim().split('\n').slice(0, 15);
|
|
@@ -1328,9 +1560,12 @@ function isCloudflaredProcess(pid) {
|
|
|
1328
1560
|
if (os.platform() === 'win32') {
|
|
1329
1561
|
const stdout = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1330
1562
|
return stdout.toLowerCase().includes('cloudflared');
|
|
1331
|
-
} else {
|
|
1563
|
+
} else if (os.platform() === 'linux') {
|
|
1332
1564
|
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
1333
1565
|
return cmdline.toLowerCase().includes('cloudflared');
|
|
1566
|
+
} else {
|
|
1567
|
+
const stdout = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1568
|
+
return stdout.toLowerCase().includes('cloudflared');
|
|
1334
1569
|
}
|
|
1335
1570
|
} catch {
|
|
1336
1571
|
return false;
|
|
@@ -1381,16 +1616,31 @@ async function verifyTunnelUrl(url, retries = 3) {
|
|
|
1381
1616
|
return false;
|
|
1382
1617
|
}
|
|
1383
1618
|
|
|
1619
|
+
function spawnCloudflared(args, opts = {}) {
|
|
1620
|
+
const bin = findCloudflared();
|
|
1621
|
+
if (!bin) {
|
|
1622
|
+
const err = new Error('cloudflared not installed');
|
|
1623
|
+
err.code = 'ENOENT';
|
|
1624
|
+
throw err;
|
|
1625
|
+
}
|
|
1626
|
+
return spawn(bin, args, opts);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1384
1629
|
function restartTunnel(id, entry) {
|
|
1385
1630
|
if (!entry.localUrl) return;
|
|
1386
1631
|
try { if (entry.proc) entry.proc.kill('SIGTERM'); } catch {}
|
|
1387
|
-
try { if (entry.pid && isCloudflaredProcess(entry.pid))
|
|
1632
|
+
try { if (entry.pid && isCloudflaredProcess(entry.pid)) killPid(entry.pid); } catch {}
|
|
1388
1633
|
tunnels.delete(id);
|
|
1389
1634
|
|
|
1390
1635
|
const url = entry.localUrl;
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1636
|
+
let proc;
|
|
1637
|
+
try {
|
|
1638
|
+
proc = spawnCloudflared(['tunnel', '--url', url], {
|
|
1639
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
1640
|
+
});
|
|
1641
|
+
} catch {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1394
1644
|
proc.unref();
|
|
1395
1645
|
|
|
1396
1646
|
const handler = data => {
|
|
@@ -1464,12 +1714,18 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1464
1714
|
const { url } = req.body;
|
|
1465
1715
|
if (!url) return res.status(400).json({ error: 'url required' });
|
|
1466
1716
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1717
|
+
if (!findCloudflared()) {
|
|
1718
|
+
return res.status(500).json({ error: 'cloudflared not installed' });
|
|
1719
|
+
}
|
|
1469
1720
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1721
|
+
let proc;
|
|
1722
|
+
try {
|
|
1723
|
+
proc = spawnCloudflared(['tunnel', '--url', url], {
|
|
1724
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
1725
|
+
});
|
|
1726
|
+
} catch (e) {
|
|
1727
|
+
return res.status(500).json({ error: e.message });
|
|
1728
|
+
}
|
|
1473
1729
|
proc.unref();
|
|
1474
1730
|
let tunnelUrl = null;
|
|
1475
1731
|
const timeout = 15000;
|
|
@@ -1493,7 +1749,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1493
1749
|
});
|
|
1494
1750
|
|
|
1495
1751
|
try {
|
|
1496
|
-
|
|
1752
|
+
await Promise.race([
|
|
1497
1753
|
urlPromise,
|
|
1498
1754
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
|
|
1499
1755
|
]);
|
|
@@ -1504,7 +1760,7 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1504
1760
|
saveTunnels();
|
|
1505
1761
|
res.json({ success: true, id, url: tunnelUrl, verified: urlOk });
|
|
1506
1762
|
} catch (e) {
|
|
1507
|
-
try { proc.kill(); } catch {}
|
|
1763
|
+
try { if (proc.pid) killPid(proc.pid); else proc.kill(); } catch {}
|
|
1508
1764
|
res.status(500).json({ error: e.message === 'timeout' ? 'Timed out waiting for tunnel URL' : e.message });
|
|
1509
1765
|
}
|
|
1510
1766
|
});
|
|
@@ -1515,9 +1771,10 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1515
1771
|
const entry = tunnels.get(id);
|
|
1516
1772
|
try {
|
|
1517
1773
|
if (entry.proc) {
|
|
1518
|
-
entry.proc.kill('SIGTERM');
|
|
1774
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
1775
|
+
if (entry.proc.pid) killPid(entry.proc.pid);
|
|
1519
1776
|
} else if (entry.pid && isCloudflaredProcess(entry.pid)) {
|
|
1520
|
-
|
|
1777
|
+
killPid(entry.pid);
|
|
1521
1778
|
}
|
|
1522
1779
|
} catch {}
|
|
1523
1780
|
tunnels.delete(id);
|
|
@@ -1530,8 +1787,12 @@ app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
|
1530
1787
|
function cleanup() {
|
|
1531
1788
|
for (const [id, entry] of tunnels) {
|
|
1532
1789
|
try {
|
|
1533
|
-
if (entry.proc)
|
|
1534
|
-
|
|
1790
|
+
if (entry.proc) {
|
|
1791
|
+
try { entry.proc.kill('SIGTERM'); } catch {}
|
|
1792
|
+
if (entry.proc.pid) killPid(entry.proc.pid);
|
|
1793
|
+
} else if (entry.pid && isCloudflaredProcess(entry.pid)) {
|
|
1794
|
+
killPid(entry.pid);
|
|
1795
|
+
}
|
|
1535
1796
|
} catch {}
|
|
1536
1797
|
}
|
|
1537
1798
|
if (TMUX) {
|
|
@@ -1552,60 +1813,12 @@ function startServer(opts = {}) {
|
|
|
1552
1813
|
loadTunnels();
|
|
1553
1814
|
cleanupOrphanTmuxSessions();
|
|
1554
1815
|
|
|
1555
|
-
return new Promise((resolve) => {
|
|
1816
|
+
return new Promise((resolve, reject) => {
|
|
1817
|
+
server.once('error', reject);
|
|
1556
1818
|
server.listen(port, host, () => {
|
|
1557
|
-
console.log(`\n WebTun running → http://localhost:${port}
|
|
1558
|
-
if (PIN) console.log(` PIN protection enabled
|
|
1559
|
-
console.log(
|
|
1560
|
-
console.log(` GET /api/files?path=<dir> — list directory`);
|
|
1561
|
-
console.log(` GET /api/files/read?path=<file> — read file content`);
|
|
1562
|
-
console.log(` POST /api/files/write — write file { path, content }`);
|
|
1563
|
-
console.log(` POST /api/files/upload?path=<dir> — upload files (multipart)`);
|
|
1564
|
-
console.log(` GET /api/files/download?path=<path> — download file/dir`);
|
|
1565
|
-
console.log(` GET /api/files/image?path=<file> — view image inline`);
|
|
1566
|
-
console.log(` POST /api/files/rename — rename { oldPath, newName }`);
|
|
1567
|
-
console.log(` POST /api/files/copy — copy { source, destination, conflict? }`);
|
|
1568
|
-
console.log(` POST /api/files/move — move { source, destination, conflict? }`);
|
|
1569
|
-
console.log(` DELETE /api/files?path=<path> — delete file/dir`);
|
|
1570
|
-
console.log(` POST /api/files/mkdir — create dir { path }`);
|
|
1571
|
-
console.log(` POST /api/files/touch — create file { path }`);
|
|
1572
|
-
console.log(` POST /api/files/zip — create zip { path }`);
|
|
1573
|
-
console.log(` POST /api/files/unzip — extract zip { path }`);
|
|
1574
|
-
console.log(` GET /api/search?q=<query>&path=<dir> — search files`);
|
|
1575
|
-
console.log(` GET /api/files/stat?path=<path> — file metadata`);
|
|
1576
|
-
console.log(` POST /api/files/batch-delete — bulk delete { paths: [...] }`);
|
|
1577
|
-
console.log(` POST /api/files/batch-copy — bulk copy { sources: [...], destination, conflict? }`);
|
|
1578
|
-
console.log(` POST /api/files/batch-move — bulk move { sources: [...], destination, conflict? }`);
|
|
1579
|
-
console.log(` POST /api/files/chmod — change perms { path, mode }`);
|
|
1580
|
-
console.log(` POST /api/files/symlink — create symlink { target, linkPath }`);
|
|
1581
|
-
console.log(` POST /api/files/search-content — full-text search { query, path, pattern?, maxResults? }`);
|
|
1582
|
-
console.log(` POST /api/files/batch-zip — multi-source zip { sources: [...], destination }`);
|
|
1583
|
-
console.log(` POST /api/files/trash — trash files { paths: [...] }`);
|
|
1584
|
-
console.log(` GET /api/files/trash — list trash`);
|
|
1585
|
-
console.log(` POST /api/files/trash/restore — restore trash { path }`);
|
|
1586
|
-
console.log(` DELETE /api/files/trash?path=<path> — delete trash item permanently`);
|
|
1587
|
-
console.log(` DELETE /api/files/trash/all — empty entire trash`);
|
|
1588
|
-
console.log(` GET /api/files/preview?path=<file> — file preview (md→html, code)`);
|
|
1589
|
-
console.log(` GET /api/files/tail?path=<file>&lines=N — tail log file (SSE)`);
|
|
1590
|
-
console.log(` Git API:`);
|
|
1591
|
-
console.log(` GET /api/git/status?path=<dir> — git status`);
|
|
1592
|
-
console.log(` POST /api/git/diff — git diff { path, file? }`);
|
|
1593
|
-
console.log(` POST /api/git/add — git add { path, files? }`);
|
|
1594
|
-
console.log(` POST /api/git/commit — git commit { path, message }`);
|
|
1595
|
-
console.log(` GET /api/git/log?path=<dir>&maxCount=N — git log`);
|
|
1596
|
-
console.log(` POST /api/git/push — git push { path, remote?, branch? }`);
|
|
1597
|
-
console.log(` POST /api/git/pull — git pull { path, remote?, branch? }`);
|
|
1598
|
-
console.log(` GET /api/git/branches?path=<dir> — list branches`);
|
|
1599
|
-
console.log(` POST /api/git/branch — create branch { path, name, switch? }`);
|
|
1600
|
-
console.log(` GET /api/git/remote?path=<dir> — list remotes`);
|
|
1601
|
-
console.log(` System:`);
|
|
1602
|
-
console.log(` GET /api/system/network — network interfaces, ports`);
|
|
1603
|
-
console.log(` GET /api/env — environment variables`);
|
|
1604
|
-
console.log(` Clipboard:`);
|
|
1605
|
-
console.log(` GET /api/clipboard — clipboard contents`);
|
|
1606
|
-
console.log(` POST /api/clipboard — set clipboard { sources, action }`);
|
|
1607
|
-
console.log(` POST /api/clipboard/paste — paste { destination, conflict? }`);
|
|
1608
|
-
console.log(` DELETE /api/clipboard — clear clipboard`);
|
|
1819
|
+
console.log(`\n WebTun running → http://localhost:${port}`);
|
|
1820
|
+
if (PIN) console.log(` PIN protection enabled`);
|
|
1821
|
+
console.log('');
|
|
1609
1822
|
resolve(server);
|
|
1610
1823
|
});
|
|
1611
1824
|
});
|
|
@@ -1624,7 +1837,7 @@ process.on('SIGTERM', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
|
1624
1837
|
process.on('SIGINT', () => { try { cleanup(); } catch {}; process.exit(0); });
|
|
1625
1838
|
process.on('exit', () => { try { cleanup(); } catch {} });
|
|
1626
1839
|
|
|
1627
|
-
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT };
|
|
1840
|
+
module.exports = { app, server, startServer, PORT, PIN, WORKSPACE_ROOT, findCloudflared };
|
|
1628
1841
|
|
|
1629
1842
|
if (require.main === module) {
|
|
1630
1843
|
startServer();
|