locadot 2.3.0 → 2.4.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 +15 -0
- package/README.md +17 -1
- package/dist/cli/commands/hosts.js +1 -1
- package/dist/cli/commands/index.js +2 -1
- package/dist/cli/commands/protect.js +76 -0
- package/dist/cli/commands/tunnel.js +12 -1
- package/dist/cli/commands/ui.js +6 -3
- package/dist/constants/index.js +1 -0
- package/dist/dashboard/api.js +11 -1
- package/dist/dashboard/index.js +2 -0
- package/dist/dashboard/page.js +445 -6
- package/dist/index.js +11 -0
- package/dist/lib/host-auth.js +123 -0
- package/dist/lib/hosts.js +53 -0
- package/dist/proxy/cloudflare-logins.js +197 -0
- package/dist/proxy/protect.js +215 -0
- package/dist/proxy/router.js +5 -0
- package/dist/proxy/tunnel.js +16 -11
- package/package.json +1 -1
package/dist/dashboard/page.js
CHANGED
|
@@ -812,8 +812,8 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
|
|
|
812
812
|
border-top: 1px solid var(--border);
|
|
813
813
|
}
|
|
814
814
|
|
|
815
|
-
/* ---------- confirm / prompt / share dialogs ---------- */
|
|
816
|
-
#confirm-dialog, #prompt-dialog, #share-dialog {
|
|
815
|
+
/* ---------- confirm / prompt / share / protect dialogs ---------- */
|
|
816
|
+
#confirm-dialog, #prompt-dialog, #share-dialog, #protect-dialog {
|
|
817
817
|
padding: 0;
|
|
818
818
|
border: 1px solid var(--border);
|
|
819
819
|
border-radius: var(--radius);
|
|
@@ -821,10 +821,54 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
|
|
|
821
821
|
color: var(--fg);
|
|
822
822
|
box-shadow: var(--shadow-md);
|
|
823
823
|
}
|
|
824
|
-
#confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
|
824
|
+
#confirm-dialog::backdrop, #prompt-dialog::backdrop, #share-dialog::backdrop, #protect-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
|
825
825
|
#confirm-dialog { width: min(420px, calc(100vw - 32px)); }
|
|
826
826
|
#prompt-dialog { width: min(420px, calc(100vw - 32px)); }
|
|
827
827
|
#share-dialog { width: min(480px, calc(100vw - 32px)); }
|
|
828
|
+
#protect-dialog { width: min(480px, calc(100vw - 32px)); }
|
|
829
|
+
.field-password { position: relative; }
|
|
830
|
+
.field-password .input { width: 100%; padding-right: 40px; }
|
|
831
|
+
.field-toggle {
|
|
832
|
+
position: absolute;
|
|
833
|
+
top: 4px;
|
|
834
|
+
right: 4px;
|
|
835
|
+
width: 28px;
|
|
836
|
+
height: 28px;
|
|
837
|
+
padding: 0;
|
|
838
|
+
border: 1px solid transparent;
|
|
839
|
+
border-radius: var(--radius-xs);
|
|
840
|
+
background: transparent;
|
|
841
|
+
color: var(--muted);
|
|
842
|
+
cursor: pointer;
|
|
843
|
+
display: inline-flex;
|
|
844
|
+
align-items: center;
|
|
845
|
+
justify-content: center;
|
|
846
|
+
}
|
|
847
|
+
.field-toggle:hover { background: var(--surface-2); color: var(--fg); }
|
|
848
|
+
.field-toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
849
|
+
.option-check {
|
|
850
|
+
width: 16px;
|
|
851
|
+
height: 16px;
|
|
852
|
+
margin-top: 2px;
|
|
853
|
+
flex-shrink: 0;
|
|
854
|
+
border-radius: 4px;
|
|
855
|
+
border: 1.5px solid var(--border-strong);
|
|
856
|
+
background: var(--surface);
|
|
857
|
+
position: relative;
|
|
858
|
+
}
|
|
859
|
+
.option-card[aria-checked="true"] .option-check { border-color: var(--accent); background: var(--accent); }
|
|
860
|
+
.option-card[aria-checked="true"] .option-check::after {
|
|
861
|
+
content: "";
|
|
862
|
+
position: absolute;
|
|
863
|
+
left: 4px;
|
|
864
|
+
top: 1px;
|
|
865
|
+
width: 4px;
|
|
866
|
+
height: 8px;
|
|
867
|
+
border: solid var(--accent-fg);
|
|
868
|
+
border-width: 0 2px 2px 0;
|
|
869
|
+
transform: rotate(45deg);
|
|
870
|
+
}
|
|
871
|
+
#protect-remove { margin-right: auto; }
|
|
828
872
|
.share-options { display: flex; flex-direction: column; gap: 10px; }
|
|
829
873
|
.option-card {
|
|
830
874
|
display: flex;
|
|
@@ -868,6 +912,15 @@ details.card[open] summary { border-bottom: 1px solid var(--border); border-bott
|
|
|
868
912
|
.option-desc { font-size: 12.5px; color: var(--muted); line-height: 1.45; }
|
|
869
913
|
.option-extra { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
870
914
|
.option-extra .field-error { margin-top: 4px; }
|
|
915
|
+
.share-progress { display: flex; gap: 14px; align-items: flex-start; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-2); }
|
|
916
|
+
.share-progress-icon { width: 36px; height: 36px; flex-shrink: 0; border-radius: 999px; display: grid; place-items: center; background: var(--accent-bg); color: var(--accent); }
|
|
917
|
+
.share-progress-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
918
|
+
.share-progress-icon .spinner { margin: 0; }
|
|
919
|
+
.share-progress-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
920
|
+
.share-progress-title { font-size: 14px; font-weight: 600; margin: 0; overflow-wrap: anywhere; }
|
|
921
|
+
.share-progress-text { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0; }
|
|
922
|
+
.share-progress-body .btn { align-self: flex-start; margin-top: 6px; text-decoration: none; }
|
|
923
|
+
.share-steps { margin: 2px 0 0; padding-left: 18px; font-size: 12.5px; color: var(--muted); line-height: 1.6; }
|
|
871
924
|
|
|
872
925
|
/* ---------- toasts ---------- */
|
|
873
926
|
.toast-container {
|
|
@@ -1221,6 +1274,7 @@ const clientJs = `
|
|
|
1221
1274
|
var cfInstallBusy = false;
|
|
1222
1275
|
var cloudflaredInstalled = false;
|
|
1223
1276
|
var cloudflareLoggedIn = false;
|
|
1277
|
+
var cloudflareZones = [];
|
|
1224
1278
|
var proxyStopped = false;
|
|
1225
1279
|
var pollTimer = null;
|
|
1226
1280
|
var remotesPollTimer = null;
|
|
@@ -1439,6 +1493,7 @@ const clientJs = `
|
|
|
1439
1493
|
var cf = sys.cloudflared || { installed: false };
|
|
1440
1494
|
cloudflaredInstalled = !!cf.installed;
|
|
1441
1495
|
cloudflareLoggedIn = !!cf.loggedIn;
|
|
1496
|
+
cloudflareZones = Array.isArray(cf.zones) ? cf.zones : [];
|
|
1442
1497
|
if (cf.installed) {
|
|
1443
1498
|
cfValueEl.textContent = "Installed";
|
|
1444
1499
|
cfValueEl.className = "tile-value ok";
|
|
@@ -2385,6 +2440,18 @@ const clientJs = `
|
|
|
2385
2440
|
var shareDomainError = document.getElementById("share-domain-error");
|
|
2386
2441
|
var shareDomainHint = document.getElementById("share-domain-hint");
|
|
2387
2442
|
var shareErrorEl = document.getElementById("share-error");
|
|
2443
|
+
var shareOptionsEl = document.getElementById("share-options");
|
|
2444
|
+
var shareProgressEl = document.getElementById("share-progress");
|
|
2445
|
+
var shareProgressIcon = document.getElementById("share-progress-icon");
|
|
2446
|
+
var shareProgressTitle = document.getElementById("share-progress-title");
|
|
2447
|
+
var shareProgressText = document.getElementById("share-progress-text");
|
|
2448
|
+
var shareStepsEl = document.getElementById("share-steps");
|
|
2449
|
+
var shareStepsDomain = document.getElementById("share-steps-domain");
|
|
2450
|
+
var shareAuthLink = document.getElementById("share-auth-link");
|
|
2451
|
+
var shareDescEl = shareDialogEl.querySelector(".modal-desc");
|
|
2452
|
+
var sharePoll = null;
|
|
2453
|
+
var shareAuthWin = null;
|
|
2454
|
+
var shareAuthOpened = false;
|
|
2388
2455
|
var shareRow = null;
|
|
2389
2456
|
var shareMode = "random";
|
|
2390
2457
|
var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
|
|
@@ -2406,9 +2473,7 @@ const clientJs = `
|
|
|
2406
2473
|
shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
|
|
2407
2474
|
shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
|
|
2408
2475
|
shareCustomExtra.hidden = mode !== "custom";
|
|
2409
|
-
|
|
2410
|
-
? "The DNS record is created or updated for you."
|
|
2411
|
-
: "You'll be asked to log in to Cloudflare once; the login link appears on the row.";
|
|
2476
|
+
updateDomainHint();
|
|
2412
2477
|
shareDomainError.hidden = true;
|
|
2413
2478
|
shareDomainError.textContent = "";
|
|
2414
2479
|
shareErrorEl.hidden = true;
|
|
@@ -2416,7 +2481,108 @@ const clientJs = `
|
|
|
2416
2481
|
if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
|
|
2417
2482
|
}
|
|
2418
2483
|
|
|
2484
|
+
function coveringZone(hostname) {
|
|
2485
|
+
for (var i = 0; i < cloudflareZones.length; i++) {
|
|
2486
|
+
var zone = cloudflareZones[i];
|
|
2487
|
+
if (hostname === zone || hostname.slice(-(zone.length + 1)) === "." + zone) return zone;
|
|
2488
|
+
}
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function updateDomainHint() {
|
|
2493
|
+
var value = normalizeHostname(shareDomainInput.value);
|
|
2494
|
+
var zone = value && !validateHostname(value) ? coveringZone(value) : null;
|
|
2495
|
+
shareDomainHint.textContent = zone
|
|
2496
|
+
? "Uses your Cloudflare login for " + zone + ". The DNS record is created or updated for you."
|
|
2497
|
+
: "Cloudflare opens in a new tab so you can authorize this domain's zone. Each zone you authorize is remembered.";
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
var ICON_CLOUD = '<svg viewBox="0 0 24 24"><path d="M7 18a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 18 9.5a4 4 0 0 1-.5 8.5z"/></svg>';
|
|
2501
|
+
|
|
2502
|
+
function stopSharePoll() {
|
|
2503
|
+
if (sharePoll) clearInterval(sharePoll);
|
|
2504
|
+
sharePoll = null;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function closeAuthWindow() {
|
|
2508
|
+
if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) shareAuthWin.close();
|
|
2509
|
+
shareAuthWin = null;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function showShareOptions() {
|
|
2513
|
+
shareOptionsEl.hidden = false;
|
|
2514
|
+
shareDescEl.hidden = false;
|
|
2515
|
+
shareProgressEl.hidden = true;
|
|
2516
|
+
shareSubmitBtn.hidden = false;
|
|
2517
|
+
shareCancelBtn.textContent = "Cancel";
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function showShareProgress(domain, t) {
|
|
2521
|
+
shareOptionsEl.hidden = true;
|
|
2522
|
+
shareDescEl.hidden = true;
|
|
2523
|
+
shareProgressEl.hidden = false;
|
|
2524
|
+
shareSubmitBtn.hidden = true;
|
|
2525
|
+
shareCancelBtn.textContent = "Close";
|
|
2526
|
+
shareCancelBtn.disabled = false;
|
|
2527
|
+
var login = t.status === "login";
|
|
2528
|
+
if (login) {
|
|
2529
|
+
shareProgressIcon.innerHTML = ICON_CLOUD;
|
|
2530
|
+
shareProgressTitle.textContent = "Authorize " + domain + " on Cloudflare";
|
|
2531
|
+
shareProgressText.textContent = "locadot doesn't have a Cloudflare login for this domain yet.";
|
|
2532
|
+
} else {
|
|
2533
|
+
shareProgressIcon.innerHTML = '<span class="spinner"></span>';
|
|
2534
|
+
shareProgressTitle.textContent = "Setting up " + domain;
|
|
2535
|
+
shareProgressText.textContent = t.loginUrl === undefined && shareAuthOpened
|
|
2536
|
+
? "Authorized. Creating the tunnel and the DNS record\u2026"
|
|
2537
|
+
: "Checking your Cloudflare login, then creating the tunnel and the DNS record\u2026";
|
|
2538
|
+
}
|
|
2539
|
+
shareStepsEl.hidden = !login;
|
|
2540
|
+
shareStepsDomain.textContent = domain;
|
|
2541
|
+
shareAuthLink.hidden = !(login && t.loginUrl);
|
|
2542
|
+
if (login && t.loginUrl) {
|
|
2543
|
+
shareAuthLink.href = t.loginUrl;
|
|
2544
|
+
if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) {
|
|
2545
|
+
shareAuthWin.location.href = t.loginUrl;
|
|
2546
|
+
shareAuthOpened = true;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function watchShare(host, domain) {
|
|
2552
|
+
stopSharePoll();
|
|
2553
|
+
showShareProgress(domain, { status: "starting" });
|
|
2554
|
+
var tick = function () {
|
|
2555
|
+
fetch("/api/hosts").then(parseJsonOrThrow).then(function (hosts) {
|
|
2556
|
+
if (shareRow === null || shareRow.host !== host) return;
|
|
2557
|
+
var row = null;
|
|
2558
|
+
for (var i = 0; i < hosts.length; i++) if (hosts[i].host === host) row = hosts[i];
|
|
2559
|
+
var t = row ? tunnelInfo(row) : { status: "error", error: host + " was removed." };
|
|
2560
|
+
if (t.status === "up") {
|
|
2561
|
+
stopSharePoll();
|
|
2562
|
+
closeAuthWindow();
|
|
2563
|
+
closeShareDialog();
|
|
2564
|
+
showToast(host + " is live at " + t.url, "success");
|
|
2565
|
+
renderHosts(hosts);
|
|
2566
|
+
} else if (t.status === "error" || t.status === "off") {
|
|
2567
|
+
stopSharePoll();
|
|
2568
|
+
closeAuthWindow();
|
|
2569
|
+
showShareOptions();
|
|
2570
|
+
shareErrorEl.textContent = t.error || "Sharing stopped.";
|
|
2571
|
+
shareErrorEl.hidden = false;
|
|
2572
|
+
shareSubmitBtn.textContent = "Try again";
|
|
2573
|
+
renderHosts(hosts);
|
|
2574
|
+
} else {
|
|
2575
|
+
showShareProgress(domain, t);
|
|
2576
|
+
}
|
|
2577
|
+
}).catch(function () {});
|
|
2578
|
+
};
|
|
2579
|
+
sharePoll = setInterval(tick, 1500);
|
|
2580
|
+
tick();
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2419
2583
|
function closeShareDialog() {
|
|
2584
|
+
stopSharePoll();
|
|
2585
|
+
closeAuthWindow();
|
|
2420
2586
|
shareRow = null;
|
|
2421
2587
|
shareDialogEl.close();
|
|
2422
2588
|
}
|
|
@@ -2430,6 +2596,9 @@ const clientJs = `
|
|
|
2430
2596
|
shareDomainError.textContent = "";
|
|
2431
2597
|
var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
|
|
2432
2598
|
shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
|
|
2599
|
+
showShareOptions();
|
|
2600
|
+
shareSubmitBtn.textContent = "Start sharing";
|
|
2601
|
+
shareAuthOpened = false;
|
|
2433
2602
|
setShareMode(hasDomain ? "custom" : "random");
|
|
2434
2603
|
shareSubmitBtn.disabled = false;
|
|
2435
2604
|
shareSubmitBtn.classList.remove("busy");
|
|
@@ -2450,6 +2619,12 @@ const clientJs = `
|
|
|
2450
2619
|
shareDialogEl.addEventListener("click", function (e) {
|
|
2451
2620
|
if (e.target === shareDialogEl) closeShareDialog();
|
|
2452
2621
|
});
|
|
2622
|
+
shareDialogEl.addEventListener("close", function () {
|
|
2623
|
+
stopSharePoll();
|
|
2624
|
+
closeAuthWindow();
|
|
2625
|
+
shareRow = null;
|
|
2626
|
+
});
|
|
2627
|
+
shareDomainInput.addEventListener("input", updateDomainHint);
|
|
2453
2628
|
shareDomainInput.addEventListener("keydown", function (e) {
|
|
2454
2629
|
if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
|
|
2455
2630
|
});
|
|
@@ -2470,13 +2645,32 @@ const clientJs = `
|
|
|
2470
2645
|
shareSubmitBtn.disabled = true;
|
|
2471
2646
|
shareSubmitBtn.classList.add("busy");
|
|
2472
2647
|
shareCancelBtn.disabled = true;
|
|
2648
|
+
shareErrorEl.hidden = true;
|
|
2649
|
+
// Opened now, inside the click, so the browser allows it; pointed at Cloudflare once the sign-in URL arrives.
|
|
2650
|
+
shareAuthOpened = false;
|
|
2651
|
+
closeAuthWindow();
|
|
2652
|
+
if (body.domain && !coveringZone(body.domain)) {
|
|
2653
|
+
shareAuthWin = window.open("", "_blank");
|
|
2654
|
+
if (shareAuthWin) {
|
|
2655
|
+
try {
|
|
2656
|
+
shareAuthWin.document.title = "Connecting to Cloudflare\u2026";
|
|
2657
|
+
shareAuthWin.document.body.style.cssText = "font:14px system-ui,sans-serif;color:#666;display:grid;place-items:center;height:100vh;margin:0";
|
|
2658
|
+
shareAuthWin.document.body.textContent = "Connecting to Cloudflare\u2026";
|
|
2659
|
+
} catch (e) {}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2473
2662
|
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
|
|
2474
2663
|
.then(function () {
|
|
2664
|
+
if (body.domain) {
|
|
2665
|
+
watchShare(row.host, body.domain);
|
|
2666
|
+
return loadHosts();
|
|
2667
|
+
}
|
|
2475
2668
|
showToast("Starting tunnel for " + row.host, "success");
|
|
2476
2669
|
closeShareDialog();
|
|
2477
2670
|
return loadHosts();
|
|
2478
2671
|
})
|
|
2479
2672
|
.catch(function (err2) {
|
|
2673
|
+
closeAuthWindow();
|
|
2480
2674
|
shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
|
|
2481
2675
|
shareErrorEl.hidden = false;
|
|
2482
2676
|
})
|
|
@@ -2616,6 +2810,167 @@ const clientJs = `
|
|
|
2616
2810
|
});
|
|
2617
2811
|
});
|
|
2618
2812
|
|
|
2813
|
+
// ---------- protect dialog ----------
|
|
2814
|
+
|
|
2815
|
+
var PROTECT_SCOPES = ["shared", "remote", "local"];
|
|
2816
|
+
var PROTECT_SCOPE_LABELS = { shared: "Shared link", remote: "Remote access", local: "This machine & network" };
|
|
2817
|
+
|
|
2818
|
+
function protectScopeNames(scopes) {
|
|
2819
|
+
return scopes.map(function (s) { return PROTECT_SCOPE_LABELS[s] || s; }).join(", ");
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
var protectDialogEl = document.getElementById("protect-dialog");
|
|
2823
|
+
var protectHostEl = document.getElementById("protect-host");
|
|
2824
|
+
var protectCloseBtn = document.getElementById("protect-close");
|
|
2825
|
+
var protectCancelBtn = document.getElementById("protect-cancel");
|
|
2826
|
+
var protectSubmitBtn = document.getElementById("protect-submit");
|
|
2827
|
+
var protectRemoveBtn = document.getElementById("protect-remove");
|
|
2828
|
+
var protectPasswordInput = document.getElementById("protect-password");
|
|
2829
|
+
var protectToggleBtn = document.getElementById("protect-toggle");
|
|
2830
|
+
var protectErrorEl = document.getElementById("protect-error");
|
|
2831
|
+
var protectHintEl = document.getElementById("protect-hint");
|
|
2832
|
+
var protectScopeErrorEl = document.getElementById("protect-scope-error");
|
|
2833
|
+
var protectOptionEls = PROTECT_SCOPES.map(function (s) { return document.getElementById("protect-option-" + s); });
|
|
2834
|
+
var protectRow = null;
|
|
2835
|
+
var protectScopes = [];
|
|
2836
|
+
|
|
2837
|
+
function protectRenderScopes() {
|
|
2838
|
+
protectOptionEls.forEach(function (el) {
|
|
2839
|
+
el.setAttribute("aria-checked", String(protectScopes.indexOf(el.getAttribute("data-scope")) !== -1));
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
function protectToggleScope(el) {
|
|
2844
|
+
var scope = el.getAttribute("data-scope");
|
|
2845
|
+
var on = protectScopes.indexOf(scope) === -1;
|
|
2846
|
+
if (on) protectScopes.push(scope);
|
|
2847
|
+
else protectScopes = protectScopes.filter(function (s) { return s !== scope; });
|
|
2848
|
+
protectRenderScopes();
|
|
2849
|
+
protectScopeErrorEl.hidden = true;
|
|
2850
|
+
protectScopeErrorEl.textContent = "";
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
protectOptionEls.forEach(function (el) {
|
|
2854
|
+
el.addEventListener("click", function () { protectToggleScope(el); });
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
protectToggleBtn.addEventListener("click", function () {
|
|
2858
|
+
var showing = protectPasswordInput.type === "text";
|
|
2859
|
+
protectPasswordInput.type = showing ? "password" : "text";
|
|
2860
|
+
protectToggleBtn.setAttribute("aria-label", showing ? "Show password" : "Hide password");
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
function closeProtectDialog() {
|
|
2864
|
+
protectRow = null;
|
|
2865
|
+
protectDialogEl.close();
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function openProtectDialog(row) {
|
|
2869
|
+
protectRow = row;
|
|
2870
|
+
protectHostEl.textContent = row.host;
|
|
2871
|
+
protectPasswordInput.value = "";
|
|
2872
|
+
protectPasswordInput.type = "password";
|
|
2873
|
+
protectToggleBtn.setAttribute("aria-label", "Show password");
|
|
2874
|
+
protectErrorEl.hidden = true;
|
|
2875
|
+
protectErrorEl.textContent = "";
|
|
2876
|
+
protectScopeErrorEl.hidden = true;
|
|
2877
|
+
protectScopeErrorEl.textContent = "";
|
|
2878
|
+
protectScopes = row.protect ? row.protect.scopes.slice() : [];
|
|
2879
|
+
protectRenderScopes();
|
|
2880
|
+
if (row.protect) {
|
|
2881
|
+
protectPasswordInput.placeholder = "Leave blank to keep the current password";
|
|
2882
|
+
protectHintEl.textContent = "Set " + fmtRelative(row.protect.updatedAt);
|
|
2883
|
+
protectHintEl.hidden = false;
|
|
2884
|
+
protectRemoveBtn.hidden = false;
|
|
2885
|
+
protectSubmitBtn.textContent = "Save";
|
|
2886
|
+
} else {
|
|
2887
|
+
protectPasswordInput.placeholder = "";
|
|
2888
|
+
protectHintEl.hidden = true;
|
|
2889
|
+
protectRemoveBtn.hidden = true;
|
|
2890
|
+
protectSubmitBtn.textContent = "Protect host";
|
|
2891
|
+
}
|
|
2892
|
+
protectSubmitBtn.disabled = false;
|
|
2893
|
+
protectSubmitBtn.classList.remove("busy");
|
|
2894
|
+
protectCancelBtn.disabled = false;
|
|
2895
|
+
protectRemoveBtn.disabled = false;
|
|
2896
|
+
protectDialogEl.showModal();
|
|
2897
|
+
setTimeout(function () { protectPasswordInput.focus(); }, 0);
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
protectCancelBtn.addEventListener("click", closeProtectDialog);
|
|
2901
|
+
protectCloseBtn.addEventListener("click", closeProtectDialog);
|
|
2902
|
+
protectDialogEl.addEventListener("click", function (e) {
|
|
2903
|
+
if (e.target === protectDialogEl) closeProtectDialog();
|
|
2904
|
+
});
|
|
2905
|
+
protectDialogEl.addEventListener("close", function () { protectRow = null; });
|
|
2906
|
+
|
|
2907
|
+
protectRemoveBtn.addEventListener("click", function () {
|
|
2908
|
+
if (!protectRow) return;
|
|
2909
|
+
var row = protectRow;
|
|
2910
|
+
confirmDialog({ title: "Remove password protection?", message: "Anyone will be able to reach " + row.host + " without a password.", confirmLabel: "Remove protection", danger: true }).then(function (ok) {
|
|
2911
|
+
if (!ok) return;
|
|
2912
|
+
protectRemoveBtn.disabled = true;
|
|
2913
|
+
protectSubmitBtn.disabled = true;
|
|
2914
|
+
protectCancelBtn.disabled = true;
|
|
2915
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: null }) })
|
|
2916
|
+
.then(function () {
|
|
2917
|
+
showToast("Removed password protection for " + row.host, "success");
|
|
2918
|
+
closeProtectDialog();
|
|
2919
|
+
return loadHosts();
|
|
2920
|
+
})
|
|
2921
|
+
.catch(function (err) {
|
|
2922
|
+
protectErrorEl.textContent = err && err.message ? err.message : String(err);
|
|
2923
|
+
protectErrorEl.hidden = false;
|
|
2924
|
+
protectRemoveBtn.disabled = false;
|
|
2925
|
+
protectSubmitBtn.disabled = false;
|
|
2926
|
+
protectCancelBtn.disabled = false;
|
|
2927
|
+
});
|
|
2928
|
+
});
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2931
|
+
protectSubmitBtn.addEventListener("click", function () {
|
|
2932
|
+
if (!protectRow) return;
|
|
2933
|
+
var row = protectRow;
|
|
2934
|
+
var password = protectPasswordInput.value;
|
|
2935
|
+
protectErrorEl.hidden = true;
|
|
2936
|
+
protectErrorEl.textContent = "";
|
|
2937
|
+
protectScopeErrorEl.hidden = true;
|
|
2938
|
+
protectScopeErrorEl.textContent = "";
|
|
2939
|
+
var required = !row.protect;
|
|
2940
|
+
if ((required || password) && password.length < 8) {
|
|
2941
|
+
protectErrorEl.textContent = "Use at least 8 characters.";
|
|
2942
|
+
protectErrorEl.hidden = false;
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
if (!protectScopes.length) {
|
|
2946
|
+
protectScopeErrorEl.textContent = "Pick at least one place to ask for the password.";
|
|
2947
|
+
protectScopeErrorEl.hidden = false;
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
var protectBody = { scopes: protectScopes.slice() };
|
|
2951
|
+
if (password) protectBody.password = password;
|
|
2952
|
+
protectSubmitBtn.disabled = true;
|
|
2953
|
+
protectSubmitBtn.classList.add("busy");
|
|
2954
|
+
protectCancelBtn.disabled = true;
|
|
2955
|
+
protectRemoveBtn.disabled = true;
|
|
2956
|
+
apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: protectBody }) })
|
|
2957
|
+
.then(function () {
|
|
2958
|
+
showToast("Password protection saved for " + row.host, "success");
|
|
2959
|
+
closeProtectDialog();
|
|
2960
|
+
return loadHosts();
|
|
2961
|
+
})
|
|
2962
|
+
.catch(function (err) {
|
|
2963
|
+
protectErrorEl.textContent = err && err.message ? err.message : String(err);
|
|
2964
|
+
protectErrorEl.hidden = false;
|
|
2965
|
+
})
|
|
2966
|
+
.then(function () {
|
|
2967
|
+
protectSubmitBtn.disabled = false;
|
|
2968
|
+
protectSubmitBtn.classList.remove("busy");
|
|
2969
|
+
protectCancelBtn.disabled = false;
|
|
2970
|
+
protectRemoveBtn.disabled = false;
|
|
2971
|
+
});
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2619
2974
|
// ---------- confirm / prompt dialogs ----------
|
|
2620
2975
|
|
|
2621
2976
|
var confirmDialogEl = document.getElementById("confirm-dialog");
|
|
@@ -2765,6 +3120,7 @@ const clientJs = `
|
|
|
2765
3120
|
if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
|
|
2766
3121
|
if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
|
|
2767
3122
|
if (row.allow && row.allow.length) flags.push(["allow " + row.allow.length, row.allow.join(", "), "badge-accent"]);
|
|
3123
|
+
if (row.protect) flags.push(["password", "Password protected on: " + protectScopeNames(row.protect.scopes), "badge-accent"]);
|
|
2768
3124
|
if (flags.length) {
|
|
2769
3125
|
optionsTd.className = "badges";
|
|
2770
3126
|
flags.forEach(function (flag) {
|
|
@@ -2814,6 +3170,20 @@ const clientJs = `
|
|
|
2814
3170
|
accessSvg.appendChild(accessPath);
|
|
2815
3171
|
accessBtn.appendChild(accessSvg);
|
|
2816
3172
|
accessBtn.addEventListener("click", function () { openAllowDialog(row); });
|
|
3173
|
+
var protectBtn = document.createElement("button");
|
|
3174
|
+
protectBtn.type = "button";
|
|
3175
|
+
var protectScopesSet = row.protect ? row.protect.scopes : [];
|
|
3176
|
+
protectBtn.className = "btn btn-sm btn-icon" + (protectScopesSet.length ? " is-set" : "");
|
|
3177
|
+
protectBtn.title = protectScopesSet.length ? "Password protected on: " + protectScopeNames(protectScopesSet) : "Password protection: require a password before visitors reach this host";
|
|
3178
|
+
protectBtn.setAttribute("aria-label", "Password protection for " + row.host);
|
|
3179
|
+
var protectSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
3180
|
+
protectSvg.setAttribute("viewBox", "0 0 24 24");
|
|
3181
|
+
protectSvg.setAttribute("aria-hidden", "true");
|
|
3182
|
+
var protectPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
3183
|
+
protectPath.setAttribute("d", "M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z");
|
|
3184
|
+
protectSvg.appendChild(protectPath);
|
|
3185
|
+
protectBtn.appendChild(protectSvg);
|
|
3186
|
+
protectBtn.addEventListener("click", function () { openProtectDialog(row); });
|
|
2817
3187
|
var editBtn = document.createElement("button");
|
|
2818
3188
|
editBtn.type = "button";
|
|
2819
3189
|
editBtn.className = "btn btn-sm";
|
|
@@ -2841,6 +3211,7 @@ const clientJs = `
|
|
|
2841
3211
|
if (!row.remote) {
|
|
2842
3212
|
actionsWrap.appendChild(shareButton(row));
|
|
2843
3213
|
actionsWrap.appendChild(accessBtn);
|
|
3214
|
+
actionsWrap.appendChild(protectBtn);
|
|
2844
3215
|
actionsWrap.appendChild(editBtn);
|
|
2845
3216
|
}
|
|
2846
3217
|
actionsWrap.appendChild(removeBtn);
|
|
@@ -4296,6 +4667,19 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
4296
4667
|
</span>
|
|
4297
4668
|
</button>
|
|
4298
4669
|
</div>
|
|
4670
|
+
<div id="share-progress" class="share-progress" hidden>
|
|
4671
|
+
<div id="share-progress-icon" class="share-progress-icon" aria-hidden="true"></div>
|
|
4672
|
+
<div class="share-progress-body">
|
|
4673
|
+
<p id="share-progress-title" class="share-progress-title"></p>
|
|
4674
|
+
<p id="share-progress-text" class="share-progress-text"></p>
|
|
4675
|
+
<ol id="share-steps" class="share-steps" hidden>
|
|
4676
|
+
<li>Sign in to Cloudflare in the tab that opens.</li>
|
|
4677
|
+
<li>Pick the zone <span id="share-steps-domain" class="mono"></span> is in, then <strong>Authorize</strong>.</li>
|
|
4678
|
+
<li>Come back here. This dialog updates by itself.</li>
|
|
4679
|
+
</ol>
|
|
4680
|
+
<a id="share-auth-link" class="btn btn-primary" target="_blank" rel="noopener noreferrer" hidden>Open Cloudflare ↗</a>
|
|
4681
|
+
</div>
|
|
4682
|
+
</div>
|
|
4299
4683
|
<div id="share-error" class="field-error" role="alert" hidden></div>
|
|
4300
4684
|
</div>
|
|
4301
4685
|
<div class="modal-foot">
|
|
@@ -4303,6 +4687,61 @@ function renderPage(nonce, token, uiAuth = false) {
|
|
|
4303
4687
|
<button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
|
|
4304
4688
|
</div>
|
|
4305
4689
|
</dialog>
|
|
4690
|
+
<dialog id="protect-dialog">
|
|
4691
|
+
<div class="modal-head">
|
|
4692
|
+
<div>
|
|
4693
|
+
<h3 class="modal-title">Password protection</h3>
|
|
4694
|
+
<p id="protect-host" class="modal-sub mono"></p>
|
|
4695
|
+
</div>
|
|
4696
|
+
<button type="button" id="protect-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
4697
|
+
</div>
|
|
4698
|
+
<div class="modal-body">
|
|
4699
|
+
<p class="modal-desc">Visitors see a sign-in page and need this password before they reach the app.</p>
|
|
4700
|
+
<div class="field">
|
|
4701
|
+
<label for="protect-password">Password</label>
|
|
4702
|
+
<div class="field-password">
|
|
4703
|
+
<input type="password" id="protect-password" class="input" autocomplete="new-password">
|
|
4704
|
+
<button type="button" id="protect-toggle" class="field-toggle" aria-label="Show password">
|
|
4705
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
4706
|
+
</button>
|
|
4707
|
+
</div>
|
|
4708
|
+
<p id="protect-hint" class="modal-hint" hidden></p>
|
|
4709
|
+
<div id="protect-error" class="field-error" role="alert" hidden></div>
|
|
4710
|
+
</div>
|
|
4711
|
+
<div class="field">
|
|
4712
|
+
<label>Ask for it on</label>
|
|
4713
|
+
<div id="protect-options" class="share-options" role="group" aria-label="Ask for the password on">
|
|
4714
|
+
<button type="button" id="protect-option-shared" class="option-card" role="checkbox" aria-checked="false" data-scope="shared">
|
|
4715
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4716
|
+
<span class="option-body">
|
|
4717
|
+
<span class="option-title">Shared link</span>
|
|
4718
|
+
<span class="option-desc">Visitors on the public tunnel URL (Share).</span>
|
|
4719
|
+
</span>
|
|
4720
|
+
</button>
|
|
4721
|
+
<button type="button" id="protect-option-remote" class="option-card" role="checkbox" aria-checked="false" data-scope="remote">
|
|
4722
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4723
|
+
<span class="option-body">
|
|
4724
|
+
<span class="option-title">Remote access</span>
|
|
4725
|
+
<span class="option-desc">Machines connected to this one through remote access.</span>
|
|
4726
|
+
</span>
|
|
4727
|
+
</button>
|
|
4728
|
+
<button type="button" id="protect-option-local" class="option-card" role="checkbox" aria-checked="false" data-scope="local">
|
|
4729
|
+
<span class="option-check" aria-hidden="true"></span>
|
|
4730
|
+
<span class="option-body">
|
|
4731
|
+
<span class="option-title">This machine & network</span>
|
|
4732
|
+
<span class="option-desc">Browsers on this computer or your local network.</span>
|
|
4733
|
+
</span>
|
|
4734
|
+
</button>
|
|
4735
|
+
</div>
|
|
4736
|
+
<div id="protect-scope-error" class="field-error" role="alert" hidden></div>
|
|
4737
|
+
</div>
|
|
4738
|
+
</div>
|
|
4739
|
+
<div class="modal-foot">
|
|
4740
|
+
<button type="button" id="protect-remove" class="btn btn-danger" hidden>Remove protection</button>
|
|
4741
|
+
<button type="button" id="protect-cancel" class="btn">Cancel</button>
|
|
4742
|
+
<button type="button" id="protect-submit" class="btn btn-primary">Protect host</button>
|
|
4743
|
+
</div>
|
|
4744
|
+
</dialog>
|
|
4306
4745
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
|
4307
4746
|
<script nonce="${safeNonce}">${clientJs}</script>
|
|
4308
4747
|
</body>
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,17 @@ program
|
|
|
55
55
|
.option("--rm", "Remove the given addresses instead of adding them")
|
|
56
56
|
.option("--clear", "Remove every allowed address")
|
|
57
57
|
.action(run((addresses, options) => commands_1.default.allow(addresses, options)));
|
|
58
|
+
program
|
|
59
|
+
.command("protect")
|
|
60
|
+
.description("Ask for a password before a domain opens: on the shared link, for connected machines and/or on this machine")
|
|
61
|
+
.addOption(hostOption())
|
|
62
|
+
.option("--shared", "Ask visitors of the shared (tunnel) link")
|
|
63
|
+
.option("--remote", "Ask machines connected through remote access")
|
|
64
|
+
.option("--local", "Ask on this machine and your network")
|
|
65
|
+
.option("--password <password>", "The password (prompted for when left out)")
|
|
66
|
+
.option("--stdin", "Read the password from stdin")
|
|
67
|
+
.option("--off", "Stop asking for a password and forget it")
|
|
68
|
+
.action(run((options) => commands_1.default.protect(options)));
|
|
58
69
|
program
|
|
59
70
|
.command("remove")
|
|
60
71
|
.alias("rm")
|