opentunnel-cli 1.0.6 → 1.0.8

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/dist/cli/index.js CHANGED
@@ -44,15 +44,72 @@ const TunnelClient_1 = require("../client/TunnelClient");
44
44
  const NgrokClient_1 = require("../client/NgrokClient");
45
45
  const utils_1 = require("../shared/utils");
46
46
  const yaml_1 = require("yaml");
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
47
49
  const CONFIG_FILE = "opentunnel.yml";
50
+ // Load .env file if exists
51
+ function loadEnvFile() {
52
+ const envPath = path.join(process.cwd(), ".env");
53
+ if (fs.existsSync(envPath)) {
54
+ const envContent = fs.readFileSync(envPath, "utf-8");
55
+ for (const line of envContent.split("\n")) {
56
+ const trimmed = line.trim();
57
+ // Skip comments and empty lines
58
+ if (!trimmed || trimmed.startsWith("#"))
59
+ continue;
60
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
61
+ if (match) {
62
+ const key = match[1].trim();
63
+ let value = match[2].trim();
64
+ // Remove surrounding quotes if present
65
+ if ((value.startsWith('"') && value.endsWith('"')) ||
66
+ (value.startsWith("'") && value.endsWith("'"))) {
67
+ value = value.slice(1, -1);
68
+ }
69
+ // Only set if not already defined in environment
70
+ if (process.env[key] === undefined) {
71
+ process.env[key] = value;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ // Docker-style environment variable substitution
78
+ // Supports: ${VAR}, ${VAR:-default}, ${VAR:=default}
79
+ function substituteEnvVars(content) {
80
+ // Pattern matches ${VAR}, ${VAR:-default}, ${VAR:=default}
81
+ const pattern = /\$\{([^}:]+)(?:(:[-=])([^}]*))?\}/g;
82
+ return content.replace(pattern, (match, varName, operator, defaultValue) => {
83
+ const envValue = process.env[varName];
84
+ if (operator === ":-" || operator === ":=") {
85
+ // Use default if variable is unset or empty
86
+ return (envValue !== undefined && envValue !== "") ? envValue : (defaultValue || "");
87
+ }
88
+ // Just ${VAR} - return value or empty string
89
+ return envValue || "";
90
+ });
91
+ }
92
+ // Load and parse config file with environment variable substitution
93
+ function loadConfig(configPath) {
94
+ if (!fs.existsSync(configPath)) {
95
+ return null;
96
+ }
97
+ // Load .env file first
98
+ loadEnvFile();
99
+ // Read and substitute environment variables
100
+ let content = fs.readFileSync(configPath, "utf-8");
101
+ content = substituteEnvVars(content);
102
+ return (0, yaml_1.parse)(content);
103
+ }
48
104
  const program = new commander_1.Command();
49
105
  program
50
106
  .name("opentunnel")
51
107
  .alias("ot")
52
108
  .description("Expose local ports to the internet via custom domains or ngrok")
53
109
  .version("1.0.0");
54
- // Helper function to build WebSocket URL from hostname
55
- function buildServerUrl(server, insecure) {
110
+ // Helper function to build WebSocket URL from domain
111
+ // User only provides base domain (e.g., fjrg2007.com), system handles the rest
112
+ function buildServerUrl(server, insecure, basePath) {
56
113
  let hostname = server;
57
114
  // Remove protocol if provided
58
115
  hostname = hostname.replace(/^(wss?|https?):\/\//, "");
@@ -60,9 +117,14 @@ function buildServerUrl(server, insecure) {
60
117
  hostname = hostname.replace(/\/_tunnel.*$/, "");
61
118
  // Remove trailing slash
62
119
  hostname = hostname.replace(/\/$/, "");
120
+ // Build the full hostname with basePath if provided and not empty
121
+ // If basePath is "op" (default), connect to op.domain.com
122
+ // If basePath is empty or not provided, connect directly to domain.com
123
+ const effectiveBasePath = basePath || "op";
124
+ const fullHostname = effectiveBasePath ? `${effectiveBasePath}.${hostname}` : hostname;
63
125
  const protocol = insecure ? "ws" : "wss";
64
126
  return {
65
- url: `${protocol}://${hostname}/_tunnel`,
127
+ url: `${protocol}://${fullHostname}/_tunnel`,
66
128
  displayName: hostname,
67
129
  };
68
130
  }
@@ -70,15 +132,16 @@ function buildServerUrl(server, insecure) {
70
132
  program
71
133
  .command("quick <port>")
72
134
  .description("Instantly expose a local port to the internet")
73
- .requiredOption("-s, --server <host>", "Server hostname (e.g., op.example.com)")
135
+ .requiredOption("-s, --server <domain>", "Server domain (e.g., example.com)")
136
+ .option("-b, --base-path <path>", "Server base path (default: op)")
74
137
  .option("-n, --subdomain <name>", "Request a specific subdomain (e.g., 'myapp')")
75
138
  .option("-p, --protocol <proto>", "Protocol (http, https, tcp)", "http")
76
139
  .option("-h, --host <host>", "Local host to forward to", "localhost")
77
140
  .option("-t, --token <token>", "Authentication token (if server requires it)")
78
141
  .option("--insecure", "Skip SSL certificate verification (for self-signed certs)")
79
142
  .action(async (port, options) => {
80
- // Build server URL from hostname
81
- const { url: serverUrl, displayName: serverDisplayName } = buildServerUrl(options.server, options.insecure);
143
+ // Build server URL from domain (user provides domain, system adds basePath)
144
+ const { url: serverUrl, displayName: serverDisplayName } = buildServerUrl(options.server, options.insecure, options.basePath);
82
145
  console.log(chalk_1.default.cyan(`
83
146
  ██████╗ ██████╗ ███████╗███╗ ██╗████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗
84
147
  ██╔═══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║
@@ -158,14 +221,14 @@ program
158
221
  program
159
222
  .command("http <port>")
160
223
  .description("Expose a local HTTP server")
161
- .option("-s, --server <host>", "Remote server hostname (if not provided, starts local server)")
224
+ .option("-s, --server <domain>", "Remote server domain (if not provided, starts local server)")
225
+ .option("-b, --base-path <path>", "Server base path (default: op)")
162
226
  .option("-t, --token <token>", "Authentication token")
163
227
  .option("-n, --subdomain <name>", "Custom subdomain (e.g., 'myapp' for myapp.op.domain.com)")
164
228
  .option("-d, --detach", "Run tunnel in background")
165
229
  .option("-h, --host <host>", "Local host", "localhost")
166
230
  .option("--domain <domain>", "Domain for the tunnel", "localhost")
167
231
  .option("--port <port>", "Server port", "443")
168
- .option("--base-path <path>", "Subdomain base path", "op")
169
232
  .option("--https", "Use HTTPS for local connection")
170
233
  .option("--insecure", "Skip SSL verification (for self-signed certs)")
171
234
  .option("--ngrok", "Use ngrok instead of OpenTunnel server")
@@ -182,9 +245,9 @@ program
182
245
  });
183
246
  return;
184
247
  }
185
- // If remote server hostname provided, just connect to it
248
+ // If remote server domain provided, just connect to it
186
249
  if (options.server) {
187
- const { url: serverUrl } = buildServerUrl(options.server, options.insecure);
250
+ const { url: serverUrl } = buildServerUrl(options.server, options.insecure, options.basePath);
188
251
  await createTunnel({
189
252
  protocol: options.https ? "https" : "http",
190
253
  localHost: options.host,
@@ -245,7 +308,8 @@ program
245
308
  program
246
309
  .command("tcp <port>")
247
310
  .description("Expose a local TCP server")
248
- .option("-s, --server <host>", "Remote server hostname (if not provided, starts local server)")
311
+ .option("-s, --server <domain>", "Remote server domain (if not provided, starts local server)")
312
+ .option("-b, --base-path <path>", "Server base path (default: op)")
249
313
  .option("-t, --token <token>", "Authentication token")
250
314
  .option("-r, --remote-port <port>", "Remote port to use")
251
315
  .option("-h, --host <host>", "Local host", "localhost")
@@ -266,9 +330,9 @@ program
266
330
  });
267
331
  return;
268
332
  }
269
- // If remote server hostname provided, just connect to it
333
+ // If remote server domain provided, just connect to it
270
334
  if (options.server) {
271
- const { url: serverUrl } = buildServerUrl(options.server, options.insecure);
335
+ const { url: serverUrl } = buildServerUrl(options.server, options.insecure, options.basePath);
272
336
  await createTunnel({
273
337
  protocol: "tcp",
274
338
  localHost: options.host,
@@ -367,13 +431,13 @@ program
367
431
  program
368
432
  .command("server")
369
433
  .description("Start the OpenTunnel server (standalone mode)")
370
- .option("-p, --port <port>", "Server port", "443")
434
+ .option("-p, --port <port>", "Server port")
371
435
  .option("--public-port <port>", "Public port shown in URLs (default: same as port)")
372
- .option("--domain <domain>", "Base domain", "localhost")
373
- .option("-b, --base-path <path>", "Subdomain base path (e.g., 'op' for *.op.domain.com)", "op")
374
- .option("--host <host>", "Bind host", "0.0.0.0")
375
- .option("--tcp-min <port>", "Minimum TCP port", "10000")
376
- .option("--tcp-max <port>", "Maximum TCP port", "20000")
436
+ .option("--domain <domain>", "Base domain")
437
+ .option("-b, --base-path <path>", "Subdomain base path (e.g., 'op' for *.op.domain.com)")
438
+ .option("--host <host>", "Bind host")
439
+ .option("--tcp-min <port>", "Minimum TCP port")
440
+ .option("--tcp-max <port>", "Maximum TCP port")
377
441
  .option("--auth-tokens <tokens>", "Comma-separated auth tokens")
378
442
  .option("--no-https", "Disable HTTPS (use plain HTTP)")
379
443
  .option("--https-cert <path>", "Path to SSL certificate (for custom certs)")
@@ -385,16 +449,48 @@ program
385
449
  .option("--duckdns-token <token>", "DuckDNS token for dynamic DNS updates")
386
450
  .option("-d, --detach", "Run server in background (detached mode)")
387
451
  .action(async (options) => {
452
+ // Load config from opentunnel.yml if exists (with env variable substitution)
453
+ const configPath = path.join(process.cwd(), CONFIG_FILE);
454
+ let fileConfig = {};
455
+ try {
456
+ const parsed = loadConfig(configPath);
457
+ if (parsed?.server && parsed.server.domain) {
458
+ fileConfig = parsed.server;
459
+ }
460
+ }
461
+ catch (err) {
462
+ // Ignore parse errors, use CLI options
463
+ }
464
+ // Merge config: CLI options override file config, then defaults
465
+ const mergedOptions = {
466
+ port: options.port || fileConfig.port?.toString() || "443",
467
+ publicPort: options.publicPort || fileConfig.publicPort?.toString(),
468
+ domain: options.domain || fileConfig.domain || "localhost",
469
+ basePath: options.basePath || fileConfig.basePath || "op",
470
+ host: options.host || fileConfig.host || "0.0.0.0",
471
+ tcpMin: options.tcpMin || fileConfig.tcpPortMin?.toString() || "10000",
472
+ tcpMax: options.tcpMax || fileConfig.tcpPortMax?.toString() || "20000",
473
+ authTokens: options.authTokens || fileConfig.token,
474
+ https: options.https !== false && fileConfig.https !== false,
475
+ httpsCert: options.httpsCert,
476
+ httpsKey: options.httpsKey,
477
+ letsencrypt: options.letsencrypt,
478
+ email: options.email,
479
+ production: options.production,
480
+ cloudflareToken: options.cloudflareToken,
481
+ duckdnsToken: options.duckdnsToken,
482
+ detach: options.detach,
483
+ };
388
484
  // Detached mode - run in background
389
- if (options.detach) {
485
+ if (mergedOptions.detach) {
390
486
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
391
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
392
- const path = await Promise.resolve().then(() => __importStar(require("path")));
393
- const pidFile = path.join(process.cwd(), ".opentunnel.pid");
394
- const logFile = path.join(process.cwd(), "opentunnel.log");
487
+ const fsAsync = await Promise.resolve().then(() => __importStar(require("fs")));
488
+ const pathAsync = await Promise.resolve().then(() => __importStar(require("path")));
489
+ const pidFile = pathAsync.join(process.cwd(), ".opentunnel.pid");
490
+ const logFile = pathAsync.join(process.cwd(), "opentunnel.log");
395
491
  // Check if already running
396
- if (fs.existsSync(pidFile)) {
397
- const oldPid = fs.readFileSync(pidFile, "utf-8").trim();
492
+ if (fsAsync.existsSync(pidFile)) {
493
+ const oldPid = fsAsync.readFileSync(pidFile, "utf-8").trim();
398
494
  try {
399
495
  process.kill(parseInt(oldPid), 0);
400
496
  console.log(chalk_1.default.yellow(`Server already running (PID: ${oldPid})`));
@@ -402,60 +498,48 @@ program
402
498
  return;
403
499
  }
404
500
  catch {
405
- fs.unlinkSync(pidFile);
501
+ fsAsync.unlinkSync(pidFile);
406
502
  }
407
503
  }
408
- // Build args without -d flag
504
+ // Build args without -d flag, using merged options
409
505
  const args = ["server"];
410
- if (options.port)
411
- args.push("-p", options.port);
412
- if (options.publicPort)
413
- args.push("--public-port", options.publicPort);
414
- if (options.domain)
415
- args.push("--domain", options.domain);
416
- if (options.basePath)
417
- args.push("-b", options.basePath);
418
- if (options.host)
419
- args.push("--host", options.host);
420
- if (options.tcpMin)
421
- args.push("--tcp-min", options.tcpMin);
422
- if (options.tcpMax)
423
- args.push("--tcp-max", options.tcpMax);
424
- if (options.authTokens)
425
- args.push("--auth-tokens", options.authTokens);
426
- if (options.https)
506
+ args.push("-p", mergedOptions.port);
507
+ args.push("--domain", mergedOptions.domain);
508
+ args.push("-b", mergedOptions.basePath);
509
+ args.push("--host", mergedOptions.host);
510
+ args.push("--tcp-min", mergedOptions.tcpMin);
511
+ args.push("--tcp-max", mergedOptions.tcpMax);
512
+ if (mergedOptions.publicPort)
513
+ args.push("--public-port", mergedOptions.publicPort);
514
+ if (mergedOptions.authTokens)
515
+ args.push("--auth-tokens", mergedOptions.authTokens);
516
+ if (mergedOptions.https)
427
517
  args.push("--https");
428
- if (options.email)
429
- args.push("--email", options.email);
430
- if (options.production)
518
+ if (mergedOptions.email)
519
+ args.push("--email", mergedOptions.email);
520
+ if (mergedOptions.production)
431
521
  args.push("--production");
432
- if (options.cloudflareToken)
433
- args.push("--cloudflare-token", options.cloudflareToken);
434
- if (options.duckdnsToken)
435
- args.push("--duckdns-token", options.duckdnsToken);
436
- if (options.autoDns)
437
- args.push("--auto-dns");
438
- if (options.dnsCreateRecords)
439
- args.push("--dns-create-records");
440
- if (options.dnsDeleteOnClose)
441
- args.push("--dns-delete-on-close");
442
- const out = fs.openSync(logFile, "a");
443
- const err = fs.openSync(logFile, "a");
522
+ if (mergedOptions.cloudflareToken)
523
+ args.push("--cloudflare-token", mergedOptions.cloudflareToken);
524
+ if (mergedOptions.duckdnsToken)
525
+ args.push("--duckdns-token", mergedOptions.duckdnsToken);
526
+ const out = fsAsync.openSync(logFile, "a");
527
+ const err = fsAsync.openSync(logFile, "a");
444
528
  const child = spawn(process.execPath, [process.argv[1], ...args], {
445
529
  detached: true,
446
530
  stdio: ["ignore", out, err],
447
531
  cwd: process.cwd(),
448
532
  });
449
533
  child.unref();
450
- fs.writeFileSync(pidFile, String(child.pid));
534
+ fsAsync.writeFileSync(pidFile, String(child.pid));
451
535
  console.log(chalk_1.default.green(`OpenTunnel server started in background`));
452
536
  console.log(chalk_1.default.gray(` PID: ${child.pid}`));
453
- console.log(chalk_1.default.gray(` Port: ${options.port}`));
454
- console.log(chalk_1.default.gray(` Domain: ${options.domain}`));
537
+ console.log(chalk_1.default.gray(` Port: ${mergedOptions.port}`));
538
+ console.log(chalk_1.default.gray(` Domain: ${mergedOptions.domain}`));
455
539
  console.log(chalk_1.default.gray(` Log: ${logFile}`));
456
540
  console.log(chalk_1.default.gray(` PID file: ${pidFile}`));
457
541
  console.log("");
458
- console.log(chalk_1.default.gray(`Stop with: node dist/cli/index.js stop`));
542
+ console.log(chalk_1.default.gray(`Stop with: opentunnel stop`));
459
543
  console.log(chalk_1.default.gray(`Logs: tail -f ${logFile}`));
460
544
  return;
461
545
  }
@@ -465,46 +549,46 @@ program
465
549
  let httpsConfig = undefined;
466
550
  let selfSignedHttpsConfig = undefined;
467
551
  let autoHttpsConfig = undefined;
468
- if (options.httpsCert && options.httpsKey) {
552
+ if (mergedOptions.httpsCert && mergedOptions.httpsKey) {
469
553
  // Custom certificates provided
470
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
554
+ const fsRead = await Promise.resolve().then(() => __importStar(require("fs")));
471
555
  httpsConfig = {
472
- cert: fs.readFileSync(options.httpsCert, "utf-8"),
473
- key: fs.readFileSync(options.httpsKey, "utf-8"),
556
+ cert: fsRead.readFileSync(mergedOptions.httpsCert, "utf-8"),
557
+ key: fsRead.readFileSync(mergedOptions.httpsKey, "utf-8"),
474
558
  };
475
559
  }
476
- else if (options.letsencrypt) {
560
+ else if (mergedOptions.letsencrypt) {
477
561
  // Let's Encrypt
478
562
  autoHttpsConfig = {
479
563
  enabled: true,
480
- email: options.email || `admin@${options.domain}`,
481
- production: options.production || false,
482
- cloudflareToken: options.cloudflareToken,
564
+ email: mergedOptions.email || `admin@${mergedOptions.domain}`,
565
+ production: mergedOptions.production || false,
566
+ cloudflareToken: mergedOptions.cloudflareToken,
483
567
  };
484
568
  }
485
569
  else {
486
570
  // Self-signed by default (use --no-https to disable)
487
571
  selfSignedHttpsConfig = {
488
- enabled: options.https !== false,
572
+ enabled: mergedOptions.https !== false,
489
573
  };
490
574
  }
491
575
  const server = new TunnelServer({
492
- port: parseInt(options.port),
493
- publicPort: options.publicPort ? parseInt(options.publicPort) : undefined,
494
- host: options.host,
495
- domain: options.domain,
496
- basePath: options.basePath,
576
+ port: parseInt(mergedOptions.port),
577
+ publicPort: mergedOptions.publicPort ? parseInt(mergedOptions.publicPort) : undefined,
578
+ host: mergedOptions.host,
579
+ domain: mergedOptions.domain,
580
+ basePath: mergedOptions.basePath,
497
581
  tunnelPortRange: {
498
- min: parseInt(options.tcpMin),
499
- max: parseInt(options.tcpMax),
582
+ min: parseInt(mergedOptions.tcpMin),
583
+ max: parseInt(mergedOptions.tcpMax),
500
584
  },
501
- auth: options.authTokens
502
- ? { required: true, tokens: options.authTokens.split(",") }
585
+ auth: mergedOptions.authTokens
586
+ ? { required: true, tokens: mergedOptions.authTokens.split(",") }
503
587
  : undefined,
504
588
  https: httpsConfig,
505
589
  selfSignedHttps: selfSignedHttpsConfig,
506
590
  autoHttps: autoHttpsConfig,
507
- autoDns: detectDnsConfig(options),
591
+ autoDns: detectDnsConfig(mergedOptions),
508
592
  });
509
593
  // Helper function to auto-detect DNS provider
510
594
  function detectDnsConfig(opts) {
@@ -517,8 +601,8 @@ program
517
601
  enabled: true,
518
602
  provider: "cloudflare",
519
603
  cloudflareToken: opts.cloudflareToken,
520
- createRecords: opts.dnsCreateRecords !== false,
521
- deleteOnClose: opts.dnsDeleteOnClose || false,
604
+ createRecords: false,
605
+ deleteOnClose: false,
522
606
  setupWildcard: true,
523
607
  };
524
608
  }
@@ -527,15 +611,11 @@ program
527
611
  enabled: true,
528
612
  provider: "duckdns",
529
613
  duckdnsToken: opts.duckdnsToken,
530
- createRecords: false, // DuckDNS doesn't support subdomains
614
+ createRecords: false,
531
615
  deleteOnClose: false,
532
616
  setupWildcard: false,
533
617
  };
534
618
  }
535
- // No auto DNS if no tokens provided
536
- if (opts.autoDns) {
537
- console.log(chalk_1.default.yellow("Warning: --auto-dns requires --cloudflare-token or --duckdns-token"));
538
- }
539
619
  return undefined;
540
620
  }
541
621
  console.log(chalk_1.default.cyan(`
@@ -553,10 +633,10 @@ program
553
633
  console.log(chalk_1.default.yellow(`[-] Tunnel closed: ${tunnelId}`));
554
634
  });
555
635
  await server.start();
556
- console.log(chalk_1.default.green(`\nServer running on ${options.host}:${options.port}`));
557
- console.log(chalk_1.default.gray(`Domain: ${options.domain}`));
558
- console.log(chalk_1.default.gray(`Subdomain pattern: *.${options.basePath}.${options.domain}`));
559
- console.log(chalk_1.default.gray(`TCP port range: ${options.tcpMin}-${options.tcpMax}\n`));
636
+ console.log(chalk_1.default.green(`\nServer running on ${mergedOptions.host}:${mergedOptions.port}`));
637
+ console.log(chalk_1.default.gray(`Domain: ${mergedOptions.domain}`));
638
+ console.log(chalk_1.default.gray(`Subdomain pattern: *.${mergedOptions.basePath}.${mergedOptions.domain}`));
639
+ console.log(chalk_1.default.gray(`TCP port range: ${mergedOptions.tcpMin}-${mergedOptions.tcpMax}\n`));
560
640
  });
561
641
  // Stop command
562
642
  program
@@ -879,76 +959,99 @@ program
879
959
  .command("init")
880
960
  .description("Create an example opentunnel.yml configuration file")
881
961
  .option("-f, --force", "Overwrite existing config file")
962
+ .option("--server", "Create server mode config")
963
+ .option("--client", "Create client mode config (default)")
882
964
  .action(async (options) => {
883
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
884
- const path = await Promise.resolve().then(() => __importStar(require("path")));
885
- const configPath = path.join(process.cwd(), CONFIG_FILE);
886
- if (fs.existsSync(configPath) && !options.force) {
965
+ const fsInit = await Promise.resolve().then(() => __importStar(require("fs")));
966
+ const pathInit = await Promise.resolve().then(() => __importStar(require("path")));
967
+ const configPath = pathInit.join(process.cwd(), CONFIG_FILE);
968
+ const envPath = pathInit.join(process.cwd(), ".env");
969
+ if (fsInit.existsSync(configPath) && !options.force) {
887
970
  console.log(chalk_1.default.yellow(`Config file already exists: ${configPath}`));
888
971
  console.log(chalk_1.default.gray("Use --force to overwrite"));
889
972
  return;
890
973
  }
891
- const exampleConfig = {
892
- version: "1.0",
893
- server: {
894
- domain: "localhost",
895
- // remote: "op.fjrg2007.com", // Use this to connect to a remote server
896
- // token: "your-auth-token",
897
- },
898
- tunnels: [
899
- {
900
- name: "web",
901
- protocol: "http",
902
- port: 3000,
903
- subdomain: "web",
904
- autostart: true,
905
- },
906
- {
907
- name: "api",
908
- protocol: "http",
909
- port: 4000,
910
- subdomain: "api",
911
- autostart: true,
912
- },
913
- {
914
- name: "database",
915
- protocol: "tcp",
916
- port: 5432,
917
- autostart: false,
918
- },
919
- ],
920
- };
921
- fs.writeFileSync(configPath, (0, yaml_1.stringify)(exampleConfig, { indent: 2 }));
974
+ // Example config with environment variable syntax
975
+ const clientConfig = `# OpenTunnel Client Configuration
976
+ # Supports environment variables: \${VAR} or \${VAR:-default}
977
+
978
+ server:
979
+ remote: \${SERVER_DOMAIN:-example.com} # Server domain (system adds basePath)
980
+ token: \${AUTH_TOKEN} # From .env (optional)
981
+
982
+ tunnels:
983
+ - name: web
984
+ protocol: http
985
+ port: 3000
986
+ subdomain: web # → web.op.example.com
987
+
988
+ - name: api
989
+ protocol: http
990
+ port: 4000
991
+ subdomain: api # → api.op.example.com
992
+
993
+ - name: database
994
+ protocol: tcp
995
+ port: 5432
996
+ remotePort: 15432 # → example.com:15432
997
+ autostart: false # Don't start automatically
998
+ `;
999
+ const serverConfig = `# OpenTunnel Server Configuration
1000
+ # Supports environment variables: \${VAR} or \${VAR:-default}
1001
+
1002
+ server:
1003
+ domain: \${DOMAIN:-example.com} # Your base domain
1004
+ token: \${AUTH_TOKEN} # From .env (optional for public server)
1005
+ # tcpPortMin: 10000 # TCP tunnel port range (optional)
1006
+ # tcpPortMax: 20000
1007
+ `;
1008
+ const envExample = `# OpenTunnel Environment Variables
1009
+ # Copy to .env and fill in your values
1010
+
1011
+ # Server/Remote domain
1012
+ DOMAIN=example.com
1013
+ SERVER_DOMAIN=example.com
1014
+
1015
+ # Authentication token (leave empty for public server)
1016
+ AUTH_TOKEN=
1017
+ `;
1018
+ const configContent = options.server ? serverConfig : clientConfig;
1019
+ fsInit.writeFileSync(configPath, configContent);
1020
+ // Create .env.example if it doesn't exist
1021
+ const envExamplePath = pathInit.join(process.cwd(), ".env.example");
1022
+ if (!fsInit.existsSync(envExamplePath) && !fsInit.existsSync(envPath)) {
1023
+ fsInit.writeFileSync(envExamplePath, envExample);
1024
+ console.log(chalk_1.default.green(`Created .env.example`));
1025
+ }
922
1026
  console.log(chalk_1.default.green(`Created ${CONFIG_FILE}`));
923
- console.log(chalk_1.default.gray(`\nEdit the file to configure your tunnels, then run:`));
1027
+ console.log(chalk_1.default.gray(`\nEnvironment variables supported:`));
1028
+ console.log(chalk_1.default.cyan(` \${VAR} → Use value of VAR`));
1029
+ console.log(chalk_1.default.cyan(` \${VAR:-default} → Use VAR or "default" if not set`));
1030
+ console.log(chalk_1.default.gray(`\nCreate a .env file for secrets, then run:`));
924
1031
  console.log(chalk_1.default.cyan(` opentunnel up # Start all tunnels`));
925
1032
  console.log(chalk_1.default.cyan(` opentunnel up -d # Start in background`));
926
1033
  });
927
- // Up command - start tunnels from config (like docker-compose up)
1034
+ // Up command - start tunnels from config
928
1035
  program
929
1036
  .command("up")
930
- .description("Start server and tunnels from opentunnel.yml (like docker-compose up)")
1037
+ .description("Start server and tunnels from opentunnel.yml")
931
1038
  .option("-d, --detach", "Run in background (detached mode)")
932
1039
  .option("-f, --file <path>", "Config file path", CONFIG_FILE)
933
1040
  .option("--no-autostart", "Ignore autostart setting, start all tunnels")
934
1041
  .action(async (options) => {
935
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
936
- const path = await Promise.resolve().then(() => __importStar(require("path")));
937
- // Load config file
938
- const configPath = path.join(process.cwd(), options.file);
939
- let config = { version: "1.0", tunnels: [] };
940
- if (fs.existsSync(configPath)) {
941
- const configContent = fs.readFileSync(configPath, "utf-8");
942
- config = (0, yaml_1.parse)(configContent);
943
- }
944
- else {
1042
+ const fsModule = await Promise.resolve().then(() => __importStar(require("fs")));
1043
+ const pathModule = await Promise.resolve().then(() => __importStar(require("path")));
1044
+ // Load config file (with env variable substitution)
1045
+ const configPath = pathModule.join(process.cwd(), options.file);
1046
+ const config = loadConfig(configPath);
1047
+ if (!config) {
945
1048
  console.log(chalk_1.default.red(`Config file not found: ${configPath}`));
946
1049
  console.log(chalk_1.default.gray(`Run 'opentunnel init' to create one`));
947
1050
  return;
948
1051
  }
949
1052
  const tunnelsToStart = options.autostart === false
950
- ? config.tunnels
951
- : config.tunnels?.filter(t => t.autostart !== false) || [];
1053
+ ? (config.tunnels || [])
1054
+ : (config.tunnels?.filter(t => t.autostart !== false) || []);
952
1055
  // Display banner
953
1056
  console.log(chalk_1.default.cyan(`
954
1057
  ██████╗ ██████╗ ███████╗███╗ ██╗████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗
@@ -975,16 +1078,16 @@ program
975
1078
  console.log(chalk_1.default.gray("\nAdd to your config:"));
976
1079
  console.log(chalk_1.default.cyan("\n # Run your own server:"));
977
1080
  console.log(chalk_1.default.white(" server:"));
978
- console.log(chalk_1.default.white(" domain: localhost"));
1081
+ console.log(chalk_1.default.white(" domain: example.com"));
979
1082
  console.log(chalk_1.default.cyan("\n # Or connect to a remote server:"));
980
1083
  console.log(chalk_1.default.white(" server:"));
981
- console.log(chalk_1.default.white(" remote: op.fjrg2007.com"));
1084
+ console.log(chalk_1.default.white(" remote: example.com"));
982
1085
  process.exit(1);
983
1086
  }
984
1087
  if (isClientMode) {
985
1088
  // CLIENT MODE: Connect to remote server
986
- const protocol = useHttps ? "wss" : "ws";
987
- const serverUrl = `${protocol}://${remote}/_tunnel`;
1089
+ // Build URL using basePath (default: op) -> wss://op.example.com/_tunnel
1090
+ const { url: serverUrl } = buildServerUrl(remote, !useHttps, basePath);
988
1091
  console.log(chalk_1.default.cyan(`Connecting to ${remote}...\n`));
989
1092
  console.log(chalk_1.default.cyan(`Starting ${tunnelsToStart.length} tunnel(s)...\n`));
990
1093
  try {
@@ -1040,10 +1143,10 @@ program
1040
1143
  }
1041
1144
  }
1042
1145
  });
1043
- // Down command - stop all tunnels (like docker-compose down)
1146
+ // Down command - stop all tunnels
1044
1147
  program
1045
1148
  .command("down")
1046
- .description("Stop all running tunnels (like docker-compose down)")
1149
+ .description("Stop all running tunnels")
1047
1150
  .action(async () => {
1048
1151
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1049
1152
  const path = await Promise.resolve().then(() => __importStar(require("path")));
@@ -1087,10 +1190,10 @@ program
1087
1190
  }
1088
1191
  console.log(chalk_1.default.green("\nAll tunnels stopped"));
1089
1192
  });
1090
- // PS command - list running tunnel processes (like docker ps)
1193
+ // PS command - list running tunnel processes
1091
1194
  program
1092
1195
  .command("ps")
1093
- .description("List running tunnel processes (like docker ps)")
1196
+ .description("List running tunnel processes")
1094
1197
  .action(async () => {
1095
1198
  const fs = await Promise.resolve().then(() => __importStar(require("fs")));
1096
1199
  const path = await Promise.resolve().then(() => __importStar(require("path")));