locadot 2.4.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,26 @@
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
+
15
+ ## 2.5.0 (2026-09-29)
16
+
17
+ ### Changed
18
+ - Dashboard redesign. Pages are grouped: Hosts has All/Shared tabs (Public sharing moved in), Remote has This
19
+ machine/Connected machines tabs, Settings has General/Security/Preferences/Developer tabs. Old links redirect.
20
+ - Hosts: Add host opens a dialog; each row has one main action plus a ⋯ menu (edit, share, access, password, remove).
21
+ - Phones and tablets: bottom tab bar, bottom-sheet menus and dialogs, card layouts, icon rail below 1100px; subtle
22
+ enter/exit animations that respect reduced motion. Logs get level colours, copy and a sticky toolbar.
23
+
3
24
  ## 2.4.0 (2026-09-29)
4
25
 
5
26
  ### Added
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),