landfall 0.4.1 → 0.4.3
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/README.md +17 -4
- package/dist/cli.mjs +174 -156
- package/dist/index.html +104 -30
- package/dist/server-BqVugweK.mjs +5 -0
- package/package.json +1 -1
- package/dist/server-BrAinE0R.mjs +0 -5
package/dist/index.html
CHANGED
|
@@ -358,9 +358,8 @@ function btn(label, fn, cls = "", title = "", f = "") {
|
|
|
358
358
|
}
|
|
359
359
|
document.addEventListener("click", (e) => {
|
|
360
360
|
const el = e.target.closest("[data-k]");
|
|
361
|
-
const menu = $("#menu");
|
|
362
361
|
// A click outside an open menu only closes it.
|
|
363
|
-
if (
|
|
362
|
+
if (menuEl && !menuEl.contains(e.target)) {
|
|
364
363
|
closeMenu(false);
|
|
365
364
|
e.preventDefault();
|
|
366
365
|
return void e.stopPropagation();
|
|
@@ -384,6 +383,7 @@ function menuBtn(items, title = "More actions", cls = "sm") {
|
|
|
384
383
|
return btn("⋯", (e, el) => openMenu(el, items), `${cls} more`, title).replace("<button", `<button aria-haspopup="menu" aria-expanded="false" aria-label="${esc(title)}"`);
|
|
385
384
|
}
|
|
386
385
|
let menuAnchor = null;
|
|
386
|
+
let menuEl = null;
|
|
387
387
|
function openMenu(anchor, items) {
|
|
388
388
|
closeMenu(false);
|
|
389
389
|
menuAnchor = anchor;
|
|
@@ -416,6 +416,7 @@ function openMenu(anchor, items) {
|
|
|
416
416
|
if (!m.contains(e.relatedTarget)) closeMenu(false);
|
|
417
417
|
});
|
|
418
418
|
document.body.append(m);
|
|
419
|
+
menuEl = m;
|
|
419
420
|
const r = anchor.getBoundingClientRect();
|
|
420
421
|
m.style.left = `${Math.max(8, Math.min(r.right - m.offsetWidth, innerWidth - m.offsetWidth - 8))}px`;
|
|
421
422
|
m.style.top = `${r.bottom + 4 + m.offsetHeight > innerHeight - 8 ? Math.max(8, r.top - m.offsetHeight - 4) : r.bottom + 4}px`;
|
|
@@ -423,10 +424,10 @@ function openMenu(anchor, items) {
|
|
|
423
424
|
}
|
|
424
425
|
/** restore: focus goes back to the "⋯" button (Esc, or an item that opens no dialog). */
|
|
425
426
|
function closeMenu(restore = true) {
|
|
426
|
-
const m =
|
|
427
|
+
const m = menuEl;
|
|
427
428
|
if (!m) return;
|
|
428
|
-
// Removing the focused item fires focusout, which calls closeMenu again:
|
|
429
|
-
|
|
429
|
+
// Removing the focused item fires focusout, which calls closeMenu again: clear menuEl first so that call is a no-op.
|
|
430
|
+
menuEl = null;
|
|
430
431
|
m.remove();
|
|
431
432
|
menuAnchor?.setAttribute("aria-expanded", "false");
|
|
432
433
|
if (restore && menuAnchor?.isConnected) menuAnchor.focus({ preventScroll: true });
|
|
@@ -582,8 +583,13 @@ function appendOutput(j) {
|
|
|
582
583
|
const frag = document.createDocumentFragment();
|
|
583
584
|
for (const l of j.lines.slice(out.textContent ? shown : 0)) {
|
|
584
585
|
const div = document.createElement("div");
|
|
585
|
-
|
|
586
|
-
|
|
586
|
+
const text = l.replace(ANSI, "") || " ";
|
|
587
|
+
// Addresses (a tunnel's localhost one, a deployed site) open in a new tab.
|
|
588
|
+
for (const [i, part] of text.split(/(https?:\/\/[^\s)]+)/).entries()) {
|
|
589
|
+
if (!(i % 2)) div.append(part);
|
|
590
|
+
else div.append(Object.assign(document.createElement("a"), { href: part, textContent: part, target: "_blank", rel: "noopener" }));
|
|
591
|
+
}
|
|
592
|
+
const c = lineClass(text);
|
|
587
593
|
if (c) div.className = c;
|
|
588
594
|
frag.append(div);
|
|
589
595
|
}
|
|
@@ -718,6 +724,11 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
|
|
|
718
724
|
(first || (danger ? $("#dlg-cancel", dlg) : submit)).focus();
|
|
719
725
|
}
|
|
720
726
|
const invalid = (field, error) => ({ field, error });
|
|
727
|
+
/** What is wrong with a login (lf newLogin's rules), as [field, message]. */
|
|
728
|
+
const loginError = (user, password) =>
|
|
729
|
+
!/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
|
|
730
|
+
: password.length < 8 ? ["password", "The password needs at least 8 characters"]
|
|
731
|
+
: null;
|
|
721
732
|
/**
|
|
722
733
|
* Passwords here are for servers and sites, not for this page: a masked text field, so browsers
|
|
723
734
|
* and password managers do not offer to save them. Browsers without text masking get a password field.
|
|
@@ -779,6 +790,7 @@ function setupWizard(base) {
|
|
|
779
790
|
{ name: "image", label: "Image", value: saved.traefikImage ?? S.state.defaultImage },
|
|
780
791
|
{ name: "email", label: "Let's Encrypt e-mail (optional)", value: saved.email ?? "" },
|
|
781
792
|
{ name: "dashboard", label: "Dashboard domain (optional)", value: "", placeholder: "traefik.example.com", hint: "Behind basic auth; the password is shown after setup." },
|
|
793
|
+
{ name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: saved.http3 !== false, hint: "Browsers that can't use it fall back to HTTP/2." },
|
|
782
794
|
],
|
|
783
795
|
},
|
|
784
796
|
{ name: "firewall", label: "Enable ufw: deny incoming except ssh, 80 and 443", type: "check", value: true, hint: "Skipped automatically if it would block ports in use." },
|
|
@@ -793,6 +805,7 @@ function setupWizard(base) {
|
|
|
793
805
|
if (v.image.trim()) f.push(`--image=${v.image.trim()}`);
|
|
794
806
|
if (v.email.trim()) f.push(`--email=${v.email.trim()}`);
|
|
795
807
|
if (v.dashboard.trim()) f.push(`--dashboard=${v.dashboard.trim()}`);
|
|
808
|
+
f.push(v.http3 ? "--http3" : "--no-http3");
|
|
796
809
|
}
|
|
797
810
|
if (!v.firewall) f.push("--no-firewall");
|
|
798
811
|
if (!v.harden) f.push("--no-harden");
|
|
@@ -877,7 +890,7 @@ function describe(d) {
|
|
|
877
890
|
d.kind === "compose" ? `Compose project <code>${esc(d.compose ?? "compose.yaml")}</code>: built and started on the server; x-landfall picks the routed service`
|
|
878
891
|
: d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
|
|
879
892
|
: d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
|
|
880
|
-
: d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.spa ? " (single-page app)" : ""}${built}`
|
|
893
|
+
: d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.tool ? ` (where ${esc(d.tool)} builds the site)` : ""}${d.spa ? " (single-page app)" : ""}${built}`
|
|
881
894
|
: d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
|
|
882
895
|
: `<span style="color:var(--warn)">No compose.yaml, Dockerfile, index.html or build script here.</span>`;
|
|
883
896
|
const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
|
|
@@ -908,11 +921,15 @@ function deployDialog(det, o = {}) {
|
|
|
908
921
|
...(det.kind === "static"
|
|
909
922
|
? [
|
|
910
923
|
{ name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
|
|
924
|
+
{
|
|
925
|
+
name: "dir", label: "Folder to publish", value: det.dir ?? ".", placeholder: ".",
|
|
926
|
+
hint: det.tool ? `Detected: ${det.tool} writes the site to ${det.dir}/ (it need not exist before the build).` : "Relative to the project; it needs an index.html after the build.",
|
|
927
|
+
},
|
|
911
928
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
912
929
|
]
|
|
913
930
|
: []),
|
|
914
|
-
{ name: "
|
|
915
|
-
{ name: "
|
|
931
|
+
{ name: "authPassword", label: "Login password (optional, at least 8 characters)", type: "password", hint: "Visitors log in with the user below and this password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
|
|
932
|
+
{ name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
|
|
916
933
|
],
|
|
917
934
|
ok: "Deploy",
|
|
918
935
|
onOk: (v) => {
|
|
@@ -923,11 +940,16 @@ function deployDialog(det, o = {}) {
|
|
|
923
940
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
|
|
924
941
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
925
942
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
926
|
-
const
|
|
927
|
-
if (
|
|
928
|
-
if (
|
|
929
|
-
|
|
930
|
-
if (
|
|
943
|
+
const dir = v.dir?.trim().replace(/\/+$/, "") || ".";
|
|
944
|
+
if (det.kind === "static" && (/^[/-]/.test(dir) || dir.split("/").includes(".."))) return invalid("dir", "A folder inside the project");
|
|
945
|
+
if (det.kind === "static" && dir !== (det.dir ?? ".")) argv.push(dir);
|
|
946
|
+
// The login counts once there is a password; the user defaults to admin.
|
|
947
|
+
if (v.authPassword) {
|
|
948
|
+
const user = v.authUser.trim() || "admin";
|
|
949
|
+
const bad = loginError(user, v.authPassword);
|
|
950
|
+
if (bad) return invalid(bad[0] === "user" ? "authUser" : "authPassword", bad[1]);
|
|
951
|
+
argv.push("--auth-user", user, "--auth-password", v.authPassword);
|
|
952
|
+
}
|
|
931
953
|
if (o.upload) argv.push("--no-save");
|
|
932
954
|
run(argv, { server: v.server, cwd: det.path, after: openAfter });
|
|
933
955
|
},
|
|
@@ -1102,9 +1124,10 @@ function authDialog(n, m) {
|
|
|
1102
1124
|
ok: "Save",
|
|
1103
1125
|
onOk: (v) => {
|
|
1104
1126
|
if (v.off) return void run(["auth", m.name, ...sv(n), "--off", "-y"], { server: n });
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1127
|
+
const user = v.user.trim() || "admin";
|
|
1128
|
+
const bad = loginError(user, v.password);
|
|
1129
|
+
if (bad) return invalid(...bad);
|
|
1130
|
+
run(["auth", m.name, ...sv(n), "-u", user, "-p", v.password], { server: n });
|
|
1108
1131
|
},
|
|
1109
1132
|
});
|
|
1110
1133
|
}
|
|
@@ -1362,15 +1385,16 @@ function sitesView(n, o) {
|
|
|
1362
1385
|
const older = [...m.releases].sort((a, b) => (a.at < b.at ? 1 : -1)).filter((r) => r.at < (cur?.at ?? ""));
|
|
1363
1386
|
const proj = (S.state.projects ?? []).find((p) => p.server === n && p.name === name);
|
|
1364
1387
|
const deleteSite = () =>
|
|
1365
|
-
|
|
1388
|
+
dialog({
|
|
1366
1389
|
title: m.adopted ? `Stop and forget ${name}?` : `Delete ${name} from ${n}?`,
|
|
1367
1390
|
text: m.adopted
|
|
1368
1391
|
? `Runs docker compose down in ${m.adopted}; its files and volumes stay.`
|
|
1369
|
-
: "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay.\nThis cannot be undone.",
|
|
1392
|
+
: "Stops it and removes its files and releases. Its named volumes (databases, uploads) stay unless you delete them too.\nThis cannot be undone.",
|
|
1393
|
+
fields: m.adopted ? [] : [{ name: "volumes", type: "check", label: "Also delete its volumes", hint: "Databases, uploads: all its data. lf sites rm --volumes" }],
|
|
1370
1394
|
ok: m.adopted ? `Forget ${name}` : `Delete ${name}`,
|
|
1371
1395
|
danger: true,
|
|
1372
1396
|
typeToConfirm: m.adopted ? "" : name,
|
|
1373
|
-
onOk: () => run(["sites", "rm", name, ...sv(n), "-y"]),
|
|
1397
|
+
onOk: (v) => run(["sites", "rm", name, ...sv(n), "-y", ...(v.volumes ? ["--volumes"] : [])]),
|
|
1374
1398
|
});
|
|
1375
1399
|
const svcMenu = (x) =>
|
|
1376
1400
|
menuBtn([
|
|
@@ -1394,6 +1418,7 @@ function sitesView(n, o) {
|
|
|
1394
1418
|
? { label: "Roll back…", hint: "lf rollback", title: `To ${older[0].id} (${ago(older[0].at)})`, run: () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)}). Volumes keep their data.`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }) }
|
|
1395
1419
|
: null,
|
|
1396
1420
|
"-",
|
|
1421
|
+
old ? null : { label: "Open via tunnel", hint: "lf tunnel", title: "An SSH tunnel straight to the container: test it on localhost, no domain needed", run: () => run(["tunnel", name, ...sv(n)], { server: n }) },
|
|
1397
1422
|
main ? { label: "Domains…", hint: "lf domains", run: () => domainsDialog(n, m) } : null,
|
|
1398
1423
|
managed ? { label: m.auth?.length ? "Login…" : "Password…", hint: "lf auth", title: "Protect the site with a user and password", run: () => authDialog(n, m) } : null,
|
|
1399
1424
|
managed ? { label: "Environment…", hint: "lf env", run: () => envDialog(n, name) } : null,
|
|
@@ -1685,13 +1710,58 @@ function settingsView(n) {
|
|
|
1685
1710
|
if (d) run(["server", "domain", n, "--add", d], reload);
|
|
1686
1711
|
}, "Add");
|
|
1687
1712
|
const dashboard = () =>
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1713
|
+
dialog({
|
|
1714
|
+
title: `Traefik dashboard on ${n}`,
|
|
1715
|
+
text: "Serves the dashboard on a domain behind a login. Without one it stays reachable through an SSH tunnel only (Open via tunnel).\nRuns lf traefik setup, which reconfigures landfall's Traefik.",
|
|
1716
|
+
fields: [
|
|
1717
|
+
{ name: "domain", label: "Domain", value: x.traefik.dashboard ?? "", placeholder: x.domains[0] ? `traefik.${x.domains[0]}` : "traefik.example.com", hint: x.traefik.dashboard ? "Empty: take the dashboard off its domain." : "Needs an A record pointing at the server." },
|
|
1718
|
+
{ name: "user", label: "Login user", value: "admin", placeholder: "admin" },
|
|
1719
|
+
{ name: "password", label: "Login password (at least 8 characters)", type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
|
|
1720
|
+
],
|
|
1721
|
+
ok: "Apply",
|
|
1722
|
+
onOk: (v) => {
|
|
1723
|
+
const d = v.domain.trim();
|
|
1724
|
+
const user = v.user.trim() || "admin";
|
|
1725
|
+
if (!d && !x.traefik.dashboard) return invalid("domain", "Give a domain, or use the tunnel");
|
|
1726
|
+
const bad = d && loginError(user, v.password || "generated");
|
|
1727
|
+
if (bad) return invalid(...bad);
|
|
1728
|
+
const argv = ["traefik", "setup", ...sv(n), "--dashboard", d || "off"];
|
|
1729
|
+
if (d) argv.push("--dashboard-user", user, ...(v.password ? ["--dashboard-password", v.password] : []));
|
|
1730
|
+
run(argv, reload);
|
|
1731
|
+
},
|
|
1732
|
+
});
|
|
1733
|
+
const traefikDialog = () =>
|
|
1734
|
+
dialog({
|
|
1735
|
+
title: `Traefik on ${n}`,
|
|
1736
|
+
text: "Runs lf traefik setup, which reconfigures landfall's Traefik and restarts it (a few seconds without traffic). The settings are remembered.",
|
|
1737
|
+
fields: [
|
|
1738
|
+
{ name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: x.traefik.http3, hint: "Faster on lossy and mobile connections; browsers that can't use it fall back to HTTP/2." },
|
|
1739
|
+
{
|
|
1740
|
+
name: "mode", label: "Ports", type: "select", value: x.traefik.hostNetwork ? "host" : "docker",
|
|
1741
|
+
options: [
|
|
1742
|
+
{ value: "docker", label: "Published by Docker (default)" },
|
|
1743
|
+
{ value: "host", label: "Host network: real client IPs, ufw guards 80/443" },
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
{ name: "image", label: "Image", value: x.traefik.image, placeholder: S.state.defaultImage },
|
|
1747
|
+
{ name: "email", label: "Let's Encrypt e-mail (optional)", value: x.traefik.email ?? "", hint: "Only the account contact; Let's Encrypt no longer mails expiry warnings." },
|
|
1748
|
+
{ name: "staging", label: "Let's Encrypt staging CA (test certificates, browsers warn)", type: "check", value: x.traefik.staging },
|
|
1749
|
+
],
|
|
1750
|
+
ok: "Apply",
|
|
1751
|
+
onOk: (v) => {
|
|
1752
|
+
const argv = ["traefik", "setup", ...sv(n), v.http3 ? "--http3" : "--no-http3", v.mode === "host" ? "--host-network" : "--no-host-network", v.staging ? "--staging" : "--no-staging"];
|
|
1753
|
+
if (v.image.trim()) argv.push("--image", v.image.trim());
|
|
1754
|
+
if (v.email.trim()) argv.push("--email", v.email.trim());
|
|
1755
|
+
run(argv, reload);
|
|
1756
|
+
},
|
|
1757
|
+
});
|
|
1758
|
+
const tunnel = () => run(["traefik", "dashboard", ...sv(n)], { server: n });
|
|
1691
1759
|
const sites = S.overview[n]?.data?.sites ?? [];
|
|
1692
|
-
//
|
|
1693
|
-
const
|
|
1694
|
-
const foreign =
|
|
1760
|
+
// lf traefik setup refuses to touch a Traefik the server already had.
|
|
1761
|
+
const o = S.overview[n]?.data;
|
|
1762
|
+
const foreign = o?.adopted ? o.proxy : "";
|
|
1763
|
+
const dash = o?.dashboard ?? { hosts: x.traefik.dashboard ? [x.traefik.dashboard] : [], tunnel: false };
|
|
1764
|
+
const link = (h) => `<a href="https://${esc(h)}" target="_blank" rel="noopener">${esc(h)}</a>`;
|
|
1695
1765
|
const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
|
|
1696
1766
|
const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
|
|
1697
1767
|
const serve = (d) => {
|
|
@@ -1728,11 +1798,15 @@ function settingsView(n) {
|
|
|
1728
1798
|
${x.suggest.length ? `<div class="note">${x.domains.length ? "Other domains" : "Domains"} your sites on this server already use: ${x.suggest.map((g) => `<b>${esc(g.domain)}</b> <span class="faint">(${g.hosts})</span> ${btn("Add", () => run(["server", "domain", n, "--add", g.domain], reload), "sm")}`).join(" ")}</div>` : ""}</div>
|
|
1729
1799
|
</div>
|
|
1730
1800
|
<div class="card">
|
|
1731
|
-
<h2>Traefik <span class="actions">${foreign ? "" : btn("Dashboard…", dashboard, "sm")}</span></h2>
|
|
1801
|
+
<h2>Traefik <span class="actions">${dash.tunnel ? btn("Open via tunnel", tunnel, "sm", "lf traefik dashboard: an SSH tunnel to the dashboard, until you stop it") : ""}${foreign ? "" : btn("Dashboard…", dashboard, "sm") + btn("Settings…", traefikDialog, "sm")}</span></h2>
|
|
1732
1802
|
<div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: its dashboard is set up in its own config.</div>` : ""}${rows([
|
|
1733
1803
|
["image", esc(x.traefik.summary)],
|
|
1734
1804
|
["e-mail", esc(x.traefik.email ?? "none")],
|
|
1735
|
-
["
|
|
1805
|
+
["HTTP/3", x.traefik.http3 ? `on <span class="dim">(443/udp)</span>` : `<span class="dim">off</span>`],
|
|
1806
|
+
["dashboard", `${dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">no domain</span>`}${
|
|
1807
|
+
dash.tunnel ? ` · <span class="dim">tunnel: lf traefik dashboard</span>`
|
|
1808
|
+
: foreign ? ` · <span class="dim">tunnel: lf traefik dashboard --port <its api port></span>`
|
|
1809
|
+
: ` · <span class="dim">no tunnel yet</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`}`],
|
|
1736
1810
|
["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
|
|
1737
1811
|
["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
|
|
1738
1812
|
])}<p class="faint">Used when landfall installs or reconfigures its own Traefik. With a Traefik that was already there, landfall only writes <span class="mono">${esc(P.proxy)}</span>.</p></div>
|
|
@@ -1833,7 +1907,7 @@ function render() {
|
|
|
1833
1907
|
}
|
|
1834
1908
|
|
|
1835
1909
|
document.addEventListener("keydown", (e) => {
|
|
1836
|
-
if (e.key === "Escape" &&
|
|
1910
|
+
if (e.key === "Escape" && menuEl) return closeMenu();
|
|
1837
1911
|
if (e.key === "Escape" && !$("#dlg").open && S.job && !e.target.closest?.("input, textarea, select")) {
|
|
1838
1912
|
S.job = null;
|
|
1839
1913
|
render();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{A as e,B as t,C as n,D as r,E as i,F as a,I as o,L as s,M as c,N as l,O as u,P as d,R as ee,S as f,T as te,_ as p,a as m,b as h,c as g,d as ne,f as _,g as v,h as y,i as b,j as x,k as S,l as re,m as ie,n as ae,o as oe,p as se,r as ce,s as le,t as C,u as ue,v as de,w as fe,x as w,y as pe,z as T}from"./cli.mjs";import{spawn as me,spawnSync as E}from"node:child_process";import D from"node:fs";import{homedir as O}from"node:os";import{dirname as k,isAbsolute as A,join as j,resolve as M}from"node:path";import{randomBytes as N,timingSafeEqual as P}from"node:crypto";import{createServer as F}from"node:http";import{Readable as I}from"node:stream";const L=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function R(){let e=a(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=f().filter(t=>!e.servers[t]&&!L.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function z(e){try{let t=w(s(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function he(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}function B(e){let t=M(!e||e===`~`?O():d(e)),n=D.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&V(j(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:k(t)===t?null:k(t),home:O(),dirs:n,detected:y(t)}}const V=e=>{try{return D.statSync(e).isDirectory()}catch{return!1}},H=j(l,`uploads`),U=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function W(e,t){D.mkdirSync(k(e),{recursive:!0}),D.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function ge(e){D.mkdirSync(H,{recursive:!0,mode:448});for(let e of D.readdirSync(H)){let t=j(H,e);Date.now()-D.statSync(t).mtimeMs>864e5&&D.rmSync(t,{recursive:!0,force:!0})}let t=D.mkdtempSync(j(H,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=j(t,`.upload-`+U(n[0].name).pop());await W(e,n[0]);let r=j(t,`files`);D.mkdirSync(r);let i=/\.zip$/i.test(e),a=i?E(`unzip`,[`-q`,e,`-d`,r]):E(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?E(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return D.rmSync(e),{path:G(r),files:_e(r)}}let i=j(t,`files`);for(let[e,t]of n.entries()){let n=U(r[e]||t.name);n.length&&await W(j(i,...n),t)}return{path:G(i),files:n.length}}function G(e){for(;;){let t=D.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!V(j(e,t[0])))return e;e=j(e,t[0])}}function _e(e){return D.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function ve(e){if(e!=null&&e!==``){if(typeof e!=`string`||!A(e)||!V(e))throw Error(`cwd must be an existing folder`);return e}}const K=()=>[process.execPath,...process.execArgv,process.argv[1]],q=5e3;function ye(e,t,n={}){let r={...process.env,NO_COLOR:`1`,...n.env};n.password&&Object.assign(r,g(n.password));let i=me(K()[0],[...K().slice(1),...e],{stdio:[`ignore`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0}),a={argv:e,lines:[],dropped:0,stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
|
|
2
|
+
`);e=n.pop();for(let e of n)a.lines.push(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``));a.lines.length>q&&(a.dropped+=a.lines.splice(0,a.lines.length-q).length),s()},flush:()=>e&&a.lines.push(e)}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const be=D.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),J=/* @__PURE__ */ new Map,xe=/* @__PURE__ */ new Set([`ui`,`web`]);function Se(){let e=j(k(x),`ui-token`);try{let t=D.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=N(24).toString(`hex`);return D.mkdirSync(k(e),{recursive:!0,mode:448}),D.writeFileSync(e,t+`
|
|
3
|
+
`,{mode:384}),t}const Y=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),X=(e,t=500)=>Y({error:e?.message??String(e)},t);function Z(e,t){let r=n(),i=/* @__PURE__ */ new Set,a=ye(e,()=>i.forEach(e=>e()),{password:t.password,cwd:t.cwd,env:{LANDFALL_RUN:r}}),o={id:r,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:he(e),job:a,listeners:i};J.set(r,o);for(let[e,t]of J){if(J.size<=50)break;t.job.code!==void 0&&J.delete(e)}return o}const Ce=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Q=e=>({id:e.id,argv:Ce(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped});function we(e,t,n){let r=new TextEncoder,i=t,a=()=>{},o=new ReadableStream({start(t){let o=e=>t.enqueue(r.encode(e)),s=()=>{let{lines:t,dropped:n,code:r}=e.job,s=Math.max(i,n);n+t.length>s&&(o(`data: ${JSON.stringify({from:s,lines:t.slice(s-n)})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(Q(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
|
|
4
|
+
|
|
5
|
+
`),15e3);a=()=>{clearInterval(c),e.listeners.delete(s);try{t.close()}catch{}},e.listeners.add(s),n.addEventListener(`abort`,a),s()},cancel:()=>a()});return new Response(o,{headers:{"content-type":`text/event-stream`,"cache-control":`no-store`}})}async function Te(n,l){let[d,f,g]=l,y=n.method;if(d===`state`&&y===`GET`){let t=a();return Y({version:C,cwd:process.cwd(),project:o(process.cwd())??null,projects:v(),defaultImage:te,paths:{root:u,sites:S,traefik:e,journal:i,proxy:r},servers:R().map(e=>({...e,target:z(e.name),saved:t.servers[e.name]??null})),jobs:[...J.values()].reverse().map(Q)})}if(d===`servers`&&f){let{server:e}=s(f);if(y===`GET`&&!g)return Y(await ie(e));if(y===`GET`&&g===`firewall`){let{login:t,probe:n,...r}=await m(e);return Y({...r,asRoot:w(t).user===`root`})}if(y===`GET`&&g===`config`){let t=ce(f),[n,r]=await Promise.all([Promise.all(t.domains.map(t=>ae(e,t))),b(e)]);return Y({...t,dns:n,suggest:r})}if(y===`GET`&&g===`history`){let t=(await le(e)).reverse();return Y(t.map(e=>({...e,pending:oe(e).length})))}if(y===`GET`&&g===`facts`){let t=await de(e),n=await h(e,t);return Y({...t,ips:n,sslipIp:pe(n)??null})}if(y===`GET`&&g===`updates`){let t=new URL(n.url).searchParams.get(`refresh`)===`1`,r=await se(e,t),i=await ne(e);return Y({packages:r,images:await _(e),engine:{...i,...ue(i)}})}if(y===`GET`&&g===`dns`){let t=(new URL(n.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>c.test(e)).slice(0,50),r=await h(e),[i,a]=await Promise.all([p(t,r),Promise.all(t.map(re))]);return Y({ips:r,domains:i.map((e,t)=>({...e,https:a[t]}))})}if(y===`POST`&&g==="default")return ee(f),Y({ok:!0})}if(d===`jobs`){if(y===`POST`&&!f){let e=await n.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||xe.has(t[0]))return X(`argv must be a list of landfall arguments`,400);let r;try{r=ve(e.cwd)}catch(e){return X(e,400)}return Y(Q(Z(t,{server:e.server,password:e.password||void 0,cwd:r})))}let e=f?J.get(f):void 0;if(f&&!e)return X(`no job ${f}`,404);if(e&&y===`GET`&&g===`events`)return we(e,Number(new URL(n.url).searchParams.get(`from`))||0,n.signal);if(e&&y===`GET`&&!g)return Y({...Q(e),lines:e.job.lines,from:e.job.dropped});if(e&&y===`POST`&&g===`stop`)return e.job.stop(),Y(Q(e))}if(d===`fs`&&y===`GET`)return Y(B(new URL(n.url).searchParams.get(`path`)??void 0));if(d===`projects`&&y===`GET`)return Y(v());if(d===`upload`&&y===`POST`){let e=await ge(await n.formData());return Y({...e,detected:B(e.path).detected})}if(d===`open`&&y===`POST`){let{url:e}=await n.json();return!e||!/^https?:\/\//.test(e)?X(`not a web address`,400):(t(e),Y({ok:!0}))}return X(`not found`,404)}const $=(e,t)=>e.length===t.length&&P(Buffer.from(e),Buffer.from(t)),Ee=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``;async function De(e){fe(!0);let n=Se(),r=F(Oe(async e=>{let t=new URL(e.url),r=e.headers.get(`host`)??``;if(!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(r))return new Response(`forbidden host`,{status:403});let i=e.headers.get(`origin`);if(i&&new URL(i).host!==r)return new Response(`forbidden origin`,{status:403});if(t.pathname===`/login`)return $(t.searchParams.get(`t`)??``,n)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${n}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!$(Ee(e),n))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(t.pathname===`/`)return new Response(be,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(t.pathname.startsWith(`/api/`))try{return await Te(e,t.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return X(e)}return new Response(`not found`,{status:404})}));r.requestTimeout=0;let i=await new Promise(t=>{r.once(`error`,t),r.listen(e.port,`127.0.0.1`,()=>t(void 0))});if(i){if(i.code!==`EADDRINUSE`)throw i;let r=`http://127.0.0.1:${e.port}/login?t=${n}`;return await fetch(`http://127.0.0.1:${e.port}/api/state`,{headers:{cookie:`landfall=${n}`}}).then(e=>e.ok,()=>!1)?(console.log(`lf ui already runs: ${T.cyan(r)}`),e.open&&t(r),0):(console.error(`${T.red(`error:`)} port ${e.port} is in use; pick another with --port`),1)}let a=`http://127.0.0.1:${r.address().port}/login?t=${n}`;return console.log(`${T.bold(`lf ui`)} ${T.dim(C)} on ${T.cyan(a)}`),console.log(T.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&t(a),new Promise(e=>{let t=()=>{for(let e of J.values())e.job.stop();r.closeAllConnections(),r.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function Oe(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let n=new Headers;for(let[e,r]of Object.entries(t.headers))for(let t of[r??[]].flat())n.append(e,t);let a=t.method===`GET`||t.method===`HEAD`?void 0:I.toWeb(t);i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:n,body:a,signal:r.signal,duplex:`half`}))}catch{i=new Response(`bad request`,{status:400})}for(let[e,t]of i.headers)e!==`set-cookie`&&n.setHeader(e,t);let a=i.headers.getSetCookie();if(a.length&&n.setHeader(`set-cookie`,a),n.writeHead(i.status),!i.body)return n.end();let o=I.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{De as webUi};
|
package/package.json
CHANGED
package/dist/server-BrAinE0R.mjs
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
import{A as e,C as t,D as n,E as r,F as i,I as a,L as o,M as s,N as c,O as l,P as u,R as d,S as f,T as ee,_ as p,a as te,b as m,c as ne,d as h,f as g,g as _,h as v,i as y,j as b,k as re,l as ie,m as ae,n as oe,o as se,p as ce,r as le,s as ue,t as x,u as de,v as fe,w as pe,x as S,y as me,z as C}from"./cli.mjs";import{spawn as w,spawnSync as T}from"node:child_process";import E from"node:fs";import{homedir as D}from"node:os";import{dirname as O,isAbsolute as k,join as A,resolve as j}from"node:path";import{randomBytes as M,timingSafeEqual as N}from"node:crypto";import{createServer as P}from"node:http";import{Readable as F}from"node:stream";const I=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function L(){let e=u(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=f().filter(t=>!e.servers[t]&&!I.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function R(e){try{let t=S(a(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function z(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}function B(e){let t=j(!e||e===`~`?D():c(e)),n=E.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&V(A(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:O(t)===t?null:O(t),home:D(),dirs:n,detected:v(t)}}const V=e=>{try{return E.statSync(e).isDirectory()}catch{return!1}},H=A(s,`uploads`),U=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function W(e,t){E.mkdirSync(O(e),{recursive:!0}),E.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function he(e){E.mkdirSync(H,{recursive:!0,mode:448});for(let e of E.readdirSync(H)){let t=A(H,e);Date.now()-E.statSync(t).mtimeMs>864e5&&E.rmSync(t,{recursive:!0,force:!0})}let t=E.mkdtempSync(A(H,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=A(t,`.upload-`+U(n[0].name).pop());await W(e,n[0]);let r=A(t,`files`);E.mkdirSync(r);let i=/\.zip$/i.test(e),a=i?T(`unzip`,[`-q`,e,`-d`,r]):T(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?T(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return E.rmSync(e),{path:G(r),files:ge(r)}}let i=A(t,`files`);for(let[e,t]of n.entries()){let n=U(r[e]||t.name);n.length&&await W(A(i,...n),t)}return{path:G(i),files:n.length}}function G(e){for(;;){let t=E.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!V(A(e,t[0])))return e;e=A(e,t[0])}}function ge(e){return E.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!k(e)||!V(e))throw Error(`cwd must be an existing folder`);return e}}const K=()=>[process.execPath,...process.execArgv,process.argv[1]],q=5e3;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,...n.env};n.password&&Object.assign(r,ne(n.password));let i=w(K()[0],[...K().slice(1),...e],{stdio:[`ignore`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0}),a={argv:e,lines:[],dropped:0,stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
|
|
2
|
-
`);e=n.pop();for(let e of n)a.lines.push(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``));a.lines.length>q&&(a.dropped+=a.lines.splice(0,a.lines.length-q).length),s()},flush:()=>e&&a.lines.push(e)}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const ye=E.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),J=/* @__PURE__ */ new Map,be=/* @__PURE__ */ new Set([`ui`,`web`]);function xe(){let t=A(O(e),`ui-token`);try{let e=E.readFileSync(t,`utf8`).trim();if(e.length>=32)return e}catch{}let n=M(24).toString(`hex`);return E.mkdirSync(O(t),{recursive:!0,mode:448}),E.writeFileSync(t,n+`
|
|
3
|
-
`,{mode:384}),n}const Y=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),X=(e,t=500)=>Y({error:e?.message??String(e)},t);function Se(e,t){let n=`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,r=/* @__PURE__ */ new Set,i=ve(e,()=>r.forEach(e=>e()),{password:t.password,cwd:t.cwd,env:{LANDFALL_RUN:n}}),a={id:n,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:z(e),job:i,listeners:r};J.set(n,a);for(let[e,t]of J){if(J.size<=50)break;t.job.code!==void 0&&J.delete(e)}return a}const Z=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Q=e=>({id:e.id,argv:Z(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped});function Ce(e,t,n){let r=new TextEncoder,i=t,a=()=>{},o=new ReadableStream({start(t){let o=e=>t.enqueue(r.encode(e)),s=()=>{let{lines:t,dropped:n,code:r}=e.job,s=Math.max(i,n);n+t.length>s&&(o(`data: ${JSON.stringify({from:s,lines:t.slice(s-n)})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(Q(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
|
|
4
|
-
|
|
5
|
-
`),15e3);a=()=>{clearInterval(c),e.listeners.delete(s);try{t.close()}catch{}},e.listeners.add(s),n.addEventListener(`abort`,a),s()},cancel:()=>a()});return new Response(o,{headers:{"content-type":`text/event-stream`,"cache-control":`no-store`}})}async function we(e,t){let[s,c,d]=t,f=e.method;if(s===`state`&&f===`GET`){let e=u();return Y({version:x,cwd:process.cwd(),project:i(process.cwd())??null,projects:_(),defaultImage:pe,paths:{root:n,sites:l,traefik:re,journal:ee,proxy:r},servers:L().map(t=>({...t,target:R(t.name),saved:e.servers[t.name]??null})),jobs:[...J.values()].reverse().map(Q)})}if(s===`servers`&&c){let{server:t}=a(c);if(f===`GET`&&!d)return Y(await ae(t));if(f===`GET`&&d===`firewall`){let{login:e,probe:n,...r}=await te(t);return Y({...r,asRoot:S(e).user===`root`})}if(f===`GET`&&d===`config`){let e=le(c),[n,r]=await Promise.all([Promise.all(e.domains.map(e=>oe(t,e))),y(t)]);return Y({...e,dns:n,suggest:r})}if(f===`GET`&&d===`history`){let e=(await ue(t)).reverse();return Y(e.map(e=>({...e,pending:se(e).length})))}if(f===`GET`&&d===`facts`){let e=await fe(t),n=await m(t,e);return Y({...e,ips:n,sslipIp:me(n)??null})}if(f===`GET`&&d===`updates`){let n=new URL(e.url).searchParams.get(`refresh`)===`1`,r=await ce(t,n),i=await h(t);return Y({packages:r,images:await g(t),engine:{...i,...de(i)}})}if(f===`GET`&&d===`dns`){let n=(new URL(e.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>b.test(e)).slice(0,50),r=await m(t),[i,a]=await Promise.all([p(n,r),Promise.all(n.map(ie))]);return Y({ips:r,domains:i.map((e,t)=>({...e,https:a[t]}))})}if(f===`POST`&&d==="default"){let e=u();return e.servers[c]??={host:c},e.default=c,o(e),Y({ok:!0})}}if(s===`jobs`){if(f===`POST`&&!c){let t=await e.json(),n=t.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||be.has(n[0]))return X(`argv must be a list of landfall arguments`,400);let r;try{r=_e(t.cwd)}catch(e){return X(e,400)}return Y(Q(Se(n,{server:t.server,password:t.password||void 0,cwd:r})))}let t=c?J.get(c):void 0;if(c&&!t)return X(`no job ${c}`,404);if(t&&f===`GET`&&d===`events`)return Ce(t,Number(new URL(e.url).searchParams.get(`from`))||0,e.signal);if(t&&f===`GET`&&!d)return Y({...Q(t),lines:t.job.lines,from:t.job.dropped});if(t&&f===`POST`&&d===`stop`)return t.job.stop(),Y(Q(t))}if(s===`fs`&&f===`GET`)return Y(B(new URL(e.url).searchParams.get(`path`)??void 0));if(s===`projects`&&f===`GET`)return Y(_());if(s===`upload`&&f===`POST`){let t=await he(await e.formData());return Y({...t,detected:B(t.path).detected})}if(s===`open`&&f===`POST`){let{url:t}=await e.json();return!t||!/^https?:\/\//.test(t)?X(`not a web address`,400):(C(t),Y({ok:!0}))}return X(`not found`,404)}const $=(e,t)=>e.length===t.length&&N(Buffer.from(e),Buffer.from(t)),Te=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``;async function Ee(e){t(!0);let n=xe(),r=P(De(async e=>{let t=new URL(e.url),r=e.headers.get(`host`)??``;if(!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(r))return new Response(`forbidden host`,{status:403});let i=e.headers.get(`origin`);if(i&&new URL(i).host!==r)return new Response(`forbidden origin`,{status:403});if(t.pathname===`/login`)return $(t.searchParams.get(`t`)??``,n)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${n}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!$(Te(e),n))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(t.pathname===`/`)return new Response(ye,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(t.pathname.startsWith(`/api/`))try{return await we(e,t.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return X(e)}return new Response(`not found`,{status:404})}));r.requestTimeout=0;let i=await new Promise(t=>{r.once(`error`,t),r.listen(e.port,`127.0.0.1`,()=>t(void 0))});if(i){if(i.code!==`EADDRINUSE`)throw i;let t=`http://127.0.0.1:${e.port}/login?t=${n}`;return await fetch(`http://127.0.0.1:${e.port}/api/state`,{headers:{cookie:`landfall=${n}`}}).then(e=>e.ok,()=>!1)?(console.log(`lf ui already runs: ${d.cyan(t)}`),e.open&&C(t),0):(console.error(`${d.red(`error:`)} port ${e.port} is in use; pick another with --port`),1)}let a=`http://127.0.0.1:${r.address().port}/login?t=${n}`;return console.log(`${d.bold(`lf ui`)} ${d.dim(x)} on ${d.cyan(a)}`),console.log(d.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&C(a),new Promise(e=>{let t=()=>{for(let e of J.values())e.job.stop();r.closeAllConnections(),r.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function De(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let n=new Headers;for(let[e,r]of Object.entries(t.headers))for(let t of[r??[]].flat())n.append(e,t);let a=t.method===`GET`||t.method===`HEAD`?void 0:F.toWeb(t);i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:n,body:a,signal:r.signal,duplex:`half`}))}catch{i=new Response(`bad request`,{status:400})}for(let[e,t]of i.headers)e!==`set-cookie`&&n.setHeader(e,t);let a=i.headers.getSetCookie();if(a.length&&n.setHeader(`set-cookie`,a),n.writeHead(i.status),!i.body)return n.end();let o=F.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{Ee as webUi};
|