locadot 2.1.0 β 2.3.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 +32 -0
- package/README.md +56 -4
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/remote.js +21 -1
- package/dist/cli/commands/tunnel.js +9 -3
- package/dist/dashboard/api.js +35 -5
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/login.js +17 -6
- package/dist/dashboard/page.js +778 -86
- package/dist/index.js +16 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/hosts.js +52 -4
- package/dist/lib/ui-auth.js +5 -0
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/hub.js +11 -3
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/request.js +3 -1
- package/dist/proxy/router.js +38 -12
- package/dist/proxy/tunnel.js +62 -22
- package/dist/server/index.js +2 -0
- package/dist/utils/startup.js +124 -34
- package/dist/utils/trust.js +52 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.0 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Share on your own hostname: `locadot tunnel --host app.localhost --domain app.example.com`, or **Share** in the
|
|
7
|
+
dashboard and choose **Custom domain**. Runs a named Cloudflare tunnel for the mapping (login once, the DNS record is
|
|
8
|
+
created for you), so the URL doesn't change. `PUT /api/hosts/:host {"tunnel": true, "domain": "β¦"}`.
|
|
9
|
+
- The dashboard no longer uses the browser's confirm and prompt boxes; confirmations and inputs open in-app dialogs.
|
|
10
|
+
- Internal access for shared mappings: `locadot allow --host app.localhost localhost:3000` (or **Access** on a row in the
|
|
11
|
+
dashboard) lets tunnel visitors and connected machines, viewers included, reach the listed internal addresses through
|
|
12
|
+
that `--cors` domain's pass-through. Normally they're limited to public hosts. locadot's own ports stay blocked.
|
|
13
|
+
`add --allow`, `PUT /api/hosts/:host {"allow": [...]}`.
|
|
14
|
+
|
|
15
|
+
## 2.2.0 (2026-09-29)
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Share the dashboard in a browser: `locadot hub:panel on` (or **Share this dashboard** under Remote access) serves the
|
|
19
|
+
control panel at the hub's public URL, behind the dashboard password. It can't be turned on without a password, and
|
|
20
|
+
removing the password turns it off. The visitor's browser never gets the API token; sign-in is rate limited per visitor IP.
|
|
21
|
+
`GET /api/hub` reports `panel`; `PUT /api/hub/panel` turns it on or off.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Start at boot on macOS: `sudo locadot startup:enable` wrote a LaunchAgent into root's home, which never runs; it now
|
|
25
|
+
installs a LaunchDaemon that starts at boot, before login, as the sudo user with their state dir. Without sudo, the
|
|
26
|
+
LaunchAgent (starts at login) now clears a disabled override left by an earlier `startup:disable`, carries port
|
|
27
|
+
overrides, escapes paths, and survives a dashboard restart. Enabling or disabling no longer stops a running proxy.
|
|
28
|
+
- `locadot trust` on macOS failed with "The authorization was denied since no user interaction was possible". It ran
|
|
29
|
+
`security` through an osascript admin prompt, which can't show the trust-settings dialog macOS requires; it now runs
|
|
30
|
+
`sudo security` in the terminal (or `security` directly from the dashboard). `sudo locadot trust` is refused on macOS,
|
|
31
|
+
since it trusted root's CA rather than the one the proxy serves.
|
|
32
|
+
- Through a tunnel or remote access, a stopped or unmapped upstream is now a 503 with locadot's "upstream unreachable"
|
|
33
|
+
page. With 502, Cloudflare replaced the page with its own "origin bad gateway" error, which hid the real cause.
|
|
34
|
+
|
|
3
35
|
## 2.1.0 (2026-09-27)
|
|
4
36
|
|
|
5
37
|
### Added
|
package/README.md
CHANGED
|
@@ -19,7 +19,13 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
|
|
|
19
19
|
- π **Any destination.** A local port (`--port 3000`), a host and port (`--target 192.168.1.5:8080`), or any URL
|
|
20
20
|
(`--target https://google.com`). Redirects and cookies are rewritten so the browser stays on the `.localhost` name.
|
|
21
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.
|
|
22
|
+
`https://<random>.trycloudflare.com` through a Cloudflare quick tunnel. No account needed. Or put it on your own
|
|
23
|
+
hostname (`--domain app.example.com`) through a named tunnel in your Cloudflare account, with a URL that never changes.
|
|
24
|
+
- π **Remote access, locadot to locadot.** Publish your whole locadot on a Cloudflare hostname and let other machines pair
|
|
25
|
+
with it as viewer, editor or admin. Your mappings show up in their locadot. Admins can also open your dashboard and any
|
|
26
|
+
port on your localhost.
|
|
27
|
+
- π₯οΈ **Share the dashboard in a browser.** `locadot hub:panel on` serves the control panel at the hub's public URL, behind the
|
|
28
|
+
dashboard password, so you can manage this machine from anywhere without installing locadot there.
|
|
23
29
|
- π§© **`--cors` for sites that call other domains.** Preflights are answered locally and cross-origin calls are routed
|
|
24
30
|
through the page's own origin, so a production frontend works against its real APIs from `.localhost`.
|
|
25
31
|
- π **Control panel at `https://localhost`.** Add, edit, filter and remove mappings, see health and traffic, share them,
|
|
@@ -59,6 +65,7 @@ npx locadot open # opens the dashboard at htt
|
|
|
59
65
|
| `locadot add --host <name>.localhost --target <url>` | Map a domain to any upstream: `3000`, `127.0.0.1:8080`, `http://10.0.0.5:8080/app`, `https://google.com`. |
|
|
60
66
|
| `locadot update --host <name>.localhost --port/--target β¦` | Change the destination of an existing domain. |
|
|
61
67
|
| `locadot remove --host <name>.localhost` (`rm`) | Remove a domain. |
|
|
68
|
+
| `locadot allow --host <name>.localhost [addressesβ¦] [--rm] [--clear]` | Let people on a tunnel or remote access reach internal addresses (`localhost:3000`) through a `--cors` domain. With no addresses, shows the list. Also `add β¦ --allow a,b`. |
|
|
62
69
|
| `locadot list` (`ls`, `host`) `[--json]` | Show all mappings. |
|
|
63
70
|
| `locadot clear:hosts` | Remove all mappings. |
|
|
64
71
|
| `locadot open [host]` | Open a domain, or the dashboard when no host is given, in your browser. |
|
|
@@ -89,7 +96,8 @@ Options for `add` / `update`:
|
|
|
89
96
|
| Command | What it does |
|
|
90
97
|
| --- | --- |
|
|
91
98
|
| `locadot tunnel --host app.localhost` | Share a mapping on a public `https://<random>.trycloudflare.com` URL through a Cloudflare quick tunnel. No Cloudflare account is needed. `cloudflared` is downloaded on first use. |
|
|
92
|
-
| `locadot tunnel --host app.localhost --
|
|
99
|
+
| `locadot tunnel --host app.localhost --domain app.example.com` | Share it on your own hostname instead, on a domain in your Cloudflare account. The first time, `cloudflared` asks you to log in (the link is printed, and shown on the row in the dashboard). locadot creates a named tunnel for the mapping (`locadot-app-localhost`), points the hostname's DNS at it (replacing an existing record) and runs it. The URL stays the same across restarts. |
|
|
100
|
+
| `locadot tunnel --host app.localhost --off` | Stop sharing it. The custom hostname is remembered for the dashboard; sharing again without `--domain` uses a random URL. |
|
|
93
101
|
| `locadot tunnel` | List the shared mappings and their public URLs. |
|
|
94
102
|
| `locadot tunnel:install` | Download `cloudflared` into the state dir (`<state>/bin`). Set `LOCADOT_CLOUDFLARED` to use your own binary; one on `PATH` is also picked up. |
|
|
95
103
|
|
|
@@ -101,6 +109,17 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
|
|
|
101
109
|
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
110
|
use **Share** / **Unshare** on a row.
|
|
103
111
|
|
|
112
|
+
**Internal access.** If the shared frontend calls an internal API (`http://localhost:3000`), allow that address on the
|
|
113
|
+
mapping, and tunnel visitors and connected machines (viewers included) can then reach it through the page:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
locadot allow --host app.localhost localhost:3000 # add; `--rm` removes, `--clear` empties, no address lists
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
In the dashboard, click **Access** on the row. An entry is `host:port`, or a host alone for any of its ports. `127.0.0.1`
|
|
120
|
+
and `0.0.0.0` count as `localhost`. Only the page's own requests can use it, and locadot's own ports stay blocked even
|
|
121
|
+
when all of `localhost` is allowed. Allow only what the app needs: anyone who can open the mapping can reach these addresses.
|
|
122
|
+
|
|
104
123
|
### Remote access (locadot to locadot)
|
|
105
124
|
|
|
106
125
|
One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
|
|
@@ -110,8 +129,10 @@ short-lived pairing string, and the sender's mappings then show up in the receiv
|
|
|
110
129
|
| --- | --- |
|
|
111
130
|
| `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
131
|
| `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`. |
|
|
132
|
+
| `locadot hub:manual --url <url>` | Sender: publish behind a URL something else already forwards to the proxy's HTTP port (ngrok, a reverse proxy). |
|
|
113
133
|
| `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
|
|
114
134
|
| `locadot hub:localhost <on\|off>` | Sender: allow or block admin peers from opening this machine's dashboard and any port on its localhost. |
|
|
135
|
+
| `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). |
|
|
115
136
|
| `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. |
|
|
116
137
|
| `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
|
|
117
138
|
| `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`. |
|
|
@@ -137,6 +158,31 @@ headers work as they do locally, and origins in the sender's pages are rewritten
|
|
|
137
158
|
An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
|
|
138
159
|
`remote:sync` picks up a sender turning `--cors` on or off.
|
|
139
160
|
|
|
161
|
+
When a target is down, requests through the hub or a public share get a **503** with locadot's "upstream unreachable" page.
|
|
162
|
+
Cloudflare replaces an origin's 502 with its own generic "bad gateway" error, which would hide the real cause.
|
|
163
|
+
|
|
164
|
+
#### Sharing the dashboard in a browser
|
|
165
|
+
|
|
166
|
+
Remote access above needs locadot on both machines. To manage this machine from a browser anywhere (a laptop, a phone),
|
|
167
|
+
share the dashboard itself on the hub's public URL:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
locadot ui:password # required: visitors sign in with it
|
|
171
|
+
locadot hub:panel on # or the "Share this dashboard" switch under Remote access
|
|
172
|
+
locadot hub # shows the URL, e.g. https://dev.example.com
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Opening the hub URL in a browser then shows the sign-in page, and after that the full dashboard. Paired machines keep
|
|
176
|
+
working on the same URL, since they authenticate with their own tokens. Some details:
|
|
177
|
+
|
|
178
|
+
- It can't be turned on without a dashboard password, and removing the password turns sharing off.
|
|
179
|
+
- The session cookie is `HttpOnly`, `SameSite=Strict` and `Secure` on https. Wrong passwords are rate limited per visitor IP.
|
|
180
|
+
- The browser never gets this machine's API token. Changes are only accepted from the page's own origin.
|
|
181
|
+
- Only the dashboard is shared. Your mappings stay reachable only by paired machines, and links in the dashboard still point
|
|
182
|
+
at `*.localhost` names, which open on the visitor's own machine.
|
|
183
|
+
- Anyone with the password controls this locadot, including remote access and stopping the proxy. Use a strong one, or
|
|
184
|
+
prefer a named tunnel (`hub:setup`) behind Cloudflare Access if you need more.
|
|
185
|
+
|
|
140
186
|
### Certificates
|
|
141
187
|
|
|
142
188
|
| Command | What it does |
|
|
@@ -152,7 +198,7 @@ An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` m
|
|
|
152
198
|
| `locadot clear:logs` | Clear the log file. |
|
|
153
199
|
| `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
|
|
154
200
|
| `locadot token` | Print the dashboard API token, for scripts and AI agents. |
|
|
155
|
-
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron
|
|
201
|
+
| `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon with `sudo`, running as you) or logon (macOS LaunchAgent without `sudo`; a hidden script in the Windows Startup folder, no admin needed). |
|
|
156
202
|
|
|
157
203
|
---
|
|
158
204
|
|
|
@@ -184,6 +230,8 @@ hosts become cards:
|
|
|
184
230
|
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).
|
|
185
231
|
If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
|
|
186
232
|
`locadot startup:enable`, and so on.
|
|
233
|
+
On macOS, run `locadot trust` without `sudo`: it asks for your password itself, and macOS then confirms the trust change in a
|
|
234
|
+
dialog, which only works in Terminal on the Mac, not over SSH. Under `sudo` it would trust root's own CA instead of yours.
|
|
187
235
|
|
|
188
236
|
### Dashboard password
|
|
189
237
|
|
|
@@ -196,6 +244,7 @@ echo "$PW" | locadot ui:password --stdin
|
|
|
196
244
|
```
|
|
197
245
|
|
|
198
246
|
You can also set, change or remove it under **Settings β Dashboard password** (changing or removing it there needs the current one).
|
|
247
|
+
It's also what [sharing the dashboard in a browser](#sharing-the-dashboard-in-a-browser) signs visitors in with.
|
|
199
248
|
Only the dashboard is protected. Your mapped hosts work as before, and so do scripts and the CLI that send `X-Locadot-Token`.
|
|
200
249
|
Sessions last 7 days, and changing the password signs everyone out. If you forget it, run `locadot ui:password` again in a terminal.
|
|
201
250
|
|
|
@@ -212,6 +261,8 @@ each time the proxy starts.
|
|
|
212
261
|
| `GET /api/logs?lines=200` | | `{ lines: [...] }` |
|
|
213
262
|
| `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
|
|
214
263
|
| `PUT /api/hosts/:host` | `{ "target": "https://example.com", "insecure": false, "cors": true }` | Change a mapping (404 if unknown). |
|
|
264
|
+
| `PUT /api/hosts/:host` | `{ "allow": ["localhost:3000"] }` | Replace the internal addresses shared visitors may reach (normalized; `[]` clears; 400 if one is invalid). |
|
|
265
|
+
| `PUT /api/hosts/:host` | `{ "tunnel": true, "domain": "app.example.com" }` | Share on a public URL: with `domain`, on that hostname through a named Cloudflare tunnel, without it on a random trycloudflare.com URL. `{ "tunnel": false }` stops sharing (400 for a bad hostname, 409 if another mapping uses it). |
|
|
215
266
|
| `DELETE /api/hosts/:host` | | Remove a mapping. |
|
|
216
267
|
| `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
|
|
217
268
|
| `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
|
|
@@ -221,8 +272,9 @@ each time the proxy starts.
|
|
|
221
272
|
| `PUT /api/settings` | `{ "httpPort": 8080, "httpsPort": 8443 }` | Save new ports to the config file (400 if invalid or equal). Takes effect after a restart. |
|
|
222
273
|
| `POST /api/proxy/restart` | | Restart the proxy (e.g. to pick up saved ports). |
|
|
223
274
|
| `PUT /api/settings/ui-password` | `{ "password": "β¦", "current": "β¦" }` or `{ "enabled": false, "current": "β¦" }` | Set, change or remove the dashboard password (`current` is required once one is set). |
|
|
224
|
-
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on). |
|
|
275
|
+
| `GET /api/hub` | | Hub status/config, plus `localhost` (sender: is localhost access on) and `panel` (is the dashboard shared). |
|
|
225
276
|
| `PUT /api/hub/localhost` | `{ "enabled": true }` | Sender: turn localhost access for admin peers on/off (400 if the hub isn't configured). |
|
|
277
|
+
| `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). |
|
|
226
278
|
| `GET /api/remotes` | | Every remote (never the token), including `domain` and `localhost`. |
|
|
227
279
|
| `POST /api/remotes` | `{ "string": "<pairing string>", "name": "alice", "domain": "dev" }` | Receiver: connect using a pairing string; `domain` is optional (admin only, random if omitted). |
|
|
228
280
|
| `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). |
|
|
@@ -38,12 +38,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.add = add;
|
|
40
40
|
exports.update = update;
|
|
41
|
+
exports.allow = allow;
|
|
41
42
|
exports.remove = remove;
|
|
42
43
|
exports.list = list;
|
|
43
44
|
exports.clearHosts = clearHosts;
|
|
44
45
|
const localhost_1 = __importStar(require("../../lib/localhost"));
|
|
45
46
|
const registry_1 = __importDefault(require("../../lib/registry"));
|
|
46
47
|
const hosts_1 = __importDefault(require("../../lib/hosts"));
|
|
48
|
+
const allow_1 = require("../../lib/allow");
|
|
47
49
|
const urls_1 = require("../../lib/urls");
|
|
48
50
|
const shared_1 = require("../shared");
|
|
49
51
|
const resolveTarget = (options) => {
|
|
@@ -61,6 +63,7 @@ const mapping = (options) => ({
|
|
|
61
63
|
target: resolveTarget(options),
|
|
62
64
|
insecure: options.insecure,
|
|
63
65
|
cors: options.cors,
|
|
66
|
+
allow: options.allow,
|
|
64
67
|
});
|
|
65
68
|
const warnIfDown = async (entry) => {
|
|
66
69
|
const probe = await localhost_1.default.probe(entry.target, 1500, entry.insecure);
|
|
@@ -82,6 +85,31 @@ async function update(options) {
|
|
|
82
85
|
(0, shared_1.print)(`β
Updated ${(0, urls_1.urlFor)(host)} β ${entry.target}`);
|
|
83
86
|
await warnIfDown(entry);
|
|
84
87
|
}
|
|
88
|
+
async function allow(addresses, options) {
|
|
89
|
+
const host = localhost_1.default.requireHost(options.host);
|
|
90
|
+
const entry = registry_1.default.read().hosts[host];
|
|
91
|
+
if (!entry)
|
|
92
|
+
throw new localhost_1.InputError(`β ${host} isn't mapped. Add it first: locadot add --host ${host} --port <port> --cors`);
|
|
93
|
+
if (options.clear && addresses.length)
|
|
94
|
+
throw new localhost_1.InputError("β Use either --clear or addresses, not both.");
|
|
95
|
+
if (options.rm && !addresses.length)
|
|
96
|
+
throw new localhost_1.InputError("β Pass the addresses to remove.");
|
|
97
|
+
const current = entry.allow ?? [];
|
|
98
|
+
let next = current;
|
|
99
|
+
if (options.clear)
|
|
100
|
+
next = [];
|
|
101
|
+
else if (options.rm) {
|
|
102
|
+
const drop = new Set((0, allow_1.parseAllowList)(addresses));
|
|
103
|
+
next = current.filter((item) => !drop.has(item));
|
|
104
|
+
}
|
|
105
|
+
else if (addresses.length)
|
|
106
|
+
next = [...current, ...(0, allow_1.parseAllowList)(addresses)];
|
|
107
|
+
const { entry: saved } = next === current ? { entry } : await hosts_1.default.setAllow({ host, allow: next });
|
|
108
|
+
const list = saved.allow ?? [];
|
|
109
|
+
(0, shared_1.print)(list.length ? `π ${(0, urls_1.urlFor)(host)} lets shared visitors reach: ${list.join(", ")}` : `π ${(0, urls_1.urlFor)(host)} lets shared visitors reach public hosts only.`);
|
|
110
|
+
if (list.length && !saved.cors)
|
|
111
|
+
(0, shared_1.print)(`βΉοΈ Takes effect once --cors is on: locadot update --host ${host} --target ${saved.target} --cors`);
|
|
112
|
+
}
|
|
85
113
|
async function remove(options) {
|
|
86
114
|
const { host } = await hosts_1.default.remove(options);
|
|
87
115
|
(0, shared_1.print)(`ποΈ Removed ${host}.`);
|
|
@@ -98,7 +126,7 @@ async function list(options) {
|
|
|
98
126
|
}
|
|
99
127
|
const width = Math.max(...hosts.map(([host]) => (0, urls_1.urlFor)(host).length));
|
|
100
128
|
for (const [host, entry] of hosts) {
|
|
101
|
-
(0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} β ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}`);
|
|
129
|
+
(0, shared_1.print)(`${(0, urls_1.urlFor)(host).padEnd(width)} β ${entry.target}${entry.insecure ? " (insecure)" : ""}${entry.cors ? " (cors)" : ""}${entry.allow?.length ? ` (allow ${entry.allow.join(", ")})` : ""}`);
|
|
102
130
|
}
|
|
103
131
|
(0, shared_1.print)(`βοΈ Total: ${hosts.length}.`);
|
|
104
132
|
}
|
|
@@ -9,6 +9,7 @@ exports.hubQuick = hubQuick;
|
|
|
9
9
|
exports.hubManual = hubManual;
|
|
10
10
|
exports.hubOff = hubOff;
|
|
11
11
|
exports.hubLocalhost = hubLocalhost;
|
|
12
|
+
exports.hubPanel = hubPanel;
|
|
12
13
|
exports.share = share;
|
|
13
14
|
exports.peers = peers;
|
|
14
15
|
exports.peersRole = peersRole;
|
|
@@ -102,7 +103,7 @@ const getHub = async () => {
|
|
|
102
103
|
};
|
|
103
104
|
const HUB_EMOJI = { off: "π", starting: "β³", up: "π", error: "β", login: "π" };
|
|
104
105
|
async function hub(options) {
|
|
105
|
-
const { hub } = await getHub();
|
|
106
|
+
const { hub, panel } = await getHub();
|
|
106
107
|
if (options.json) {
|
|
107
108
|
console.log(JSON.stringify(hub, null, 2));
|
|
108
109
|
return;
|
|
@@ -116,6 +117,8 @@ async function hub(options) {
|
|
|
116
117
|
(0, shared_1.print)(` Open to finish login: ${hub.loginUrl}`);
|
|
117
118
|
if (hub.error)
|
|
118
119
|
(0, shared_1.print)(` ${hub.error}`);
|
|
120
|
+
if (panel)
|
|
121
|
+
(0, shared_1.print)(` Dashboard shared${hub.url ? ` at ${hub.url}` : ""} (password sign-in). Turn off: locadot hub:panel off`);
|
|
119
122
|
}
|
|
120
123
|
async function hubSetup(options) {
|
|
121
124
|
if (!options.domain)
|
|
@@ -172,6 +175,23 @@ async function hubLocalhost(value) {
|
|
|
172
175
|
throw new localhost_1.InputError(`β Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
173
176
|
(0, shared_1.print)(json.localhost ? "π₯οΈ Admin peers can now reach this machine's localhost." : "π Admin peers can no longer reach this machine's localhost.");
|
|
174
177
|
}
|
|
178
|
+
async function hubPanel(value) {
|
|
179
|
+
if (value !== "on" && value !== "off")
|
|
180
|
+
throw new localhost_1.InputError('β Use "on" or "off".');
|
|
181
|
+
requireRunning();
|
|
182
|
+
const { status, json } = await dashboardRequest("PUT", "/api/hub/panel", {
|
|
183
|
+
headers: { "X-Locadot-Token": apiToken() },
|
|
184
|
+
body: { enabled: value === "on" },
|
|
185
|
+
});
|
|
186
|
+
if (status === 400)
|
|
187
|
+
throw new localhost_1.InputError(`β ${json?.error || "Set up the hub first: locadot hub:setup / hub:quick / hub:manual."}${json?.hint ? ` ${json.hint}` : ""}`);
|
|
188
|
+
if (status !== 200)
|
|
189
|
+
throw new localhost_1.InputError(`β Couldn't update (${status})${json?.error ? `: ${json.error}` : ""}.`);
|
|
190
|
+
if (!json.panel)
|
|
191
|
+
return (0, shared_1.print)("π The dashboard is no longer shared.");
|
|
192
|
+
const { hub } = await getHub();
|
|
193
|
+
(0, shared_1.print)(`π The dashboard is shared${hub.url ? ` at ${hub.url}` : ""}. Visitors sign in with the dashboard password.`);
|
|
194
|
+
}
|
|
175
195
|
async function share(options) {
|
|
176
196
|
const role = requireRole(options.role);
|
|
177
197
|
if (options.hosts && role !== "viewer") {
|
|
@@ -73,7 +73,7 @@ async function installTunnel() {
|
|
|
73
73
|
const info = await (0, tunnel_1.installCloudflared)();
|
|
74
74
|
(0, shared_1.print)(`β
cloudflared ${info.version ?? ""} installed at ${info.path}`);
|
|
75
75
|
}
|
|
76
|
-
/** Shares a mapping on a public trycloudflare.com URL
|
|
76
|
+
/** Shares a mapping on a public trycloudflare.com URL or `--domain`, or lists what's shared when no host is given. */
|
|
77
77
|
async function tunnel(options) {
|
|
78
78
|
if (!options.host) {
|
|
79
79
|
const shared = await tunnelRows();
|
|
@@ -91,11 +91,17 @@ async function tunnel(options) {
|
|
|
91
91
|
}
|
|
92
92
|
if (!(0, tunnel_1.cloudflaredInfo)(true).installed)
|
|
93
93
|
await installTunnel();
|
|
94
|
-
await hosts_1.default.setTunnel({ host, tunnel: true });
|
|
94
|
+
await hosts_1.default.setTunnel({ host, tunnel: true, domain: options.domain });
|
|
95
95
|
await (0, shared_1.ensureRunning)();
|
|
96
|
-
|
|
96
|
+
let deadline = Date.now() + TUNNEL_TIMEOUT_MS;
|
|
97
|
+
let loginShown = false;
|
|
97
98
|
while (Date.now() < deadline) {
|
|
98
99
|
const state = (await tunnelRows()).find((row) => row.host === host)?.tunnel;
|
|
100
|
+
if (state?.status === "login" && state.loginUrl && !loginShown) {
|
|
101
|
+
loginShown = true;
|
|
102
|
+
deadline = Date.now() + 10 * 60000;
|
|
103
|
+
(0, shared_1.print)(`π Log in to Cloudflare to finish: ${state.loginUrl}`);
|
|
104
|
+
}
|
|
99
105
|
if (state?.status === "up") {
|
|
100
106
|
(0, shared_1.print)(`π ${host} is public at ${state.url}`);
|
|
101
107
|
(0, shared_1.print)(" Anyone with the link can reach it. Stop with: locadot tunnel --host " + host + " --off");
|
package/dist/dashboard/api.js
CHANGED
|
@@ -321,6 +321,7 @@ function buildHubBody(ctx) {
|
|
|
321
321
|
hub: ctx.hub(),
|
|
322
322
|
config: config ?? null,
|
|
323
323
|
localhost: config?.localhost !== false,
|
|
324
|
+
panel: config?.panel === true,
|
|
324
325
|
invites: invites.map(sanitizeInvite),
|
|
325
326
|
peers: peers.map(sanitizePeer),
|
|
326
327
|
};
|
|
@@ -355,6 +356,7 @@ async function route(req, url, ctx) {
|
|
|
355
356
|
(["/api/startup", "/api/trust", "/api/logs/clear", "/api/proxy/stop", "/api/proxy/restart", "/api/cloudflared/install", "/api/hub", "/api/invites", "/api/remotes"].includes(path) &&
|
|
356
357
|
method === "POST") ||
|
|
357
358
|
(path === "/api/hub/localhost" && method === "PUT") ||
|
|
359
|
+
(path === "/api/hub/panel" && method === "PUT") ||
|
|
358
360
|
(path === "/api/settings" && method === "PUT") ||
|
|
359
361
|
(inviteMatch && method === "DELETE") ||
|
|
360
362
|
(peerMatch && (method === "PUT" || method === "DELETE")) ||
|
|
@@ -392,7 +394,13 @@ async function route(req, url, ctx) {
|
|
|
392
394
|
return { status: 200, body: buildSettingsBody(ctx, warnings.length ? warnings.join("; ") : undefined) };
|
|
393
395
|
}
|
|
394
396
|
if (path === "/api/hosts") {
|
|
395
|
-
const { host, entry } = await hosts_1.default.add({
|
|
397
|
+
const { host, entry } = await hosts_1.default.add({
|
|
398
|
+
host: body.host,
|
|
399
|
+
target: body.target,
|
|
400
|
+
insecure: optionalBool(body.insecure, "insecure"),
|
|
401
|
+
cors: optionalBool(body.cors, "cors"),
|
|
402
|
+
allow: body.allow,
|
|
403
|
+
});
|
|
396
404
|
ctx.reload();
|
|
397
405
|
logger_1.default.info(`β dashboard: ${host} β ${entry.target}`);
|
|
398
406
|
return { status: 201, body: { ok: true, host, ...entry, url: hostUrl(host, ctx) } };
|
|
@@ -407,13 +415,24 @@ async function route(req, url, ctx) {
|
|
|
407
415
|
}
|
|
408
416
|
const tunnel = optionalBool(body.tunnel, "tunnel");
|
|
409
417
|
let updated;
|
|
410
|
-
if (body.
|
|
411
|
-
updated = await hosts_1.default.
|
|
418
|
+
if (body.allow !== undefined && body.target === undefined) {
|
|
419
|
+
updated = await hosts_1.default.setAllow({ host, allow: body.allow });
|
|
420
|
+
logger_1.default.info(`βοΈ dashboard: ${updated.host} allows ${updated.entry.allow?.join(", ") || "no internal addresses"}`);
|
|
421
|
+
}
|
|
422
|
+
if (body.target !== undefined || (tunnel === undefined && body.allow === undefined)) {
|
|
423
|
+
updated = await hosts_1.default.update({
|
|
424
|
+
host,
|
|
425
|
+
target: body.target,
|
|
426
|
+
insecure: optionalBool(body.insecure, "insecure"),
|
|
427
|
+
cors: optionalBool(body.cors, "cors"),
|
|
428
|
+
allow: body.allow,
|
|
429
|
+
});
|
|
412
430
|
logger_1.default.info(`βοΈ dashboard: ${updated.host} β ${updated.entry.target}`);
|
|
413
431
|
}
|
|
414
432
|
if (tunnel !== undefined) {
|
|
415
|
-
updated = await hosts_1.default.setTunnel({ host, tunnel });
|
|
416
|
-
|
|
433
|
+
updated = await hosts_1.default.setTunnel({ host, tunnel, domain: body.domain });
|
|
434
|
+
const on = updated.entry.tunnelDomain && tunnel ? `sharing ${updated.host} on ${updated.entry.tunnelDomain}` : `sharing ${updated.host}`;
|
|
435
|
+
logger_1.default.info(`π dashboard: ${tunnel ? on : `stopped sharing ${updated.host}`}`);
|
|
417
436
|
}
|
|
418
437
|
ctx.reload();
|
|
419
438
|
if (tunnel)
|
|
@@ -467,6 +486,17 @@ async function route(req, url, ctx) {
|
|
|
467
486
|
ctx.reloadHub();
|
|
468
487
|
return { status: 200, body: { localhost: enabled } };
|
|
469
488
|
}
|
|
489
|
+
if (path === "/api/hub/panel" && method === "PUT") {
|
|
490
|
+
const enabled = requiredBool(body.enabled, "enabled");
|
|
491
|
+
const config = hub_config_1.default.read();
|
|
492
|
+
if (!config)
|
|
493
|
+
throw new ApiError(400, "The hub isn't configured yet. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
|
|
494
|
+
if (enabled && !ui_auth_1.default.enabled())
|
|
495
|
+
throw new ApiError(400, "Set a dashboard password first.", "Settings β Dashboard password, or `locadot ui:password`.");
|
|
496
|
+
hub_config_1.default.write({ ...config, panel: enabled });
|
|
497
|
+
ctx.reloadHub();
|
|
498
|
+
return { status: 200, body: { panel: enabled } };
|
|
499
|
+
}
|
|
470
500
|
if (path === "/api/invites" && method === "POST") {
|
|
471
501
|
if (!isRole(body.role))
|
|
472
502
|
throw new ApiError(400, "`role` must be viewer, editor or admin.");
|
package/dist/dashboard/index.js
CHANGED
|
@@ -91,6 +91,8 @@ async function buildHosts(ctx) {
|
|
|
91
91
|
target: entry.target,
|
|
92
92
|
insecure: !!entry.insecure,
|
|
93
93
|
cors: !!entry.cors,
|
|
94
|
+
allow: entry.allow ?? [],
|
|
95
|
+
tunnelDomain: entry.tunnelDomain ?? null,
|
|
94
96
|
createdAt: entry.createdAt,
|
|
95
97
|
updatedAt: entry.updatedAt,
|
|
96
98
|
urls: hostUrls(host, httpPort, httpsPort),
|
|
@@ -177,7 +179,7 @@ function handleDashboardRequest(req, res, ctx) {
|
|
|
177
179
|
async function uiPassword(req, res, ctx) {
|
|
178
180
|
(0, api_1.assertTrusted)(req, ctx);
|
|
179
181
|
const body = await (0, api_1.readJson)(req);
|
|
180
|
-
const ip = req
|
|
182
|
+
const ip = (0, login_1.loginIp)(req);
|
|
181
183
|
if (ui_auth_1.default.enabled()) {
|
|
182
184
|
if (ui_auth_1.default.limited(ip))
|
|
183
185
|
throw new api_1.ApiError(429, "Too many wrong passwords. Wait a few minutes and try again.");
|
package/dist/dashboard/login.js
CHANGED
|
@@ -36,13 +36,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.sessionCookie = exports.signedIn = void 0;
|
|
39
|
+
exports.sessionCookie = exports.signedIn = exports.loginIp = void 0;
|
|
40
40
|
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
44
|
const api_1 = require("./api");
|
|
45
45
|
const ui_auth_1 = __importStar(require("../lib/ui-auth"));
|
|
46
|
+
const request_1 = require("../proxy/request");
|
|
47
|
+
const hub_1 = require("../proxy/hub");
|
|
46
48
|
const MAX_LOGIN_BODY = 4096;
|
|
47
49
|
const loginPage = (nonce, error) => `<!doctype html>
|
|
48
50
|
<html lang="en">
|
|
@@ -90,7 +92,9 @@ const safeEqual = (a, b) => {
|
|
|
90
92
|
const y = Buffer.from(b);
|
|
91
93
|
return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
|
|
92
94
|
};
|
|
93
|
-
|
|
95
|
+
/** Through the shared dashboard every visitor arrives from cloudflared on loopback, so limit by the visitor's IP. */
|
|
96
|
+
const loginIp = (req) => ((0, request_1.isPanel)(req) ? (0, hub_1.clientIp)(req) : req.socket.remoteAddress || "");
|
|
97
|
+
exports.loginIp = loginIp;
|
|
94
98
|
const sameOrigin = (req) => {
|
|
95
99
|
// Browsers send `Origin: null` on form posts under Referrer-Policy: no-referrer, so trust Sec-Fetch-Site when present.
|
|
96
100
|
// "same-site" is refused: every *.localhost app is same-site with the dashboard.
|
|
@@ -102,7 +106,9 @@ const sameOrigin = (req) => {
|
|
|
102
106
|
return !origin;
|
|
103
107
|
return (0, api_1.isDashboardOrigin)(req, origin);
|
|
104
108
|
};
|
|
105
|
-
|
|
109
|
+
// The shared dashboard's public URL is https even though cloudflared talks plain http to us.
|
|
110
|
+
const secure = (req) => ((0, request_1.isPanel)(req) ? (0, request_1.isTls)(req) : Boolean(req.socket.encrypted));
|
|
111
|
+
const cookie = (req, value, maxAge) => `${ui_auth_1.SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? "; Secure" : ""}`;
|
|
106
112
|
const sendLogin = (res, method, nonce, status, error) => {
|
|
107
113
|
// The dashboard's CSP forbids form posts; the login form needs one.
|
|
108
114
|
res.setHeader("Content-Security-Policy", `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`);
|
|
@@ -149,8 +155,13 @@ exports.sessionCookie = sessionCookie;
|
|
|
149
155
|
* are let through untouched. Returns true when it answered the request itself.
|
|
150
156
|
*/
|
|
151
157
|
function gate(req, res, url, token, nonce) {
|
|
152
|
-
if (!ui_auth_1.default.enabled())
|
|
153
|
-
|
|
158
|
+
if (!ui_auth_1.default.enabled()) {
|
|
159
|
+
if (!(0, request_1.isPanel)(req))
|
|
160
|
+
return false;
|
|
161
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
162
|
+
res.end("locadot: set a dashboard password to share the dashboard.\n");
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
154
165
|
const method = req.method || "GET";
|
|
155
166
|
const path = url.pathname;
|
|
156
167
|
if (path === "/login") {
|
|
@@ -162,7 +173,7 @@ function gate(req, res, url, token, nonce) {
|
|
|
162
173
|
}
|
|
163
174
|
if (method !== "POST")
|
|
164
175
|
return false;
|
|
165
|
-
const ip = req
|
|
176
|
+
const ip = (0, exports.loginIp)(req);
|
|
166
177
|
if (!sameOrigin(req)) {
|
|
167
178
|
sendLogin(res, method, nonce, 403, "Cross-site sign-in is not allowed.");
|
|
168
179
|
return true;
|