locadot 2.0.0 → 2.1.0-beta.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 +10 -0
- package/README.md +24 -0
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/remote.js +276 -0
- package/dist/constants/index.js +4 -0
- package/dist/dashboard/api.js +253 -1
- package/dist/dashboard/index.js +23 -2
- package/dist/dashboard/page.js +872 -11
- package/dist/index.js +86 -1
- package/dist/lib/hub-config.js +28 -0
- package/dist/lib/links.js +163 -0
- package/dist/lib/localhost.js +2 -2
- package/dist/lib/remotes.js +272 -0
- package/dist/proxy/hub-tunnel.js +241 -0
- package/dist/proxy/hub.js +280 -0
- package/dist/proxy/remote.js +51 -0
- package/dist/proxy/request.js +4 -2
- package/dist/proxy/router.js +62 -3
- package/dist/server/index.js +43 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.0-beta.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Remote access: share your whole locadot with another locadot over a Cloudflare named tunnel, a quick tunnel, or any URL.
|
|
7
|
+
Receivers pair with a one-time string (5 minutes) and get viewer, editor or admin access, which the sender can change or
|
|
8
|
+
revoke. The sender's mappings appear in the receiver's locadot; clashing names become `<host>.<remote>.localhost`.
|
|
9
|
+
New commands: `hub*`, `share`, `peers*`, `connect`, `remotes`, `remote:*` and `disconnect`. There are also new dashboard cards.
|
|
10
|
+
- Dashboard: a collapsible sidebar with section links, active-section highlighting and counts. Collapse it to an icon rail
|
|
11
|
+
(remembered per browser); on small screens it is a slide-out drawer.
|
|
12
|
+
|
|
3
13
|
## 2.0.0 (2026-09-26)
|
|
4
14
|
|
|
5
15
|
### Changed
|
package/README.md
CHANGED
|
@@ -101,6 +101,30 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
|
|
|
101
101
|
on the address actually dialled, so the tunnel can't be used to reach your machine or network. The dashboard can also do this:
|
|
102
102
|
use **Share** / **Unshare** on a row.
|
|
103
103
|
|
|
104
|
+
### Remote access (locadot to locadot)
|
|
105
|
+
|
|
106
|
+
One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
|
|
107
|
+
short-lived pairing string, and the sender's mappings then show up in the receiver's own locadot and dashboard.
|
|
108
|
+
|
|
109
|
+
| Command | What it does |
|
|
110
|
+
| --- | --- |
|
|
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
|
+
| `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
|
+
| `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
|
|
114
|
+
| `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
|
+
| `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. |
|
|
118
|
+
| `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
|
|
119
|
+
| `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
|
|
120
|
+
| `locadot remote:url <name> <url>` | Receiver: point a connection at the sender's new URL. |
|
|
121
|
+
|
|
122
|
+
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. Traffic goes receiver → Cloudflare → sender →
|
|
124
|
+
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. The sender's dashboard is never reachable through the hub.
|
|
126
|
+
The dashboard has **Remote access** and **Connected machines** cards for all of this.
|
|
127
|
+
|
|
104
128
|
### Certificates
|
|
105
129
|
|
|
106
130
|
| Command | What it does |
|
|
@@ -38,6 +38,7 @@ const proxy = __importStar(require("./proxy"));
|
|
|
38
38
|
const files = __importStar(require("./files"));
|
|
39
39
|
const system = __importStar(require("./system"));
|
|
40
40
|
const tunnel = __importStar(require("./tunnel"));
|
|
41
|
+
const remote = __importStar(require("./remote"));
|
|
41
42
|
const doctor_1 = require("./doctor");
|
|
42
|
-
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, doctor: doctor_1.doctor };
|
|
43
|
+
const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, doctor: doctor_1.doctor };
|
|
43
44
|
exports.default = Commands;
|
|
@@ -0,0 +1,276 @@
|
|
|
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.hub = hub;
|
|
7
|
+
exports.hubSetup = hubSetup;
|
|
8
|
+
exports.hubQuick = hubQuick;
|
|
9
|
+
exports.hubManual = hubManual;
|
|
10
|
+
exports.hubOff = hubOff;
|
|
11
|
+
exports.share = share;
|
|
12
|
+
exports.peers = peers;
|
|
13
|
+
exports.peersRole = peersRole;
|
|
14
|
+
exports.peersRevoke = peersRevoke;
|
|
15
|
+
exports.connect = connect;
|
|
16
|
+
exports.remotes = remotes;
|
|
17
|
+
exports.remoteSync = remoteSync;
|
|
18
|
+
exports.remoteAlias = remoteAlias;
|
|
19
|
+
exports.remoteUrl = remoteUrl;
|
|
20
|
+
exports.remoteAdd = remoteAdd;
|
|
21
|
+
exports.remoteUpdate = remoteUpdate;
|
|
22
|
+
exports.remoteRm = remoteRm;
|
|
23
|
+
exports.disconnect = disconnect;
|
|
24
|
+
const http_1 = __importDefault(require("http"));
|
|
25
|
+
const constants_1 = __importDefault(require("../../constants"));
|
|
26
|
+
const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
|
|
27
|
+
const localhost_1 = require("../../lib/localhost");
|
|
28
|
+
const urls_1 = require("../../lib/urls");
|
|
29
|
+
const registry_1 = __importDefault(require("../../lib/registry"));
|
|
30
|
+
const file_1 = __importDefault(require("../../utils/file"));
|
|
31
|
+
const links_1 = __importDefault(require("../../lib/links"));
|
|
32
|
+
const hub_config_1 = __importDefault(require("../../lib/hub-config"));
|
|
33
|
+
const hub_tunnel_1 = require("../../proxy/hub-tunnel");
|
|
34
|
+
const remotes_1 = __importDefault(require("../../lib/remotes"));
|
|
35
|
+
const shared_1 = require("../shared");
|
|
36
|
+
const ROLES = ["viewer", "editor", "admin"];
|
|
37
|
+
const requireRole = (value) => {
|
|
38
|
+
if (typeof value !== "string" || !ROLES.includes(value)) {
|
|
39
|
+
throw new localhost_1.InputError(`❌ Invalid role "${value}". Use viewer, editor or admin.`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
const splitHosts = (value) => value
|
|
44
|
+
? value
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
: undefined;
|
|
49
|
+
const requireRunning = () => {
|
|
50
|
+
if (!proxy_control_1.default.running())
|
|
51
|
+
throw new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.");
|
|
52
|
+
};
|
|
53
|
+
const apiToken = () => {
|
|
54
|
+
const token = proxy_control_1.default.running() ? file_1.default.read("API_TOKEN")?.trim() : undefined;
|
|
55
|
+
if (!token)
|
|
56
|
+
throw new localhost_1.InputError("❌ No API token found. Run `locadot start`.");
|
|
57
|
+
return token;
|
|
58
|
+
};
|
|
59
|
+
/** Talks to the running proxy's local dashboard API on its HTTP port. */
|
|
60
|
+
const dashboardRequest = (method, path, options = {}) => new Promise((resolve, reject) => {
|
|
61
|
+
const data = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
62
|
+
const req = http_1.default.request({
|
|
63
|
+
host: "127.0.0.1",
|
|
64
|
+
port: constants_1.default.server.httpPort,
|
|
65
|
+
path,
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
Host: "localhost",
|
|
69
|
+
...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}),
|
|
70
|
+
...options.headers,
|
|
71
|
+
},
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
}, (res) => {
|
|
74
|
+
let body = "";
|
|
75
|
+
res.on("data", (chunk) => (body += chunk));
|
|
76
|
+
res.on("end", () => {
|
|
77
|
+
try {
|
|
78
|
+
resolve({ status: res.statusCode || 0, json: body ? JSON.parse(body) : undefined });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
reject(new localhost_1.InputError("❌ The proxy didn't answer. Is it running? Try `locadot status`."));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
86
|
+
req.on("error", () => reject(new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.")));
|
|
87
|
+
if (data)
|
|
88
|
+
req.write(data);
|
|
89
|
+
req.end();
|
|
90
|
+
});
|
|
91
|
+
const getHub = async () => {
|
|
92
|
+
requireRunning();
|
|
93
|
+
const { status, json } = await dashboardRequest("GET", "/api/hub");
|
|
94
|
+
if (status !== 200)
|
|
95
|
+
throw new localhost_1.InputError("❌ Couldn't read hub status. Try `locadot status`.");
|
|
96
|
+
return json;
|
|
97
|
+
};
|
|
98
|
+
const HUB_EMOJI = { off: "🔒", starting: "⏳", up: "🌍", error: "❌", login: "🔑" };
|
|
99
|
+
async function hub(options) {
|
|
100
|
+
const { hub } = await getHub();
|
|
101
|
+
if (options.json) {
|
|
102
|
+
console.log(JSON.stringify(hub, null, 2));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!hub.enabled) {
|
|
106
|
+
(0, shared_1.print)("🔒 Remote access is off. Turn it on: locadot hub:setup / hub:quick / hub:manual");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
(0, shared_1.print)(`${HUB_EMOJI[hub.status] ?? "❔"} ${hub.mode} — ${hub.status}${hub.url ? `: ${hub.url}` : ""}`);
|
|
110
|
+
if (hub.loginUrl)
|
|
111
|
+
(0, shared_1.print)(` Open to finish login: ${hub.loginUrl}`);
|
|
112
|
+
if (hub.error)
|
|
113
|
+
(0, shared_1.print)(` ${hub.error}`);
|
|
114
|
+
}
|
|
115
|
+
async function hubSetup(options) {
|
|
116
|
+
if (!options.domain)
|
|
117
|
+
throw new localhost_1.InputError("❌ Missing --domain <domain>.");
|
|
118
|
+
(0, shared_1.print)(`🔗 Setting up a named tunnel for ${options.domain}…`);
|
|
119
|
+
const config = await (0, hub_tunnel_1.setupNamedTunnel)(options.domain, options.tunnel, (loginUrl) => {
|
|
120
|
+
(0, shared_1.print)(`🔑 Open this URL to log in to Cloudflare: ${loginUrl}`);
|
|
121
|
+
(0, shared_1.print)(" Waiting for login…");
|
|
122
|
+
});
|
|
123
|
+
hub_config_1.default.write(config);
|
|
124
|
+
(0, shared_1.print)(`✅ Hub configured: https://${options.domain}`);
|
|
125
|
+
(0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
|
|
126
|
+
}
|
|
127
|
+
async function hubQuick() {
|
|
128
|
+
hub_config_1.default.write({ mode: "quick" });
|
|
129
|
+
(0, shared_1.print)("🌍 Quick tunnel enabled (trycloudflare.com).");
|
|
130
|
+
(0, shared_1.print)("⚠️ The trycloudflare URL changes whenever cloudflared or the proxy restarts.");
|
|
131
|
+
(0, shared_1.print)(" When that happens, tell receivers to run: locadot remote:url <name> <new-url>");
|
|
132
|
+
(0, shared_1.print)(" Check the current URL any time: locadot hub");
|
|
133
|
+
(0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
|
|
134
|
+
}
|
|
135
|
+
async function hubManual(options) {
|
|
136
|
+
if (!options.url)
|
|
137
|
+
throw new localhost_1.InputError("❌ Missing --url <url>.");
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = new URL(options.url);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new localhost_1.InputError(`❌ Invalid URL "${options.url}".`);
|
|
144
|
+
}
|
|
145
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
146
|
+
throw new localhost_1.InputError(`❌ Invalid URL "${options.url}". Use http:// or https://.`);
|
|
147
|
+
}
|
|
148
|
+
hub_config_1.default.write({ mode: "manual", url: options.url.replace(/\/+$/, "") });
|
|
149
|
+
(0, shared_1.print)(`✅ Hub configured: ${parsed.origin}`);
|
|
150
|
+
(0, shared_1.print)(" The running proxy watches this automatically. Not running? `locadot start`.");
|
|
151
|
+
}
|
|
152
|
+
async function hubOff() {
|
|
153
|
+
hub_config_1.default.clear();
|
|
154
|
+
(0, shared_1.print)("🔒 Remote access turned off.");
|
|
155
|
+
}
|
|
156
|
+
async function share(options) {
|
|
157
|
+
const role = requireRole(options.role);
|
|
158
|
+
if (options.hosts && role !== "viewer") {
|
|
159
|
+
(0, shared_1.print)(`⚠️ --hosts is ignored for ${role}s: they see every host.`);
|
|
160
|
+
}
|
|
161
|
+
const hosts = role === "viewer" ? splitHosts(options.hosts) : undefined;
|
|
162
|
+
requireRunning();
|
|
163
|
+
const { status, json } = await dashboardRequest("POST", "/api/invites", {
|
|
164
|
+
headers: { "X-Locadot-Token": apiToken() },
|
|
165
|
+
body: { role, hosts },
|
|
166
|
+
});
|
|
167
|
+
if (status === 409) {
|
|
168
|
+
throw new localhost_1.InputError("❌ The hub isn't up. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
|
|
169
|
+
}
|
|
170
|
+
if (status !== 200 && status !== 201) {
|
|
171
|
+
throw new localhost_1.InputError(`❌ Couldn't create an invite (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
172
|
+
}
|
|
173
|
+
(0, shared_1.print)("🔗 Pairing code (valid for 5 minutes, one use):");
|
|
174
|
+
(0, shared_1.print)("");
|
|
175
|
+
(0, shared_1.print)(` ${json.string}`);
|
|
176
|
+
(0, shared_1.print)("");
|
|
177
|
+
(0, shared_1.print)(` Role: ${json.role}${json.hosts ? ` — hosts: ${json.hosts.join(", ")}` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
async function peers(options) {
|
|
180
|
+
const { invites, peers } = links_1.default.list();
|
|
181
|
+
if (options.json) {
|
|
182
|
+
console.log(JSON.stringify({ invites, peers }, null, 2));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!peers.length)
|
|
186
|
+
(0, shared_1.print)("No peers yet. Create an invite: locadot share --role viewer");
|
|
187
|
+
for (const peer of peers) {
|
|
188
|
+
(0, shared_1.print)(`${peer.id} ${peer.name} ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""} last seen: ${peer.lastSeen ?? "never"}`);
|
|
189
|
+
}
|
|
190
|
+
if (invites.length)
|
|
191
|
+
(0, shared_1.print)(`⏳ Pending invites: ${invites.length}`);
|
|
192
|
+
}
|
|
193
|
+
async function peersRole(id, role, options) {
|
|
194
|
+
const parsedRole = requireRole(role);
|
|
195
|
+
const hosts = parsedRole === "viewer" ? splitHosts(options.hosts) : undefined;
|
|
196
|
+
const peer = links_1.default.setRole(id, parsedRole, hosts);
|
|
197
|
+
(0, shared_1.print)(`✅ ${peer.name} is now ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""}.`);
|
|
198
|
+
}
|
|
199
|
+
async function peersRevoke(id) {
|
|
200
|
+
links_1.default.revoke(id);
|
|
201
|
+
(0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
|
|
202
|
+
}
|
|
203
|
+
async function connect(value, options) {
|
|
204
|
+
const { remote, mapped, skipped } = await remotes_1.default.connect(value, { name: options.name });
|
|
205
|
+
(0, shared_1.print)(`✅ Connected to ${remote.name} (${remote.sender.hostname}) as ${remote.role}.`);
|
|
206
|
+
for (const m of mapped)
|
|
207
|
+
(0, shared_1.print)(` ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
|
|
208
|
+
for (const reason of skipped)
|
|
209
|
+
(0, shared_1.print)(` ⚠️ skipped: ${reason}`);
|
|
210
|
+
}
|
|
211
|
+
async function remotes(options) {
|
|
212
|
+
const list = remotes_1.default.list();
|
|
213
|
+
const registry = registry_1.default.read();
|
|
214
|
+
const aliasesFor = (name) => Object.entries(registry.hosts)
|
|
215
|
+
.filter(([, entry]) => entry.remote?.name === name)
|
|
216
|
+
.map(([local, entry]) => ({ local, host: entry.remote.host }));
|
|
217
|
+
if (options.json) {
|
|
218
|
+
console.log(JSON.stringify(list.map(({ token, ...remote }) => ({ ...remote, aliases: aliasesFor(remote.name) })), null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!list.length) {
|
|
222
|
+
(0, shared_1.print)("No remotes. Connect to one: locadot connect <pairing-string>");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
for (const remote of list) {
|
|
226
|
+
(0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})`);
|
|
227
|
+
for (const alias of aliasesFor(remote.name))
|
|
228
|
+
(0, shared_1.print)(` ${(0, urls_1.urlFor)(alias.local)} → ${alias.host}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function remoteSync(name) {
|
|
232
|
+
const { mapped } = await remotes_1.default.sync(name);
|
|
233
|
+
(0, shared_1.print)(`✅ ${name} synced.`);
|
|
234
|
+
for (const m of mapped)
|
|
235
|
+
(0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
|
|
236
|
+
}
|
|
237
|
+
async function remoteAlias(name, remoteHost, localHost) {
|
|
238
|
+
await remotes_1.default.alias(name, remoteHost, localHost);
|
|
239
|
+
(0, shared_1.print)(`✅ ${(0, urls_1.urlFor)(localHost)} → ${remoteHost} (${name})`);
|
|
240
|
+
}
|
|
241
|
+
async function remoteUrl(name, url) {
|
|
242
|
+
let parsed;
|
|
243
|
+
try {
|
|
244
|
+
parsed = new URL(url);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
throw new localhost_1.InputError(`❌ Invalid URL "${url}".`);
|
|
248
|
+
}
|
|
249
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
250
|
+
throw new localhost_1.InputError(`❌ Invalid URL "${url}". Use http:// or https://.`);
|
|
251
|
+
}
|
|
252
|
+
remotes_1.default.setUrl(name, url.replace(/\/+$/, ""));
|
|
253
|
+
(0, shared_1.print)(`✅ ${name} now points to ${parsed.origin}.`);
|
|
254
|
+
}
|
|
255
|
+
async function remoteAdd(name, options) {
|
|
256
|
+
if (!options.host)
|
|
257
|
+
throw new localhost_1.InputError("❌ Missing --host <host>.");
|
|
258
|
+
if (!options.target)
|
|
259
|
+
throw new localhost_1.InputError("❌ Missing --target <url>.");
|
|
260
|
+
await remotes_1.default.addHost(name, { host: options.host, target: options.target, insecure: options.insecure, cors: options.cors });
|
|
261
|
+
(0, shared_1.print)(`✅ ${options.host} → ${options.target} on ${name}.`);
|
|
262
|
+
}
|
|
263
|
+
async function remoteUpdate(name, options) {
|
|
264
|
+
if (!options.host)
|
|
265
|
+
throw new localhost_1.InputError("❌ Missing --host <host>.");
|
|
266
|
+
await remotes_1.default.updateHost(name, options.host, { target: options.target, cors: options.cors });
|
|
267
|
+
(0, shared_1.print)(`✅ Updated ${options.host} on ${name}.`);
|
|
268
|
+
}
|
|
269
|
+
async function remoteRm(name, host) {
|
|
270
|
+
await remotes_1.default.removeHost(name, host);
|
|
271
|
+
(0, shared_1.print)(`🗑️ Removed ${host} from ${name}.`);
|
|
272
|
+
}
|
|
273
|
+
async function disconnect(name) {
|
|
274
|
+
await remotes_1.default.disconnect(name);
|
|
275
|
+
(0, shared_1.print)(`🔌 Disconnected from ${name}.`);
|
|
276
|
+
}
|
package/dist/constants/index.js
CHANGED
|
@@ -38,6 +38,10 @@ Constants.paths = {
|
|
|
38
38
|
// Secret for the dashboard's mutating API; rotated on every proxy start.
|
|
39
39
|
API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
|
|
40
40
|
CONFIG_FILE,
|
|
41
|
+
// Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
|
|
42
|
+
HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
|
|
43
|
+
LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
|
|
44
|
+
REMOTES_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-remotes.json"),
|
|
41
45
|
};
|
|
42
46
|
Constants.validPort = validPort;
|
|
43
47
|
Constants.server = {
|
package/dist/dashboard/api.js
CHANGED
|
@@ -51,6 +51,9 @@ const logger_1 = __importDefault(require("../utils/logger"));
|
|
|
51
51
|
const system_1 = require("../lib/system");
|
|
52
52
|
const tunnel_1 = require("../proxy/tunnel");
|
|
53
53
|
const urls_1 = require("../lib/urls");
|
|
54
|
+
const links_1 = __importStar(require("../lib/links"));
|
|
55
|
+
const hub_config_1 = __importDefault(require("../lib/hub-config"));
|
|
56
|
+
const remotes_1 = __importStar(require("../lib/remotes"));
|
|
54
57
|
const MAX_BODY = 64 * 1024;
|
|
55
58
|
class ApiError extends Error {
|
|
56
59
|
constructor(status, message, hint) {
|
|
@@ -149,6 +152,103 @@ const privileged = async (action, command) => {
|
|
|
149
152
|
}
|
|
150
153
|
};
|
|
151
154
|
const hostUrl = (host, ctx) => (0, urls_1.formatUrl)(host, true, ctx.proxyInfo.httpsPort);
|
|
155
|
+
const ROLES = ["viewer", "editor", "admin"];
|
|
156
|
+
const isRole = (value) => typeof value === "string" && ROLES.includes(value);
|
|
157
|
+
const optionalString = (value, name) => {
|
|
158
|
+
if (value === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (typeof value !== "string")
|
|
161
|
+
throw new ApiError(400, `\`${name}\` must be a string.`);
|
|
162
|
+
return value;
|
|
163
|
+
};
|
|
164
|
+
const optionalHosts = (value) => {
|
|
165
|
+
if (value === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
168
|
+
throw new ApiError(400, "`hosts` must be an array of strings.");
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
};
|
|
172
|
+
/** Public hostname a Cloudflare named tunnel can route to (not *.localhost, no scheme/path). */
|
|
173
|
+
const isValidPublicDomain = (value) => {
|
|
174
|
+
if (typeof value !== "string")
|
|
175
|
+
return false;
|
|
176
|
+
const host = value.trim().toLowerCase();
|
|
177
|
+
if (!host || host.length > 253 || host.includes("/") || host.includes(":"))
|
|
178
|
+
return false;
|
|
179
|
+
const labels = host.split(".");
|
|
180
|
+
if (labels.length < 2 || labels[labels.length - 1] === "localhost")
|
|
181
|
+
return false;
|
|
182
|
+
return labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
183
|
+
};
|
|
184
|
+
const sanitizeInvite = (invite) => ({
|
|
185
|
+
id: invite.id,
|
|
186
|
+
role: invite.role,
|
|
187
|
+
hosts: invite.hosts,
|
|
188
|
+
expiresAt: invite.expiresAt,
|
|
189
|
+
});
|
|
190
|
+
const sanitizePeer = (peer) => ({
|
|
191
|
+
id: peer.id,
|
|
192
|
+
name: peer.name,
|
|
193
|
+
role: peer.role,
|
|
194
|
+
hosts: peer.hosts,
|
|
195
|
+
createdAt: peer.createdAt,
|
|
196
|
+
lastSeen: peer.lastSeen,
|
|
197
|
+
});
|
|
198
|
+
const sanitizeRemote = (remote) => {
|
|
199
|
+
const { token, ...rest } = remote;
|
|
200
|
+
return rest;
|
|
201
|
+
};
|
|
202
|
+
const REMOTE_SYNC_TIMEOUT_MS = 5000;
|
|
203
|
+
async function buildRemoteRows(ctx) {
|
|
204
|
+
const remotes = remotes_1.default.list();
|
|
205
|
+
let changed = false;
|
|
206
|
+
const rows = await Promise.all(remotes.map(async (remote) => {
|
|
207
|
+
try {
|
|
208
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), REMOTE_SYNC_TIMEOUT_MS));
|
|
209
|
+
const result = await Promise.race([remotes_1.default.sync(remote.name), timeout]);
|
|
210
|
+
changed = true;
|
|
211
|
+
return {
|
|
212
|
+
name: result.remote.name,
|
|
213
|
+
url: result.remote.url,
|
|
214
|
+
role: result.remote.role,
|
|
215
|
+
hosts: result.remote.hosts,
|
|
216
|
+
sender: result.remote.sender,
|
|
217
|
+
connectedAt: result.remote.connectedAt,
|
|
218
|
+
status: "ok",
|
|
219
|
+
available: result.hosts,
|
|
220
|
+
mapped: result.mapped,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
name: remote.name,
|
|
226
|
+
url: remote.url,
|
|
227
|
+
role: remote.role,
|
|
228
|
+
hosts: remote.hosts,
|
|
229
|
+
sender: remote.sender,
|
|
230
|
+
connectedAt: remote.connectedAt,
|
|
231
|
+
status: "error",
|
|
232
|
+
error: clean(String(error?.message || error)),
|
|
233
|
+
available: null,
|
|
234
|
+
mapped: [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}));
|
|
238
|
+
if (changed)
|
|
239
|
+
ctx.reload();
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
function buildHubBody(ctx) {
|
|
243
|
+
const config = hub_config_1.default.read();
|
|
244
|
+
const { invites, peers } = links_1.default.list();
|
|
245
|
+
return {
|
|
246
|
+
hub: ctx.hub(),
|
|
247
|
+
config: config ?? null,
|
|
248
|
+
invites: invites.map(sanitizeInvite),
|
|
249
|
+
peers: peers.map(sanitizePeer),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
152
252
|
/** Routes under /api/ other than the read-only status/hosts. Returns undefined when nothing matched. */
|
|
153
253
|
async function route(req, url, ctx) {
|
|
154
254
|
const method = req.method || "GET";
|
|
@@ -157,10 +257,31 @@ async function route(req, url, ctx) {
|
|
|
157
257
|
const lines = Math.min(Math.max(Number(url.searchParams.get("lines")) || 200, 1), 2000);
|
|
158
258
|
return { status: 200, body: { lines: file_1.default.lastLines("LOGS", lines) } };
|
|
159
259
|
}
|
|
260
|
+
if (path === "/api/hub" && (method === "GET" || method === "HEAD")) {
|
|
261
|
+
return { status: 200, body: buildHubBody(ctx) };
|
|
262
|
+
}
|
|
263
|
+
if (path === "/api/remotes" && (method === "GET" || method === "HEAD")) {
|
|
264
|
+
return { status: 200, body: { remotes: await buildRemoteRows(ctx) } };
|
|
265
|
+
}
|
|
160
266
|
const hostMatch = /^\/api\/hosts\/([^/]+)$/.exec(path);
|
|
267
|
+
const inviteMatch = /^\/api\/invites\/([^/]+)$/.exec(path);
|
|
268
|
+
const peerMatch = /^\/api\/peers\/([^/]+)$/.exec(path);
|
|
269
|
+
const remoteMatch = /^\/api\/remotes\/([^/]+)$/.exec(path);
|
|
270
|
+
const remoteSyncMatch = /^\/api\/remotes\/([^/]+)\/sync$/.exec(path);
|
|
271
|
+
const remoteAliasMatch = /^\/api\/remotes\/([^/]+)\/aliases$/.exec(path);
|
|
272
|
+
const remoteHostsMatch = /^\/api\/remotes\/([^/]+)\/hosts$/.exec(path);
|
|
273
|
+
const remoteHostMatch = /^\/api\/remotes\/([^/]+)\/hosts\/([^/]+)$/.exec(path);
|
|
161
274
|
const known = (path === "/api/hosts" && method === "POST") ||
|
|
162
275
|
(hostMatch && (method === "PUT" || method === "DELETE")) ||
|
|
163
|
-
(["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/cloudflared/install"].includes(path) &&
|
|
276
|
+
(["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/cloudflared/install", "/api/hub", "/api/invites", "/api/remotes"].includes(path) &&
|
|
277
|
+
method === "POST") ||
|
|
278
|
+
(inviteMatch && method === "DELETE") ||
|
|
279
|
+
(peerMatch && (method === "PUT" || method === "DELETE")) ||
|
|
280
|
+
(remoteMatch && (method === "PUT" || method === "DELETE")) ||
|
|
281
|
+
(remoteSyncMatch && method === "POST") ||
|
|
282
|
+
(remoteAliasMatch && method === "POST") ||
|
|
283
|
+
(remoteHostsMatch && method === "POST") ||
|
|
284
|
+
(remoteHostMatch && (method === "PUT" || method === "DELETE"));
|
|
164
285
|
if (!known)
|
|
165
286
|
return undefined;
|
|
166
287
|
assertTrusted(req, ctx);
|
|
@@ -196,6 +317,131 @@ async function route(req, url, ctx) {
|
|
|
196
317
|
const { host: name, entry } = updated;
|
|
197
318
|
return { status: 200, body: { ok: true, host: name, ...entry, url: hostUrl(name, ctx), tunnel: ctx.tunnel(name) } };
|
|
198
319
|
}
|
|
320
|
+
if (path === "/api/hub" && method === "POST") {
|
|
321
|
+
const mode = body.mode;
|
|
322
|
+
if (mode === "named") {
|
|
323
|
+
if (!isValidPublicDomain(body.domain))
|
|
324
|
+
throw new ApiError(400, "`domain` must be a valid public hostname.");
|
|
325
|
+
const tunnel = body.tunnel === undefined ? undefined : String(body.tunnel);
|
|
326
|
+
ctx.setupNamedHub(String(body.domain).trim().toLowerCase(), tunnel);
|
|
327
|
+
}
|
|
328
|
+
else if (mode === "quick") {
|
|
329
|
+
hub_config_1.default.write({ mode: "quick" });
|
|
330
|
+
ctx.reloadHub();
|
|
331
|
+
}
|
|
332
|
+
else if (mode === "manual") {
|
|
333
|
+
if (typeof body.url !== "string" || !body.url.trim())
|
|
334
|
+
throw new ApiError(400, "`url` is required for manual mode.");
|
|
335
|
+
let parsed;
|
|
336
|
+
try {
|
|
337
|
+
parsed = new URL(body.url.trim());
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
throw new ApiError(400, "`url` must be a valid http(s) URL.");
|
|
341
|
+
}
|
|
342
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
343
|
+
throw new ApiError(400, "`url` must be http or https.");
|
|
344
|
+
const config = { mode: "manual", url: body.url.trim().replace(/\/+$/, "") };
|
|
345
|
+
hub_config_1.default.write(config);
|
|
346
|
+
ctx.reloadHub();
|
|
347
|
+
}
|
|
348
|
+
else if (mode === "off") {
|
|
349
|
+
hub_config_1.default.clear();
|
|
350
|
+
ctx.reloadHub();
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
throw new ApiError(400, "`mode` must be one of named, quick, manual, off.");
|
|
354
|
+
}
|
|
355
|
+
return { status: 200, body: { hub: ctx.hub() } };
|
|
356
|
+
}
|
|
357
|
+
if (path === "/api/invites" && method === "POST") {
|
|
358
|
+
if (!isRole(body.role))
|
|
359
|
+
throw new ApiError(400, "`role` must be viewer, editor or admin.");
|
|
360
|
+
const hosts = optionalHosts(body.hosts);
|
|
361
|
+
if (ctx.hub().status !== "up") {
|
|
362
|
+
throw new ApiError(409, "The hub isn't up. Start sharing first.", "locadot hub:quick");
|
|
363
|
+
}
|
|
364
|
+
const { invite, code } = links_1.default.createInvite({ role: body.role, hosts });
|
|
365
|
+
const string = links_1.default.inviteString(ctx.hub().url, code);
|
|
366
|
+
return { status: 200, body: { id: invite.id, code, string, role: invite.role, hosts: invite.hosts, expiresAt: invite.expiresAt } };
|
|
367
|
+
}
|
|
368
|
+
if (inviteMatch && method === "DELETE") {
|
|
369
|
+
links_1.default.revokeInvite(decodeURIComponent(inviteMatch[1]));
|
|
370
|
+
return { status: 200, body: { ok: true } };
|
|
371
|
+
}
|
|
372
|
+
if (peerMatch && method === "PUT") {
|
|
373
|
+
const id = decodeURIComponent(peerMatch[1]);
|
|
374
|
+
if (!isRole(body.role))
|
|
375
|
+
throw new ApiError(400, "`role` must be viewer, editor or admin.");
|
|
376
|
+
const hosts = optionalHosts(body.hosts);
|
|
377
|
+
const peer = links_1.default.setRole(id, body.role, hosts);
|
|
378
|
+
return { status: 200, body: { peer: sanitizePeer(peer) } };
|
|
379
|
+
}
|
|
380
|
+
if (peerMatch && method === "DELETE") {
|
|
381
|
+
links_1.default.revoke(decodeURIComponent(peerMatch[1]));
|
|
382
|
+
return { status: 200, body: { ok: true } };
|
|
383
|
+
}
|
|
384
|
+
if (path === "/api/remotes" && method === "POST") {
|
|
385
|
+
if (typeof body.string !== "string" || !body.string.trim())
|
|
386
|
+
throw new ApiError(400, "`string` is required.");
|
|
387
|
+
const name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : undefined;
|
|
388
|
+
const result = await remotes_1.default.connect(body.string.trim(), { name });
|
|
389
|
+
ctx.reload();
|
|
390
|
+
return { status: 200, body: { ...result, remote: sanitizeRemote(result.remote) } };
|
|
391
|
+
}
|
|
392
|
+
if (remoteMatch && method === "PUT") {
|
|
393
|
+
if (typeof body.url !== "string" || !body.url.trim())
|
|
394
|
+
throw new ApiError(400, "`url` is required.");
|
|
395
|
+
remotes_1.default.setUrl(decodeURIComponent(remoteMatch[1]), body.url.trim());
|
|
396
|
+
return { status: 200, body: { ok: true } };
|
|
397
|
+
}
|
|
398
|
+
if (remoteMatch && method === "DELETE") {
|
|
399
|
+
await remotes_1.default.disconnect(decodeURIComponent(remoteMatch[1]));
|
|
400
|
+
ctx.reload();
|
|
401
|
+
return { status: 200, body: { ok: true } };
|
|
402
|
+
}
|
|
403
|
+
if (remoteSyncMatch && method === "POST") {
|
|
404
|
+
const result = await remotes_1.default.sync(decodeURIComponent(remoteSyncMatch[1]));
|
|
405
|
+
ctx.reload();
|
|
406
|
+
return { status: 200, body: { ...result, remote: sanitizeRemote(result.remote) } };
|
|
407
|
+
}
|
|
408
|
+
if (remoteAliasMatch && method === "POST") {
|
|
409
|
+
if (typeof body.host !== "string" || typeof body.local !== "string") {
|
|
410
|
+
throw new ApiError(400, "`host` and `local` are required.");
|
|
411
|
+
}
|
|
412
|
+
await remotes_1.default.alias(decodeURIComponent(remoteAliasMatch[1]), body.host, body.local);
|
|
413
|
+
ctx.reload();
|
|
414
|
+
return { status: 200, body: { ok: true } };
|
|
415
|
+
}
|
|
416
|
+
if (remoteHostsMatch && method === "POST") {
|
|
417
|
+
if (typeof body.host !== "string" || typeof body.target !== "string") {
|
|
418
|
+
throw new ApiError(400, "`host` and `target` are required.");
|
|
419
|
+
}
|
|
420
|
+
const host = await remotes_1.default.addHost(decodeURIComponent(remoteHostsMatch[1]), {
|
|
421
|
+
host: body.host,
|
|
422
|
+
target: body.target,
|
|
423
|
+
insecure: optionalBool(body.insecure, "insecure"),
|
|
424
|
+
cors: optionalBool(body.cors, "cors"),
|
|
425
|
+
});
|
|
426
|
+
ctx.reload();
|
|
427
|
+
return { status: 201, body: { host } };
|
|
428
|
+
}
|
|
429
|
+
if (remoteHostMatch) {
|
|
430
|
+
const name = decodeURIComponent(remoteHostMatch[1]);
|
|
431
|
+
const hostName = decodeURIComponent(remoteHostMatch[2]);
|
|
432
|
+
if (method === "DELETE") {
|
|
433
|
+
await remotes_1.default.removeHost(name, hostName);
|
|
434
|
+
ctx.reload();
|
|
435
|
+
return { status: 200, body: { ok: true } };
|
|
436
|
+
}
|
|
437
|
+
const host = await remotes_1.default.updateHost(name, hostName, {
|
|
438
|
+
target: optionalString(body.target, "target"),
|
|
439
|
+
insecure: optionalBool(body.insecure, "insecure"),
|
|
440
|
+
cors: optionalBool(body.cors, "cors"),
|
|
441
|
+
tunnel: optionalBool(body.tunnel, "tunnel"),
|
|
442
|
+
});
|
|
443
|
+
return { status: 200, body: { host } };
|
|
444
|
+
}
|
|
199
445
|
}
|
|
200
446
|
catch (error) {
|
|
201
447
|
if (error instanceof hosts_1.NotFoundError)
|
|
@@ -206,6 +452,12 @@ async function route(req, url, ctx) {
|
|
|
206
452
|
throw new ApiError(400, clean(error.message));
|
|
207
453
|
if (error instanceof registry_1.RegistryError)
|
|
208
454
|
throw new ApiError(500, clean(error.message));
|
|
455
|
+
if (error instanceof links_1.LinkError)
|
|
456
|
+
throw new ApiError(error.status, clean(error.message));
|
|
457
|
+
if (error instanceof remotes_1.RemoteError)
|
|
458
|
+
throw new ApiError(error.status || 502, clean(error.message));
|
|
459
|
+
if (error instanceof ApiError)
|
|
460
|
+
throw error;
|
|
209
461
|
throw error;
|
|
210
462
|
}
|
|
211
463
|
switch (path) {
|