locadot 2.5.0 → 2.6.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0 (2026-09-30)
4
+
5
+ ### Added
6
+ - http → https redirect. `locadot https:redirect on` (or Settings → Proxy in the dashboard, or
7
+ `PUT /api/settings {"httpsRedirect": true}`) sends plain `http://` requests for every domain and the dashboard to
8
+ `https://` with a 307. Per domain, `locadot add/update --https-redirect on|off|default` (the edit row in the
9
+ dashboard, `httpsRedirect: true|false|null` in the API) overrides it either way. Off by default; applies without a
10
+ restart. Shared links, remote access, WebSockets and the dashboard's `/api` and `/healthz` are left alone.
11
+
12
+ ### Fixed
13
+ - `locadot start --port/--https-port` no longer drops other settings saved in the config file.
14
+
3
15
  ## 2.5.0 (2026-09-29)
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -79,6 +79,7 @@ Options for `add` / `update`:
79
79
  | `-t, --target <url>` | Any http(s) upstream. A bare `host:port` means `http://host:port`. |
80
80
  | `-k, --insecure` | Don't verify the TLS certificate of an `https` target (self-signed upstreams). |
81
81
  | `--cors` / `--no-cors` | Bypass CORS for this domain. The upstream gets `Origin`/`Referer` as its own origin, preflights are answered locally, any origin may read responses (with credentials), and cookies become `SameSite=None` over HTTPS. The page's calls to other domains go through locadot automatically, with no extra mapping (see [Sites that call other domains](#sites-that-call-other-domains---cors)). |
82
+ | `--https-redirect on\|off\|default` | Send plain `http://` requests for this domain to `https://` (`on`), never (`off`), or follow `locadot https:redirect` (`default`, the initial state). `locadot update --host h --https-redirect on` changes only this, without a target. |
82
83
  | `--no-start` | Save the mapping without starting the proxy. |
83
84
 
84
85
  ### Proxy
@@ -88,6 +89,7 @@ Options for `add` / `update`:
88
89
  | `locadot start [--port 8080] [--https-port 8443]` | Start the central proxy. Default ports are 80 and 443. Ports you pass are remembered for later commands and start-at-boot, and a running proxy is moved to them. Fails loudly with the reason if it can't bind its ports. |
89
90
  | `locadot stop` | Stop the proxy. Mappings and logs are kept. |
90
91
  | `locadot restart [--port …] [--https-port …]` | Stop, then start. |
92
+ | `locadot https:redirect [on\|off]` | Redirect plain `http://` requests to `https://` (307) for every domain and the dashboard; without an argument, show the setting. Off by default. A domain's own `--https-redirect` wins. Applies at once, without a restart. Shared links, remote access, WebSockets and the dashboard's `/api` and `/healthz` are never redirected. |
91
93
  | `locadot kill` | Stop the proxy, remove all mappings and clear the logs. |
92
94
  | `locadot status [--json]` | Is it running, its PID, ports, number of hosts, CA trust, start-at-boot, dashboard URL. |
93
95
  | `locadot doctor [--host h]` | Check the proxy, ports, permissions, CA trust, the registry and every target. Exits 1 if anything fails. |
@@ -276,6 +278,7 @@ each time the proxy starts.
276
278
  | `GET /api/logs?lines=200` | | `{ lines: [...] }` |
277
279
  | `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
278
280
  | `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
281
+ | `PUT /api/hosts/:host` | `{ "httpsRedirect": true }` | Redirect this mapping's plain http to https (`true`), never (`false`), or follow the global setting (`null`). Also accepted by `POST /api/hosts` and alongside `target`. Rows show `httpsRedirect: true \| false \| null`. |
279
282
  | `PUT /api/hosts/:host` | `{ "allow": ["localhost:3000"] }` | Replace the internal addresses shared visitors may reach (normalized; `[]` clears; 400 if one is invalid). |
280
283
  | `PUT /api/hosts/:host` | `{ "protect": { "password": "…", "scopes": ["shared", "remote", "local"] } }` | Ask for a password on those paths in (at least one; `password` may be left out to keep the current one). `{ "protect": null }` removes it. Rows show `protect: { scopes, updatedAt }`, never the password. |
281
284
  | `PUT /api/hosts/:host` | `{ "tunnel": true, "domain": "app.example.com" }` | Share on a public URL: with `domain`, on that hostname through a named Cloudflare tunnel, without it on a random trycloudflare.com URL. `{ "tunnel": false }` stops sharing (400 for a bad hostname, 409 if another mapping uses it). |
@@ -284,8 +287,9 @@ each time the proxy starts.
284
287
  | `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
285
288
  | `POST /api/logs/clear` | | Clear the log. |
286
289
  | `POST /api/proxy/stop` | | Stop the proxy. Use `locadot start` to bring it back. |
287
- | `GET /api/settings` | | Running ports/bind, `logLevel`, `stateDir`, `saved`/`env` overrides, and `restartRequired`. |
290
+ | `GET /api/settings` | | Running ports/bind, `logLevel`, `stateDir`, `saved`/`env` overrides, `restartRequired` and `httpsRedirect`. |
288
291
  | `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
292
+ | `PUT /api/settings` | `{ "httpsRedirect": true }` | Turn the global http → https redirect on or off; takes effect at once. |
289
293
  | `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
290
294
  | `PUT /api/settings/ui-password` | `{ "password": "…", "current": "…" }` or `{ "enabled": false, "current": "…" }` | Set, change or remove the dashboard password (`current` is required once one is set). |
291
295
  | `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on) and `panel` (is the dashboard shared). |
@@ -48,6 +48,18 @@ const hosts_1 = __importDefault(require("../../lib/hosts"));
48
48
  const allow_1 = require("../../lib/allow");
49
49
  const urls_1 = require("../../lib/urls");
50
50
  const shared_1 = require("../shared");
51
+ const parseRedirect = (value) => {
52
+ if (value === undefined)
53
+ return undefined;
54
+ if (value === "on")
55
+ return true;
56
+ if (value === "off")
57
+ return false;
58
+ if (value === "default")
59
+ return null;
60
+ throw new localhost_1.InputError(`❌ --https-redirect takes on, off or default, got "${value}".`);
61
+ };
62
+ const redirectNote = (entry) => entry.httpsRedirect === undefined ? "" : entry.httpsRedirect ? " (https redirect)" : " (no https redirect)";
51
63
  const resolveTarget = (options) => {
52
64
  if (options.port !== undefined && options.target !== undefined) {
53
65
  throw new localhost_1.InputError("❌ Use either --port or --target, not both.");
@@ -64,6 +76,7 @@ const mapping = (options) => ({
64
76
  insecure: options.insecure,
65
77
  cors: options.cors,
66
78
  allow: options.allow,
79
+ httpsRedirect: parseRedirect(options.httpsRedirect),
67
80
  });
68
81
  const warnIfDown = async (entry) => {
69
82
  const probe = await localhost_1.default.probe(entry.target, 1500, entry.insecure);
@@ -80,6 +93,12 @@ async function add(options) {
80
93
  await warnIfDown(entry);
81
94
  }
82
95
  async function update(options) {
96
+ if (options.port === undefined && options.target === undefined && options.httpsRedirect !== undefined) {
97
+ const { host, entry } = await hosts_1.default.setHttpsRedirect({ host: options.host, httpsRedirect: parseRedirect(options.httpsRedirect) });
98
+ await (0, shared_1.ensureRunning)(options);
99
+ (0, shared_1.print)(`✅ ${(0, urls_1.urlFor)(host)}: ${entry.httpsRedirect === undefined ? "https redirect follows the global setting (locadot https:redirect)" : entry.httpsRedirect ? "plain http redirects to https" : "plain http is never redirected"}`);
100
+ return;
101
+ }
83
102
  const { host, entry } = await hosts_1.default.update(mapping(options));
84
103
  await (0, shared_1.ensureRunning)(options);
85
104
  (0, shared_1.print)(`✅ Updated ${(0, urls_1.urlFor)(host)} → ${entry.target}`);
@@ -126,7 +145,7 @@ async function list(options) {
126
145
  }
127
146
  const width = Math.max(...hosts.map(([host]) => (0, urls_1.urlFor)(host).length));
128
147
  for (const [host, entry] of hosts) {
129
- (0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}${entry.allow?.length ? ` (allow ${entry.allow.join(", ")})` : ""}${entry.protect ? ` (password: ${entry.protect.scopes.join(", ")})` : ""}`);
148
+ (0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} → ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}${entry.allow?.length ? ` (allow ${entry.allow.join(", ")})` : ""}${entry.protect ? ` (password: ${entry.protect.scopes.join(", ")})` : ""}${redirectNote(entry)}`);
130
149
  }
131
150
  (0, shared_1.print)(`☑️ Total: ${hosts.length}.`);
132
151
  }
@@ -7,14 +7,14 @@ exports.status = status;
7
7
  exports.start = start;
8
8
  exports.stop = stop;
9
9
  exports.restart = restart;
10
+ exports.httpsRedirect = httpsRedirect;
10
11
  exports.kill = kill;
11
- const fs_1 = __importDefault(require("fs"));
12
12
  const localhost_1 = require("../../lib/localhost");
13
13
  const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
14
14
  const registry_1 = __importDefault(require("../../lib/registry"));
15
15
  const urls_1 = require("../../lib/urls");
16
16
  const constants_1 = __importDefault(require("../../constants"));
17
- const file_1 = __importDefault(require("../../utils/file"));
17
+ const config_1 = __importDefault(require("../../lib/config"));
18
18
  const startup_1 = __importDefault(require("../../utils/startup"));
19
19
  const trust_1 = require("../../utils/trust");
20
20
  const shared_1 = require("../shared");
@@ -35,8 +35,7 @@ const usePorts = (options) => {
35
35
  if (httpPort === httpsPort)
36
36
  throw new localhost_1.InputError(`❌ HTTP and HTTPS can't share port ${httpPort}.`);
37
37
  Object.assign(constants_1.default.server, { httpPort, httpsPort });
38
- file_1.default.ensureDir();
39
- fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify({ httpPort, httpsPort }, null, 2) + "\n");
38
+ config_1.default.write({ httpPort, httpsPort });
40
39
  return true;
41
40
  };
42
41
  async function status(options) {
@@ -53,6 +52,7 @@ async function status(options) {
53
52
  bind: info?.bind ?? constants_1.default.server.bind,
54
53
  hosts,
55
54
  caTrusted: trusted,
55
+ httpsRedirect: config_1.default.httpsRedirect(),
56
56
  startupEnabled: startup,
57
57
  dashboard: (0, urls_1.urlFor)("localhost"),
58
58
  stateDir: constants_1.default.paths.HOME,
@@ -65,6 +65,7 @@ async function status(options) {
65
65
  (0, shared_1.print)(` Ports: http ${report.httpPort}, https ${report.httpsPort} on ${report.bind.join(", ")}`);
66
66
  (0, shared_1.print)(` Hosts: ${hosts}`);
67
67
  (0, shared_1.print)(` CA: ${trusted === undefined ? "unknown" : trusted ? "trusted" : "not trusted (run `locadot trust`)"}`);
68
+ (0, shared_1.print)(` Redirect: ${report.httpsRedirect ? "http → https" : "off"}`);
68
69
  (0, shared_1.print)(` Startup: ${startup ? "enabled" : "disabled"}`);
69
70
  (0, shared_1.print)(` Dashboard: ${report.dashboard}`);
70
71
  (0, shared_1.print)(` State dir: ${report.stateDir}`);
@@ -94,6 +95,16 @@ async function restart(options = {}) {
94
95
  const info = await proxy_control_1.default.restart();
95
96
  (0, shared_1.print)(`☑️ Proxy restarted on http ${info.httpPort}, https ${info.httpsPort} (pid ${info.pid}).`);
96
97
  }
98
+ /** The global http → https redirect; the running proxy picks the change up without a restart. */
99
+ async function httpsRedirect(value) {
100
+ if (value !== undefined && value !== "on" && value !== "off")
101
+ throw new localhost_1.InputError('❌ Use "on" or "off".');
102
+ if (value !== undefined)
103
+ config_1.default.write({ httpsRedirect: value === "on" || undefined });
104
+ const on = config_1.default.httpsRedirect();
105
+ (0, shared_1.print)(on ? `🔒 Plain http is redirected to https (${(0, urls_1.urlFor)("localhost")}).` : "☑️ Plain http is served as-is (no https redirect).");
106
+ (0, shared_1.print)(" Per domain: locadot update --host <host> --https-redirect on|off|default");
107
+ }
97
108
  async function kill() {
98
109
  await proxy_control_1.default.kill();
99
110
  (0, shared_1.print)(constants_1.default.proxyInfo.proxyClose);
@@ -59,6 +59,7 @@ const links_1 = __importStar(require("../lib/links"));
59
59
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
60
60
  const remotes_1 = __importStar(require("../lib/remotes"));
61
61
  const ui_auth_1 = __importDefault(require("../lib/ui-auth"));
62
+ const config_1 = __importDefault(require("../lib/config"));
62
63
  const request_1 = require("../proxy/request");
63
64
  const MAX_BODY = 64 * 1024;
64
65
  class ApiError extends Error {
@@ -152,6 +153,12 @@ const optionalBool = (value, name) => {
152
153
  throw new ApiError(400, `\`${name}\` must be true or false.`);
153
154
  return value;
154
155
  };
156
+ /** A mapping's https redirect: true/false, or null to follow the global setting. */
157
+ const optionalRedirect = (value) => {
158
+ if (value === undefined || value === null || typeof value === "boolean")
159
+ return value;
160
+ throw new ApiError(400, "`httpsRedirect` must be true, false or null (follow the global setting).");
161
+ };
155
162
  const requiredBool = (value, name) => {
156
163
  const result = optionalBool(value, name);
157
164
  if (result === undefined)
@@ -215,18 +222,8 @@ const sanitizeRemote = (remote) => {
215
222
  const { token, ...rest } = remote;
216
223
  return rest;
217
224
  };
218
- /** Reads CONFIG_FILE as-is (whatever `locadot start --port/--https-port` last wrote), tolerating a missing/corrupt file. */
219
- function readConfig() {
220
- try {
221
- const raw = JSON.parse(fs_1.default.readFileSync(constants_1.default.paths.CONFIG_FILE, "utf8"));
222
- return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
223
- }
224
- catch {
225
- return {};
226
- }
227
- }
228
225
  const savedPorts = () => {
229
- const config = readConfig();
226
+ const config = config_1.default.read();
230
227
  return {
231
228
  httpPort: constants_1.default.validPort(config.httpPort),
232
229
  httpsPort: constants_1.default.validPort(config.httpsPort),
@@ -252,6 +249,7 @@ function buildSettingsBody(ctx, warning) {
252
249
  saved,
253
250
  env,
254
251
  restartRequired,
252
+ httpsRedirect: config_1.default.httpsRedirect(),
255
253
  uiAuth: { enabled: ui_auth_1.default.enabled(), updatedAt: ui_auth_1.default.read()?.updatedAt },
256
254
  ...(warning ? { warning } : {}),
257
255
  };
@@ -373,23 +371,29 @@ async function route(req, url, ctx) {
373
371
  if (path === "/api/settings" && method === "PUT") {
374
372
  const newHttp = validPortInput(body.httpPort, "httpPort");
375
373
  const newHttps = validPortInput(body.httpsPort, "httpsPort");
376
- if (newHttp === undefined && newHttps === undefined)
377
- throw new ApiError(400, "Provide `httpPort` and/or `httpsPort`.");
378
- const config = readConfig();
379
- const saved = savedPorts();
380
- const finalHttp = newHttp ?? saved.httpPort ?? ctx.proxyInfo.httpPort;
381
- const finalHttps = newHttps ?? saved.httpsPort ?? ctx.proxyInfo.httpsPort;
382
- if (finalHttp === finalHttps)
383
- throw new ApiError(400, "`httpPort` and `httpsPort` can't be the same.");
384
- file_1.default.ensureDir();
385
- fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify({ ...config, httpPort: finalHttp, httpsPort: finalHttps }, null, 2) + "\n");
386
- logger_1.default.info(`⚙️ dashboard: settings saved → http ${finalHttp}, https ${finalHttps}`);
387
- const warnings = [];
388
- if (newHttp !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTP_PORT") !== undefined) {
389
- warnings.push("LOCADOT_HTTP_PORT is set and overrides this");
374
+ const httpsRedirect = optionalBool(body.httpsRedirect, "httpsRedirect");
375
+ if (newHttp === undefined && newHttps === undefined && httpsRedirect === undefined) {
376
+ throw new ApiError(400, "Provide `httpPort`, `httpsPort` and/or `httpsRedirect`.");
377
+ }
378
+ if (httpsRedirect !== undefined) {
379
+ config_1.default.write({ httpsRedirect: httpsRedirect || undefined });
380
+ logger_1.default.info(`⚙️ dashboard: http → https redirect ${httpsRedirect ? "on" : "off"}`);
390
381
  }
391
- if (newHttps !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTPS_PORT") !== undefined) {
392
- warnings.push("LOCADOT_HTTPS_PORT is set and overrides this");
382
+ const warnings = [];
383
+ if (newHttp !== undefined || newHttps !== undefined) {
384
+ const saved = savedPorts();
385
+ const finalHttp = newHttp ?? saved.httpPort ?? ctx.proxyInfo.httpPort;
386
+ const finalHttps = newHttps ?? saved.httpsPort ?? ctx.proxyInfo.httpsPort;
387
+ if (finalHttp === finalHttps)
388
+ throw new ApiError(400, "`httpPort` and `httpsPort` can't be the same.");
389
+ config_1.default.write({ httpPort: finalHttp, httpsPort: finalHttps });
390
+ logger_1.default.info(`⚙️ dashboard: settings saved → http ${finalHttp}, https ${finalHttps}`);
391
+ if (newHttp !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTP_PORT") !== undefined) {
392
+ warnings.push("LOCADOT_HTTP_PORT is set and overrides this");
393
+ }
394
+ if (newHttps !== undefined && constants_1.default.userEnvPort("LOCADOT_HTTPS_PORT") !== undefined) {
395
+ warnings.push("LOCADOT_HTTPS_PORT is set and overrides this");
396
+ }
393
397
  }
394
398
  return { status: 200, body: buildSettingsBody(ctx, warnings.length ? warnings.join("; ") : undefined) };
395
399
  }
@@ -400,6 +404,7 @@ async function route(req, url, ctx) {
400
404
  insecure: optionalBool(body.insecure, "insecure"),
401
405
  cors: optionalBool(body.cors, "cors"),
402
406
  allow: body.allow,
407
+ httpsRedirect: optionalRedirect(body.httpsRedirect),
403
408
  });
404
409
  ctx.reload();
405
410
  logger_1.default.info(`➕ dashboard: ${host} → ${entry.target}`);
@@ -429,13 +434,19 @@ async function route(req, url, ctx) {
429
434
  const scopes = updated.entry.protect?.scopes;
430
435
  logger_1.default.info(`🔒 dashboard: ${scopes ? `${updated.host} asks for a password on ${scopes.join(", ")}${protect?.password ? " (new password)" : ""}` : `${updated.host} is no longer password protected`}`);
431
436
  }
432
- if (body.target !== undefined || (tunnel === undefined && body.allow === undefined && body.protect === undefined)) {
437
+ const httpsRedirect = optionalRedirect(body.httpsRedirect);
438
+ if (httpsRedirect !== undefined && body.target === undefined) {
439
+ updated = await hosts_1.default.setHttpsRedirect({ host, httpsRedirect });
440
+ logger_1.default.info(`✏️ dashboard: ${updated.host} https redirect ${httpsRedirect === null ? "follows the global setting" : httpsRedirect ? "on" : "off"}`);
441
+ }
442
+ if (body.target !== undefined || (tunnel === undefined && body.allow === undefined && body.protect === undefined && httpsRedirect === undefined)) {
433
443
  updated = await hosts_1.default.update({
434
444
  host,
435
445
  target: body.target,
436
446
  insecure: optionalBool(body.insecure, "insecure"),
437
447
  cors: optionalBool(body.cors, "cors"),
438
448
  allow: body.allow,
449
+ httpsRedirect,
439
450
  });
440
451
  logger_1.default.info(`✏️ dashboard: ${updated.host} → ${updated.entry.target}`);
441
452
  }
@@ -95,6 +95,7 @@ async function buildHosts(ctx) {
95
95
  allow: entry.allow ?? [],
96
96
  tunnelDomain: entry.tunnelDomain ?? null,
97
97
  protect: entry.protect ? { scopes: entry.protect.scopes, updatedAt: host_auth_1.default.updatedAt(host) ?? null } : null,
98
+ httpsRedirect: entry.httpsRedirect ?? null,
98
99
  createdAt: entry.createdAt,
99
100
  updatedAt: entry.updatedAt,
100
101
  urls: hostUrls(host, httpPort, httpsPort),
@@ -210,6 +210,8 @@ exports.jsHostsTable = ` // ---------- hosts table ----------
210
210
  if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
211
211
  if (row.allow && row.allow.length) flags.push(["internal access", "Internal access: " + row.allow.join(", "), "badge-accent"]);
212
212
  if (row.protect) flags.push(["password", "Password protected on: " + protectScopeNames(row.protect.scopes), "badge-accent"]);
213
+ if (row.httpsRedirect === true) flags.push(["https only", "Plain http redirects to https, whatever the global setting", "badge-accent"]);
214
+ if (row.httpsRedirect === false) flags.push(["http allowed", "Plain http is never redirected, whatever the global setting", "badge-warn"]);
213
215
  if (flags.length) {
214
216
  td.className = "badges";
215
217
  flags.forEach(function (flag) {
@@ -995,6 +997,20 @@ exports.jsHostRows = ` function labelCells(tr, labels) {
995
997
  corsLabel.appendChild(corsInput);
996
998
  corsLabel.appendChild(document.createTextNode("cors"));
997
999
  insecureTd.appendChild(corsLabel);
1000
+ var redirectLabel = document.createElement("label");
1001
+ redirectLabel.className = "inline-check";
1002
+ var redirectInput = document.createElement("select");
1003
+ redirectInput.className = "input input-sm";
1004
+ [["", "https redirect: global"], ["on", "https redirect: on"], ["off", "https redirect: off"]].forEach(function (opt) {
1005
+ var o = document.createElement("option");
1006
+ o.value = opt[0];
1007
+ o.textContent = opt[1];
1008
+ redirectInput.appendChild(o);
1009
+ });
1010
+ redirectInput.value = row.httpsRedirect === true ? "on" : row.httpsRedirect === false ? "off" : "";
1011
+ redirectInput.setAttribute("aria-label", "https redirect for " + row.host);
1012
+ redirectLabel.appendChild(redirectInput);
1013
+ insecureTd.appendChild(redirectLabel);
998
1014
  tr.appendChild(insecureTd);
999
1015
 
1000
1016
  var hintTd = makeCell("Enter to save \\u00b7 Esc to cancel", "dim edit-hint");
@@ -1027,7 +1043,12 @@ exports.jsHostRows = ` function labelCells(tr, labels) {
1027
1043
  cancelBtn.disabled = true;
1028
1044
  apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
1029
1045
  method: "PUT",
1030
- body: JSON.stringify({ target: newTarget, insecure: !!insecureInput.checked, cors: !!corsInput.checked })
1046
+ body: JSON.stringify({
1047
+ target: newTarget,
1048
+ insecure: !!insecureInput.checked,
1049
+ cors: !!corsInput.checked,
1050
+ httpsRedirect: redirectInput.value === "on" ? true : redirectInput.value === "off" ? false : null
1051
+ })
1031
1052
  })
1032
1053
  .then(function () {
1033
1054
  editingHost = null;
@@ -254,6 +254,7 @@ exports.jsSettings = ` // ---------- settings page ----------
254
254
  var portsSave = document.getElementById("ports-save");
255
255
  var restartBtn = document.getElementById("restart-proxy-btn");
256
256
  var restartNote = document.getElementById("restart-note");
257
+ var setHttpsRedirect = document.getElementById("set-https-redirect");
257
258
  var setBind = document.getElementById("set-bind");
258
259
  var setBindNote = document.getElementById("set-bind-note");
259
260
  var setHome = document.getElementById("set-home");
@@ -281,6 +282,7 @@ exports.jsSettings = ` // ---------- settings page ----------
281
282
  if (s.env && s.env.httpsPort) notes.push("LOCADOT_HTTPS_PORT is set and overrides the saved HTTPS port.");
282
283
  portsNote.textContent = notes.join(" ");
283
284
  restartNote.hidden = !s.restartRequired;
285
+ setSwitch(setHttpsRedirect, !!s.httpsRedirect);
284
286
  setBind.textContent = (s.bind || []).join(", ");
285
287
  setBindNote.textContent = s.env && s.env.bind ? "Set by LOCADOT_BIND." : "Loopback only. Set LOCADOT_BIND to change it.";
286
288
  setHome.textContent = s.stateDir || "";
@@ -312,6 +314,19 @@ exports.jsSettings = ` // ---------- settings page ----------
312
314
  .then(function () { portsSave.disabled = false; portsSave.classList.remove("busy"); });
313
315
  });
314
316
 
317
+ setHttpsRedirect.addEventListener("click", function () {
318
+ var next = !(settingsLoaded && settingsLoaded.httpsRedirect);
319
+ setHttpsRedirect.disabled = true;
320
+ apiFetch("/api/settings", { method: "PUT", body: JSON.stringify({ httpsRedirect: next }) })
321
+ .then(function (s) {
322
+ renderSettings(s);
323
+ showToast(next ? "Plain http now redirects to https" : "https redirect off", "success");
324
+ loadHosts();
325
+ })
326
+ .catch(function (err) { apiError(err, "Couldn't change the redirect"); })
327
+ .then(function () { setHttpsRedirect.disabled = false; });
328
+ });
329
+
315
330
  restartBtn.addEventListener("click", function () {
316
331
  confirmDialog({ title: "Restart the proxy?", message: "Hosts are unavailable for a few seconds.", confirmLabel: "Restart", danger: true }).then(function (ok) {
317
332
  if (!ok) return;
@@ -573,6 +588,8 @@ exports.settingsView = ` <section class="view" data-view="settings" aria-label=
573
588
  <div id="restart-note" class="hub-warn" hidden>Saved. Restart the proxy to use the new ports.</div>
574
589
  </form>
575
590
  <div class="stat-list">
591
+ <div class="stat-row"><div class="stat-main"><div class="label">Redirect http to https</div><div class="tile-sub">Plain http on this machine goes to https, for every host and the dashboard. A host's own setting wins.</div></div>
592
+ <div class="stat-side"><button type="button" id="set-https-redirect" class="switch" role="switch" aria-checked="false" aria-label="Redirect http to https"><span class="switch-knob"></span></button></div></div>
576
593
  <div class="stat-row"><div class="stat-main"><div class="label">Listening on</div><div id="set-bind" class="tile-value mono">—</div><div id="set-bind-note" class="tile-sub"></div></div></div>
577
594
  <div class="stat-row"><div class="stat-main"><div class="label">State directory</div><div id="set-home" class="tile-value mono">—</div></div><div class="stat-side"><button type="button" id="set-home-copy" class="copy-btn">Copy</button></div></div>
578
595
  <div class="stat-row"><div class="stat-main"><div class="label">Log level</div><div id="set-loglevel" class="tile-value mono">—</div><div class="tile-sub">Set LOCADOT_LOG_LEVEL before starting to change it.</div></div></div>
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ const destinationOptions = (command) => command
38
38
  .option("-k, --insecure", "Don't verify the TLS certificate of an https target")
39
39
  .option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
40
40
  .option("--no-cors", "Turn --cors off again (update)")
41
+ .option("--https-redirect <mode>", "Send plain http to https: on, off, or default (follow `locadot https:redirect`)")
41
42
  .option("--no-start", "Only save the mapping; don't start the proxy");
42
43
  program
43
44
  .name("locadot")
@@ -106,6 +107,10 @@ const portOptions = (command) => command
106
107
  portOptions(program.command("start").description("Start the central proxy")).action(run((options) => commands_1.default.start(options)));
107
108
  program.command("stop").description("Stop the central proxy (keeps hosts and logs)").action(run(() => commands_1.default.stop()));
108
109
  portOptions(program.command("restart").description("Restart the central proxy")).action(run((options) => commands_1.default.restart(options)));
110
+ program
111
+ .command("https:redirect [onOrOff]")
112
+ .description("Redirect plain http to https for every domain and the dashboard (on/off); shows the setting without an argument")
113
+ .action(run((value) => commands_1.default.httpsRedirect(value)));
109
114
  program.command("kill").description("Stop the proxy, remove all hosts and clear logs").action(run(() => commands_1.default.kill()));
110
115
  program.command("trust").description("Install the locadot CA in the system trust store (sudo)").action(run(() => commands_1.default.trust()));
111
116
  program.command("untrust").description("Remove the locadot CA from the system trust store (sudo)").action(run(() => commands_1.default.untrust()));
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const constants_1 = __importDefault(require("../constants"));
8
+ const file_1 = __importDefault(require("../utils/file"));
9
+ let cached;
10
+ class ConfigStore {
11
+ /** The file as-is, tolerating a missing or corrupt one. */
12
+ static read() {
13
+ try {
14
+ const raw = JSON.parse(fs_1.default.readFileSync(constants_1.default.paths.CONFIG_FILE, "utf8"));
15
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ /** Merges into the file, so saving the ports keeps the other settings and the other way round. */
22
+ static write(patch) {
23
+ file_1.default.ensureDir();
24
+ const next = { ...ConfigStore.read(), ...patch };
25
+ for (const key of Object.keys(next))
26
+ if (next[key] === undefined)
27
+ delete next[key];
28
+ fs_1.default.writeFileSync(constants_1.default.paths.CONFIG_FILE, JSON.stringify(next, null, 2) + "\n");
29
+ cached = undefined;
30
+ }
31
+ /** For the request path: re-reads only when the file changed. */
32
+ static readCached() {
33
+ let mtimeMs = -1;
34
+ try {
35
+ mtimeMs = fs_1.default.statSync(constants_1.default.paths.CONFIG_FILE).mtimeMs;
36
+ }
37
+ catch { }
38
+ if (cached?.mtimeMs !== mtimeMs)
39
+ cached = { mtimeMs, config: mtimeMs < 0 ? {} : ConfigStore.read() };
40
+ return cached.config;
41
+ }
42
+ static httpsRedirect() {
43
+ return ConfigStore.readCached().httpsRedirect === true;
44
+ }
45
+ }
46
+ exports.default = ConfigStore;
package/dist/lib/hosts.js CHANGED
@@ -79,7 +79,7 @@ class HostOps {
79
79
  if (existing) {
80
80
  throw new ConflictError(`❌ ${host} is already mapped to ${existing.target}. Use \`locadot update --host ${host} ...\` instead.`);
81
81
  }
82
- registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, createdAt: now, updatedAt: now };
82
+ registry.hosts[host] = { target, insecure: input.insecure || undefined, cors: input.cors || undefined, allow: allow.length ? allow : undefined, httpsRedirect: input.httpsRedirect ?? undefined, createdAt: now, updatedAt: now };
83
83
  return registry.hosts[host];
84
84
  });
85
85
  return { host, entry };
@@ -101,12 +101,24 @@ class HostOps {
101
101
  insecure: insecure || undefined,
102
102
  cors: cors || undefined,
103
103
  allow: list?.length ? list : undefined,
104
+ httpsRedirect: input.httpsRedirect === undefined ? existing.httpsRedirect : input.httpsRedirect ?? undefined,
104
105
  updatedAt: new Date().toISOString(),
105
106
  };
106
107
  return registry.hosts[host];
107
108
  });
108
109
  return { host, entry };
109
110
  }
111
+ static async setHttpsRedirect(input) {
112
+ const host = localhost_1.default.requireHost(input.host);
113
+ const entry = await registry_1.default.mutate((registry) => {
114
+ const existing = registry.hosts[host];
115
+ if (!existing)
116
+ throw new NotFoundError(`${constants_1.default.proxyInfo.hostNotFound} (${host})`);
117
+ registry.hosts[host] = { ...existing, httpsRedirect: input.httpsRedirect ?? undefined, updatedAt: new Date().toISOString() };
118
+ return registry.hosts[host];
119
+ });
120
+ return { host, entry };
121
+ }
110
122
  /** Sharing on: `domain` shares on that hostname, none on a random trycloudflare.com URL. Sharing off keeps the hostname for next time. */
111
123
  static async setTunnel(input) {
112
124
  const host = localhost_1.default.requireHost(input.host);
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.redirectToHttps = exports.wantsHttps = void 0;
7
+ const net_1 = __importDefault(require("net"));
8
+ const constants_1 = __importDefault(require("../constants"));
9
+ const urls_1 = require("../lib/urls");
10
+ const request_1 = require("./request");
11
+ /** The CLI and scripts talk to the dashboard's API and health check over plain http. */
12
+ const dashboardExempt = (url) => {
13
+ const path = url.split("?")[0];
14
+ return path === "/healthz" || path === "/api" || path.startsWith("/api/");
15
+ };
16
+ /** Whether this request should be sent to https: the mapping's own choice, else the global setting. */
17
+ const wantsHttps = (req, ctx, host, hub) => {
18
+ // Tunnels, the hub and peers reach the http port on purpose and are already https on their public side.
19
+ if ((0, request_1.isTls)(req) || (hub && hub.kind !== "none") || (0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req))
20
+ return false;
21
+ if (req.headers["cf-ray"] || req.headers["cf-connecting-ip"] || req.headers["cdn-loop"])
22
+ return false;
23
+ const global = ctx.httpsRedirect?.() === true;
24
+ if (constants_1.default.dashboardHosts.includes(host))
25
+ return global && !dashboardExempt(req.url || "/");
26
+ const entry = ctx.lookup(host);
27
+ if (entry)
28
+ return entry.httpsRedirect ?? global;
29
+ return global && Boolean(ctx.localFor?.(host));
30
+ };
31
+ exports.wantsHttps = wantsHttps;
32
+ /** 307 keeps the method and body, and isn't cached, so turning the setting off takes effect straight away. */
33
+ const redirectToHttps = (req, res, ctx) => {
34
+ const name = (0, request_1.hostOf)(req);
35
+ const origin = (0, urls_1.formatUrl)(net_1.default.isIPv6(name) ? `[${name}]` : name, true, ctx.httpsPort?.() ?? constants_1.default.server.httpsPort);
36
+ const path = req.url?.startsWith("/") ? req.url : "/";
37
+ res.writeHead(307, { Location: origin + path, "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" });
38
+ res.end(`Redirecting to ${origin}${path}\n`);
39
+ };
40
+ exports.redirectToHttps = redirectToHttps;
@@ -20,6 +20,7 @@ const passthrough_1 = require("./passthrough");
20
20
  const request_1 = require("./request");
21
21
  const stats_1 = require("./stats");
22
22
  const protect_1 = require("./protect");
23
+ const https_redirect_1 = require("./https-redirect");
23
24
  // Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
24
25
  const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
25
26
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
@@ -123,6 +124,10 @@ function handleRequest(req, res, ctx) {
123
124
  }
124
125
  const host = resolveHost(req, ctx, hub);
125
126
  try {
127
+ if ((0, https_redirect_1.wantsHttps)(req, ctx, host, hub)) {
128
+ (0, https_redirect_1.redirectToHttps)(req, res, ctx);
129
+ return;
130
+ }
126
131
  if (constants_1.default.dashboardHosts.includes(host)) {
127
132
  ctx.dashboard(req, res);
128
133
  return;
@@ -17,6 +17,7 @@ const hub_tunnel_1 = require("../proxy/hub-tunnel");
17
17
  const hub_1 = require("../proxy/hub");
18
18
  const remote_1 = require("../proxy/remote");
19
19
  const hub_config_1 = __importDefault(require("../lib/hub-config"));
20
+ const config_1 = __importDefault(require("../lib/config"));
20
21
  const file_1 = __importDefault(require("../utils/file"));
21
22
  const logger_1 = __importDefault(require("../utils/logger"));
22
23
  const certs_1 = require("../utils/certs");
@@ -68,6 +69,8 @@ async function startCentralProxy() {
68
69
  lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
69
70
  tunnelFor: (host) => tunnels.hostFor(host),
70
71
  ownPorts: () => [info.httpPort, info.httpsPort],
72
+ httpsRedirect: () => config_1.default.httpsRedirect(),
73
+ httpsPort: () => info.httpsPort,
71
74
  remoteFor: remote_1.remoteFor,
72
75
  localFor: remote_1.localFor,
73
76
  remoteHosts: (name) => Object.entries(registry.get().hosts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",