locadot 2.6.1 → 2.7.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 +18 -0
- package/README.md +10 -1
- package/dist/cli/commands/remote.js +52 -2
- package/dist/constants/template.js +166 -65
- package/dist/dashboard/api.js +3 -1
- package/dist/dashboard/index.js +4 -2
- package/dist/dashboard/login.js +5 -4
- package/dist/dashboard/logo.js +17 -0
- package/dist/dashboard/page.js +1 -0
- package/dist/dashboard/ui/remote.js +61 -2
- package/dist/dashboard/ui/theme.js +10 -17
- package/dist/index.js +6 -0
- package/dist/lib/links.js +12 -0
- package/dist/lib/remotes.js +12 -3
- package/dist/proxy/auto-sync.js +59 -0
- package/dist/proxy/router.js +29 -2
- package/dist/server/index.js +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.7.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Change which hosts a viewer sees after pairing: ⋯ → **Choose hosts…** on a viewer under Remote → Peers, `locadot
|
|
7
|
+
peers:hosts <id> [hosts] [--add …] [--remove …]`, or `PUT /api/peers/:id {"hosts": [...]}`. Peers now show the hosts
|
|
8
|
+
they see.
|
|
9
|
+
- Opening an unmapped `*.localhost` on a connected machine syncs with the machines it's connected to first, then
|
|
10
|
+
carries on to the page if the name turned up. Otherwise the "not mapped" page says what each sync found, or its
|
|
11
|
+
error, and has a **Sync again** link.
|
|
12
|
+
- A locadot logo (a padlock whose keyhole is the "dot"). The dashboard and sign-in page use it as their favicon
|
|
13
|
+
(`/favicon.svg`; `/favicon.ico` serves the same) and in the sidebar.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Syncing a connection now also removes mappings (and aliases) the sender no longer shares with you, instead of
|
|
17
|
+
leaving them behind. `remote:sync` lists them.
|
|
18
|
+
- The proxy's own pages ("not mapped", "upstream unreachable" and a remote's localhost page) have the dashboard's
|
|
19
|
+
look: light and dark themes, the logo, buttons for the dashboard and docs, and copyable `locadot add` commands.
|
|
20
|
+
|
|
3
21
|
## 2.6.1 (2026-09-30)
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/logo.svg" alt="" width="72" align="right">
|
|
2
|
+
|
|
1
3
|
# locadot 🔐
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/locadot)
|
|
@@ -152,8 +154,9 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
152
154
|
| `locadot hub:panel <on\|off>` | Sender: serve this dashboard to any browser at the hub's public URL, behind the [dashboard password](#dashboard-password). Off by default. See [Sharing the dashboard](#sharing-the-dashboard-in-a-browser). |
|
|
153
155
|
| `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. |
|
|
154
156
|
| `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
|
|
157
|
+
| `locadot peers:hosts <id> [a.localhost,b.localhost] [--add …] [--remove …]` | Sender: show or change the mappings a viewer sees after pairing. A list replaces them; `--add`/`--remove` edit them. Taking one away cuts it off at once; the viewer gets the new list on its next sync. Also under ⋯ → **Choose hosts…** on a viewer in the dashboard. |
|
|
155
158
|
| `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`. |
|
|
156
|
-
| `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings, or remove the connection and its names. |
|
|
159
|
+
| `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections (with their domain), pull new mappings and drop ones no longer shared, or remove the connection and its names. |
|
|
157
160
|
| `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`). |
|
|
158
161
|
| `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
|
|
159
162
|
| `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
|
|
@@ -175,6 +178,11 @@ headers work as they do locally, and origins in the sender's pages are rewritten
|
|
|
175
178
|
An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
|
|
176
179
|
`remote:sync` picks up a sender turning `--cors` on or off.
|
|
177
180
|
|
|
181
|
+
Opening a name that isn't mapped on the receiver (say a host the sender just added, or assigned to you) syncs every
|
|
182
|
+
connected machine first, then sends the browser back to the same URL if the name turned up. If it didn't, the "not mapped"
|
|
183
|
+
page lists what each sync found or why it failed, with a **Sync again** link. Requests through a tunnel or the hub never
|
|
184
|
+
trigger a sync, and a sync less than 10 seconds old is reused (2 seconds for **Sync again**).
|
|
185
|
+
|
|
178
186
|
When a target is down, requests through the hub or a public share get a **503** with locadot's "upstream unreachable" page.
|
|
179
187
|
Cloudflare replaces an origin's 502 with its own generic "bad gateway" error, which would hide the real cause.
|
|
180
188
|
|
|
@@ -295,6 +303,7 @@ each time the proxy starts.
|
|
|
295
303
|
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on) and `panel` (is the dashboard shared). |
|
|
296
304
|
| `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
|
|
297
305
|
| `PUT /api/hub/panel` | `{ "enabled": true }` | Sender: share the dashboard at the hub's public URL on/off (400 if the hub isn't configured, or no dashboard password is set). |
|
|
306
|
+
| `PUT /api/peers/:id` | `{ "role": "viewer", "hosts": ["app.localhost"] }` or `{ "hosts": [...] }` | Sender: change a peer's role, or only a viewer's mappings (400 for an editor or admin, 404 unknown). |
|
|
298
307
|
| `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
|
|
299
308
|
| `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
|
|
300
309
|
| `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). |
|
|
@@ -1,4 +1,37 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -13,6 +46,7 @@ exports.hubPanel = hubPanel;
|
|
|
13
46
|
exports.share = share;
|
|
14
47
|
exports.peers = peers;
|
|
15
48
|
exports.peersRole = peersRole;
|
|
49
|
+
exports.peersHosts = peersHosts;
|
|
16
50
|
exports.peersRevoke = peersRevoke;
|
|
17
51
|
exports.connect = connect;
|
|
18
52
|
exports.remotes = remotes;
|
|
@@ -31,7 +65,7 @@ const localhost_1 = require("../../lib/localhost");
|
|
|
31
65
|
const urls_1 = require("../../lib/urls");
|
|
32
66
|
const registry_1 = __importDefault(require("../../lib/registry"));
|
|
33
67
|
const file_1 = __importDefault(require("../../utils/file"));
|
|
34
|
-
const links_1 =
|
|
68
|
+
const links_1 = __importStar(require("../../lib/links"));
|
|
35
69
|
const hub_config_1 = __importDefault(require("../../lib/hub-config"));
|
|
36
70
|
const hub_tunnel_1 = require("../../proxy/hub-tunnel");
|
|
37
71
|
const remotes_1 = __importDefault(require("../../lib/remotes"));
|
|
@@ -235,6 +269,20 @@ async function peersRole(id, role, options) {
|
|
|
235
269
|
const peer = links_1.default.setRole(id, parsedRole, hosts);
|
|
236
270
|
(0, shared_1.print)(`✅ ${peer.name} is now ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""}.`);
|
|
237
271
|
}
|
|
272
|
+
/** A viewer's hosts: replace them, or --add / --remove some; with neither, print them. */
|
|
273
|
+
async function peersHosts(id, hosts, options) {
|
|
274
|
+
const peer = links_1.default.list().peers.find((p) => p.id === id);
|
|
275
|
+
if (!peer)
|
|
276
|
+
throw new links_1.LinkError(404, "Peer not found.");
|
|
277
|
+
if (hosts === undefined && !options.add && !options.remove) {
|
|
278
|
+
(0, shared_1.print)(peer.role === "viewer" ? `${peer.name}: ${peer.hosts?.length ? peer.hosts.join(", ") : "no hosts"}` : `${peer.name} is ${peer.role} and sees every host.`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const remove = new Set(splitHosts(options.remove)?.map((h) => h.toLowerCase()));
|
|
282
|
+
const next = [...(hosts !== undefined ? splitHosts(hosts) ?? [] : peer.hosts ?? []), ...(splitHosts(options.add) ?? [])].filter((h) => !remove.has(h.toLowerCase()));
|
|
283
|
+
const updated = links_1.default.setHosts(id, next);
|
|
284
|
+
(0, shared_1.print)(`✅ ${updated.name} now sees ${updated.hosts?.length ? updated.hosts.join(", ") : "no hosts"}. They get the change on their next sync.`);
|
|
285
|
+
}
|
|
238
286
|
async function peersRevoke(id) {
|
|
239
287
|
links_1.default.revoke(id);
|
|
240
288
|
(0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
|
|
@@ -277,10 +325,12 @@ async function remotes(options) {
|
|
|
277
325
|
}
|
|
278
326
|
}
|
|
279
327
|
async function remoteSync(name) {
|
|
280
|
-
const { remote, mapped } = await remotes_1.default.sync(name);
|
|
328
|
+
const { remote, mapped, removed } = await remotes_1.default.sync(name);
|
|
281
329
|
(0, shared_1.print)(`✅ ${name} synced.`);
|
|
282
330
|
for (const m of mapped)
|
|
283
331
|
(0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
|
|
332
|
+
for (const local of removed)
|
|
333
|
+
(0, shared_1.print)(` - ${(0, urls_1.urlFor)(local)} (no longer shared)`);
|
|
284
334
|
printDomain(remote);
|
|
285
335
|
}
|
|
286
336
|
async function remoteDomain(name, domain, options) {
|
|
@@ -2,87 +2,188 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.upstreamDown = exports.remoteLocalhost = exports.proxyNotFound = void 0;
|
|
4
4
|
const escape_1 = require("../dashboard/escape");
|
|
5
|
-
const
|
|
5
|
+
const logo_1 = require("../dashboard/logo");
|
|
6
|
+
const theme_1 = require("../dashboard/ui/theme");
|
|
7
|
+
/** The proxy's own pages (not mapped, upstream unreachable, remote localhost), in the dashboard's look. */
|
|
8
|
+
const page = (opts) => `<!DOCTYPE html>
|
|
6
9
|
<html lang="en">
|
|
7
10
|
<head>
|
|
8
11
|
<meta charset="UTF-8" />
|
|
9
12
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
10
|
-
<
|
|
13
|
+
<meta name="color-scheme" content="dark light" />
|
|
14
|
+
<title>${(0, escape_1.escapeHtml)(opts.title)}</title>
|
|
11
15
|
<style>
|
|
16
|
+
:root {${theme_1.darkVars}
|
|
17
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
18
|
+
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: light) { :root {${theme_1.lightVars}} }
|
|
21
|
+
* { box-sizing: border-box; }
|
|
12
22
|
body {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
margin: 0; min-height: 100vh; padding: 24px;
|
|
24
|
+
display: flex; align-items: center; justify-content: center;
|
|
25
|
+
background: var(--bg); color: var(--fg);
|
|
26
|
+
font: 14px/1.55 var(--sans); -webkit-font-smoothing: antialiased;
|
|
27
|
+
}
|
|
28
|
+
a { color: var(--accent); text-decoration: none; }
|
|
29
|
+
a:hover { text-decoration: underline; text-underline-offset: 2px; }
|
|
30
|
+
*:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
|
|
31
|
+
.card {
|
|
32
|
+
width: 100%; max-width: 600px; background: var(--surface); border: 1px solid var(--border);
|
|
33
|
+
border-radius: 16px; box-shadow: var(--shadow-md); overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
.head { display: flex; align-items: center; gap: 10px; padding: 16px 24px; border-bottom: 1px solid var(--border); }
|
|
36
|
+
.logo { width: 24px; height: 24px; flex: none; }
|
|
37
|
+
.logo svg { display: block; width: 100%; height: 100%; }
|
|
38
|
+
.wordmark { font-weight: 700; font-size: 15px; letter-spacing: -0.01em; }
|
|
39
|
+
.badge {
|
|
40
|
+
margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px;
|
|
41
|
+
border-radius: 999px; font-size: 11.5px; font-weight: 600; white-space: nowrap;
|
|
22
42
|
}
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
44
|
+
.tone-down { color: var(--down); background: var(--down-bg); }
|
|
45
|
+
.tone-warn { color: var(--warn); background: var(--warn-bg); }
|
|
46
|
+
.tone-accent { color: var(--accent-strong); background: var(--accent-bg); }
|
|
47
|
+
.body { padding: 24px; }
|
|
48
|
+
h1 { margin: 0 0 6px; font-size: 22px; line-height: 1.25; letter-spacing: -0.015em; }
|
|
49
|
+
p { margin: 0 0 14px; color: var(--fg-2); }
|
|
50
|
+
.muted { color: var(--muted); }
|
|
51
|
+
code { font-family: var(--mono); font-size: 0.92em; background: var(--surface-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; color: var(--fg); overflow-wrap: anywhere; }
|
|
52
|
+
h2 { margin: 22px 0 8px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
53
|
+
.cmd {
|
|
54
|
+
display: flex; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 8px 8px 12px;
|
|
55
|
+
background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
|
|
32
56
|
}
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
.cmd code { flex: 1; background: none; border: 0; padding: 0; line-height: 1.6; }
|
|
58
|
+
.cmd code::before { content: "$ "; color: var(--muted-dim); }
|
|
59
|
+
.list { list-style: none; margin: 0 0 12px; padding: 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
60
|
+
.list li { display: flex; align-items: baseline; gap: 10px; padding: 9px 12px; }
|
|
61
|
+
.list li + li { border-top: 1px solid var(--border); }
|
|
62
|
+
.list .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
|
|
63
|
+
.dot.up { background: var(--up); } .dot.down { background: var(--down); }
|
|
64
|
+
.list .name { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
65
|
+
.list .detail { color: var(--muted); font-size: 13px; }
|
|
66
|
+
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 14px; }
|
|
67
|
+
.route .arrow { color: var(--muted); }
|
|
68
|
+
.note { padding: 10px 12px; border-radius: 10px; background: var(--down-bg); color: var(--fg-2); margin: 0 0 14px; }
|
|
69
|
+
.btn {
|
|
70
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; padding: 0 14px;
|
|
71
|
+
border-radius: 8px; border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--fg);
|
|
72
|
+
font: 600 13px/1 var(--sans); cursor: pointer; white-space: nowrap;
|
|
73
|
+
transition: background 120ms, border-color 120ms;
|
|
39
74
|
}
|
|
40
|
-
.
|
|
41
|
-
.
|
|
75
|
+
.btn:hover { text-decoration: none; background: var(--surface-3); }
|
|
76
|
+
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
|
77
|
+
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
78
|
+
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
|
|
79
|
+
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
|
|
80
|
+
.btn-ghost:hover { background: var(--surface-3); color: var(--fg); }
|
|
81
|
+
.foot { display: flex; flex-wrap: wrap; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--border); background: var(--surface-2); }
|
|
82
|
+
.foot .spacer { flex: 1; }
|
|
83
|
+
form.go { display: flex; gap: 8px; margin: 0 0 8px; }
|
|
84
|
+
.input {
|
|
85
|
+
flex: 1; min-width: 0; height: 34px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--border-strong);
|
|
86
|
+
background: var(--bg); color: var(--fg); font: 14px var(--mono);
|
|
87
|
+
}
|
|
88
|
+
.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
|
|
89
|
+
@media (max-width: 520px) { body { padding: 12px; } .body, .head, .foot { padding-left: 16px; padding-right: 16px; } }
|
|
42
90
|
</style>
|
|
43
91
|
</head>
|
|
44
92
|
<body>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
93
|
+
<main class="card">
|
|
94
|
+
<div class="head">
|
|
95
|
+
<span class="logo" aria-hidden="true">${logo_1.LOGO_SVG}</span>
|
|
96
|
+
<span class="wordmark">locadot</span>
|
|
97
|
+
<span class="badge tone-${opts.tone}">${(0, escape_1.escapeHtml)(opts.badge)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="body">
|
|
100
|
+
<h1>${opts.heading}</h1>
|
|
101
|
+
${opts.body}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="foot">
|
|
104
|
+
<a class="btn btn-primary" href="${(0, escape_1.escapeHtml)(opts.dashboardUrl)}">Open the dashboard</a>
|
|
105
|
+
<a class="btn" href="https://github.com/avinashid/locadot#readme" target="_blank" rel="noopener noreferrer">Docs</a>
|
|
106
|
+
</div>
|
|
107
|
+
</main>
|
|
108
|
+
<script>
|
|
109
|
+
document.querySelectorAll("[data-copy]").forEach(function (b) {
|
|
110
|
+
b.addEventListener("click", function () {
|
|
111
|
+
var text = b.parentNode.querySelector("code").textContent;
|
|
112
|
+
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
|
|
113
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function () {});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
</script>
|
|
51
117
|
</body>
|
|
52
118
|
</html>
|
|
53
119
|
`;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
120
|
+
const cmd = (text) => `<div class="cmd"><code>${(0, escape_1.escapeHtml)(text)}</code><button type="button" class="btn btn-sm btn-ghost" data-copy>Copy</button></div>`;
|
|
121
|
+
const syncLine = (r) => {
|
|
122
|
+
if (!r.ok)
|
|
123
|
+
return `<li><span class="dot down"></span><span class="name">${(0, escape_1.escapeHtml)(r.name)}</span><span class="detail">couldn't sync: ${(0, escape_1.escapeHtml)(r.error || "unknown error")}</span></li>`;
|
|
124
|
+
const changes = [...r.added.map((h) => `+${h}`), ...r.removed.map((h) => `−${h}`)];
|
|
125
|
+
return `<li><span class="dot up"></span><span class="name">${(0, escape_1.escapeHtml)(r.name)}</span><span class="detail">synced, ${changes.length ? (0, escape_1.escapeHtml)(changes.join(", ")) : "nothing new"}</span></li>`;
|
|
126
|
+
};
|
|
127
|
+
/** `sync`: when there are connected machines, what syncing with them just now found, and the "Sync again" link. */
|
|
128
|
+
const proxyNotFound = (host, dashboardUrl, sync) => page({
|
|
129
|
+
title: "locadot: host not mapped",
|
|
130
|
+
badge: "Not mapped",
|
|
131
|
+
tone: "down",
|
|
132
|
+
heading: "Not mapped",
|
|
133
|
+
body: `<p>No locadot mapping exists for <code>${(0, escape_1.escapeHtml)(host)}</code>.</p>
|
|
134
|
+
${sync
|
|
135
|
+
? `<h2>Connected machines</h2>
|
|
136
|
+
<p class="muted">Synced just now; none of them shares <code>${(0, escape_1.escapeHtml)(host)}</code> with you.</p>
|
|
137
|
+
<ul class="list">${sync.reports.map(syncLine).join("")}</ul>
|
|
138
|
+
<p><a class="btn" href="${(0, escape_1.escapeHtml)(sync.href)}">🔄 Sync again</a></p>
|
|
139
|
+
<p class="muted">If it should be shared with you, ask whoever shared their locadot to add it to your hosts. Or map it here:</p>`
|
|
140
|
+
: `<h2>Map it</h2>`}
|
|
141
|
+
${cmd(`npx locadot add --host ${host} --port PORT`)}
|
|
142
|
+
${cmd(`npx locadot add --host ${host} --target https://example.com`)}`,
|
|
143
|
+
dashboardUrl,
|
|
144
|
+
});
|
|
60
145
|
exports.proxyNotFound = proxyNotFound;
|
|
61
|
-
const upstreamDown = (host, target, reason, dashboardUrl) => page(
|
|
62
|
-
|
|
63
|
-
|
|
146
|
+
const upstreamDown = (host, target, reason, dashboardUrl) => page({
|
|
147
|
+
title: "locadot: upstream unreachable",
|
|
148
|
+
badge: "Upstream unreachable",
|
|
149
|
+
tone: "down",
|
|
150
|
+
heading: "Upstream unreachable",
|
|
151
|
+
body: `<div class="route"><code>${(0, escape_1.escapeHtml)(host)}</code><span class="arrow">→</span><code>${(0, escape_1.escapeHtml)(target)}</code></div>
|
|
152
|
+
<div class="note">${(0, escape_1.escapeHtml)(reason)}</div>
|
|
153
|
+
<p class="muted">Is the app running? Start it, then reload this page.</p>`,
|
|
154
|
+
dashboardUrl,
|
|
155
|
+
});
|
|
64
156
|
exports.upstreamDown = upstreamDown;
|
|
65
157
|
/** Receiver side: `<domain>.localhost/_locadot/ports`, the port picker and shared hosts of an admin remote. */
|
|
66
|
-
const remoteLocalhost = (opts) => page(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
158
|
+
const remoteLocalhost = (opts) => page({
|
|
159
|
+
title: `locadot: ${opts.sender} localhost`,
|
|
160
|
+
badge: opts.allowed ? "Remote" : "Not available",
|
|
161
|
+
tone: opts.allowed ? "accent" : "warn",
|
|
162
|
+
heading: `${(0, escape_1.escapeHtml)(opts.sender)}: localhost`,
|
|
163
|
+
body: opts.allowed
|
|
164
|
+
? `<p>Its dashboard is <a href="/">${(0, escape_1.escapeHtml)(opts.domain)}.localhost</a>.
|
|
165
|
+
Any port on that machine is at <code>${(0, escape_1.escapeHtml)(opts.portUrl.replace("PORT", "<port>"))}</code>.</p>
|
|
166
|
+
<h2>Open a port</h2>
|
|
167
|
+
<form id="go" class="go">
|
|
168
|
+
<input id="port" class="input" inputmode="numeric" pattern="[0-9]{1,5}" placeholder="3000" required aria-label="Port" />
|
|
169
|
+
<button class="btn btn-primary">Open</button>
|
|
170
|
+
</form>
|
|
171
|
+
<script>
|
|
172
|
+
document.getElementById("go").addEventListener("submit", function (e) {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
var port = document.getElementById("port").value.trim();
|
|
175
|
+
if (/^[0-9]{1,5}$/.test(port)) location.href = ${JSON.stringify(opts.portUrl)}.replace("PORT", port);
|
|
176
|
+
});
|
|
177
|
+
</script>
|
|
178
|
+
${opts.hosts.length
|
|
179
|
+
? `<h2>Shared hosts</h2><ul class="list">${opts.hosts
|
|
180
|
+
.map((h) => `<li><span class="dot up"></span><a class="name" href="${(0, escape_1.escapeHtml)(h.url)}">${(0, escape_1.escapeHtml)(h.local)}</a></li>`)
|
|
181
|
+
.join("")}</ul>`
|
|
182
|
+
: ""}`
|
|
183
|
+
: `<p><code>${(0, escape_1.escapeHtml)(opts.domain)}.localhost</code> reaches ${(0, escape_1.escapeHtml)(opts.sender)}'s localhost only while you're an
|
|
184
|
+
admin there and it allows localhost access.</p>
|
|
185
|
+
<p class="muted">Ask its owner, then run:</p>
|
|
186
|
+
${cmd(`locadot remote:sync ${opts.name}`)}`,
|
|
187
|
+
dashboardUrl: opts.dashboardUrl,
|
|
188
|
+
});
|
|
88
189
|
exports.remoteLocalhost = remoteLocalhost;
|
package/dist/dashboard/api.js
CHANGED
|
@@ -535,9 +535,11 @@ async function route(req, url, ctx) {
|
|
|
535
535
|
}
|
|
536
536
|
if (peerMatch && method === "PUT") {
|
|
537
537
|
const id = decodeURIComponent(peerMatch[1]);
|
|
538
|
+
const hosts = optionalHosts(body.hosts);
|
|
539
|
+
if (body.role === undefined && hosts !== undefined)
|
|
540
|
+
return { status: 200, body: { peer: sanitizePeer(links_1.default.setHosts(id, hosts)) } };
|
|
538
541
|
if (!isRole(body.role))
|
|
539
542
|
throw new ApiError(400, "`role` must be viewer, editor or admin.");
|
|
540
|
-
const hosts = optionalHosts(body.hosts);
|
|
541
543
|
const peer = links_1.default.setRole(id, body.role, hosts);
|
|
542
544
|
return { status: 200, body: { peer: sanitizePeer(peer) } };
|
|
543
545
|
}
|
package/dist/dashboard/index.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.handleDashboardRequest = handleDashboardRequest;
|
|
|
40
40
|
const crypto_1 = __importDefault(require("crypto"));
|
|
41
41
|
const host_auth_1 = __importDefault(require("../lib/host-auth"));
|
|
42
42
|
const page_1 = require("./page");
|
|
43
|
+
const logo_1 = require("./logo");
|
|
43
44
|
const api_1 = require("./api");
|
|
44
45
|
const request_1 = require("../proxy/request");
|
|
45
46
|
const login_1 = require("./login");
|
|
@@ -149,9 +150,10 @@ function handleDashboardRequest(req, res, ctx) {
|
|
|
149
150
|
case "/":
|
|
150
151
|
sendBody(res, method, 200, "text/html; charset=utf-8", (0, page_1.renderPage)(nonce, (0, request_1.peerOriginOf)(req) ? api_1.PEER_TOKEN : ctx.token, ui_auth_1.default.enabled()));
|
|
151
152
|
return;
|
|
153
|
+
case "/favicon.svg":
|
|
152
154
|
case "/favicon.ico":
|
|
153
|
-
res.
|
|
154
|
-
res.
|
|
155
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
156
|
+
sendBody(res, method, 200, "image/svg+xml", logo_1.LOGO_SVG);
|
|
155
157
|
return;
|
|
156
158
|
case "/healthz":
|
|
157
159
|
sendBody(res, method, 200, "text/plain; charset=utf-8", "ok");
|
package/dist/dashboard/login.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.gate = gate;
|
|
|
41
41
|
const crypto_1 = __importDefault(require("crypto"));
|
|
42
42
|
const logger_1 = __importDefault(require("../utils/logger"));
|
|
43
43
|
const escape_1 = require("./escape");
|
|
44
|
+
const logo_1 = require("./logo");
|
|
44
45
|
const api_1 = require("./api");
|
|
45
46
|
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
46
47
|
const request_1 = require("../proxy/request");
|
|
@@ -53,6 +54,7 @@ const loginPage = (nonce, error) => `<!doctype html>
|
|
|
53
54
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
54
55
|
<meta name="color-scheme" content="dark light">
|
|
55
56
|
<title>Sign in · locadot</title>
|
|
57
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
56
58
|
<style nonce="${(0, escape_1.escapeHtml)(nonce)}">
|
|
57
59
|
:root { --bg: #f6f7f9; --surface: #fff; --border: #e3e6eb; --fg: #111827; --muted: #6b7280; --accent: #2563eb; --down: #dc2626; }
|
|
58
60
|
@media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --surface: #161b22; --border: #30363d; --fg: #e6edf3; --muted: #8b949e; --accent: #3b82f6; --down: #f87171; } }
|
|
@@ -60,8 +62,7 @@ const loginPage = (nonce, error) => `<!doctype html>
|
|
|
60
62
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
61
63
|
form { width: 100%; max-width: 340px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 28px; display: flex; flex-direction: column; gap: 14px; }
|
|
62
64
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; }
|
|
63
|
-
.logo { width: 24px; height: 24px;
|
|
64
|
-
.logo::after { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: #fff; }
|
|
65
|
+
.logo { width: 24px; height: 24px; background: url("${logo_1.LOGO_DATA_URI}") center / contain no-repeat; }
|
|
65
66
|
p { margin: 0; color: var(--muted); }
|
|
66
67
|
label { font-weight: 600; font-size: 13px; }
|
|
67
68
|
input { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font: inherit; }
|
|
@@ -111,7 +112,7 @@ const secure = (req) => ((0, request_1.isPanel)(req) ? (0, request_1.isTls)(req)
|
|
|
111
112
|
const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? "; Secure" : ""}`;
|
|
112
113
|
const sendLogin = (res, method, nonce, status, error) => {
|
|
113
114
|
// The dashboard's CSP forbids form posts; the login form needs one.
|
|
114
|
-
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
|
|
115
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
|
|
115
116
|
res.statusCode = status;
|
|
116
117
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
117
118
|
res.end(method === "HEAD" ? undefined : loginPage(nonce, error));
|
|
@@ -207,7 +208,7 @@ function gate(req, res, url, token, nonce) {
|
|
|
207
208
|
res.end(JSON.stringify({ ok: true }));
|
|
208
209
|
return true;
|
|
209
210
|
}
|
|
210
|
-
if (path === "/healthz" || path === "/favicon.ico")
|
|
211
|
+
if (path === "/healthz" || path === "/favicon.ico" || path === "/favicon.svg")
|
|
211
212
|
return false;
|
|
212
213
|
if ((0, exports.signedIn)(req) || hasToken(req, token))
|
|
213
214
|
return false;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOGO_DATA_URI = exports.LOGO_SVG = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The locadot mark: a padlock on a blue tile, its body the "dot". Served as the favicon and used by the
|
|
6
|
+
* sidebar and sign-in page (.logo background). docs/logo.svg is the same drawing for the README.
|
|
7
|
+
*/
|
|
8
|
+
exports.LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` +
|
|
9
|
+
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
|
10
|
+
`<stop offset="0" stop-color="#4f8ff7"/><stop offset=".55" stop-color="#2563eb"/><stop offset="1" stop-color="#1e4fd6"/>` +
|
|
11
|
+
`</linearGradient></defs>` +
|
|
12
|
+
`<rect width="64" height="64" rx="15" fill="url(#g)"/>` +
|
|
13
|
+
`<path d="M23.5 30v-6.5a8.5 8.5 0 0 1 17 0V30" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>` +
|
|
14
|
+
`<rect x="15" y="28" width="34" height="24" rx="6" fill="#fff"/>` +
|
|
15
|
+
`<circle cx="32" cy="40" r="5" fill="#2563eb"/>` +
|
|
16
|
+
`</svg>`;
|
|
17
|
+
exports.LOGO_DATA_URI = `data:image/svg+xml,${encodeURIComponent(exports.LOGO_SVG)}`;
|
package/dist/dashboard/page.js
CHANGED
|
@@ -36,6 +36,7 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
36
36
|
<meta name="locadot-token" content="${safeToken}">
|
|
37
37
|
<meta name="locadot-ui-auth" content="${uiAuth ? "on" : "off"}">
|
|
38
38
|
<title>locadot dashboard</title>
|
|
39
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
39
40
|
<script nonce="${safeNonce}">try { var t = localStorage.getItem("locadot.theme"); if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t); var s = localStorage.getItem("locadot.sidebar"); if (s === "collapsed" || (s !== "expanded" && window.innerWidth < 1100)) document.documentElement.setAttribute("data-sidebar", "collapsed"); } catch (e) {}</script>
|
|
40
41
|
<style nonce="${safeNonce}">${theme_1.css}${hosts_1.css}${remote_1.css}${system_1.css}</style>
|
|
41
42
|
</head>
|
|
@@ -56,6 +56,13 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
|
|
|
56
56
|
sub.className = "muted";
|
|
57
57
|
sub.textContent = "last seen " + fmtRelative(peer.lastSeen);
|
|
58
58
|
main.appendChild(sub);
|
|
59
|
+
if (peer.role === "viewer") {
|
|
60
|
+
var seesLine = document.createElement("div");
|
|
61
|
+
seesLine.className = "muted peer-hosts";
|
|
62
|
+
var sees = peer.hosts || [];
|
|
63
|
+
seesLine.textContent = sees.length ? "sees " + sees.join(", ") : "sees no hosts";
|
|
64
|
+
main.appendChild(seesLine);
|
|
65
|
+
}
|
|
59
66
|
row.appendChild(main);
|
|
60
67
|
|
|
61
68
|
var meta = document.createElement("div");
|
|
@@ -80,9 +87,10 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
|
|
|
80
87
|
});
|
|
81
88
|
}
|
|
82
89
|
meta.appendChild(makeMoreButton(function () {
|
|
83
|
-
|
|
90
|
+
var items = peer.role === "viewer" ? [{ label: "Choose hosts\\u2026", icon: ICONS.edit, onSelect: function () { choosePeerHosts(peer); } }, "-"] : [];
|
|
91
|
+
return items.concat(["viewer", "editor", "admin"].map(function (r) {
|
|
84
92
|
return { label: "Set as " + r.charAt(0).toUpperCase() + r.slice(1), disabled: r === peer.role, onSelect: function () { setRole(r); } };
|
|
85
|
-
}).concat(["-", { label: "Revoke", icon: ICONS.trash, danger: true, onSelect: revoke }]);
|
|
93
|
+
})).concat(["-", { label: "Revoke", icon: ICONS.trash, danger: true, onSelect: revoke }]);
|
|
86
94
|
}, { label: "Actions for " + peer.name, small: true }));
|
|
87
95
|
row.appendChild(meta);
|
|
88
96
|
peersListEl.appendChild(row);
|
|
@@ -90,6 +98,57 @@ exports.jsHub = ` // ---------- remote access: hub (sender) ----------
|
|
|
90
98
|
});
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
// Which of this machine's hosts a viewer peer sees; the peer picks the change up on its next sync.
|
|
102
|
+
var peerHostsDialog = null;
|
|
103
|
+
function choosePeerHosts(peer) {
|
|
104
|
+
if (!peerHostsDialog) {
|
|
105
|
+
peerHostsDialog = document.createElement("dialog");
|
|
106
|
+
peerHostsDialog.id = "peer-hosts-dialog";
|
|
107
|
+
peerHostsDialog.innerHTML =
|
|
108
|
+
'<div class="modal-head"><div><h3 class="modal-title"></h3><p class="modal-desc">They see only the hosts you tick. Changes reach them on their next sync.</p></div>' +
|
|
109
|
+
'<button type="button" class="btn btn-ghost modal-close" data-close aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div>' +
|
|
110
|
+
'<div class="modal-body"><div class="host-checks"></div><p class="dim" data-empty hidden>No hosts on this machine yet.</p></div>' +
|
|
111
|
+
'<div class="modal-foot"><button type="button" class="btn" data-close>Cancel</button><button type="button" class="btn btn-primary" data-save>Save</button></div>';
|
|
112
|
+
document.body.appendChild(peerHostsDialog);
|
|
113
|
+
Array.prototype.forEach.call(peerHostsDialog.querySelectorAll("[data-close]"), function (b) {
|
|
114
|
+
b.addEventListener("click", function () { peerHostsDialog.close(); });
|
|
115
|
+
});
|
|
116
|
+
peerHostsDialog.addEventListener("click", function (e) { if (e.target === peerHostsDialog) peerHostsDialog.close(); });
|
|
117
|
+
}
|
|
118
|
+
var dialog = peerHostsDialog;
|
|
119
|
+
dialog.querySelector(".modal-title").textContent = "Hosts " + peer.name + " sees";
|
|
120
|
+
var checks = dialog.querySelector(".host-checks");
|
|
121
|
+
clear(checks);
|
|
122
|
+
var current = {};
|
|
123
|
+
(peer.hosts || []).forEach(function (h) { current[h] = true; });
|
|
124
|
+
var own = lastHosts.filter(function (r) { return !r.remote; }).map(function (r) { return r.host; });
|
|
125
|
+
// Keep assigned names that have no mapping right now, so saving doesn't drop them silently.
|
|
126
|
+
(peer.hosts || []).forEach(function (h) { if (own.indexOf(h) < 0) own.push(h); });
|
|
127
|
+
own.forEach(function (h) {
|
|
128
|
+
var label = document.createElement("label");
|
|
129
|
+
var cb = document.createElement("input");
|
|
130
|
+
cb.type = "checkbox";
|
|
131
|
+
cb.value = h;
|
|
132
|
+
cb.checked = Boolean(current[h]);
|
|
133
|
+
label.appendChild(cb);
|
|
134
|
+
label.appendChild(document.createTextNode(h));
|
|
135
|
+
checks.appendChild(label);
|
|
136
|
+
});
|
|
137
|
+
dialog.querySelector("[data-empty]").hidden = own.length !== 0;
|
|
138
|
+
var save = dialog.querySelector("[data-save]");
|
|
139
|
+
var fresh = save.cloneNode(true);
|
|
140
|
+
save.parentNode.replaceChild(fresh, save);
|
|
141
|
+
fresh.addEventListener("click", function () {
|
|
142
|
+
var hosts = Array.prototype.filter.call(checks.querySelectorAll("input"), function (cb) { return cb.checked; }).map(function (cb) { return cb.value; });
|
|
143
|
+
fresh.disabled = true;
|
|
144
|
+
apiFetch("/api/peers/" + encodeURIComponent(peer.id), { method: "PUT", body: JSON.stringify({ hosts: hosts }) })
|
|
145
|
+
.then(function () { dialog.close(); showToast("Updated hosts for " + peer.name, "success"); return loadHub(); })
|
|
146
|
+
.catch(function (err) { apiError(err, "Couldn't update hosts"); })
|
|
147
|
+
.then(function () { fresh.disabled = false; });
|
|
148
|
+
});
|
|
149
|
+
dialog.showModal();
|
|
150
|
+
}
|
|
151
|
+
|
|
93
152
|
function renderInvites(invites) {
|
|
94
153
|
invitesEmptyEl.hidden = invites.length !== 0;
|
|
95
154
|
clear(invitesListEl);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.css = exports.darkVars = exports.lightVars = void 0;
|
|
2
4
|
/**
|
|
3
5
|
* The dashboard's styles: design tokens, base, app shell, shared components, pages, responsive rules.
|
|
4
6
|
*
|
|
@@ -21,8 +23,7 @@
|
|
|
21
23
|
* Motion .anim-in (one element), .anim-list (children stagger in); all motion off under prefers-reduced-motion
|
|
22
24
|
* Tooltips data-tip="text" on any element (data-tip-pos="right|bottom|top")
|
|
23
25
|
*/
|
|
24
|
-
|
|
25
|
-
exports.css = exports.lightVars = void 0;
|
|
26
|
+
const logo_1 = require("../logo");
|
|
26
27
|
exports.lightVars = `
|
|
27
28
|
color-scheme: light;
|
|
28
29
|
--bg: #f7f7f8;
|
|
@@ -59,8 +60,7 @@ exports.lightVars = `
|
|
|
59
60
|
--shadow-md: 0 4px 12px rgba(16, 16, 24, 0.08), 0 1px 3px rgba(16, 16, 24, 0.06);
|
|
60
61
|
--shadow-lg: 0 16px 40px rgba(16, 16, 24, 0.16), 0 2px 6px rgba(16, 16, 24, 0.06);
|
|
61
62
|
`;
|
|
62
|
-
exports.
|
|
63
|
-
:root {
|
|
63
|
+
exports.darkVars = `
|
|
64
64
|
color-scheme: dark;
|
|
65
65
|
--bg: #09090b;
|
|
66
66
|
--surface: #111114;
|
|
@@ -95,7 +95,9 @@ exports.css = `
|
|
|
95
95
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
96
96
|
--shadow-md: 0 6px 16px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.02);
|
|
97
97
|
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
|
98
|
-
|
|
98
|
+
`;
|
|
99
|
+
exports.css = `
|
|
100
|
+
:root {${exports.darkVars}
|
|
99
101
|
--radius-xs: 6px;
|
|
100
102
|
--radius-sm: 8px;
|
|
101
103
|
--radius-md: 10px;
|
|
@@ -199,19 +201,10 @@ header {
|
|
|
199
201
|
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
200
202
|
.logo {
|
|
201
203
|
width: 24px; height: 24px;
|
|
202
|
-
border-radius:
|
|
203
|
-
background:
|
|
204
|
-
position: relative;
|
|
204
|
+
border-radius: 6px;
|
|
205
|
+
background: url("${logo_1.LOGO_DATA_URI}") center / contain no-repeat;
|
|
205
206
|
flex-shrink: 0;
|
|
206
|
-
box-shadow:
|
|
207
|
-
}
|
|
208
|
-
.logo::after {
|
|
209
|
-
content: "";
|
|
210
|
-
position: absolute;
|
|
211
|
-
left: 8px; top: 8px;
|
|
212
|
-
width: 8px; height: 8px;
|
|
213
|
-
border-radius: 50%;
|
|
214
|
-
background: #fff;
|
|
207
|
+
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35);
|
|
215
208
|
}
|
|
216
209
|
.wordmark {
|
|
217
210
|
font-size: 15px;
|
package/dist/index.js
CHANGED
|
@@ -188,6 +188,12 @@ program
|
|
|
188
188
|
.description("Change a peer's role")
|
|
189
189
|
.option("--hosts <hosts>", "Comma separated hosts (viewer role only)")
|
|
190
190
|
.action(run((id, role, options) => commands_1.default.peersRole(id, role, options)));
|
|
191
|
+
program
|
|
192
|
+
.command("peers:hosts <id> [hosts]")
|
|
193
|
+
.description("Show or change the hosts a viewer peer sees (comma separated hosts replace the list)")
|
|
194
|
+
.option("--add <hosts>", "Comma separated hosts to add")
|
|
195
|
+
.option("--remove <hosts>", "Comma separated hosts to remove")
|
|
196
|
+
.action(run((id, hosts, options) => commands_1.default.peersHosts(id, hosts, options)));
|
|
191
197
|
program.command("peers:revoke <id>").description("Revoke a peer").action(run((id) => commands_1.default.peersRevoke(id)));
|
|
192
198
|
program
|
|
193
199
|
.command("connect <string>")
|
package/dist/lib/links.js
CHANGED
|
@@ -134,6 +134,18 @@ class Links {
|
|
|
134
134
|
save(data);
|
|
135
135
|
return peer;
|
|
136
136
|
}
|
|
137
|
+
/** A viewer's mappings, changed after pairing. Editors and admins see everything, so they have no list. */
|
|
138
|
+
static setHosts(peerId, hosts) {
|
|
139
|
+
const data = load();
|
|
140
|
+
const peer = data.peers.find((p) => p.id === peerId);
|
|
141
|
+
if (!peer)
|
|
142
|
+
throw new LinkError(404, "Peer not found.");
|
|
143
|
+
if (peer.role !== "viewer")
|
|
144
|
+
throw new LinkError(400, `${peer.name} is ${peer.role === "admin" ? "an admin" : "an editor"} and already sees every host.`);
|
|
145
|
+
peer.hosts = [...new Set(hosts.map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
|
|
146
|
+
save(data);
|
|
147
|
+
return peer;
|
|
148
|
+
}
|
|
137
149
|
static revoke(peerId) {
|
|
138
150
|
const data = load();
|
|
139
151
|
const peers = data.peers.filter((p) => p.id !== peerId);
|
package/dist/lib/remotes.js
CHANGED
|
@@ -244,15 +244,23 @@ class Remotes {
|
|
|
244
244
|
const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
|
|
245
245
|
const hosts = hostsRes.hosts;
|
|
246
246
|
const now = new Date().toISOString();
|
|
247
|
+
const added = [];
|
|
248
|
+
const removed = [];
|
|
247
249
|
await registry_1.default.mutate((registry) => {
|
|
248
250
|
const already = new Set();
|
|
249
251
|
const flags = new Map(hosts.map((h) => [h.host, h]));
|
|
250
252
|
for (const [local, entry] of Object.entries(registry.hosts)) {
|
|
251
253
|
if (entry.remote?.name !== name)
|
|
252
254
|
continue;
|
|
253
|
-
already.add(entry.remote.host);
|
|
254
255
|
const h = flags.get(entry.remote.host);
|
|
255
|
-
|
|
256
|
+
// No longer shared with us (unassigned from a viewer, or deleted on the sender): drop it, aliases too.
|
|
257
|
+
if (!h) {
|
|
258
|
+
delete registry.hosts[local];
|
|
259
|
+
removed.push(local);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
already.add(entry.remote.host);
|
|
263
|
+
if (Boolean(h.cors) !== Boolean(entry.cors))
|
|
256
264
|
registry.hosts[local] = { ...entry, cors: h.cors || undefined };
|
|
257
265
|
}
|
|
258
266
|
const taken = new Set(Object.keys(registry.hosts));
|
|
@@ -264,13 +272,14 @@ class Remotes {
|
|
|
264
272
|
continue;
|
|
265
273
|
taken.add(local);
|
|
266
274
|
registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
|
|
275
|
+
added.push(local);
|
|
267
276
|
}
|
|
268
277
|
});
|
|
269
278
|
const registry = registry_1.default.read();
|
|
270
279
|
const mapped = Object.entries(registry.hosts)
|
|
271
280
|
.filter(([, e]) => e.remote?.name === name)
|
|
272
281
|
.map(([local, e]) => ({ local, host: e.remote.host }));
|
|
273
|
-
return { remote: updated, hosts, mapped, domain: updated.domain };
|
|
282
|
+
return { remote: updated, hosts, mapped, added, removed, domain: updated.domain };
|
|
274
283
|
}
|
|
275
284
|
static async alias(name, remoteHost, localHost) {
|
|
276
285
|
const remote = Remotes.mustGet(name);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withoutSyncParam = exports.wantsForcedSync = exports.mayAutoSync = exports.SYNC_PARAM = void 0;
|
|
4
|
+
exports.createAutoSync = createAutoSync;
|
|
5
|
+
const request_1 = require("./request");
|
|
6
|
+
/** The "Sync again" button on the not-mapped page: same URL plus this query parameter. */
|
|
7
|
+
exports.SYNC_PARAM = "locadot-sync";
|
|
8
|
+
const withTimeout = (promise, ms) => new Promise((resolve, reject) => {
|
|
9
|
+
const timer = setTimeout(() => reject(new Error(`timed out after ${Math.round(ms / 1000)}s`)), ms);
|
|
10
|
+
promise.then((value) => (clearTimeout(timer), resolve(value)), (error) => (clearTimeout(timer), reject(error)));
|
|
11
|
+
});
|
|
12
|
+
const message = (error) => String(error?.message || error).replace(/^❌\s*/u, "");
|
|
13
|
+
/**
|
|
14
|
+
* Syncs every connected machine when a browser asks for a name that isn't mapped: the sender may have
|
|
15
|
+
* added or assigned it since the last sync. Concurrent requests share one run, and a recent run is reused.
|
|
16
|
+
* Resolves undefined when there's nothing to sync with.
|
|
17
|
+
*/
|
|
18
|
+
function createAutoSync(opts) {
|
|
19
|
+
const freshMs = opts.freshMs ?? 10000;
|
|
20
|
+
const forcedFreshMs = opts.forcedFreshMs ?? 2000;
|
|
21
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
22
|
+
let running;
|
|
23
|
+
let last;
|
|
24
|
+
const run = async (names) => {
|
|
25
|
+
const reports = await Promise.all(names.map((name) => withTimeout(opts.sync(name), timeoutMs).then((r) => ({ name, ok: true, added: r.added, removed: r.removed }), (error) => ({ name, ok: false, added: [], removed: [], error: message(error) }))));
|
|
26
|
+
if (reports.some((r) => r.added.length || r.removed.length))
|
|
27
|
+
opts.changed();
|
|
28
|
+
return reports;
|
|
29
|
+
};
|
|
30
|
+
return (force = false) => {
|
|
31
|
+
const names = opts.remotes();
|
|
32
|
+
if (!names.length)
|
|
33
|
+
return Promise.resolve(undefined);
|
|
34
|
+
if (running)
|
|
35
|
+
return running;
|
|
36
|
+
if (last && Date.now() - last.at < (force ? forcedFreshMs : freshMs))
|
|
37
|
+
return Promise.resolve(last.reports);
|
|
38
|
+
running = run(names).then((reports) => {
|
|
39
|
+
last = { at: Date.now(), reports };
|
|
40
|
+
return reports;
|
|
41
|
+
});
|
|
42
|
+
running.finally(() => (running = undefined)).catch(() => { });
|
|
43
|
+
return running;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Only this machine's own browser triggers a sync; tunnels, the hub and peers just get the page. */
|
|
47
|
+
const mayAutoSync = (req, hub) => !(hub && hub.kind !== "none") && !(0, request_1.fromTunnel)(req) && !(0, request_1.fromRemote)(req) && !req.headers["cf-ray"];
|
|
48
|
+
exports.mayAutoSync = mayAutoSync;
|
|
49
|
+
const wantsForcedSync = (req) => new URLSearchParams((req.url || "").split("?")[1] || "").has(exports.SYNC_PARAM);
|
|
50
|
+
exports.wantsForcedSync = wantsForcedSync;
|
|
51
|
+
/** The URL the browser asked for, minus the sync parameter: where to send it once the name is mapped. */
|
|
52
|
+
const withoutSyncParam = (url) => {
|
|
53
|
+
const [path, query = ""] = url.split("?");
|
|
54
|
+
const params = new URLSearchParams(query);
|
|
55
|
+
params.delete(exports.SYNC_PARAM);
|
|
56
|
+
const rest = params.toString();
|
|
57
|
+
return (path.startsWith("/") ? path : "/") + (rest ? `?${rest}` : "");
|
|
58
|
+
};
|
|
59
|
+
exports.withoutSyncParam = withoutSyncParam;
|
package/dist/proxy/router.js
CHANGED
|
@@ -21,6 +21,7 @@ const request_1 = require("./request");
|
|
|
21
21
|
const stats_1 = require("./stats");
|
|
22
22
|
const protect_1 = require("./protect");
|
|
23
23
|
const https_redirect_1 = require("./https-redirect");
|
|
24
|
+
const auto_sync_1 = require("./auto-sync");
|
|
24
25
|
// Cloudflare swaps an origin's 502/504 for its own "bad gateway" page, so through a tunnel say 503 and the page survives.
|
|
25
26
|
const downStatus = (req) => ((0, request_1.fromTunnel)(req) || (0, request_1.fromRemote)(req) ? 503 : 502);
|
|
26
27
|
/** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
|
|
@@ -111,6 +112,33 @@ const localTarget = (req, ctx, host, hub) => {
|
|
|
111
112
|
return undefined;
|
|
112
113
|
return ctx.localFor?.(host);
|
|
113
114
|
};
|
|
115
|
+
/** An unknown name may be one a connected machine shared since the last sync: sync, then retry or explain. */
|
|
116
|
+
function notMapped(req, res, ctx, host, hub) {
|
|
117
|
+
const notFound = (reports) => {
|
|
118
|
+
res.writeHead(downStatus(req), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
119
|
+
const href = (path) => `${path}${path.includes("?") ? "&" : "?"}${auto_sync_1.SYNC_PARAM}=1`;
|
|
120
|
+
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req), reports && { reports, href: href((0, auto_sync_1.withoutSyncParam)(req.url || "/")) }));
|
|
121
|
+
};
|
|
122
|
+
if (!ctx.autoSync || !(0, auto_sync_1.mayAutoSync)(req, hub))
|
|
123
|
+
return notFound();
|
|
124
|
+
ctx
|
|
125
|
+
.autoSync((0, auto_sync_1.wantsForcedSync)(req))
|
|
126
|
+
.then((reports) => {
|
|
127
|
+
if (res.headersSent || res.destroyed)
|
|
128
|
+
return;
|
|
129
|
+
if (reports && (ctx.lookup(host) || localTarget(req, ctx, host, hub))) {
|
|
130
|
+
res.writeHead(307, { Location: (0, auto_sync_1.withoutSyncParam)(req.url || "/"), "Cache-Control": "no-store" });
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
notFound(reports);
|
|
135
|
+
})
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
logger_1.default.warn(`auto-sync for ${host}: ${error?.message || error}`);
|
|
138
|
+
if (!res.headersSent)
|
|
139
|
+
notFound();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
114
142
|
function handleRequest(req, res, ctx) {
|
|
115
143
|
const hub = ctx.hub?.classify(req);
|
|
116
144
|
if (hub?.kind === "api") {
|
|
@@ -139,8 +167,7 @@ function handleRequest(req, res, ctx) {
|
|
|
139
167
|
forwardLocal(req, res, ctx, host, local);
|
|
140
168
|
return;
|
|
141
169
|
}
|
|
142
|
-
|
|
143
|
-
res.end((0, template_1.proxyNotFound)(host, dashboardUrl(req)));
|
|
170
|
+
notMapped(req, res, ctx, host, hub);
|
|
144
171
|
return;
|
|
145
172
|
}
|
|
146
173
|
if (entry.remote) {
|
package/dist/server/index.js
CHANGED
|
@@ -16,6 +16,8 @@ const tunnel_1 = require("../proxy/tunnel");
|
|
|
16
16
|
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
|
+
const auto_sync_1 = require("../proxy/auto-sync");
|
|
20
|
+
const remotes_1 = __importDefault(require("../lib/remotes"));
|
|
19
21
|
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
20
22
|
const config_1 = __importDefault(require("../lib/config"));
|
|
21
23
|
const file_1 = __importDefault(require("../utils/file"));
|
|
@@ -71,6 +73,11 @@ async function startCentralProxy() {
|
|
|
71
73
|
ownPorts: () => [info.httpPort, info.httpsPort],
|
|
72
74
|
httpsRedirect: () => config_1.default.httpsRedirect(),
|
|
73
75
|
httpsPort: () => info.httpsPort,
|
|
76
|
+
autoSync: (0, auto_sync_1.createAutoSync)({
|
|
77
|
+
remotes: () => remotes_1.default.list().map((remote) => remote.name),
|
|
78
|
+
sync: (name) => remotes_1.default.sync(name),
|
|
79
|
+
changed: registry.reload,
|
|
80
|
+
}),
|
|
74
81
|
remoteFor: remote_1.remoteFor,
|
|
75
82
|
localFor: remote_1.localFor,
|
|
76
83
|
remoteHosts: (name) => Object.entries(registry.get().hosts)
|
package/package.json
CHANGED