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/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
- const val = trimmed.slice(idx + 1).trim();
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: sudo apt-get install -y python3 make g++');
37
- console.error(' macOS: xcode-select --install');
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
- const SHELL = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh'));
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
- app.set('trust proxy', 1);
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: 'Invalid PIN' });
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 fsPromises.rename(oldPath, newPath);
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 fsPromises.rename(src, dst);
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
- const st = await fsPromises.stat(p);
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
- const zipDir = path.dirname(zipPath);
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
- execSync(`unzip -o "${p}" -d "${destDir}"`, { stdio: 'pipe' });
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
- // Stream zip to response via system zip command
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 (!finalDest.startsWith(destDir + path.sep) && finalDest !== destDir) {
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(/[^a-zA-Z0-9_.\-]/g, '_'))
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
- stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
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
- stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
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 out = execFileSync('du', ['-sb', p], { encoding: 'utf8', stdio: 'pipe', timeout: 30000 });
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
- res.json({ success: true, mode: req.body.mode });
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)?$/, ` (${counter})${ext}`);
1011
+ dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
823
1012
  counter++;
824
1013
  }
825
- const zipDir = path.dirname(dest);
826
- const zipTarget = path.basename(dest);
827
- const zipArgs = resolved.map(s => {
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
- const resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
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
- proc = pty.spawn(SHELL, [], {
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
- proc.onData(data => send(0x00, data));
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 64KB per message
1164
- const payload = buf.slice(1, Math.min(buf.length, 65537));
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)) process.kill(entry.pid, 'SIGTERM'); } catch {}
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
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1392
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
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
- try { execSync(os.platform() === 'win32' ? 'where cloudflared' : 'command -v cloudflared', { stdio: 'ignore' }); }
1468
- catch { return res.status(500).json({ error: 'cloudflared not installed' }); }
1717
+ if (!findCloudflared()) {
1718
+ return res.status(500).json({ error: 'cloudflared not installed' });
1719
+ }
1469
1720
 
1470
- const proc = spawn('cloudflared', ['tunnel', '--url', url], {
1471
- detached: true, stdio: ['ignore', 'pipe', 'pipe']
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
- const result = await Promise.race([
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
- process.kill(entry.pid, 'SIGTERM');
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) entry.proc.kill('SIGTERM');
1534
- else if (entry.pid && isCloudflaredProcess(entry.pid)) process.kill(entry.pid, 'SIGTERM');
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}\n`);
1558
- if (PIN) console.log(` PIN protection enabled\n`);
1559
- console.log(` File API examples:`);
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();