locadot 1.6.0-beta.7 → 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 +11 -5
- package/README.md +75 -11
- 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 +882 -17
- 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,12 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 2.1.0-beta.0 (2026-09-27)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
|
|
13
|
+
## 2.0.0 (2026-09-26)
|
|
7
14
|
|
|
8
15
|
### Changed
|
|
9
|
-
- Dashboard: two-column layout, host filter, and a System / Light / Dark theme toggle (saved per browser).
|
|
10
16
|
- Internal restructure into `cli/commands/*`, `server/*` and `proxy/*` modules. There are no behaviour changes; see `tasks/README.md`.
|
|
11
17
|
|
|
12
18
|
### Breaking
|
|
@@ -18,7 +24,7 @@ breaking items below.
|
|
|
18
24
|
|
|
19
25
|
### Added
|
|
20
26
|
- `locadot tunnel --host <h>` (with `--off`, and `tunnel:install`) shares a mapping on a public `https://*.trycloudflare.com` URL through a Cloudflare quick tunnel, with no account needed. `cloudflared` is downloaded on demand into the state dir. The dashboard has a Cloudflare Tunnel card, a Public URL column and Share/Unshare per row. The API accepts `PUT /api/hosts/:host {"tunnel": true}` and has `POST /api/cloudflared/install`. `--cors` also works for tunnel visitors (the shim, preflights and origin rewriting to the public URL), but their pass-through calls may only reach public addresses; loopback, LAN and metadata addresses are refused at connect time.
|
|
21
|
-
- The dashboard is redesigned: a
|
|
27
|
+
- The dashboard is redesigned: a two-column layout, a host filter, loading/offline/empty states, cards on small screens, and a system / light / dark theme toggle.
|
|
22
28
|
- `locadot start --port <http> --https-port <https>` (also on `restart`) runs the proxy on other ports than 80/443. The choice is saved in the state dir, so `add`, `status`, `open` and start-at-boot keep using it; `--port 80 --https-port 443` goes back. Running it while the proxy is up on other ports restarts it on the new ones.
|
|
23
29
|
- `--cors` per mapping (CLI, API and dashboard). `Origin`/`Referer` are sent as the target's own origin, CORS preflights are answered locally, and any origin may call the domain with credentials. Absolute URLs of other mapped domains in text responses are rewritten to their `.localhost` names, and a mapped caller is sent as its real origin, so a site plus its API both work locally. Pages also get a small script that sends calls to any other origin through the page's own origin (`/__locadot/x/…`), so no mapping per API or third-party domain is needed. (ENH-12, BUG-15)
|
|
24
30
|
- A control panel at `https://localhost` with a dark theme. You can add, edit and remove mappings, toggle CA trust and start-at-boot, see root/admin and privileged-port status,
|
package/README.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# locadot 🔐
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/locadot)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
HTTPS custom domains for local development. Point `https://app.localhost` at your dev server on port 3000, or
|
|
4
|
-
`https://google.localhost` at any upstream URL,
|
|
7
|
+
`https://google.localhost` at any upstream URL, share any of them on a public URL, and manage it all from a dashboard at
|
|
8
|
+
`https://localhost`.
|
|
9
|
+
|
|
10
|
+
<picture>
|
|
11
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/dashboard-dark.png">
|
|
12
|
+
<img alt="The locadot dashboard: hosts with health, traffic and public URLs, plus add-host, system and sharing panels" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/dashboard-light.png">
|
|
13
|
+
</picture>
|
|
5
14
|
|
|
6
15
|
## ✨ Features
|
|
7
16
|
|
|
@@ -9,8 +18,13 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
|
|
|
9
18
|
Run `locadot trust` once to add the CA to your system and browsers.
|
|
10
19
|
- 🔁 **Any destination.** A local port (`--port 3000`), a host and port (`--target 192.168.1.5:8080`), or any URL
|
|
11
20
|
(`--target https://google.com`). Redirects and cookies are rewritten so the browser stays on the `.localhost` name.
|
|
12
|
-
-
|
|
13
|
-
|
|
21
|
+
- 🌍 **Share on a public URL.** `locadot tunnel --host app.localhost` (or **Share** in the dashboard) puts a mapping on
|
|
22
|
+
`https://<random>.trycloudflare.com` through a Cloudflare quick tunnel. No account needed.
|
|
23
|
+
- 🧩 **`--cors` for sites that call other domains.** Preflights are answered locally and cross-origin calls are routed
|
|
24
|
+
through the page's own origin, so a production frontend works against its real APIs from `.localhost`.
|
|
25
|
+
- 📋 **Control panel at `https://localhost`.** Add, edit, filter and remove mappings, see health and traffic, share them,
|
|
26
|
+
and toggle CA trust and start-at-boot. Light, dark or system theme. Includes a token-protected JSON API, so scripts and
|
|
27
|
+
AI agents can do everything the page does.
|
|
14
28
|
- 🔌 **WebSockets** are proxied too, so HMR and dev-server live reload work.
|
|
15
29
|
- 🩺 **`status` and `doctor`** show why a domain isn't working: port conflicts, permissions, CA trust, a target that isn't answering.
|
|
16
30
|
- 🛡️ **Local by default.** The proxy binds to `127.0.0.1` / `::1` only. Domains must end in `.localhost`, which browsers
|
|
@@ -22,6 +36,7 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
|
|
|
22
36
|
## 🚀 Quick start
|
|
23
37
|
|
|
24
38
|
```bash
|
|
39
|
+
npm i -g locadot # or prefix each command with npx
|
|
25
40
|
npx locadot trust # once: trust the locadot CA (asks for your password)
|
|
26
41
|
npx locadot add --host app.localhost --port 3000 # https://app.localhost → http://localhost:3000
|
|
27
42
|
npx locadot add --host google.localhost --target https://google.com
|
|
@@ -86,6 +101,30 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
|
|
|
86
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:
|
|
87
102
|
use **Share** / **Unshare** on a row.
|
|
88
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
|
+
|
|
89
128
|
### Certificates
|
|
90
129
|
|
|
91
130
|
| Command | What it does |
|
|
@@ -108,15 +147,27 @@ use **Share** / **Unshare** on a row.
|
|
|
108
147
|
## 📋 Dashboard & control panel
|
|
109
148
|
|
|
110
149
|
Open `https://localhost` or `http://localhost` (or run `locadot open`). Requests for bare `localhost` / `127.0.0.1` / `::1`
|
|
111
|
-
are answered by locadot itself, not proxied.
|
|
150
|
+
are answered by locadot itself, not proxied. On wide screens the page is a two-column layout: your hosts on the left,
|
|
151
|
+
controls on the right. The button at the top right switches between the system, light and dark themes (saved per browser).
|
|
152
|
+
|
|
153
|
+
<img alt="Hosts table with status, latency, traffic, a shared public URL and per-row actions" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/hosts.png">
|
|
112
154
|
|
|
113
|
-
- **
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
- **
|
|
118
|
-
|
|
119
|
-
- **
|
|
155
|
+
- **Hosts:** every mapping with its target, options (`cors`, insecure TLS), up/down status, latency, traffic (hits, errors,
|
|
156
|
+
average ms) and public tunnel URL. Filter the list, edit a target inline (Enter saves, Esc cancels), **Share** / **Unshare**
|
|
157
|
+
or remove it. A host whose target doesn't answer is marked in red. It refreshes every 5 s.
|
|
158
|
+
- **Add host:** host + port / host:port / URL, with optional "Insecure TLS" and "Bypass CORS" boxes.
|
|
159
|
+
- **System:** ports, bind addresses and state dir, whether the proxy runs as root/admin, whether ports below 1024 can be bound
|
|
160
|
+
(with the Linux sysctl fix when they can't), CA trust and start-at-boot toggles, and a **Stop proxy** button.
|
|
161
|
+
- **Sharing:** whether `cloudflared` is installed, with an **Install** button when it isn't.
|
|
162
|
+
- **Logs:** a live tail with refresh and clear. **CLI & API:** snippets you can copy.
|
|
163
|
+
|
|
164
|
+
If the proxy stops, the page says so, disables the controls and reconnects by itself when it's back. On phones and tablets
|
|
165
|
+
hosts become cards:
|
|
166
|
+
|
|
167
|
+
<p>
|
|
168
|
+
<img alt="Dashboard on a phone, light theme" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/mobile.png" width="260">
|
|
169
|
+
<img alt="Dashboard on a phone, dark theme" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/mobile-dark.png" width="260">
|
|
170
|
+
</p>
|
|
120
171
|
|
|
121
172
|
Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit on Linux, the password dialog on macOS, UAC on Windows).
|
|
122
173
|
If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
|
|
@@ -160,6 +211,19 @@ The dashboard is served only for the bare `localhost` host names, which also blo
|
|
|
160
211
|
|
|
161
212
|
---
|
|
162
213
|
|
|
214
|
+
## ⬆️ Upgrading from 1.x
|
|
215
|
+
|
|
216
|
+
2.0 changes a few defaults. Your mappings are migrated automatically.
|
|
217
|
+
|
|
218
|
+
- The proxy listens on `127.0.0.1` and `::1` only. Set `LOCADOT_BIND=0.0.0.0` if you relied on LAN access.
|
|
219
|
+
- The registry is stored in a new format that 1.x can't read. Don't downgrade without backing up `locadot path:hosts`.
|
|
220
|
+
- `-h` means `--host` on every command. Help is `--help`.
|
|
221
|
+
- Bare `localhost` is the dashboard and can't be mapped.
|
|
222
|
+
|
|
223
|
+
See [CHANGELOG.md](CHANGELOG.md) for everything new.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
163
227
|
## ⚙️ Configuration
|
|
164
228
|
|
|
165
229
|
All optional, via environment variables. Set them for the CLI; the proxy it starts inherits them.
|
|
@@ -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 = {
|