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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- The version in `package.json` is 1.6.0-beta.5 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
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`. The `--cors` pass-through is disabled for tunnel visitors.
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
- For safety, the `--cors` pass-through (`/__locadot/x/…`) is turned off for visitors coming through the tunnel, so the tunnel
84
- can't be used to reach other hosts from your machine. The dashboard can also do this: use **Share** / **Unshare** on a row.
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
 
@@ -9,10 +9,10 @@ const escape_1 = require("./escape");
9
9
  const css = `
10
10
  :root {
11
11
  --bg: #0b0d10;
12
- --surface: #12151a;
13
- --surface-2: #171b21;
14
- --border: #22262e;
15
- --border-strong: #2c313a;
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 var(--border-strong);
181
- background: var(--surface-2);
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: var(--muted);
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-bg); border-color: var(--accent); }
195
- .switch.on .switch-knob { transform: translateX(16px); background: var(--accent); }
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: 999px;
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: 160px;
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(2, minmax(0, 1fr)); }
413
- .tile-wide { grid-column: span 1; }
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
- tr.appendChild(makeCell("\\u2014", "dim"));
1062
- tr.appendChild(makeCell("\\u2014", "dim"));
1063
- tr.appendChild(makeCell("\\u2014", "dim"));
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
- showToast("Target is required", "error");
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
- apiError(err);
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
- showToast("Refresh failed: " + (err && err.message ? err.message : err), "error");
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://google.com" autocomplete="off" required>
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"></pre>
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
+ };
@@ -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}`;
@@ -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)
@@ -16,14 +16,22 @@ const DECODERS = {
16
16
  deflate: zlib_1.default.inflateSync,
17
17
  br: zlib_1.default.brotliDecompressSync,
18
18
  };
19
- /** [real origin, local origin] for every mapping, e.g. https://api.x.com → https://api.x.localhost. */
20
- const originMap = (req, hosts) => {
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.hostOf)(req);
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, (0, urls_1.urlFor)(host, tls), host === self]);
34
+ pairs.push([new URL(entry.target).origin, local, host === self]);
27
35
  }
28
36
  catch { }
29
37
  }
@@ -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
- // The pass-through fetches arbitrary URLs from this machine; never offer it to the internet.
27
- const passThrough = (req, entry) => Boolean(entry.cors) && !(0, request_1.fromTunnel)(req);
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 && !(0, passthrough_1.isSameOrigin)(req)) {
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 && !(0, passthrough_1.isSameOrigin)(req)) {
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
  }
@@ -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.5",
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",