locadot 2.2.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 +12 -0
- package/README.md +18 -2
- package/dist/cli/commands/hosts.js +29 -1
- package/dist/cli/commands/tunnel.js +9 -3
- package/dist/dashboard/api.js +22 -5
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/page.js +740 -86
- package/dist/index.js +12 -2
- package/dist/lib/allow.js +60 -0
- package/dist/lib/hosts.js +52 -4
- package/dist/proxy/guard.js +30 -1
- package/dist/proxy/options.js +10 -2
- package/dist/proxy/router.js +27 -7
- package/dist/proxy/tunnel.js +62 -22
- package/dist/server/index.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 2.2.0 (2026-09-29)
|
|
4
16
|
|
|
5
17
|
### Added
|
package/README.md
CHANGED
|
@@ -19,7 +19,8 @@ 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.
|
|
23
24
|
- π **Remote access, locadot to locadot.** Publish your whole locadot on a Cloudflare hostname and let other machines pair
|
|
24
25
|
with it as viewer, editor or admin. Your mappings show up in their locadot. Admins can also open your dashboard and any
|
|
25
26
|
port on your localhost.
|
|
@@ -64,6 +65,7 @@ npx locadot open # opens the dashboard at htt
|
|
|
64
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`. |
|
|
65
66
|
| `locadot update --host <name>.localhost --port/--target β¦` | Change the destination of an existing domain. |
|
|
66
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`. |
|
|
67
69
|
| `locadot list` (`ls`, `host`) `[--json]` | Show all mappings. |
|
|
68
70
|
| `locadot clear:hosts` | Remove all mappings. |
|
|
69
71
|
| `locadot open [host]` | Open a domain, or the dashboard when no host is given, in your browser. |
|
|
@@ -94,7 +96,8 @@ Options for `add` / `update`:
|
|
|
94
96
|
| Command | What it does |
|
|
95
97
|
| --- | --- |
|
|
96
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. |
|
|
97
|
-
| `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. |
|
|
98
101
|
| `locadot tunnel` | List the shared mappings and their public URLs. |
|
|
99
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. |
|
|
100
103
|
|
|
@@ -106,6 +109,17 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
|
|
|
106
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:
|
|
107
110
|
use **Share** / **Unshare** on a row.
|
|
108
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
|
+
|
|
109
123
|
### Remote access (locadot to locadot)
|
|
110
124
|
|
|
111
125
|
One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
|
|
@@ -247,6 +261,8 @@ each time the proxy starts.
|
|
|
247
261
|
| `GET /api/logs?lines=200` | | `{ lines: [...] }` |
|
|
248
262
|
| `POST /api/hosts` | `{ "host": "app.localhost", "target": "3000", "insecure": false, "cors": false }` | Add a mapping (201; 409 if it exists). |
|
|
249
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). |
|
|
250
266
|
| `DELETE /api/hosts/:host` | | Remove a mapping. |
|
|
251
267
|
| `POST /api/startup` | `{ "enabled": true }` | Start at boot on/off. |
|
|
252
268
|
| `POST /api/trust` | `{ "trusted": true }` | Trust or untrust the CA. |
|
|
@@ -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
|
}
|
|
@@ -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
|
@@ -394,7 +394,13 @@ async function route(req, url, ctx) {
|
|
|
394
394
|
return { status: 200, body: buildSettingsBody(ctx, warnings.length ? warnings.join("; ") : undefined) };
|
|
395
395
|
}
|
|
396
396
|
if (path === "/api/hosts") {
|
|
397
|
-
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
|
+
});
|
|
398
404
|
ctx.reload();
|
|
399
405
|
logger_1.default.info(`β dashboard: ${host} β ${entry.target}`);
|
|
400
406
|
return { status: 201, body: { ok: true, host, ...entry, url: hostUrl(host, ctx) } };
|
|
@@ -409,13 +415,24 @@ async function route(req, url, ctx) {
|
|
|
409
415
|
}
|
|
410
416
|
const tunnel = optionalBool(body.tunnel, "tunnel");
|
|
411
417
|
let updated;
|
|
412
|
-
if (body.
|
|
413
|
-
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
|
+
});
|
|
414
430
|
logger_1.default.info(`βοΈ dashboard: ${updated.host} β ${updated.entry.target}`);
|
|
415
431
|
}
|
|
416
432
|
if (tunnel !== undefined) {
|
|
417
|
-
updated = await hosts_1.default.setTunnel({ host, tunnel });
|
|
418
|
-
|
|
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}`}`);
|
|
419
436
|
}
|
|
420
437
|
ctx.reload();
|
|
421
438
|
if (tunnel)
|
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),
|