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 +12 -0
- package/README.md +5 -1
- package/dist/cli/commands/hosts.js +20 -1
- package/dist/cli/commands/proxy.js +15 -4
- package/dist/dashboard/api.js +39 -28
- package/dist/dashboard/index.js +1 -0
- package/dist/dashboard/ui/hosts.js +22 -1
- package/dist/dashboard/ui/system.js +17 -0
- package/dist/index.js +5 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/hosts.js +13 -1
- package/dist/proxy/https-redirect.js +40 -0
- package/dist/proxy/router.js +5 -0
- package/dist/server/index.js +3 -0
- package/package.json +1 -1
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 `
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/dashboard/api.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/dashboard/index.js
CHANGED
|
@@ -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({
|
|
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;
|
package/dist/proxy/router.js
CHANGED
|
@@ -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;
|
package/dist/server/index.js
CHANGED
|
@@ -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