locadot 2.1.0-beta.0 → 2.1.0-beta.2
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 +36 -0
- package/README.md +34 -5
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/remote.js +42 -3
- package/dist/cli/commands/tunnel.js +2 -1
- package/dist/cli/commands/ui.js +94 -0
- package/dist/constants/index.js +19 -0
- package/dist/constants/template.js +25 -1
- package/dist/dashboard/api.js +147 -8
- package/dist/dashboard/index.js +72 -1
- package/dist/dashboard/login.js +223 -0
- package/dist/dashboard/page.js +1046 -196
- package/dist/index.js +16 -0
- package/dist/lib/hub-config.js +18 -0
- package/dist/lib/proxy-control.js +2 -1
- package/dist/lib/remotes.js +103 -5
- package/dist/lib/ui-auth.js +121 -0
- package/dist/lib/words.js +32 -0
- package/dist/proxy/hub.js +48 -7
- package/dist/proxy/remote.js +47 -4
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +73 -2
- package/dist/server/index.js +9 -1
- package/dist/utils/startup.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Dashboard pages: every sidebar item now opens its own page (`#/overview`, `#/hosts`, `#/sharing`, `#/remote`, `#/machines`,
|
|
7
|
+
`#/logs`, `#/settings`); they are bookmarkable and work with back/forward.
|
|
8
|
+
- **Overview**: tiles for hosts, down targets, requests, public shares, remote access and connected machines, plus host health, proxy checks and recent activity.
|
|
9
|
+
- **Public sharing**: a per-host share list.
|
|
10
|
+
- **Logs**: filter, level, line count, live mode and download.
|
|
11
|
+
- Settings page:
|
|
12
|
+
- HTTP/HTTPS ports with a **Restart proxy** button, which follows the dashboard to the new port.
|
|
13
|
+
- Bind address, state directory, log level and the API token (reveal/copy).
|
|
14
|
+
- Dashboard preferences: theme, refresh interval, start page, collapsed sidebar, confirm-before-remove.
|
|
15
|
+
- New-host defaults (insecure TLS, CORS) and a danger zone.
|
|
16
|
+
- API: `GET/PUT /api/settings` and `POST /api/proxy/restart`.
|
|
17
|
+
- Remote localhost for admins: an admin peer reaches any port on the sender's machine at `<port>.<domain>.localhost`, where
|
|
18
|
+
`<domain>.localhost` is a landing page listing that machine's shared hosts. The domain is chosen with
|
|
19
|
+
`connect --domain` or in the dashboard, and otherwise defaults to two random words (e.g. `brave-otter`). Change it with
|
|
20
|
+
`remote:domain`. The sender checks the role on every request, never exposes its own proxy ports, and can turn this off
|
|
21
|
+
with `hub:localhost off` or the dashboard switch.
|
|
22
|
+
|
|
23
|
+
- Dashboard password (UI only): `locadot ui:password` sets or resets it from the terminal (scrypt hash, 0600 file), `--off` removes it,
|
|
24
|
+
and **Settings → Dashboard password** changes it in the UI. When it is set, the dashboard shows a sign-in page and never serves the API token
|
|
25
|
+
to a signed-out visitor. It uses a 7-day HttpOnly SameSite=Strict session, rate-limits wrong passwords, and adds a Sign out button to the sidebar.
|
|
26
|
+
Mapped hosts are not affected, and neither are scripts or the CLI that send `X-Locadot-Token`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- `<domain>.localhost` now opens the sender's own dashboard for an admin peer, with full control (hosts, sharing, peers,
|
|
30
|
+
settings), instead of a landing page; no port needed. The port picker moved to `<domain>.localhost/_locadot/ports`.
|
|
31
|
+
The page never carries the sender's API token, the API only accepts calls from that `<domain>.localhost` origin, the sender
|
|
32
|
+
checks the admin role on every request, `hub:localhost off` turns it off, and a dashboard password still asks for sign-in.
|
|
33
|
+
Senders must run this version too; an older sender answers `<domain>.localhost` with a 404.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
|
|
37
|
+
(`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
|
|
38
|
+
|
|
3
39
|
## 2.1.0-beta.0 (2026-09-27)
|
|
4
40
|
|
|
5
41
|
### Added
|
package/README.md
CHANGED
|
@@ -111,18 +111,24 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
111
111
|
| `locadot hub:setup --domain dev.example.com` | Sender: publish through your own named tunnel on your Cloudflare domain (runs `cloudflared` login, creates the tunnel and routes DNS). The address stays the same. |
|
|
112
112
|
| `locadot hub:quick` | Sender: publish on a trycloudflare URL. No account is needed, but **the URL changes if the tunnel restarts**, so receivers have to run `remote:url`. |
|
|
113
113
|
| `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
|
|
114
|
+
| `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from opening this machine's dashboard and any port on its localhost. |
|
|
114
115
|
| `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
|
|
115
116
|
| `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
|
|
116
|
-
| `locadot connect "<pairing string>" [--name alice]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. |
|
|
117
|
-
| `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections, pull new mappings, or remove the connection and its names. |
|
|
117
|
+
| `locadot connect "<pairing string>" [--name alice] [--domain dev]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. Admins get a local domain that opens the sender's dashboard and localhost (see below); `--domain` picks it, otherwise it's a random `adjective-noun`. |
|
|
118
|
+
| `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings, or remove the connection and its names. |
|
|
119
|
+
| `locadot remote:domain <name> [domain] [--off]` | Receiver, admin only: set the local domain for a remote's localhost, randomize it (no domain given), or remove it (`--off`). |
|
|
118
120
|
| `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
|
|
119
121
|
| `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
|
|
120
122
|
| `locadot remote:url <name> <url>` | Receiver: point a connection at the sender's new URL. |
|
|
121
123
|
|
|
122
124
|
Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
|
|
123
|
-
mappings on the sender. **admin** can also delete them and change sharing
|
|
125
|
+
mappings on the sender. **admin** can also delete them and change sharing, and (unless the sender turned it off with
|
|
126
|
+
`hub:localhost off`) open the sender's own dashboard at `https://<domain>.localhost` and manage it as if they were sitting at it,
|
|
127
|
+
and reach any port on the sender's localhost at `https://<port>.<domain>.localhost` (`sender's localhost:<port>`). The port
|
|
128
|
+
picker is at `https://<domain>.localhost/_locadot/ports`. The admin's browser never gets the sender's API token, so revoking or
|
|
129
|
+
demoting the peer cuts off its dashboard access at once; a dashboard password on the sender still applies. Traffic goes receiver → Cloudflare → sender →
|
|
124
130
|
target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
|
|
125
|
-
of the tokens are stored, and failed attempts are rate limited.
|
|
131
|
+
of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
|
|
126
132
|
The dashboard has **Remote access** and **Connected machines** cards for all of this.
|
|
127
133
|
|
|
128
134
|
### Certificates
|
|
@@ -173,9 +179,23 @@ Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit o
|
|
|
173
179
|
If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
|
|
174
180
|
`locadot startup:enable`, and so on.
|
|
175
181
|
|
|
182
|
+
### Dashboard password
|
|
183
|
+
|
|
184
|
+
The dashboard is open to anyone on this machine by default. To require a password for the UI only:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
locadot ui:password # set or reset it (prompts twice; stored as an scrypt hash, 0600)
|
|
188
|
+
locadot ui:password --off # remove it
|
|
189
|
+
echo "$PW" | locadot ui:password --stdin
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
You can also set, change or remove it under **Settings → Dashboard password** (changing or removing it there needs the current one).
|
|
193
|
+
Only the dashboard is protected. Your mapped hosts work as before, and so do scripts and the CLI that send `X-Locadot-Token`.
|
|
194
|
+
Sessions last 7 days, and changing the password signs everyone out. If you forget it, run `locadot ui:password` again in a terminal.
|
|
195
|
+
|
|
176
196
|
### JSON API (for scripts and AI agents)
|
|
177
197
|
|
|
178
|
-
Everything the page does is a plain JSON API on the dashboard origin. The read routes are open to local callers. Every mutating route needs the
|
|
198
|
+
Everything the page does is a plain JSON API on the dashboard origin. The read routes are open to local callers, unless a [dashboard password](#dashboard-password) is set; then they need a signed-in session or the token. Every mutating route needs the
|
|
179
199
|
`X-Locadot-Token` header. Get the token with `locadot token`. It lives in `<state dir>/.locadot-token` (mode 0600) and is regenerated
|
|
180
200
|
each time the proxy starts.
|
|
181
201
|
|
|
@@ -191,6 +211,15 @@ each time the proxy starts.
|
|
|
191
211
|
| `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
|
|
192
212
|
| `POST /api/logs/clear` | | Clear the log. |
|
|
193
213
|
| `POST /api/proxy/stop` | | Stop the proxy. Use `locadot start` to bring it back. |
|
|
214
|
+
| `GET /api/settings` | | Running ports/bind, `logLevel`, `stateDir`, `saved`/`env` overrides, and `restartRequired`. |
|
|
215
|
+
| `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
|
|
216
|
+
| `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
|
|
217
|
+
| `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). |
|
|
218
|
+
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on). |
|
|
219
|
+
| `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
|
|
220
|
+
| `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
|
|
221
|
+
| `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
|
|
222
|
+
| `PUT /api/remotes/:name` | `{ "domain": "dev" }` or `{ "domain": "random" }` or `{ "domain": null }` | Receiver: set, randomize or remove a remote's local domain (400 invalid, 409 clash, 404 unknown). |
|
|
194
223
|
|
|
195
224
|
Errors are `{ "error": "...", "hint": "<CLI command>" }` with a 4xx/5xx status.
|
|
196
225
|
|
|
@@ -39,6 +39,7 @@ const files = __importStar(require("./files"));
|
|
|
39
39
|
const system = __importStar(require("./system"));
|
|
40
40
|
const tunnel = __importStar(require("./tunnel"));
|
|
41
41
|
const remote = __importStar(require("./remote"));
|
|
42
|
+
const ui = __importStar(require("./ui"));
|
|
42
43
|
const doctor_1 = require("./doctor");
|
|
43
|
-
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, doctor: doctor_1.doctor };
|
|
44
|
+
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, ...ui, doctor: doctor_1.doctor };
|
|
44
45
|
exports.default = Commands;
|
|
@@ -8,6 +8,7 @@ exports.hubSetup = hubSetup;
|
|
|
8
8
|
exports.hubQuick = hubQuick;
|
|
9
9
|
exports.hubManual = hubManual;
|
|
10
10
|
exports.hubOff = hubOff;
|
|
11
|
+
exports.hubLocalhost = hubLocalhost;
|
|
11
12
|
exports.share = share;
|
|
12
13
|
exports.peers = peers;
|
|
13
14
|
exports.peersRole = peersRole;
|
|
@@ -15,6 +16,7 @@ exports.peersRevoke = peersRevoke;
|
|
|
15
16
|
exports.connect = connect;
|
|
16
17
|
exports.remotes = remotes;
|
|
17
18
|
exports.remoteSync = remoteSync;
|
|
19
|
+
exports.remoteDomain = remoteDomain;
|
|
18
20
|
exports.remoteAlias = remoteAlias;
|
|
19
21
|
exports.remoteUrl = remoteUrl;
|
|
20
22
|
exports.remoteAdd = remoteAdd;
|
|
@@ -59,6 +61,7 @@ const apiToken = () => {
|
|
|
59
61
|
/** Talks to the running proxy's local dashboard API on its HTTP port. */
|
|
60
62
|
const dashboardRequest = (method, path, options = {}) => new Promise((resolve, reject) => {
|
|
61
63
|
const data = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
64
|
+
const token = file_1.default.read("API_TOKEN")?.trim();
|
|
62
65
|
const req = http_1.default.request({
|
|
63
66
|
host: "127.0.0.1",
|
|
64
67
|
port: constants_1.default.server.httpPort,
|
|
@@ -66,6 +69,8 @@ const dashboardRequest = (method, path, options = {}) => new Promise((resolve, r
|
|
|
66
69
|
method,
|
|
67
70
|
headers: {
|
|
68
71
|
Host: "localhost",
|
|
72
|
+
// Reads need it too once the dashboard has a password.
|
|
73
|
+
...(token ? { "X-Locadot-Token": token } : {}),
|
|
69
74
|
...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}),
|
|
70
75
|
...options.headers,
|
|
71
76
|
},
|
|
@@ -153,6 +158,20 @@ async function hubOff() {
|
|
|
153
158
|
hub_config_1.default.clear();
|
|
154
159
|
(0, shared_1.print)("🔒 Remote access turned off.");
|
|
155
160
|
}
|
|
161
|
+
async function hubLocalhost(value) {
|
|
162
|
+
if (value !== "on" && value !== "off")
|
|
163
|
+
throw new localhost_1.InputError('❌ Use "on" or "off".');
|
|
164
|
+
requireRunning();
|
|
165
|
+
const { status, json } = await dashboardRequest("PUT", "/api/hub/localhost", {
|
|
166
|
+
headers: { "X-Locadot-Token": apiToken() },
|
|
167
|
+
body: { enabled: value === "on" },
|
|
168
|
+
});
|
|
169
|
+
if (status === 400)
|
|
170
|
+
throw new localhost_1.InputError(`❌ ${json?.error || "Set up the hub first: locadot hub:setup / hub:quick / hub:manual."}`);
|
|
171
|
+
if (status !== 200)
|
|
172
|
+
throw new localhost_1.InputError(`❌ Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
173
|
+
(0, shared_1.print)(json.localhost ? "🖥️ Admin peers can now reach this machine's localhost." : "🔒 Admin peers can no longer reach this machine's localhost.");
|
|
174
|
+
}
|
|
156
175
|
async function share(options) {
|
|
157
176
|
const role = requireRole(options.role);
|
|
158
177
|
if (options.hosts && role !== "viewer") {
|
|
@@ -200,13 +219,22 @@ async function peersRevoke(id) {
|
|
|
200
219
|
links_1.default.revoke(id);
|
|
201
220
|
(0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
|
|
202
221
|
}
|
|
222
|
+
/** After connect/sync/remote:domain: the sender's dashboard and localhost, reachable when this remote has a domain and we're admin. */
|
|
223
|
+
const printDomain = (remote) => {
|
|
224
|
+
if (remote.domain && remote.role === "admin") {
|
|
225
|
+
(0, shared_1.print)(`🖥️ ${remote.sender.hostname} dashboard: ${(0, urls_1.urlFor)(`${remote.domain}.localhost`)} (any port: ${(0, urls_1.urlFor)(`<port>.${remote.domain}.localhost`)})`);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
203
228
|
async function connect(value, options) {
|
|
204
|
-
const { remote, mapped, skipped } = await remotes_1.default.connect(value, { name: options.name });
|
|
229
|
+
const { remote, mapped, skipped, note } = await remotes_1.default.connect(value, { name: options.name, domain: options.domain });
|
|
205
230
|
(0, shared_1.print)(`✅ Connected to ${remote.name} (${remote.sender.hostname}) as ${remote.role}.`);
|
|
206
231
|
for (const m of mapped)
|
|
207
232
|
(0, shared_1.print)(` ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
|
|
208
233
|
for (const reason of skipped)
|
|
209
234
|
(0, shared_1.print)(` ⚠️ skipped: ${reason}`);
|
|
235
|
+
if (note)
|
|
236
|
+
(0, shared_1.print)(` ⚠️ ${note}`);
|
|
237
|
+
printDomain(remote);
|
|
210
238
|
}
|
|
211
239
|
async function remotes(options) {
|
|
212
240
|
const list = remotes_1.default.list();
|
|
@@ -223,16 +251,27 @@ async function remotes(options) {
|
|
|
223
251
|
return;
|
|
224
252
|
}
|
|
225
253
|
for (const remote of list) {
|
|
226
|
-
(0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})`);
|
|
254
|
+
(0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})${remote.domain ? ` domain: ${remote.domain}.localhost` : ""}`);
|
|
227
255
|
for (const alias of aliasesFor(remote.name))
|
|
228
256
|
(0, shared_1.print)(` ${(0, urls_1.urlFor)(alias.local)} → ${alias.host}`);
|
|
229
257
|
}
|
|
230
258
|
}
|
|
231
259
|
async function remoteSync(name) {
|
|
232
|
-
const { mapped } = await remotes_1.default.sync(name);
|
|
260
|
+
const { remote, mapped } = await remotes_1.default.sync(name);
|
|
233
261
|
(0, shared_1.print)(`✅ ${name} synced.`);
|
|
234
262
|
for (const m of mapped)
|
|
235
263
|
(0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
|
|
264
|
+
printDomain(remote);
|
|
265
|
+
}
|
|
266
|
+
async function remoteDomain(name, domain, options) {
|
|
267
|
+
if (options.off) {
|
|
268
|
+
remotes_1.default.setDomain(name, null);
|
|
269
|
+
(0, shared_1.print)(`🗑️ Removed the local domain for ${name}.`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const remote = remotes_1.default.setDomain(name, domain || "random");
|
|
273
|
+
(0, shared_1.print)(`✅ ${name} domain: ${remote.domain}.localhost.`);
|
|
274
|
+
printDomain(remote);
|
|
236
275
|
}
|
|
237
276
|
async function remoteAlias(name, remoteHost, localHost) {
|
|
238
277
|
await remotes_1.default.alias(name, remoteHost, localHost);
|
|
@@ -43,11 +43,12 @@ const localhost_1 = __importStar(require("../../lib/localhost"));
|
|
|
43
43
|
const hosts_1 = __importDefault(require("../../lib/hosts"));
|
|
44
44
|
const constants_1 = __importDefault(require("../../constants"));
|
|
45
45
|
const tunnel_1 = require("../../proxy/tunnel");
|
|
46
|
+
const file_1 = __importDefault(require("../../utils/file"));
|
|
46
47
|
const shared_1 = require("../shared");
|
|
47
48
|
const TUNNEL_TIMEOUT_MS = 45000;
|
|
48
49
|
/** Tunnel state lives in the proxy process; ask it over the local read-only API. */
|
|
49
50
|
const tunnelRows = () => new Promise((resolve, reject) => {
|
|
50
|
-
const req = http_1.default.get({ host: "127.0.0.1", port: constants_1.default.server.httpPort, path: "/api/hosts", headers: { Host: "localhost" }, timeout: 5000 }, (res) => {
|
|
51
|
+
const req = http_1.default.get({ host: "127.0.0.1", port: constants_1.default.server.httpPort, path: "/api/hosts", headers: { Host: "localhost", "X-Locadot-Token": file_1.default.read("API_TOKEN")?.trim() || "" }, timeout: 5000 }, (res) => {
|
|
51
52
|
let body = "";
|
|
52
53
|
res.on("data", (chunk) => (body += chunk));
|
|
53
54
|
res.on("end", () => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.uiPassword = uiPassword;
|
|
40
|
+
const readline_1 = __importDefault(require("readline"));
|
|
41
|
+
const localhost_1 = require("../../lib/localhost");
|
|
42
|
+
const ui_auth_1 = __importStar(require("../../lib/ui-auth"));
|
|
43
|
+
const shared_1 = require("../shared");
|
|
44
|
+
const readStdin = () => new Promise((resolve, reject) => {
|
|
45
|
+
let data = "";
|
|
46
|
+
process.stdin.setEncoding("utf8");
|
|
47
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
48
|
+
process.stdin.on("end", () => resolve(data.split(/\r?\n/)[0]));
|
|
49
|
+
process.stdin.on("error", reject);
|
|
50
|
+
});
|
|
51
|
+
/** Reads a line without echoing it. */
|
|
52
|
+
const askHidden = (question) => new Promise((resolve) => {
|
|
53
|
+
const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
54
|
+
const write = rl._writeToOutput.bind(rl);
|
|
55
|
+
let prompted = false;
|
|
56
|
+
rl._writeToOutput = (s) => {
|
|
57
|
+
if (!prompted) {
|
|
58
|
+
prompted = true;
|
|
59
|
+
write(s);
|
|
60
|
+
}
|
|
61
|
+
else if (s.includes("\n"))
|
|
62
|
+
write("\n");
|
|
63
|
+
};
|
|
64
|
+
rl.question(question, (answer) => {
|
|
65
|
+
rl.close();
|
|
66
|
+
resolve(answer);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
/** Sets, resets or removes the dashboard password. Works whether or not the proxy is running. */
|
|
70
|
+
async function uiPassword(options) {
|
|
71
|
+
if (options.off) {
|
|
72
|
+
const was = ui_auth_1.default.enabled();
|
|
73
|
+
ui_auth_1.default.clear();
|
|
74
|
+
(0, shared_1.print)(was ? "🔓 Dashboard password removed. The dashboard is open to anyone on this machine again." : "ℹ️ No dashboard password was set.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let password;
|
|
78
|
+
if (options.stdin || !process.stdin.isTTY) {
|
|
79
|
+
password = await readStdin();
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
password = await askHidden("New dashboard password: ");
|
|
83
|
+
const again = await askHidden("Repeat it: ");
|
|
84
|
+
if (password !== again)
|
|
85
|
+
throw new localhost_1.InputError("❌ The passwords don't match.");
|
|
86
|
+
}
|
|
87
|
+
if (password.length < ui_auth_1.MIN_PASSWORD)
|
|
88
|
+
throw new localhost_1.InputError(`❌ The password must be at least ${ui_auth_1.MIN_PASSWORD} characters.`);
|
|
89
|
+
const was = ui_auth_1.default.enabled();
|
|
90
|
+
ui_auth_1.default.set(password);
|
|
91
|
+
(0, shared_1.print)(was
|
|
92
|
+
? "🔑 Dashboard password reset. Everyone signed in has been signed out."
|
|
93
|
+
: "🔒 The dashboard now asks for this password. Scripts using the API token (X-Locadot-Token) are unaffected.");
|
|
94
|
+
}
|
package/dist/constants/index.js
CHANGED
|
@@ -26,6 +26,21 @@ const savedPorts = () => {
|
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
const envPort = (name, saved, fallback) => validPort(process.env[name]) ?? validPort(saved) ?? fallback;
|
|
29
|
+
const PORT_ENV = ["LOCADOT_HTTP_PORT", "LOCADOT_HTTPS_PORT"];
|
|
30
|
+
// The CLI pins both port vars on every proxy it spawns, so the proxy can't tell them from a
|
|
31
|
+
// user's override. LOCADOT_USER_PORTS carries which ones the user actually set ("none" if neither).
|
|
32
|
+
const userPortEnv = () => {
|
|
33
|
+
if (process.env.LOCADOT_USER_PORTS)
|
|
34
|
+
return process.env.LOCADOT_USER_PORTS;
|
|
35
|
+
return PORT_ENV.filter((name) => validPort(process.env[name]) !== undefined).join(",") || "none";
|
|
36
|
+
};
|
|
37
|
+
/** The port var's value only if the user set it (not the CLI's pinned copy). */
|
|
38
|
+
const userEnvPort = (name) => {
|
|
39
|
+
const marker = process.env.LOCADOT_USER_PORTS;
|
|
40
|
+
if (marker && !marker.split(",").includes(name))
|
|
41
|
+
return undefined;
|
|
42
|
+
return validPort(process.env[name]);
|
|
43
|
+
};
|
|
29
44
|
class Constants {
|
|
30
45
|
}
|
|
31
46
|
Constants.paths = {
|
|
@@ -38,12 +53,16 @@ Constants.paths = {
|
|
|
38
53
|
// Secret for the dashboard's mutating API; rotated on every proxy start.
|
|
39
54
|
API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
|
|
40
55
|
CONFIG_FILE,
|
|
56
|
+
// Optional dashboard password: scrypt hash + session signing secret (0600).
|
|
57
|
+
UI_AUTH_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-ui-auth.json"),
|
|
41
58
|
// Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
|
|
42
59
|
HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
|
|
43
60
|
LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
|
|
44
61
|
REMOTES_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-remotes.json"),
|
|
45
62
|
};
|
|
46
63
|
Constants.validPort = validPort;
|
|
64
|
+
Constants.userPortEnv = userPortEnv;
|
|
65
|
+
Constants.userEnvPort = userEnvPort;
|
|
47
66
|
Constants.server = {
|
|
48
67
|
httpPort: envPort("LOCADOT_HTTP_PORT", savedPorts().httpPort, 80),
|
|
49
68
|
httpsPort: envPort("LOCADOT_HTTPS_PORT", savedPorts().httpsPort, 443),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.upstreamDown = exports.proxyNotFound = void 0;
|
|
3
|
+
exports.upstreamDown = exports.remoteLocalhost = exports.proxyNotFound = void 0;
|
|
4
4
|
const escape_1 = require("../dashboard/escape");
|
|
5
5
|
const page = (title, body, dashboardUrl) => `<!DOCTYPE html>
|
|
6
6
|
<html lang="en">
|
|
@@ -62,3 +62,27 @@ const upstreamDown = (host, target, reason, dashboardUrl) => page("locadot: upst
|
|
|
62
62
|
<code>${(0, escape_1.escapeHtml)(host)}</code> → <code>${(0, escape_1.escapeHtml)(target)}</code><br/><br/>
|
|
63
63
|
${(0, escape_1.escapeHtml)(reason)}. Is the app running?`, dashboardUrl);
|
|
64
64
|
exports.upstreamDown = upstreamDown;
|
|
65
|
+
/** Receiver side: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
|
|
66
|
+
const remoteLocalhost = (opts) => page(`locadot: ${opts.sender} localhost`, opts.allowed
|
|
67
|
+
? `<strong style="color: #7ee2a8;">${(0, escape_1.escapeHtml)(opts.sender)}</strong>: localhost<br/><br/>
|
|
68
|
+
Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.<br/>
|
|
69
|
+
Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.<br/><br/>
|
|
70
|
+
<form id="go" style="display:flex;gap:8px">
|
|
71
|
+
<input id="port" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required
|
|
72
|
+
style="flex:1;padding:6px 10px;border-radius:4px;border:1px solid #444;background:#2e2e2e;color:#fff" />
|
|
73
|
+
<button style="padding:6px 14px;border-radius:4px;border:0;background:#61dafb;color:#000;font-weight:bold">Open</button>
|
|
74
|
+
</form>
|
|
75
|
+
<script>
|
|
76
|
+
document.getElementById("go").addEventListener("submit", function (e) {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
var port = document.getElementById("port").value.trim();
|
|
79
|
+
if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
82
|
+
${opts.hosts.length
|
|
83
|
+
? `<br/>Shared hosts:<br/>${opts.hosts.map((h) => `<a href="${(0, escape_1.escapeHtml)(h.url)}">${(0, escape_1.escapeHtml)(h.local)}</a>`).join("<br/>")}`
|
|
84
|
+
: ""}`
|
|
85
|
+
: `<strong style="color: #ff6b6b;">Not available:</strong>
|
|
86
|
+
<code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
|
|
87
|
+
admin there and it allows localhost access. Ask its owner, then run <code>locadot remote:sync ${(0, escape_1.escapeHtml)(opts.name)}</code>.`, opts.dashboardUrl);
|
|
88
|
+
exports.remoteLocalhost = remoteLocalhost;
|