opentunnel-cli 1.0.19 → 1.0.20

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 CHANGED
@@ -421,6 +421,47 @@ server:
421
421
  basePath: op
422
422
  ```
423
423
 
424
+ ## Domains Without Wildcard Support (DuckDNS)
425
+
426
+ Some DNS providers like **DuckDNS** don't support wildcard subdomains (`*.domain`). OpenTunnel automatically detects DuckDNS domains and uses **port-based routing** instead of subdomains.
427
+
428
+ **Auto-detection:** Domains ending in `.duckdns.org` automatically use port-based mode.
429
+
430
+ **Important:** DuckDNS domains cannot use `basePath` - it will throw an error:
431
+
432
+ ```yaml
433
+ # ❌ WRONG - Will throw an error
434
+ server:
435
+ domains:
436
+ - domain: myapp.duckdns.org
437
+ basePath: op # Error! DuckDNS doesn't support subdomains
438
+
439
+ # ✅ CORRECT
440
+ server:
441
+ domains:
442
+ - domain: fjrg2007.com
443
+ basePath: op # Subdomain-based: *.op.fjrg2007.com
444
+ - domain: myapp.duckdns.org
445
+ # Port-based: myapp.duckdns.org:<port>
446
+ ```
447
+
448
+ **Manual configuration:** Use `wildcard: false` for other domains without wildcard support:
449
+
450
+ ```yaml
451
+ server:
452
+ domains:
453
+ - domain: fjrg2007.com
454
+ basePath: op # Subdomain-based: *.op.fjrg2007.com
455
+ - domain: other-no-wildcard.com
456
+ wildcard: false # Manual: port-based
457
+ ```
458
+
459
+ **How it works:**
460
+ - **Wildcard domains:** `https://myapp.op.fjrg2007.com`
461
+ - **Non-wildcard domains:** `https://myapp.duckdns.org:10001`
462
+
463
+ Clients connecting to non-wildcard domains receive port-based URLs automatically.
464
+
424
465
  ## SSL Certificates
425
466
 
426
467
  When using self-signed certificates with multiple domains, OpenTunnel automatically generates a **SAN (Subject Alternative Name) certificate** that covers all configured domains and their wildcards.
package/dist/cli/index.js CHANGED
@@ -53,6 +53,28 @@ function getRegistryPath() {
53
53
  const registryDir = path.join(os.homedir(), ".opentunnel");
54
54
  return path.join(registryDir, "registry.json");
55
55
  }
56
+ function getLogsDir() {
57
+ const os = require("os");
58
+ const path = require("path");
59
+ const fs = require("fs");
60
+ const logsDir = path.join(os.homedir(), ".opentunnel", "logs");
61
+ // Ensure directory exists
62
+ if (!fs.existsSync(logsDir)) {
63
+ fs.mkdirSync(logsDir, { recursive: true });
64
+ }
65
+ return logsDir;
66
+ }
67
+ function getLogFilePath(instanceName) {
68
+ const path = require("path");
69
+ // Sanitize instance name for filename
70
+ const safeName = instanceName.replace(/[^a-zA-Z0-9_-]/g, "_");
71
+ return path.join(getLogsDir(), `${safeName}.log`);
72
+ }
73
+ function getPidFilePath(instanceName) {
74
+ const path = require("path");
75
+ const safeName = instanceName.replace(/[^a-zA-Z0-9_-]/g, "_");
76
+ return path.join(getLogsDir(), `${safeName}.pid`);
77
+ }
56
78
  function loadRegistry() {
57
79
  const fs = require("fs");
58
80
  const path = require("path");
@@ -156,7 +178,7 @@ program
156
178
  .name("opentunnel")
157
179
  .alias("ot")
158
180
  .description("Expose local ports to the internet via custom domains or ngrok")
159
- .version("1.0.19");
181
+ .version("1.0.20");
160
182
  // Helper function to build WebSocket URL from domain
161
183
  // User only provides base domain (e.g., fjrg2007.com), system handles the rest
162
184
  // Note: --insecure flag only affects certificate verification, not the protocol
@@ -550,8 +572,8 @@ program
550
572
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
551
573
  const fsAsync = await Promise.resolve().then(() => __importStar(require("fs")));
552
574
  const pathAsync = await Promise.resolve().then(() => __importStar(require("path")));
553
- const pidFile = pathAsync.join(process.cwd(), ".opentunnel.pid");
554
- const logFile = pathAsync.join(process.cwd(), "opentunnel.log");
575
+ const pidFile = getPidFilePath("server");
576
+ const logFile = getLogFilePath("server");
555
577
  // Check if already running
556
578
  if (fsAsync.existsSync(pidFile)) {
557
579
  const oldPid = fsAsync.readFileSync(pidFile, "utf-8").trim();
@@ -722,7 +744,7 @@ program
722
744
  .action(async () => {
723
745
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
724
746
  const path = await Promise.resolve().then(() => __importStar(require("path")));
725
- const pidFile = path.join(process.cwd(), ".opentunnel.pid");
747
+ const pidFile = getPidFilePath("server");
726
748
  if (!fs.existsSync(pidFile)) {
727
749
  console.log(chalk_1.default.yellow("No server running (PID file not found)"));
728
750
  return;
@@ -742,43 +764,6 @@ program
742
764
  console.log(chalk_1.default.red(`Failed to stop server: ${err.message}`));
743
765
  }
744
766
  });
745
- // Logs command
746
- program
747
- .command("logs")
748
- .description("Show server logs")
749
- .option("-f, --follow", "Follow log output")
750
- .option("-n, --lines <n>", "Number of lines to show", "50")
751
- .action(async (options) => {
752
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
753
- const path = await Promise.resolve().then(() => __importStar(require("path")));
754
- const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
755
- const logFile = path.join(process.cwd(), "opentunnel.log");
756
- if (!fs.existsSync(logFile)) {
757
- console.log(chalk_1.default.yellow("No log file found"));
758
- return;
759
- }
760
- if (options.follow) {
761
- // Use tail -f on Unix or PowerShell on Windows
762
- const isWindows = process.platform === "win32";
763
- if (isWindows) {
764
- const child = spawn("powershell", ["-Command", `Get-Content -Path "${logFile}" -Tail ${options.lines} -Wait`], {
765
- stdio: "inherit"
766
- });
767
- child.on("error", () => {
768
- // Fallback: just read the file
769
- console.log(fs.readFileSync(logFile, "utf-8"));
770
- });
771
- }
772
- else
773
- spawn("tail", ["-f", "-n", options.lines, logFile], { stdio: "inherit" });
774
- }
775
- else {
776
- const content = fs.readFileSync(logFile, "utf-8");
777
- const lines = content.split("\n");
778
- const lastLines = lines.slice(-parseInt(options.lines));
779
- console.log(lastLines.join("\n"));
780
- }
781
- });
782
767
  // Status command
783
768
  program
784
769
  .command("status")
@@ -1158,8 +1143,8 @@ program
1158
1143
  const shouldDetach = options.detach === true;
1159
1144
  if (shouldDetach) {
1160
1145
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
1161
- const pidFile = pathModule.join(process.cwd(), `.opentunnel-${instanceName}.pid`);
1162
- const logFile = pathModule.join(process.cwd(), `opentunnel-${instanceName}.log`);
1146
+ const pidFile = getPidFilePath(instanceName);
1147
+ const logFile = getLogFilePath(instanceName);
1163
1148
  // Check if already running
1164
1149
  if (fsModule.existsSync(pidFile)) {
1165
1150
  const oldPid = fsModule.readFileSync(pidFile, "utf-8").trim();
@@ -1298,13 +1283,25 @@ program
1298
1283
  const useHttps = config.server?.https !== false;
1299
1284
  const hasTunnels = tunnelsToStart.length > 0;
1300
1285
  // Parse multiple domains from config
1286
+ // Helper to check if domain is DuckDNS
1287
+ const isDuckDns = (domain) => domain.toLowerCase().endsWith(".duckdns.org");
1301
1288
  let serverDomains;
1302
1289
  if (config.server?.domains && config.server.domains.length > 0) {
1303
1290
  serverDomains = config.server.domains.map(d => {
1304
1291
  if (typeof d === "string") {
1305
- return { domain: d, basePath: basePath };
1292
+ // DuckDNS domains don't use basePath
1293
+ return { domain: d, basePath: isDuckDns(d) ? "" : basePath };
1306
1294
  }
1307
- return { domain: d.domain, basePath: d.basePath || basePath };
1295
+ // If basePath is explicitly set for DuckDNS, it will error in TunnelServer
1296
+ // If not set, default to empty for DuckDNS, or global basePath for others
1297
+ const domainBasePath = d.basePath !== undefined
1298
+ ? d.basePath
1299
+ : (isDuckDns(d.domain) ? "" : basePath);
1300
+ return {
1301
+ domain: d.domain,
1302
+ basePath: domainBasePath,
1303
+ wildcard: d.wildcard,
1304
+ };
1308
1305
  });
1309
1306
  }
1310
1307
  // Check if we have domain configuration (single or multiple)
@@ -1554,18 +1551,27 @@ program
1554
1551
  const pidFile = instance.pidFile;
1555
1552
  const instanceName = instance.name;
1556
1553
  const cwd = instance.cwd;
1557
- const logStream = fs.createWriteStream(logFile, { flags: "a" });
1554
+ // Open file descriptor for logging (required for detached processes)
1555
+ const logFd = fs.openSync(logFile, "a");
1558
1556
  const child = spawn(process.execPath, [process.argv[1], "up", "-f", pathModule.basename(configPath)], {
1559
1557
  cwd,
1560
1558
  detached: true,
1561
- stdio: ["ignore", logStream, logStream],
1559
+ stdio: ["ignore", logFd, logFd],
1562
1560
  env: { ...process.env, OPENTUNNEL_INSTANCE_NAME: instanceName },
1563
1561
  });
1564
1562
  child.unref();
1565
- // Wait and check if process started
1566
- await new Promise(resolve => setTimeout(resolve, 1000));
1563
+ fs.closeSync(logFd);
1564
+ // Wait and check if process started successfully
1565
+ await new Promise(resolve => setTimeout(resolve, 1500));
1566
+ let processRunning = false;
1567
1567
  try {
1568
1568
  process.kill(child.pid, 0);
1569
+ processRunning = true;
1570
+ }
1571
+ catch {
1572
+ processRunning = false;
1573
+ }
1574
+ if (processRunning) {
1569
1575
  // Process is running, register it
1570
1576
  registerInstance({
1571
1577
  name: instanceName,
@@ -1580,11 +1586,43 @@ program
1580
1586
  fs.writeFileSync(pidFile, child.pid.toString());
1581
1587
  console.log(chalk_1.default.green(` ↑ Started ${instanceName} (PID: ${child.pid})`));
1582
1588
  }
1583
- catch {
1589
+ else {
1590
+ // Process failed - check logs for error
1584
1591
  console.log(chalk_1.default.red(` ✗ Failed to start ${instanceName}`));
1592
+ // Read last lines of log to show error
1593
+ if (fs.existsSync(logFile)) {
1594
+ const logContent = fs.readFileSync(logFile, "utf-8");
1595
+ const lines = logContent.split("\n");
1596
+ // Find error lines
1597
+ const errorLines = [];
1598
+ let capturing = false;
1599
+ for (let i = lines.length - 1; i >= 0 && errorLines.length < 10; i--) {
1600
+ const line = lines[i];
1601
+ if (line.includes("Error:") || line.includes("error:") || capturing) {
1602
+ errorLines.unshift(line);
1603
+ capturing = true;
1604
+ }
1605
+ if (line.includes("throw new Error") || line.includes("at new")) {
1606
+ capturing = true;
1607
+ }
1608
+ }
1609
+ if (errorLines.length > 0) {
1610
+ console.log(chalk_1.default.red("\n Error details:"));
1611
+ console.log(chalk_1.default.gray(" " + "─".repeat(60)));
1612
+ // Find the actual error message
1613
+ const errorMsg = errorLines.find(l => l.includes("Error:"));
1614
+ if (errorMsg) {
1615
+ const match = errorMsg.match(/Error:\s*(.+)/);
1616
+ if (match) {
1617
+ console.log(chalk_1.default.red(` ${match[1]}`));
1618
+ }
1619
+ }
1620
+ console.log(chalk_1.default.gray(" " + "─".repeat(60)));
1621
+ }
1622
+ }
1585
1623
  }
1586
1624
  }
1587
- console.log(chalk_1.default.green(`\nRestart complete`));
1625
+ console.log();
1588
1626
  });
1589
1627
  // PS command - list running tunnel processes (global)
1590
1628
  program
@@ -1725,6 +1763,104 @@ program
1725
1763
  console.log(lines.join("\n"));
1726
1764
  }
1727
1765
  });
1766
+ // Logs clean command - remove log files
1767
+ program
1768
+ .command("logs-clean [name]")
1769
+ .description("Clean log files")
1770
+ .option("--all", "Clean all log files")
1771
+ .action(async (name, options) => {
1772
+ const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1773
+ const logsDir = getLogsDir();
1774
+ if (!fs.existsSync(logsDir)) {
1775
+ console.log(chalk_1.default.yellow("No logs directory found"));
1776
+ return;
1777
+ }
1778
+ let files;
1779
+ if (options.all) {
1780
+ // Clean all log files
1781
+ files = fs.readdirSync(logsDir).filter(f => f.endsWith(".log"));
1782
+ }
1783
+ else if (name) {
1784
+ // Clean specific instance logs
1785
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
1786
+ const logFile = `${safeName}.log`;
1787
+ files = fs.existsSync(path.join(logsDir, logFile)) ? [logFile] : [];
1788
+ }
1789
+ else {
1790
+ console.log(chalk_1.default.yellow("Specify instance name or use --all to clean all logs"));
1791
+ console.log(chalk_1.default.gray(` opentunnel logs-clean <name> Clean logs for specific instance`));
1792
+ console.log(chalk_1.default.gray(` opentunnel logs-clean --all Clean all log files`));
1793
+ return;
1794
+ }
1795
+ if (files.length === 0) {
1796
+ console.log(chalk_1.default.yellow(name ? `No logs found for "${name}"` : "No log files found"));
1797
+ return;
1798
+ }
1799
+ let totalSize = 0;
1800
+ for (const file of files) {
1801
+ const filePath = path.join(logsDir, file);
1802
+ try {
1803
+ const stat = fs.statSync(filePath);
1804
+ totalSize += stat.size;
1805
+ fs.unlinkSync(filePath);
1806
+ console.log(chalk_1.default.green(` ✓ Deleted ${file} (${(stat.size / 1024).toFixed(1)} KB)`));
1807
+ }
1808
+ catch (err) {
1809
+ console.log(chalk_1.default.red(` ✗ Failed to delete ${file}: ${err.message}`));
1810
+ }
1811
+ }
1812
+ console.log(chalk_1.default.gray(`\nCleaned ${files.length} file(s), freed ${(totalSize / 1024).toFixed(1)} KB`));
1813
+ console.log(chalk_1.default.gray(`Logs directory: ${logsDir}`));
1814
+ });
1815
+ // Logs list command - show logs directory and list log files
1816
+ program
1817
+ .command("logs-list")
1818
+ .description("List all log files and show logs directory")
1819
+ .action(async () => {
1820
+ const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1821
+ const logsDir = getLogsDir();
1822
+ console.log(chalk_1.default.cyan(`Logs directory: ${logsDir}\n`));
1823
+ if (!fs.existsSync(logsDir)) {
1824
+ console.log(chalk_1.default.yellow("No logs directory found"));
1825
+ return;
1826
+ }
1827
+ const files = fs.readdirSync(logsDir).filter(f => f.endsWith(".log") || f.endsWith(".pid"));
1828
+ if (files.length === 0) {
1829
+ console.log(chalk_1.default.yellow("No log files found"));
1830
+ return;
1831
+ }
1832
+ const logFiles = files.filter(f => f.endsWith(".log"));
1833
+ const pidFiles = files.filter(f => f.endsWith(".pid"));
1834
+ if (logFiles.length > 0) {
1835
+ console.log(chalk_1.default.white("Log files:"));
1836
+ let totalSize = 0;
1837
+ for (const file of logFiles) {
1838
+ const filePath = path.join(logsDir, file);
1839
+ const stat = fs.statSync(filePath);
1840
+ const size = (stat.size / 1024).toFixed(1);
1841
+ const modified = stat.mtime.toLocaleString();
1842
+ totalSize += stat.size;
1843
+ console.log(chalk_1.default.gray(` ${file.padEnd(35)} ${size.padStart(8)} KB ${modified}`));
1844
+ }
1845
+ console.log(chalk_1.default.gray(`\n Total: ${logFiles.length} file(s), ${(totalSize / 1024).toFixed(1)} KB`));
1846
+ }
1847
+ if (pidFiles.length > 0) {
1848
+ console.log(chalk_1.default.white("\nPID files (active instances):"));
1849
+ for (const file of pidFiles) {
1850
+ const filePath = path.join(logsDir, file);
1851
+ const pid = fs.readFileSync(filePath, "utf-8").trim();
1852
+ const name = file.replace(".pid", "");
1853
+ let status = chalk_1.default.green("running");
1854
+ try {
1855
+ process.kill(parseInt(pid), 0);
1856
+ }
1857
+ catch {
1858
+ status = chalk_1.default.red("stale");
1859
+ }
1860
+ console.log(chalk_1.default.gray(` ${name.padEnd(35)} PID: ${pid.padStart(6)} ${status}`));
1861
+ }
1862
+ }
1863
+ });
1728
1864
  // Test server command - simple HTTP server for testing tunnels
1729
1865
  program
1730
1866
  .command("test-server")
@@ -1738,8 +1874,8 @@ program
1738
1874
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
1739
1875
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1740
1876
  const path = await Promise.resolve().then(() => __importStar(require("path")));
1741
- const pidFile = path.join(process.cwd(), `.test-server-${port}.pid`);
1742
- const logFile = path.join(process.cwd(), `test-server-${port}.log`);
1877
+ const pidFile = getPidFilePath(`test-server-${port}`);
1878
+ const logFile = getLogFilePath(`test-server-${port}`);
1743
1879
  if (fs.existsSync(pidFile)) {
1744
1880
  const oldPid = fs.readFileSync(pidFile, "utf-8").trim();
1745
1881
  try {
@@ -1827,22 +1963,25 @@ program
1827
1963
  .option("-p, --port <port>", "Stop specific port")
1828
1964
  .action(async (options) => {
1829
1965
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1830
- const path = await Promise.resolve().then(() => __importStar(require("path")));
1966
+ const logsDir = getLogsDir();
1831
1967
  let pidFiles;
1832
1968
  if (options.port) {
1833
- const specific = `.test-server-${options.port}.pid`;
1834
- pidFiles = fs.existsSync(path.join(process.cwd(), specific)) ? [specific] : [];
1969
+ const pidPath = getPidFilePath(`test-server-${options.port}`);
1970
+ pidFiles = fs.existsSync(pidPath) ? [pidPath] : [];
1971
+ }
1972
+ else {
1973
+ pidFiles = fs.readdirSync(logsDir)
1974
+ .filter(f => f.startsWith("test-server-") && f.endsWith(".pid"))
1975
+ .map(f => path.join(logsDir, f));
1835
1976
  }
1836
- else
1837
- pidFiles = fs.readdirSync(process.cwd()).filter(f => f.startsWith(".test-server-") && f.endsWith(".pid"));
1838
1977
  if (pidFiles.length === 0) {
1839
1978
  console.log(chalk_1.default.yellow("No test servers running"));
1840
1979
  return;
1841
1980
  }
1842
- for (const pidFile of pidFiles) {
1843
- const pidPath = path.join(process.cwd(), pidFile);
1981
+ for (const pidPath of pidFiles) {
1844
1982
  const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim());
1845
- const port = pidFile.replace(".test-server-", "").replace(".pid", "");
1983
+ const fileName = path.basename(pidPath);
1984
+ const port = fileName.replace("test-server-", "").replace(".pid", "");
1846
1985
  try {
1847
1986
  process.kill(pid, "SIGTERM");
1848
1987
  fs.unlinkSync(pidPath);
@@ -1860,8 +1999,8 @@ async function runTunnelInBackgroundFromConfig(name, protocol, port, options) {
1860
1999
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
1861
2000
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1862
2001
  const path = await Promise.resolve().then(() => __importStar(require("path")));
1863
- const pidFile = path.join(process.cwd(), `.opentunnel-${name}.pid`);
1864
- const logFile = path.join(process.cwd(), `opentunnel-${name}.log`);
2002
+ const pidFile = getPidFilePath(name);
2003
+ const logFile = getLogFilePath(name);
1865
2004
  // Check if already running
1866
2005
  if (fs.existsSync(pidFile)) {
1867
2006
  const oldPid = fs.readFileSync(pidFile, "utf-8").trim();
@@ -1978,8 +2117,8 @@ async function runTunnelInBackground(command, port, options) {
1978
2117
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1979
2118
  const path = await Promise.resolve().then(() => __importStar(require("path")));
1980
2119
  const tunnelId = `tunnel-${port}-${Date.now()}`;
1981
- const pidFile = path.join(process.cwd(), `.opentunnel-${port}.pid`);
1982
- const logFile = path.join(process.cwd(), `opentunnel-${port}.log`);
2120
+ const pidFile = getPidFilePath(`tunnel-${port}`);
2121
+ const logFile = getLogFilePath(`tunnel-${port}`);
1983
2122
  // Check if already running
1984
2123
  if (fs.existsSync(pidFile)) {
1985
2124
  const oldPid = fs.readFileSync(pidFile, "utf-8").trim();