locadot 1.6.0-beta.5 → 1.6.0-beta.6
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 +2 -2
- package/README.md +5 -2
- package/dist/dashboard/page.js +279 -43
- package/dist/proxy/guard.js +84 -0
- package/dist/proxy/options.js +2 -0
- package/dist/proxy/response.js +2 -5
- package/dist/proxy/rewrite.js +12 -4
- package/dist/proxy/router.js +7 -5
- package/dist/server/index.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
The version in `package.json` is 1.6.0-beta.
|
|
5
|
+
The version in `package.json` is 1.6.0-beta.6 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
|
|
6
6
|
breaking items below.
|
|
7
7
|
|
|
8
8
|
### Changed
|
|
@@ -16,7 +16,7 @@ breaking items below.
|
|
|
16
16
|
- Bare `localhost` is reserved for the dashboard and can't be mapped.
|
|
17
17
|
|
|
18
18
|
### Added
|
|
19
|
-
- `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`.
|
|
19
|
+
- `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.
|
|
20
20
|
- The dashboard is redesigned: a neutral palette that follows the system light/dark theme, a responsive grid and a single Traffic column.
|
|
21
21
|
- `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.
|
|
22
22
|
- `--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)
|
package/README.md
CHANGED
|
@@ -80,8 +80,11 @@ Options for `add` / `update`:
|
|
|
80
80
|
|
|
81
81
|
The tunnel runs inside the proxy, so a shared mapping keeps working after `restart` and comes back at boot. Redirects to the
|
|
82
82
|
target are rewritten to the public URL. **Anyone with the link can reach the mapping**, so only share what you'd put online.
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
`--cors` works through the tunnel too: CORS headers and preflights, the pass-through shim, and the target's own
|
|
84
|
+
origin in pages rewritten to the public URL (other shared mappings get their public URLs too). For safety, a tunnel visitor's
|
|
85
|
+
pass-through calls may only go to public addresses: loopback, LAN, link-local and cloud-metadata addresses are refused, checked
|
|
86
|
+
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
|
+
use **Share** / **Unshare** on a row.
|
|
85
88
|
|
|
86
89
|
### Certificates
|
|
87
90
|
|
package/dist/dashboard/page.js
CHANGED
|
@@ -9,10 +9,10 @@ const escape_1 = require("./escape");
|
|
|
9
9
|
const css = `
|
|
10
10
|
:root {
|
|
11
11
|
--bg: #0b0d10;
|
|
12
|
-
--surface: #
|
|
13
|
-
--surface-2: #
|
|
14
|
-
--border: #
|
|
15
|
-
--border-strong: #
|
|
12
|
+
--surface: #14181e;
|
|
13
|
+
--surface-2: #1a1f26;
|
|
14
|
+
--border: #282d36;
|
|
15
|
+
--border-strong: #353b46;
|
|
16
16
|
--fg: #e6e8eb;
|
|
17
17
|
--muted: #8b929c;
|
|
18
18
|
--muted-dim: #5f6672;
|
|
@@ -177,8 +177,8 @@ section h2 {
|
|
|
177
177
|
width: 38px;
|
|
178
178
|
height: 22px;
|
|
179
179
|
border-radius: 999px;
|
|
180
|
-
border: 1px solid
|
|
181
|
-
background: var(--
|
|
180
|
+
border: 1px solid transparent;
|
|
181
|
+
background: var(--border-strong);
|
|
182
182
|
cursor: pointer;
|
|
183
183
|
flex-shrink: 0;
|
|
184
184
|
transition: background 0.15s ease, border-color 0.15s ease;
|
|
@@ -188,11 +188,12 @@ section h2 {
|
|
|
188
188
|
top: 2px; left: 2px;
|
|
189
189
|
width: 16px; height: 16px;
|
|
190
190
|
border-radius: 50%;
|
|
191
|
-
background:
|
|
191
|
+
background: #fff;
|
|
192
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
192
193
|
transition: transform 0.15s ease, background 0.15s ease;
|
|
193
194
|
}
|
|
194
|
-
.switch.on { background: var(--accent
|
|
195
|
-
.switch.on .switch-knob { transform: translateX(16px);
|
|
195
|
+
.switch.on { background: var(--accent); }
|
|
196
|
+
.switch.on .switch-knob { transform: translateX(16px); }
|
|
196
197
|
.switch.busy { opacity: 0.6; cursor: wait; }
|
|
197
198
|
.switch:disabled { cursor: not-allowed; opacity: 0.5; }
|
|
198
199
|
|
|
@@ -218,6 +219,8 @@ section h2 {
|
|
|
218
219
|
.btn-primary:hover:not(:disabled) { background: var(--accent-strong); border-color: var(--accent-strong); }
|
|
219
220
|
.btn-danger { border-color: var(--down); color: var(--down); background: transparent; }
|
|
220
221
|
.btn-danger:hover:not(:disabled) { background: var(--down-bg); }
|
|
222
|
+
.row-actions .btn-danger:not(:hover):not(:focus-visible) { border-color: var(--border-strong); color: var(--muted); }
|
|
223
|
+
.btn-primary.busy::after { border-color: rgba(255, 255, 255, 0.35); border-top-color: #fff; }
|
|
221
224
|
.btn-ghost { background: transparent; border-color: var(--border); }
|
|
222
225
|
.btn.busy { opacity: 0.6; cursor: wait; }
|
|
223
226
|
|
|
@@ -267,7 +270,7 @@ tr:last-child td { border-bottom: none; }
|
|
|
267
270
|
display: inline-block;
|
|
268
271
|
font-size: 11px;
|
|
269
272
|
padding: 2px 7px;
|
|
270
|
-
border-radius:
|
|
273
|
+
border-radius: 6px;
|
|
271
274
|
border: 1px solid var(--warn);
|
|
272
275
|
color: var(--warn);
|
|
273
276
|
background: var(--warn-bg);
|
|
@@ -288,6 +291,15 @@ tr:last-child td { border-bottom: none; }
|
|
|
288
291
|
.copy-btn:hover { color: var(--fg); border-color: var(--muted); }
|
|
289
292
|
.row-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
290
293
|
th.actions { text-align: right; }
|
|
294
|
+
.edit-hint { font-size: 12px; }
|
|
295
|
+
td.edit-error { color: var(--down); white-space: normal; }
|
|
296
|
+
body.offline .switch, body.offline #add-submit { opacity: 0.5; pointer-events: none; }
|
|
297
|
+
.field input.invalid:focus-visible, .edit-input.invalid:focus-visible { outline-color: var(--down); }
|
|
298
|
+
.edit-input.invalid { border-color: var(--down); box-shadow: 0 0 0 3px var(--down-bg); }
|
|
299
|
+
.chip:empty { display: none; }
|
|
300
|
+
body.boot #updated { visibility: hidden; }
|
|
301
|
+
body.boot #stop-proxy-btn, body.offline #stop-proxy-btn { display: none; }
|
|
302
|
+
body.offline .live-label { color: var(--down); }
|
|
291
303
|
.edit-input {
|
|
292
304
|
font-family: var(--mono);
|
|
293
305
|
font-size: 13px;
|
|
@@ -297,9 +309,11 @@ th.actions { text-align: right; }
|
|
|
297
309
|
background: var(--surface-2);
|
|
298
310
|
color: var(--fg);
|
|
299
311
|
width: 100%;
|
|
300
|
-
min-width:
|
|
312
|
+
min-width: 240px;
|
|
301
313
|
}
|
|
302
314
|
.badges { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
315
|
+
td.badges { display: table-cell; }
|
|
316
|
+
td.badges .badge + .badge { margin-left: 4px; }
|
|
303
317
|
.badge-cors { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
|
|
304
318
|
td .inline-check + .inline-check { margin-top: 4px; }
|
|
305
319
|
.inline-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
@@ -326,6 +340,7 @@ td .inline-check + .inline-check { margin-top: 4px; }
|
|
|
326
340
|
color: var(--muted);
|
|
327
341
|
text-align: center;
|
|
328
342
|
}
|
|
343
|
+
#hosts-error { border-color: var(--down); }
|
|
329
344
|
.empty pre { display: inline-block; text-align: left; }
|
|
330
345
|
|
|
331
346
|
pre.code-block {
|
|
@@ -334,6 +349,8 @@ pre.code-block {
|
|
|
334
349
|
border-radius: var(--radius-sm);
|
|
335
350
|
padding: 10px 12px;
|
|
336
351
|
overflow-x: auto;
|
|
352
|
+
white-space: pre-wrap;
|
|
353
|
+
word-break: break-all;
|
|
337
354
|
font-family: var(--mono);
|
|
338
355
|
font-size: 12.5px;
|
|
339
356
|
color: var(--fg);
|
|
@@ -341,7 +358,7 @@ pre.code-block {
|
|
|
341
358
|
}
|
|
342
359
|
|
|
343
360
|
.cli-item { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 10px; }
|
|
344
|
-
.cli-item pre { flex: 1; }
|
|
361
|
+
.cli-item pre { flex: 1; min-width: 0; }
|
|
345
362
|
.cli-item:last-child { margin-bottom: 0; }
|
|
346
363
|
|
|
347
364
|
details.card { padding: 0; }
|
|
@@ -405,21 +422,119 @@ details.card[open] summary::after { content: "\\2212"; }
|
|
|
405
422
|
.toast-hint { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
406
423
|
.toast-hint code { font-size: 11.5px; color: var(--fg); }
|
|
407
424
|
|
|
425
|
+
.banner-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
426
|
+
|
|
427
|
+
@keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
428
|
+
body.boot .tile-value,
|
|
429
|
+
body.boot .tile-sub,
|
|
430
|
+
body.boot #version,
|
|
431
|
+
body.boot #uptime,
|
|
432
|
+
body.boot #pid {
|
|
433
|
+
color: transparent !important;
|
|
434
|
+
display: inline-block;
|
|
435
|
+
min-width: 70px;
|
|
436
|
+
border-radius: 4px;
|
|
437
|
+
background: linear-gradient(90deg, var(--surface-2) 25%, var(--border-strong) 37%, var(--surface-2) 63%);
|
|
438
|
+
background-size: 400% 100%;
|
|
439
|
+
animation: skeleton-shimmer 1.4s ease infinite;
|
|
440
|
+
}
|
|
441
|
+
.skel-bar {
|
|
442
|
+
display: block;
|
|
443
|
+
width: 80%;
|
|
444
|
+
height: 14px;
|
|
445
|
+
border-radius: 4px;
|
|
446
|
+
background: linear-gradient(90deg, var(--surface-2) 25%, var(--border-strong) 37%, var(--surface-2) 63%);
|
|
447
|
+
background-size: 400% 100%;
|
|
448
|
+
animation: skeleton-shimmer 1.4s ease infinite;
|
|
449
|
+
}
|
|
450
|
+
.skeleton-row td { border-bottom: 1px solid var(--border); padding: 14px 9px; }
|
|
451
|
+
.skeleton-row:nth-child(1) .skel-bar { width: 92%; }
|
|
452
|
+
.skeleton-row:nth-child(2) .skel-bar { width: 70%; }
|
|
453
|
+
.skeleton-row:nth-child(3) .skel-bar { width: 82%; }
|
|
454
|
+
|
|
455
|
+
tr.row-down td:first-child { border-left: 3px solid var(--down); padding-left: 6px; }
|
|
456
|
+
|
|
457
|
+
.tunnel-error-text {
|
|
458
|
+
color: var(--down);
|
|
459
|
+
cursor: help;
|
|
460
|
+
overflow: hidden;
|
|
461
|
+
text-overflow: ellipsis;
|
|
462
|
+
white-space: nowrap;
|
|
463
|
+
max-width: 170px;
|
|
464
|
+
display: inline-block;
|
|
465
|
+
vertical-align: middle;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.field input.invalid { border-color: var(--down); }
|
|
469
|
+
|
|
470
|
+
.btn.busy, #ca-toggle.busy, #startup-toggle.busy { color: transparent !important; pointer-events: none; }
|
|
471
|
+
.btn.busy::after {
|
|
472
|
+
content: "";
|
|
473
|
+
position: absolute;
|
|
474
|
+
width: 13px; height: 13px;
|
|
475
|
+
margin-left: -6px;
|
|
476
|
+
top: 50%; left: 50%;
|
|
477
|
+
margin-top: -6.5px;
|
|
478
|
+
border-radius: 50%;
|
|
479
|
+
border: 2px solid rgba(127, 127, 127, 0.35);
|
|
480
|
+
border-top-color: var(--accent-strong);
|
|
481
|
+
animation: spin 0.7s linear infinite;
|
|
482
|
+
}
|
|
483
|
+
.btn { position: relative; }
|
|
484
|
+
.switch.busy .switch-knob { opacity: 0; }
|
|
485
|
+
.switch.busy::after {
|
|
486
|
+
content: "";
|
|
487
|
+
position: absolute;
|
|
488
|
+
top: 3px; left: 11px;
|
|
489
|
+
width: 14px; height: 14px;
|
|
490
|
+
border-radius: 50%;
|
|
491
|
+
border: 2px solid var(--border-strong);
|
|
492
|
+
border-top-color: var(--accent);
|
|
493
|
+
animation: spin 0.7s linear infinite;
|
|
494
|
+
}
|
|
495
|
+
|
|
408
496
|
footer { text-align: center; padding: 20px; color: var(--muted-dim); font-size: 12px; }
|
|
409
497
|
[hidden] { display: none !important; }
|
|
410
498
|
|
|
411
499
|
@media (max-width: 900px) {
|
|
412
|
-
.tiles { grid-template-columns: repeat(
|
|
413
|
-
.tile
|
|
500
|
+
.tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
501
|
+
.tile { grid-column: span 2; }
|
|
502
|
+
.tile-wide, .tile-wide + .tile { grid-column: span 3; }
|
|
414
503
|
}
|
|
415
504
|
@media (max-width: 600px) {
|
|
416
505
|
.tiles { grid-template-columns: 1fr; }
|
|
506
|
+
.tile, .tile-wide, .tile-wide + .tile { grid-column: auto; }
|
|
417
507
|
#add-submit { margin-left: 0; }
|
|
418
508
|
}
|
|
419
509
|
|
|
510
|
+
@media (max-width: 1200px) {
|
|
511
|
+
#table th:nth-child(2), #table td.arrow { display: none; }
|
|
512
|
+
.tunnel-status a { max-width: 120px; }
|
|
513
|
+
th, td { padding-left: 6px; padding-right: 6px; }
|
|
514
|
+
}
|
|
515
|
+
@media (min-width: 641px) and (max-width: 1023px) {
|
|
516
|
+
#table tbody { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
517
|
+
#table tbody tr:nth-child(odd) { border-right: 1px solid var(--border); }
|
|
518
|
+
#table tbody tr:nth-last-child(2):nth-child(odd) { border-bottom: none; }
|
|
519
|
+
}
|
|
520
|
+
@media (max-width: 1023px) {
|
|
521
|
+
#table { min-width: 0; }
|
|
522
|
+
#table thead { display: none; }
|
|
523
|
+
#table, #table tbody, #table tr { display: block; width: 100%; }
|
|
524
|
+
#table tr { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
|
525
|
+
#table tr:last-child { border-bottom: none; }
|
|
526
|
+
#table td { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 5px 0; border: none; white-space: normal; text-align: right; min-width: 0; }
|
|
527
|
+
#table td::before { content: attr(data-label); color: var(--muted); font-size: 12px; flex-shrink: 0; text-align: left; }
|
|
528
|
+
#table td[data-label=""]::before { content: none; }
|
|
529
|
+
#table td[data-label=""]:first-child { padding-bottom: 8px; justify-content: flex-start; font-weight: 600; }
|
|
530
|
+
#table td[data-label=""] { justify-content: flex-end; padding-top: 10px; }
|
|
531
|
+
#table .edit-input { min-width: 0; }
|
|
532
|
+
}
|
|
420
533
|
@media (max-width: 640px) {
|
|
534
|
+
#pid, #updated { display: none; }
|
|
421
535
|
header { padding: 12px 16px; }
|
|
422
536
|
main { padding: 18px 16px 48px; }
|
|
537
|
+
.toast-container { left: 12px; right: 12px; bottom: 12px; max-width: none; }
|
|
423
538
|
}
|
|
424
539
|
|
|
425
540
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -442,6 +557,8 @@ const clientJs = `
|
|
|
442
557
|
var uptimeEl = document.getElementById("uptime");
|
|
443
558
|
var pidEl = document.getElementById("pid");
|
|
444
559
|
var liveDot = document.getElementById("live-dot");
|
|
560
|
+
var liveLabel = document.getElementById("live-label");
|
|
561
|
+
var hostsErrorEl = document.getElementById("hosts-error");
|
|
445
562
|
var updatedEl = document.getElementById("updated");
|
|
446
563
|
|
|
447
564
|
var rootValueEl = document.getElementById("tile-root-value");
|
|
@@ -477,6 +594,20 @@ const clientJs = `
|
|
|
477
594
|
var emptyEl = document.getElementById("empty");
|
|
478
595
|
var tableWrap = document.getElementById("table-wrap");
|
|
479
596
|
var tbody = document.getElementById("tbody");
|
|
597
|
+
var hostsSkeletonEl = document.getElementById("hosts-skeleton");
|
|
598
|
+
// As many skeleton rows as hosts last time, so the table doesn't jump when data lands.
|
|
599
|
+
try {
|
|
600
|
+
var skelBody = hostsSkeletonEl.querySelector("tbody");
|
|
601
|
+
var lastCount = Math.max(1, Math.min(8, parseInt(localStorage.getItem("locadot.hostCount") || "3", 10) || 3));
|
|
602
|
+
while (skelBody.children.length > lastCount) skelBody.removeChild(skelBody.lastElementChild);
|
|
603
|
+
while (skelBody.children.length < lastCount) skelBody.appendChild(skelBody.firstElementChild.cloneNode(true));
|
|
604
|
+
} catch (e) {}
|
|
605
|
+
var hostsBooted = false;
|
|
606
|
+
|
|
607
|
+
var loadErrorBanner = document.getElementById("load-error-banner");
|
|
608
|
+
var loadErrorText = document.getElementById("load-error-text");
|
|
609
|
+
var loadErrorRetry = document.getElementById("load-error-retry");
|
|
610
|
+
var isDown = false;
|
|
480
611
|
|
|
481
612
|
var logsPanel = document.getElementById("logs-panel");
|
|
482
613
|
var logsBox = document.getElementById("logs-box");
|
|
@@ -589,8 +720,9 @@ const clientJs = `
|
|
|
589
720
|
}, timeout);
|
|
590
721
|
}
|
|
591
722
|
|
|
592
|
-
function apiError(err) {
|
|
723
|
+
function apiError(err, context) {
|
|
593
724
|
var message = err && err.message ? err.message : String(err);
|
|
725
|
+
if (context) message = context + ": " + message;
|
|
594
726
|
showToast(message, "error", err && err.hint ? err.hint : undefined);
|
|
595
727
|
}
|
|
596
728
|
|
|
@@ -619,7 +751,40 @@ const clientJs = `
|
|
|
619
751
|
|
|
620
752
|
// ---------- header + system tiles ----------
|
|
621
753
|
|
|
754
|
+
function hideHostsSkeleton() {
|
|
755
|
+
if (!hostsBooted) {
|
|
756
|
+
hostsBooted = true;
|
|
757
|
+
hostsSkeletonEl.hidden = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function showLoadError(err) {
|
|
762
|
+
isDown = true;
|
|
763
|
+
var reason = (err && err.message) ? err.message : "connection failed";
|
|
764
|
+
loadErrorText.textContent = "Can't reach the locadot proxy (" + reason + "). Start it with locadot start; this page retries every 5 s.";
|
|
765
|
+
loadErrorBanner.hidden = false;
|
|
766
|
+
if (!lastStatus) hostsErrorEl.hidden = false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function hideLoadError() {
|
|
770
|
+
isDown = false;
|
|
771
|
+
loadErrorBanner.hidden = true;
|
|
772
|
+
hostsErrorEl.hidden = true;
|
|
773
|
+
liveLabel.textContent = "live";
|
|
774
|
+
document.body.classList.remove("offline");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
loadErrorRetry.addEventListener("click", function () {
|
|
778
|
+
loadErrorRetry.disabled = true;
|
|
779
|
+
loadErrorRetry.classList.add("busy");
|
|
780
|
+
refreshAll().then(function () {
|
|
781
|
+
loadErrorRetry.disabled = false;
|
|
782
|
+
loadErrorRetry.classList.remove("busy");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
622
786
|
function renderHeader(status) {
|
|
787
|
+
document.body.classList.remove("boot");
|
|
623
788
|
versionEl.textContent = "v" + status.proxy.version;
|
|
624
789
|
uptimeEl.textContent = "uptime " + fmtUptime(status.uptimeSec);
|
|
625
790
|
pidEl.textContent = "pid " + status.proxy.pid;
|
|
@@ -631,6 +796,8 @@ const clientJs = `
|
|
|
631
796
|
function markDown() {
|
|
632
797
|
liveDot.classList.remove("up");
|
|
633
798
|
liveDot.classList.add("down");
|
|
799
|
+
liveLabel.textContent = "offline";
|
|
800
|
+
document.body.classList.add("offline");
|
|
634
801
|
}
|
|
635
802
|
|
|
636
803
|
function renderSystemTiles(status) {
|
|
@@ -711,7 +878,7 @@ const clientJs = `
|
|
|
711
878
|
showToast("CA trust setting updated", "success");
|
|
712
879
|
return refreshAll();
|
|
713
880
|
})
|
|
714
|
-
.catch(function (err) { apiError(err); })
|
|
881
|
+
.catch(function (err) { apiError(err, "Couldn't update CA trust"); })
|
|
715
882
|
.then(function () {
|
|
716
883
|
caBusy = false;
|
|
717
884
|
caToggle.disabled = false;
|
|
@@ -730,7 +897,7 @@ const clientJs = `
|
|
|
730
897
|
showToast("Startup setting updated", "success");
|
|
731
898
|
return refreshAll();
|
|
732
899
|
})
|
|
733
|
-
.catch(function (err) { apiError(err); })
|
|
900
|
+
.catch(function (err) { apiError(err, "Couldn't update start at boot"); })
|
|
734
901
|
.then(function () {
|
|
735
902
|
startupBusy = false;
|
|
736
903
|
startupToggle.disabled = false;
|
|
@@ -758,7 +925,7 @@ const clientJs = `
|
|
|
758
925
|
showToast("Proxy stopped", "warn");
|
|
759
926
|
})
|
|
760
927
|
.catch(function (err) {
|
|
761
|
-
apiError(err);
|
|
928
|
+
apiError(err, "Couldn't stop the proxy");
|
|
762
929
|
stopProxyBtn.disabled = false;
|
|
763
930
|
stopProxyBtn.classList.remove("busy");
|
|
764
931
|
});
|
|
@@ -776,7 +943,7 @@ const clientJs = `
|
|
|
776
943
|
showToast("cloudflared installed", "success");
|
|
777
944
|
return refreshAll();
|
|
778
945
|
})
|
|
779
|
-
.catch(function (err) { apiError(err); })
|
|
946
|
+
.catch(function (err) { apiError(err, "Couldn't install cloudflared"); })
|
|
780
947
|
.then(function () {
|
|
781
948
|
cfInstallBusy = false;
|
|
782
949
|
cfInstallBtn.disabled = false;
|
|
@@ -807,15 +974,41 @@ const clientJs = `
|
|
|
807
974
|
clear(addErrorEl);
|
|
808
975
|
}
|
|
809
976
|
|
|
977
|
+
function markFieldInvalid(el, invalid) {
|
|
978
|
+
el.classList.toggle("invalid", !!invalid);
|
|
979
|
+
el.setAttribute("aria-invalid", invalid ? "true" : "false");
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
addHostInput.addEventListener("input", function () {
|
|
983
|
+
if (addHostInput.value.trim()) markFieldInvalid(addHostInput, false);
|
|
984
|
+
hideAddError();
|
|
985
|
+
});
|
|
986
|
+
addTargetInput.addEventListener("input", function () {
|
|
987
|
+
if (addTargetInput.value.trim()) markFieldInvalid(addTargetInput, false);
|
|
988
|
+
hideAddError();
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
addForm.addEventListener("focusout", function (e) {
|
|
992
|
+
if (e.relatedTarget && addForm.contains(e.relatedTarget)) return;
|
|
993
|
+
hideAddError();
|
|
994
|
+
markFieldInvalid(addHostInput, false);
|
|
995
|
+
markFieldInvalid(addTargetInput, false);
|
|
996
|
+
});
|
|
997
|
+
|
|
810
998
|
addForm.addEventListener("submit", function (e) {
|
|
811
999
|
e.preventDefault();
|
|
812
1000
|
hideAddError();
|
|
813
1001
|
var hostVal = addHostInput.value.trim();
|
|
814
1002
|
var targetVal = addTargetInput.value.trim();
|
|
815
1003
|
if (!hostVal || !targetVal) {
|
|
1004
|
+
markFieldInvalid(addHostInput, !hostVal);
|
|
1005
|
+
markFieldInvalid(addTargetInput, !targetVal);
|
|
816
1006
|
showAddError("Host and target are required.");
|
|
1007
|
+
(!hostVal ? addHostInput : addTargetInput).focus();
|
|
817
1008
|
return;
|
|
818
1009
|
}
|
|
1010
|
+
markFieldInvalid(addHostInput, false);
|
|
1011
|
+
markFieldInvalid(addTargetInput, false);
|
|
819
1012
|
if (hostVal.indexOf(".") === -1) hostVal = hostVal + ".localhost";
|
|
820
1013
|
var insecureVal = !!addInsecureInput.checked;
|
|
821
1014
|
var corsVal = !!addCorsInput.checked;
|
|
@@ -890,9 +1083,12 @@ const clientJs = `
|
|
|
890
1083
|
statusWrap.appendChild(spin);
|
|
891
1084
|
statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
|
|
892
1085
|
} else if (t.status === "error") {
|
|
1086
|
+
var errDot = document.createElement("span");
|
|
1087
|
+
errDot.className = "dot down";
|
|
1088
|
+
statusWrap.appendChild(errDot);
|
|
893
1089
|
var errText = document.createElement("span");
|
|
894
|
-
errText.className = "tunnel-error";
|
|
895
|
-
errText.textContent = "Error";
|
|
1090
|
+
errText.className = "tunnel-error-text";
|
|
1091
|
+
errText.textContent = "Error: " + (t.error || "tunnel failed to start");
|
|
896
1092
|
errText.title = t.error || "Tunnel error";
|
|
897
1093
|
statusWrap.appendChild(errText);
|
|
898
1094
|
} else {
|
|
@@ -908,11 +1104,12 @@ const clientJs = `
|
|
|
908
1104
|
function shareButton(row) {
|
|
909
1105
|
var t = tunnelInfo(row);
|
|
910
1106
|
var isOn = t.enabled || t.status === "up" || t.status === "starting";
|
|
1107
|
+
var isRetry = !isOn && t.status === "error";
|
|
911
1108
|
var shareBtn = document.createElement("button");
|
|
912
1109
|
shareBtn.type = "button";
|
|
913
1110
|
shareBtn.className = "btn btn-sm" + (isOn ? " btn-danger" : "");
|
|
914
|
-
shareBtn.textContent = isOn ? "Unshare" : "Share";
|
|
915
|
-
shareBtn.title = isOn ? "Stop the public tunnel" : "Share on a public trycloudflare.com URL";
|
|
1111
|
+
shareBtn.textContent = isOn ? "Unshare" : (isRetry ? "Retry" : "Share");
|
|
1112
|
+
shareBtn.title = isOn ? "Stop the public tunnel" : (isRetry ? "Retry starting the tunnel" : "Share on a public trycloudflare.com URL");
|
|
916
1113
|
if (!cloudflaredInstalled && !isOn) {
|
|
917
1114
|
shareBtn.disabled = true;
|
|
918
1115
|
shareBtn.title = "Install cloudflared first";
|
|
@@ -927,7 +1124,7 @@ const clientJs = `
|
|
|
927
1124
|
return loadHosts();
|
|
928
1125
|
})
|
|
929
1126
|
.catch(function (err) {
|
|
930
|
-
apiError(err);
|
|
1127
|
+
apiError(err, "Couldn't change sharing for " + row.host);
|
|
931
1128
|
shareBtn.disabled = false;
|
|
932
1129
|
shareBtn.classList.remove("busy");
|
|
933
1130
|
});
|
|
@@ -935,8 +1132,13 @@ const clientJs = `
|
|
|
935
1132
|
return shareBtn;
|
|
936
1133
|
}
|
|
937
1134
|
|
|
1135
|
+
function labelCells(tr, labels) {
|
|
1136
|
+
for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
938
1139
|
function buildDisplayRow(tr, row) {
|
|
939
1140
|
clear(tr);
|
|
1141
|
+
tr.classList.toggle("row-down", !row.probe.up);
|
|
940
1142
|
|
|
941
1143
|
var hostTd = document.createElement("td");
|
|
942
1144
|
hostTd.className = "mono";
|
|
@@ -1011,7 +1213,7 @@ const clientJs = `
|
|
|
1011
1213
|
return loadHosts();
|
|
1012
1214
|
})
|
|
1013
1215
|
.catch(function (err) {
|
|
1014
|
-
apiError(err);
|
|
1216
|
+
apiError(err, "Couldn't remove " + row.host);
|
|
1015
1217
|
removeBtn.disabled = false;
|
|
1016
1218
|
});
|
|
1017
1219
|
});
|
|
@@ -1019,6 +1221,7 @@ const clientJs = `
|
|
|
1019
1221
|
actionsWrap.appendChild(editBtn);
|
|
1020
1222
|
actionsWrap.appendChild(removeBtn);
|
|
1021
1223
|
tr.appendChild(actionsTd);
|
|
1224
|
+
labelCells(tr, ["", "", "Target", "Options", "Status", "Public URL", "Traffic", ""]);
|
|
1022
1225
|
}
|
|
1023
1226
|
|
|
1024
1227
|
function enterEditMode(row) {
|
|
@@ -1035,6 +1238,7 @@ const clientJs = `
|
|
|
1035
1238
|
targetInput.type = "text";
|
|
1036
1239
|
targetInput.className = "edit-input mono";
|
|
1037
1240
|
targetInput.value = row.target;
|
|
1241
|
+
targetInput.title = row.target;
|
|
1038
1242
|
targetInput.setAttribute("aria-label", "Target for " + row.host);
|
|
1039
1243
|
targetTd.appendChild(targetInput);
|
|
1040
1244
|
tr.appendChild(targetTd);
|
|
@@ -1058,12 +1262,9 @@ const clientJs = `
|
|
|
1058
1262
|
insecureTd.appendChild(corsLabel);
|
|
1059
1263
|
tr.appendChild(insecureTd);
|
|
1060
1264
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
tr.appendChild(
|
|
1064
|
-
tr.appendChild(makeCell("\\u2014", "dim"));
|
|
1065
|
-
tr.appendChild(makeCell("\\u2014", "dim"));
|
|
1066
|
-
tr.appendChild(makeCell("\\u2014", "dim"));
|
|
1265
|
+
var hintTd = makeCell("Enter to save \\u00b7 Esc to cancel", "dim edit-hint");
|
|
1266
|
+
hintTd.colSpan = 3;
|
|
1267
|
+
tr.appendChild(hintTd);
|
|
1067
1268
|
|
|
1068
1269
|
var actionsTd = document.createElement("td");
|
|
1069
1270
|
var actionsWrap = document.createElement("div");
|
|
@@ -1081,10 +1282,13 @@ const clientJs = `
|
|
|
1081
1282
|
saveBtn.addEventListener("click", function () {
|
|
1082
1283
|
var newTarget = targetInput.value.trim();
|
|
1083
1284
|
if (!newTarget) {
|
|
1084
|
-
|
|
1285
|
+
targetInput.classList.add("invalid");
|
|
1286
|
+
targetInput.setAttribute("aria-invalid", "true");
|
|
1287
|
+
targetInput.focus();
|
|
1085
1288
|
return;
|
|
1086
1289
|
}
|
|
1087
1290
|
saveBtn.disabled = true;
|
|
1291
|
+
saveBtn.classList.add("busy");
|
|
1088
1292
|
cancelBtn.disabled = true;
|
|
1089
1293
|
apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
|
|
1090
1294
|
method: "PUT",
|
|
@@ -1096,8 +1300,10 @@ const clientJs = `
|
|
|
1096
1300
|
return loadHosts();
|
|
1097
1301
|
})
|
|
1098
1302
|
.catch(function (err) {
|
|
1099
|
-
|
|
1303
|
+
hintTd.textContent = "Couldn't save: " + (err && err.message ? err.message : err) + ". Fix it and press Enter to retry.";
|
|
1304
|
+
hintTd.className = "edit-hint edit-error";
|
|
1100
1305
|
saveBtn.disabled = false;
|
|
1306
|
+
saveBtn.classList.remove("busy");
|
|
1101
1307
|
cancelBtn.disabled = false;
|
|
1102
1308
|
});
|
|
1103
1309
|
});
|
|
@@ -1107,12 +1313,21 @@ const clientJs = `
|
|
|
1107
1313
|
buildDisplayRow(tr, row);
|
|
1108
1314
|
});
|
|
1109
1315
|
|
|
1316
|
+
targetInput.addEventListener("keydown", function (e) {
|
|
1317
|
+
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
|
|
1318
|
+
if (e.key === "Escape") { e.preventDefault(); cancelBtn.click(); }
|
|
1319
|
+
});
|
|
1320
|
+
setTimeout(function () { targetInput.focus(); targetInput.select(); }, 0);
|
|
1321
|
+
|
|
1110
1322
|
actionsWrap.appendChild(saveBtn);
|
|
1111
1323
|
actionsWrap.appendChild(cancelBtn);
|
|
1112
1324
|
tr.appendChild(actionsTd);
|
|
1325
|
+
labelCells(tr, ["", "", "Target", "Options", "", ""]);
|
|
1113
1326
|
}
|
|
1114
1327
|
|
|
1115
1328
|
function renderHosts(hosts) {
|
|
1329
|
+
try { localStorage.setItem("locadot.hostCount", String(hosts.length || 1)); } catch (e) {}
|
|
1330
|
+
hideHostsSkeleton();
|
|
1116
1331
|
lastHosts = hosts;
|
|
1117
1332
|
emptyEl.hidden = hosts.length !== 0;
|
|
1118
1333
|
tableWrap.hidden = hosts.length === 0;
|
|
@@ -1148,9 +1363,9 @@ const clientJs = `
|
|
|
1148
1363
|
function loadLogs() {
|
|
1149
1364
|
return fetch("/api/logs?lines=200").then(parseJsonOrThrow).then(function (data) {
|
|
1150
1365
|
var lines = (data && data.lines) || [];
|
|
1151
|
-
logsBox.textContent = lines.join("\\n");
|
|
1366
|
+
logsBox.textContent = lines.length ? lines.join("\\n") : "No log lines yet.";
|
|
1152
1367
|
logsBox.scrollTop = logsBox.scrollHeight;
|
|
1153
|
-
}).catch(function (err) { apiError(err); });
|
|
1368
|
+
}).catch(function (err) { apiError(err, "Couldn't load logs"); });
|
|
1154
1369
|
}
|
|
1155
1370
|
|
|
1156
1371
|
logsPanel.addEventListener("toggle", function () {
|
|
@@ -1164,7 +1379,7 @@ const clientJs = `
|
|
|
1164
1379
|
showToast("Logs cleared", "success");
|
|
1165
1380
|
return loadLogs();
|
|
1166
1381
|
})
|
|
1167
|
-
.catch(function (err) { apiError(err); });
|
|
1382
|
+
.catch(function (err) { apiError(err, "Couldn't clear logs"); });
|
|
1168
1383
|
});
|
|
1169
1384
|
|
|
1170
1385
|
cliAddCopy.addEventListener("click", function () { copyText(cliAddPre.textContent, cliAddCopy); });
|
|
@@ -1184,9 +1399,12 @@ const clientJs = `
|
|
|
1184
1399
|
renderSystemTiles(lastStatus);
|
|
1185
1400
|
renderCli(lastStatus);
|
|
1186
1401
|
renderHosts(results[1]);
|
|
1402
|
+
hideLoadError();
|
|
1187
1403
|
}).catch(function (err) {
|
|
1404
|
+
document.body.classList.remove("boot");
|
|
1405
|
+
hideHostsSkeleton();
|
|
1188
1406
|
markDown();
|
|
1189
|
-
|
|
1407
|
+
showLoadError(err);
|
|
1190
1408
|
});
|
|
1191
1409
|
}
|
|
1192
1410
|
|
|
@@ -1216,14 +1434,14 @@ function renderPage(nonce, token) {
|
|
|
1216
1434
|
<title>locadot dashboard</title>
|
|
1217
1435
|
<style nonce="${safeNonce}">${css}</style>
|
|
1218
1436
|
</head>
|
|
1219
|
-
<body>
|
|
1437
|
+
<body class="boot">
|
|
1220
1438
|
<header>
|
|
1221
1439
|
<div class="brand">
|
|
1222
1440
|
<h1 class="wordmark">locadot</h1>
|
|
1223
1441
|
<span id="version" class="muted mono"></span>
|
|
1224
1442
|
</div>
|
|
1225
1443
|
<div class="header-meta">
|
|
1226
|
-
<span class="chip"><span id="live-dot" class="live-dot"></span> live</span>
|
|
1444
|
+
<span class="chip"><span id="live-dot" class="live-dot"></span> <span id="live-label" class="live-label">live</span></span>
|
|
1227
1445
|
<span id="uptime" class="chip"></span>
|
|
1228
1446
|
<span id="pid" class="chip"></span>
|
|
1229
1447
|
<span id="updated" class="muted"></span>
|
|
@@ -1231,6 +1449,12 @@ function renderPage(nonce, token) {
|
|
|
1231
1449
|
</header>
|
|
1232
1450
|
<main>
|
|
1233
1451
|
<div id="stopped-banner" class="banner" role="status" hidden></div>
|
|
1452
|
+
<div id="load-error-banner" class="banner" role="alert" hidden>
|
|
1453
|
+
<div class="banner-row">
|
|
1454
|
+
<span id="load-error-text"></span>
|
|
1455
|
+
<button type="button" id="load-error-retry" class="btn btn-sm">Retry</button>
|
|
1456
|
+
</div>
|
|
1457
|
+
</div>
|
|
1234
1458
|
|
|
1235
1459
|
<section aria-label="System status">
|
|
1236
1460
|
<h2>System</h2>
|
|
@@ -1294,15 +1518,15 @@ function renderPage(nonce, token) {
|
|
|
1294
1518
|
|
|
1295
1519
|
<section aria-label="Add proxy host">
|
|
1296
1520
|
<h2>Add host</h2>
|
|
1297
|
-
<form id="add-form" class="card form-card">
|
|
1521
|
+
<form id="add-form" class="card form-card" novalidate>
|
|
1298
1522
|
<div class="form-grid">
|
|
1299
1523
|
<div class="field">
|
|
1300
1524
|
<label for="add-host">Host</label>
|
|
1301
|
-
<input type="text" id="add-host" name="host" placeholder="app.localhost" autocomplete="off" required>
|
|
1525
|
+
<input type="text" id="add-host" name="host" placeholder="app.localhost" autocomplete="off" aria-describedby="add-error" required>
|
|
1302
1526
|
</div>
|
|
1303
1527
|
<div class="field">
|
|
1304
1528
|
<label for="add-target">Target</label>
|
|
1305
|
-
<input type="text" id="add-target" name="target" placeholder="3000, 127.0.0.1:8080 or https://
|
|
1529
|
+
<input type="text" id="add-target" name="target" placeholder="3000, 127.0.0.1:8080 or https://example.com" autocomplete="off" aria-describedby="add-error" required>
|
|
1306
1530
|
</div>
|
|
1307
1531
|
<div class="field field-checkbox">
|
|
1308
1532
|
<input type="checkbox" id="add-insecure" name="insecure">
|
|
@@ -1320,6 +1544,18 @@ function renderPage(nonce, token) {
|
|
|
1320
1544
|
|
|
1321
1545
|
<section aria-label="Registered hosts">
|
|
1322
1546
|
<h2>Hosts</h2>
|
|
1547
|
+
<div id="hosts-skeleton" class="table-wrap card" aria-hidden="true">
|
|
1548
|
+
<table>
|
|
1549
|
+
<tbody>
|
|
1550
|
+
<tr class="skeleton-row"><td colspan="8"><span class="skel-bar"></span></td></tr>
|
|
1551
|
+
<tr class="skeleton-row"><td colspan="8"><span class="skel-bar"></span></td></tr>
|
|
1552
|
+
<tr class="skeleton-row"><td colspan="8"><span class="skel-bar"></span></td></tr>
|
|
1553
|
+
</tbody>
|
|
1554
|
+
</table>
|
|
1555
|
+
</div>
|
|
1556
|
+
<div id="hosts-error" class="empty" hidden>
|
|
1557
|
+
<p>Hosts couldn't be loaded while the proxy is unreachable.</p>
|
|
1558
|
+
</div>
|
|
1323
1559
|
<div id="empty" class="empty" hidden>
|
|
1324
1560
|
<p>No hosts registered yet. Add one above, or from the CLI:</p>
|
|
1325
1561
|
<pre class="code-block">locadot add --host app.localhost --port 3000</pre>
|
|
@@ -1361,7 +1597,7 @@ function renderPage(nonce, token) {
|
|
|
1361
1597
|
<button type="button" id="cli-add-copy" class="copy-btn">Copy</button>
|
|
1362
1598
|
</div>
|
|
1363
1599
|
<div class="cli-item">
|
|
1364
|
-
<pre id="cli-curl-pre" class="code-block mono"
|
|
1600
|
+
<pre id="cli-curl-pre" class="code-block mono">curl -X POST http://localhost/api/hosts -H "X-Locadot-Token: $(locadot token)" -H 'Content-Type: application/json' -d '{"host":"app.localhost","target":"3000"}'</pre>
|
|
1365
1601
|
<button type="button" id="cli-curl-copy" class="copy-btn">Copy</button>
|
|
1366
1602
|
</div>
|
|
1367
1603
|
<div class="cli-item">
|
|
@@ -0,0 +1,84 @@
|
|
|
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.publicOnlyAgents = exports.isPublicHost = exports.isPublicAddress = void 0;
|
|
7
|
+
const dns_1 = __importDefault(require("dns"));
|
|
8
|
+
const http_1 = __importDefault(require("http"));
|
|
9
|
+
const https_1 = __importDefault(require("https"));
|
|
10
|
+
const net_1 = __importDefault(require("net"));
|
|
11
|
+
/**
|
|
12
|
+
* Tunnel visitors are the internet. Their --cors pass-through calls may only leave this machine
|
|
13
|
+
* for public addresses, never loopback, the LAN or cloud metadata.
|
|
14
|
+
*/
|
|
15
|
+
const blocked = new net_1.default.BlockList();
|
|
16
|
+
for (const [prefix, bits] of [
|
|
17
|
+
["0.0.0.0", 8],
|
|
18
|
+
["10.0.0.0", 8],
|
|
19
|
+
["100.64.0.0", 10],
|
|
20
|
+
["127.0.0.0", 8],
|
|
21
|
+
["169.254.0.0", 16],
|
|
22
|
+
["172.16.0.0", 12],
|
|
23
|
+
["192.0.0.0", 24],
|
|
24
|
+
["192.168.0.0", 16],
|
|
25
|
+
["198.18.0.0", 15],
|
|
26
|
+
["224.0.0.0", 3],
|
|
27
|
+
]) {
|
|
28
|
+
blocked.addSubnet(prefix, bits, "ipv4");
|
|
29
|
+
}
|
|
30
|
+
for (const [prefix, bits] of [
|
|
31
|
+
["::", 96],
|
|
32
|
+
["fc00::", 7],
|
|
33
|
+
["fe80::", 10],
|
|
34
|
+
["ff00::", 8],
|
|
35
|
+
]) {
|
|
36
|
+
blocked.addSubnet(prefix, bits, "ipv6");
|
|
37
|
+
}
|
|
38
|
+
/** IPv4 inside IPv6 (::ffff:10.0.0.1, 64:ff9b::a00:1) is judged by its IPv4 part. */
|
|
39
|
+
const embeddedV4 = (address) => {
|
|
40
|
+
const lower = address.toLowerCase();
|
|
41
|
+
const dotted = lower.match(/^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
42
|
+
if (dotted)
|
|
43
|
+
return dotted;
|
|
44
|
+
const hex = lower.match(/^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
45
|
+
if (!hex)
|
|
46
|
+
return undefined;
|
|
47
|
+
const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
48
|
+
return [hi >> 8, hi & 255, lo >> 8, lo & 255].join(".");
|
|
49
|
+
};
|
|
50
|
+
const isPublicAddress = (address) => {
|
|
51
|
+
const v4 = net_1.default.isIPv4(address) ? address : embeddedV4(address);
|
|
52
|
+
if (v4)
|
|
53
|
+
return !blocked.check(v4, "ipv4");
|
|
54
|
+
return net_1.default.isIPv6(address) && !blocked.check(address, "ipv6");
|
|
55
|
+
};
|
|
56
|
+
exports.isPublicAddress = isPublicAddress;
|
|
57
|
+
/** A host the pass-through may call for a tunnel visitor, judged before any DNS lookup. */
|
|
58
|
+
const isPublicHost = (host) => {
|
|
59
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
|
|
60
|
+
if (net_1.default.isIP(name))
|
|
61
|
+
return (0, exports.isPublicAddress)(name);
|
|
62
|
+
return name !== "localhost" && !name.endsWith(".localhost") && !name.endsWith(".local") && name.includes(".");
|
|
63
|
+
};
|
|
64
|
+
exports.isPublicHost = isPublicHost;
|
|
65
|
+
// Checked at connect time, on the addresses actually dialled, so DNS rebinding can't slip past.
|
|
66
|
+
const publicLookup = (hostname, options, callback) => {
|
|
67
|
+
dns_1.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
68
|
+
if (err)
|
|
69
|
+
return callback(err, "", 0);
|
|
70
|
+
const allowed = addresses.filter((entry) => (0, exports.isPublicAddress)(entry.address));
|
|
71
|
+
if (!allowed.length) {
|
|
72
|
+
const error = new Error(`${hostname} is not a public address`);
|
|
73
|
+
error.code = "EPUBLICONLY";
|
|
74
|
+
return callback(error, "", 0);
|
|
75
|
+
}
|
|
76
|
+
if (options.all)
|
|
77
|
+
return callback(null, allowed);
|
|
78
|
+
callback(null, allowed[0].address, allowed[0].family);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
exports.publicOnlyAgents = {
|
|
82
|
+
http: new http_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
|
|
83
|
+
https: new https_1.default.Agent({ keepAlive: true, lookup: publicLookup }),
|
|
84
|
+
};
|
package/dist/proxy/options.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.viaOrigin = exports.viaOptions = exports.proxyOptions = void 0;
|
|
4
4
|
const cors_1 = require("./cors");
|
|
5
|
+
const guard_1 = require("./guard");
|
|
5
6
|
const passthrough_1 = require("./passthrough");
|
|
6
7
|
const request_1 = require("./request");
|
|
7
8
|
// --cors bodies are rewritten, so only ask for encodings we can decode (browsers also offer zstd).
|
|
@@ -28,6 +29,7 @@ const viaOptions = (req, entry, via) => ({
|
|
|
28
29
|
secure: !entry.insecure,
|
|
29
30
|
cookieDomainRewrite: { "*": "" },
|
|
30
31
|
headers: { ...(0, passthrough_1.viaHeaders)(req, via, entry.target), ...DECODABLE },
|
|
32
|
+
...((0, request_1.fromTunnel)(req) ? { agent: via.scheme === "https" || via.scheme === "wss" ? guard_1.publicOnlyAgents.https : guard_1.publicOnlyAgents.http } : {}),
|
|
31
33
|
});
|
|
32
34
|
exports.viaOptions = viaOptions;
|
|
33
35
|
const viaOrigin = (via) => `${via.scheme}://${via.host}`;
|
package/dist/proxy/response.js
CHANGED
|
@@ -18,7 +18,7 @@ const stripHopByHop = (headers) => {
|
|
|
18
18
|
}
|
|
19
19
|
};
|
|
20
20
|
/** http-proxy `proxyRes` pipeline: hop-by-hop cleanup, then --cors headers and origin rewriting. */
|
|
21
|
-
const handleProxyResponse = (proxyRes, req, res, hosts) => {
|
|
21
|
+
const handleProxyResponse = (proxyRes, req, res, hosts, publicUrl = () => undefined) => {
|
|
22
22
|
if (proxyRes.statusCode === 101)
|
|
23
23
|
return;
|
|
24
24
|
stripHopByHop(proxyRes.headers);
|
|
@@ -26,11 +26,8 @@ const handleProxyResponse = (proxyRes, req, res, hosts) => {
|
|
|
26
26
|
if (!entry?.cors)
|
|
27
27
|
return;
|
|
28
28
|
(0, cors_1.applyCors)(req, proxyRes.headers);
|
|
29
|
-
// Rewriting to .localhost names only helps a browser on this machine.
|
|
30
|
-
if ((0, request_1.fromTunnel)(req))
|
|
31
|
-
return;
|
|
32
29
|
const via = (0, request_1.viaOf)(req);
|
|
33
|
-
const pairs = (0, rewrite_1.originMap)(req, hosts);
|
|
30
|
+
const pairs = (0, rewrite_1.originMap)(req, hosts, publicUrl);
|
|
34
31
|
if (via)
|
|
35
32
|
(0, passthrough_1.rewriteViaResponse)(proxyRes.headers, via, entry.target);
|
|
36
33
|
else if (proxyRes.headers.location)
|
package/dist/proxy/rewrite.js
CHANGED
|
@@ -16,14 +16,22 @@ const DECODERS = {
|
|
|
16
16
|
deflate: zlib_1.default.inflateSync,
|
|
17
17
|
br: zlib_1.default.brotliDecompressSync,
|
|
18
18
|
};
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* [real origin, local origin] for every mapping, e.g. https://api.x.com → https://api.x.localhost.
|
|
21
|
+
* A tunnel visitor can't reach .localhost, so for them only shared mappings are rewritten, to
|
|
22
|
+
* their public URLs; the shim sends calls to the rest through the pass-through.
|
|
23
|
+
*/
|
|
24
|
+
const originMap = (req, hosts, publicUrl = () => undefined) => {
|
|
21
25
|
const tls = (0, request_1.isTls)(req);
|
|
22
|
-
const self = (0, request_1.
|
|
26
|
+
const self = (0, request_1.mappedHost)(req);
|
|
27
|
+
const tunnel = (0, request_1.fromTunnel)(req);
|
|
23
28
|
const pairs = [];
|
|
24
29
|
for (const [host, entry] of Object.entries(hosts)) {
|
|
30
|
+
const local = tunnel ? publicUrl(host) : (0, urls_1.urlFor)(host, tls);
|
|
31
|
+
if (!local)
|
|
32
|
+
continue;
|
|
25
33
|
try {
|
|
26
|
-
pairs.push([new URL(entry.target).origin,
|
|
34
|
+
pairs.push([new URL(entry.target).origin, local, host === self]);
|
|
27
35
|
}
|
|
28
36
|
catch { }
|
|
29
37
|
}
|
package/dist/proxy/router.js
CHANGED
|
@@ -11,6 +11,7 @@ const logger_1 = __importDefault(require("../utils/logger"));
|
|
|
11
11
|
const urls_1 = require("../lib/urls");
|
|
12
12
|
const cors_1 = require("./cors");
|
|
13
13
|
const options_1 = require("./options");
|
|
14
|
+
const guard_1 = require("./guard");
|
|
14
15
|
const passthrough_1 = require("./passthrough");
|
|
15
16
|
const request_1 = require("./request");
|
|
16
17
|
const stats_1 = require("./stats");
|
|
@@ -23,8 +24,9 @@ const resolveHost = (req, ctx) => {
|
|
|
23
24
|
return local ?? host;
|
|
24
25
|
};
|
|
25
26
|
const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.isTls)(req))}/`;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const passThrough = (req, entry) => Boolean(entry.cors);
|
|
28
|
+
/** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
|
|
29
|
+
const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
|
|
28
30
|
const reason = (err) => err?.code || err?.message;
|
|
29
31
|
function handleRequest(req, res, ctx) {
|
|
30
32
|
const host = resolveHost(req, ctx);
|
|
@@ -51,9 +53,9 @@ function handleRequest(req, res, ctx) {
|
|
|
51
53
|
return;
|
|
52
54
|
}
|
|
53
55
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
54
|
-
if (via && !(
|
|
56
|
+
if (via && !viaAllowed(req, via)) {
|
|
55
57
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
56
|
-
res.end("locadot: the pass-through only serves the page's own requests.\n");
|
|
58
|
+
res.end("locadot: the pass-through only serves the page's own requests, and only public hosts through a tunnel.\n");
|
|
57
59
|
return;
|
|
58
60
|
}
|
|
59
61
|
const started = Date.now();
|
|
@@ -95,7 +97,7 @@ function handleUpgrade(req, socket, head, ctx) {
|
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
99
|
const via = passThrough(req, entry) ? (0, passthrough_1.parseVia)(req.url) : undefined;
|
|
98
|
-
if (via && !(
|
|
100
|
+
if (via && !viaAllowed(req, via)) {
|
|
99
101
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
100
102
|
return;
|
|
101
103
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -33,7 +33,7 @@ async function startCentralProxy() {
|
|
|
33
33
|
const stats = new Map();
|
|
34
34
|
const proxy = http_proxy_1.default.createProxyServer({});
|
|
35
35
|
proxy.on("error", (err) => logger_1.default.warn(`proxy error: ${err.message}`));
|
|
36
|
-
proxy.on("proxyRes", (proxyRes, req, res) => (0, response_1.handleProxyResponse)(proxyRes, req, res, registry.get().hosts));
|
|
36
|
+
proxy.on("proxyRes", (proxyRes, req, res) => (0, response_1.handleProxyResponse)(proxyRes, req, res, registry.get().hosts, (host) => tunnels.state(host).url));
|
|
37
37
|
const info = {
|
|
38
38
|
pid: process.pid,
|
|
39
39
|
version: package_json_1.version,
|
|
@@ -51,7 +51,7 @@ async function startCentralProxy() {
|
|
|
51
51
|
const ctx = {
|
|
52
52
|
proxy,
|
|
53
53
|
stats,
|
|
54
|
-
lookup: (host) => registry.get().hosts[host],
|
|
54
|
+
lookup: (host) => registry.get().hosts[tunnels.hostFor(host) ?? host],
|
|
55
55
|
tunnelFor: (host) => tunnels.hostFor(host),
|
|
56
56
|
dashboard: (req, res) => (0, dashboard_1.handleDashboardRequest)(req, res, {
|
|
57
57
|
getRegistry: registry.get,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "locadot",
|
|
3
|
-
"version": "1.6.0-beta.
|
|
3
|
+
"version": "1.6.0-beta.6",
|
|
4
4
|
"description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
|
|
5
5
|
"homepage": "https://www.npmjs.com/package/locadot",
|
|
6
6
|
"main": "dist/index.js",
|