opentunnel-cli 1.0.6 → 1.0.7

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,48 +959,82 @@ 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
+ # Create a .env file for secrets (automatically loaded)
978
+
979
+ version: "1.0"
980
+
981
+ server:
982
+ remote: \${SERVER_DOMAIN:-example.com} # Server domain (system adds basePath)
983
+ token: \${AUTH_TOKEN} # From .env (required for private servers)
984
+
985
+ tunnels:
986
+ - name: web
987
+ protocol: http
988
+ port: 3000
989
+ subdomain: web # → web.op.example.com
990
+ autostart: true
991
+
992
+ - name: api
993
+ protocol: http
994
+ port: 4000
995
+ subdomain: api # → api.op.example.com
996
+
997
+ - name: database
998
+ protocol: tcp
999
+ port: 5432
1000
+ remotePort: 15432 # → example.com:15432
1001
+ autostart: false
1002
+ `;
1003
+ const serverConfig = `# OpenTunnel Server Configuration
1004
+ # Supports environment variables: \${VAR} or \${VAR:-default}
1005
+ # Create a .env file for secrets (automatically loaded)
1006
+
1007
+ version: "1.0"
1008
+
1009
+ server:
1010
+ domain: \${DOMAIN:-example.com} # Your base domain
1011
+ token: \${AUTH_TOKEN} # From .env (optional for public server)
1012
+
1013
+ tunnels: []
1014
+ `;
1015
+ const envExample = `# OpenTunnel Environment Variables
1016
+ # Copy to .env and fill in your values
1017
+
1018
+ # Server/Remote domain
1019
+ DOMAIN=example.com
1020
+ SERVER_DOMAIN=example.com
1021
+
1022
+ # Authentication token (leave empty for public server)
1023
+ AUTH_TOKEN=
1024
+ `;
1025
+ const configContent = options.server ? serverConfig : clientConfig;
1026
+ fsInit.writeFileSync(configPath, configContent);
1027
+ // Create .env.example if it doesn't exist
1028
+ const envExamplePath = pathInit.join(process.cwd(), ".env.example");
1029
+ if (!fsInit.existsSync(envExamplePath) && !fsInit.existsSync(envPath)) {
1030
+ fsInit.writeFileSync(envExamplePath, envExample);
1031
+ console.log(chalk_1.default.green(`Created .env.example`));
1032
+ }
922
1033
  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:`));
1034
+ console.log(chalk_1.default.gray(`\nEnvironment variables supported:`));
1035
+ console.log(chalk_1.default.cyan(` \${VAR} → Use value of VAR`));
1036
+ console.log(chalk_1.default.cyan(` \${VAR:-default} → Use VAR or "default" if not set`));
1037
+ console.log(chalk_1.default.gray(`\nCreate a .env file for secrets, then run:`));
924
1038
  console.log(chalk_1.default.cyan(` opentunnel up # Start all tunnels`));
925
1039
  console.log(chalk_1.default.cyan(` opentunnel up -d # Start in background`));
926
1040
  });
@@ -932,16 +1046,12 @@ program
932
1046
  .option("-f, --file <path>", "Config file path", CONFIG_FILE)
933
1047
  .option("--no-autostart", "Ignore autostart setting, start all tunnels")
934
1048
  .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 {
1049
+ const fsModule = await Promise.resolve().then(() => __importStar(require("fs")));
1050
+ const pathModule = await Promise.resolve().then(() => __importStar(require("path")));
1051
+ // Load config file (with env variable substitution)
1052
+ const configPath = pathModule.join(process.cwd(), options.file);
1053
+ const config = loadConfig(configPath);
1054
+ if (!config) {
945
1055
  console.log(chalk_1.default.red(`Config file not found: ${configPath}`));
946
1056
  console.log(chalk_1.default.gray(`Run 'opentunnel init' to create one`));
947
1057
  return;
@@ -975,16 +1085,16 @@ program
975
1085
  console.log(chalk_1.default.gray("\nAdd to your config:"));
976
1086
  console.log(chalk_1.default.cyan("\n # Run your own server:"));
977
1087
  console.log(chalk_1.default.white(" server:"));
978
- console.log(chalk_1.default.white(" domain: localhost"));
1088
+ console.log(chalk_1.default.white(" domain: example.com"));
979
1089
  console.log(chalk_1.default.cyan("\n # Or connect to a remote server:"));
980
1090
  console.log(chalk_1.default.white(" server:"));
981
- console.log(chalk_1.default.white(" remote: op.fjrg2007.com"));
1091
+ console.log(chalk_1.default.white(" remote: example.com"));
982
1092
  process.exit(1);
983
1093
  }
984
1094
  if (isClientMode) {
985
1095
  // CLIENT MODE: Connect to remote server
986
- const protocol = useHttps ? "wss" : "ws";
987
- const serverUrl = `${protocol}://${remote}/_tunnel`;
1096
+ // Build URL using basePath (default: op) -> wss://op.example.com/_tunnel
1097
+ const { url: serverUrl } = buildServerUrl(remote, !useHttps, basePath);
988
1098
  console.log(chalk_1.default.cyan(`Connecting to ${remote}...\n`));
989
1099
  console.log(chalk_1.default.cyan(`Starting ${tunnelsToStart.length} tunnel(s)...\n`));
990
1100
  try {