landfall 0.4.5 → 0.5.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/README.md +93 -41
- package/dist/cli.mjs +236 -212
- package/dist/index.html +88 -37
- package/dist/server-DA-e5nav.mjs +9 -0
- package/package.json +2 -2
- package/dist/server-BqVugweK.mjs +0 -5
package/dist/index.html
CHANGED
|
@@ -85,6 +85,7 @@ nav h3 span { font-weight: 500; letter-spacing: 0; }
|
|
|
85
85
|
.sections .badge { margin-left: auto; }
|
|
86
86
|
.head h1 .crumb { font-weight: 400; color: var(--dim); }
|
|
87
87
|
.tunnel-url { font-family: var(--mono); font-size: var(--fs-sm); }
|
|
88
|
+
.tunnel-lan::before { content: "· LAN "; color: var(--faint); font-size: var(--fs-sm); }
|
|
88
89
|
.star { color: var(--warn); font-size: var(--fs-xs); }
|
|
89
90
|
main { overflow: auto; padding: 20px 24px 40px; min-width: 0; container-type: inline-size; }
|
|
90
91
|
.show-sm { display: none !important; }
|
|
@@ -264,6 +265,8 @@ body.dragging::after {
|
|
|
264
265
|
main { padding: 14px 16px 40px; }
|
|
265
266
|
.project .path { display: none; }
|
|
266
267
|
aside.job { inset: 52px 0 0 0; width: 100vw; }
|
|
268
|
+
td.acts { white-space: normal; }
|
|
269
|
+
td.acts button { margin: 2px 0 2px 4px; }
|
|
267
270
|
}
|
|
268
271
|
</style>
|
|
269
272
|
</head>
|
|
@@ -505,7 +508,10 @@ async function refreshState() {
|
|
|
505
508
|
async function run(argv, o = {}) {
|
|
506
509
|
let info;
|
|
507
510
|
try {
|
|
508
|
-
|
|
511
|
+
// A retry runs the job's own argv on the server: the page only has it with passwords masked.
|
|
512
|
+
info = o.retry
|
|
513
|
+
? await http(`/api/jobs/${encodeURIComponent(o.retry)}/force`, {})
|
|
514
|
+
: await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
|
|
509
515
|
} catch (e) {
|
|
510
516
|
return toast(e.message);
|
|
511
517
|
}
|
|
@@ -521,7 +527,8 @@ function stream(j) {
|
|
|
521
527
|
const es = new EventSource(`/api/jobs/${j.info.id}/events?from=${j.lines.length}`);
|
|
522
528
|
j.es = es;
|
|
523
529
|
es.onmessage = (e) => {
|
|
524
|
-
const { from, lines } = JSON.parse(e.data);
|
|
530
|
+
const { from, lines, report } = JSON.parse(e.data);
|
|
531
|
+
if (report) j.report = report;
|
|
525
532
|
j.lines.length = Math.min(j.lines.length, from);
|
|
526
533
|
j.lines.push(...lines);
|
|
527
534
|
if (S.job === j.info.id) appendOutput(j);
|
|
@@ -531,6 +538,7 @@ function stream(j) {
|
|
|
531
538
|
es.addEventListener("done", (e) => {
|
|
532
539
|
es.close();
|
|
533
540
|
j.info = JSON.parse(e.data);
|
|
541
|
+
j.report = j.info.report;
|
|
534
542
|
finished(j);
|
|
535
543
|
});
|
|
536
544
|
// Dropped connection while it still runs: pick up where it left off.
|
|
@@ -545,12 +553,12 @@ function finished(j) {
|
|
|
545
553
|
const failed = info.code !== 0 && info.code !== null;
|
|
546
554
|
j.actions = (j.after && j.after(j)) || [];
|
|
547
555
|
// Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
|
|
548
|
-
|
|
549
|
-
|
|
556
|
+
const why = rep(j).force;
|
|
557
|
+
if (failed && !info.argv.includes("--force") && why) {
|
|
550
558
|
j.actions.push({
|
|
551
559
|
label: "Do it anyway (--force)…",
|
|
552
560
|
cls: "danger",
|
|
553
|
-
run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server }) }),
|
|
561
|
+
run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server, retry: info.id }) }),
|
|
554
562
|
});
|
|
555
563
|
}
|
|
556
564
|
if (info.undoable && info.server && info.code !== null) {
|
|
@@ -582,14 +590,53 @@ const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === und
|
|
|
582
590
|
const isTunnel = (info) => info.argv[0] === "tunnel" || (info.argv[0] === "traefik" && info.argv[1] === "dashboard");
|
|
583
591
|
/** Tunnels the UI opened to server n that are still open. */
|
|
584
592
|
const openTunnels = (n) => runningJobs().filter((j) => j.info.server === n && isTunnel(j.info));
|
|
585
|
-
|
|
593
|
+
/** What the job reported through the CLI's report(): never parsed from its text output. */
|
|
594
|
+
const rep = (j) => j.report ?? j.info.report ?? {};
|
|
595
|
+
const tunnelUrl = (j) => rep(j).url;
|
|
596
|
+
/** With --lan: the addresses other devices on the network open it at. */
|
|
597
|
+
const lanUrls = (j) => rep(j).lan ?? [];
|
|
586
598
|
const closeTunnel = (j) => http(`/api/jobs/${j.info.id}/stop`, {}).catch((e) => toast(e.message));
|
|
599
|
+
/** argv plus --port, --local and --lan, picked in a dialog. ports: the ones the container listens on, if known. */
|
|
600
|
+
function tunnelOptions(n, argv, ports = []) {
|
|
601
|
+
// A choice only when there is one to make: else free text, empty meaning the CLI's default.
|
|
602
|
+
const port = ports.length > 1
|
|
603
|
+
? { name: "port", label: "Container port", type: "select", value: String(ports[0]), options: ports.map((p) => ({ value: String(p), label: String(p) })) }
|
|
604
|
+
: { name: "port", label: "Container port", placeholder: ports[0] ? String(ports[0]) : "the one Traefik routes to, else the one it exposes", value: "" };
|
|
605
|
+
dialog({
|
|
606
|
+
title: argv[0] === "traefik" ? "Tunnel to the Traefik dashboard" : `Tunnel to ${argv.slice(1).filter((a, i, l) => a !== "-s" && l[i - 1] !== "-s").join(" · ")}`,
|
|
607
|
+
text: `Forwards a port on this machine to it until you close it: lf ${argv.join(" ")}`,
|
|
608
|
+
ok: "Open tunnel",
|
|
609
|
+
fields: [
|
|
610
|
+
...(argv[0] === "tunnel" ? [port] : []),
|
|
611
|
+
{ name: "local", label: "Local port", placeholder: "the container's port, else a free one", value: "", hint: "Fails if something else listens on it." },
|
|
612
|
+
{ name: "lan", label: "Reachable from my network too", type: "check", value: false, hint: "Open it from a phone or another device on the same Wi-Fi. Anyone on that network can reach it." },
|
|
613
|
+
],
|
|
614
|
+
onOk: (v) => {
|
|
615
|
+
const isPort = (x) => /^\d+$/.test(x) && +x >= 1 && +x <= 65535;
|
|
616
|
+
const to = v.port?.trim() ?? "", local = v.local.trim();
|
|
617
|
+
if (to && !isPort(to)) return invalid("port", "A port from 1 to 65535");
|
|
618
|
+
if (local && !isPort(local)) return invalid("local", "A port from 1 to 65535");
|
|
619
|
+
run([...argv, ...(to ? ["--port", to] : []), ...(local ? ["-l", local] : []), ...(v.lan ? ["--lan"] : [])], { server: n });
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
}
|
|
587
623
|
/** Open a tunnel with argv, or, when it is open, its address and a way to close it. */
|
|
588
|
-
function tunnelBtn(n, argv, label = "Open tunnel") {
|
|
589
|
-
|
|
590
|
-
|
|
624
|
+
function tunnelBtn(n, argv, label = "Open tunnel", ports = []) {
|
|
625
|
+
// Opened with options, the job's argv goes on after argv.
|
|
626
|
+
const j = openTunnels(n).find((t) => t.info.argv.slice(0, argv.length).join(" ") === argv.join(" "));
|
|
627
|
+
if (!j)
|
|
628
|
+
return `${btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`)}${btn(
|
|
629
|
+
"On network",
|
|
630
|
+
() => run([...argv, "--lan"], { server: n }),
|
|
631
|
+
"sm",
|
|
632
|
+
"Open it for every device on your network too (a phone, a colleague): anyone there reaches it at this machine's address",
|
|
633
|
+
)}${btn("…", () => tunnelOptions(n, argv, ports), "sm", "Container port, local port, network")}`;
|
|
591
634
|
const url = tunnelUrl(j);
|
|
592
|
-
|
|
635
|
+
// A database's address is for a client, not the browser.
|
|
636
|
+
const link = (u) => u.startsWith("http://") ? `<a class="tunnel-url" href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace("http://", ""))}</a>` : `<code class="tunnel-url" title="Connect a client to it">${esc(u)}</code>`;
|
|
637
|
+
const lan = lanUrls(j);
|
|
638
|
+
const addr = !url ? `<span class="spin" role="img" aria-label="opening"></span>` : link(url);
|
|
639
|
+
return `${addr}${lan.length ? ` <span class="tunnel-lan" title="From other devices on your network">${lan.map(link).join(" ")}</span>` : ""}
|
|
593
640
|
${btn("Output", () => showJob(j.info.id), "sm ghost")}${btn("Close", () => closeTunnel(j), "sm danger", "Ends the tunnel")}`;
|
|
594
641
|
}
|
|
595
642
|
|
|
@@ -753,9 +800,9 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
|
|
|
753
800
|
}
|
|
754
801
|
const invalid = (field, error) => ({ field, error });
|
|
755
802
|
/** What is wrong with a login (lf newLogin's rules), as [field, message]. */
|
|
756
|
-
const loginError = (user, password) =>
|
|
757
|
-
|
|
758
|
-
: password.length <
|
|
803
|
+
const loginError = (user, password, { loginName, minPassword } = S.state.rules) =>
|
|
804
|
+
!new RegExp(loginName).test(user) ? ["user", "Only letters, digits and . _ @ -"]
|
|
805
|
+
: password.length < minPassword ? ["password", `The password needs at least ${minPassword} characters`]
|
|
759
806
|
: null;
|
|
760
807
|
/**
|
|
761
808
|
* Passwords here are for servers and sites, not for this page: a masked text field, so browsers
|
|
@@ -784,10 +831,10 @@ function setupFlow(args, base = args) {
|
|
|
784
831
|
server: args[0],
|
|
785
832
|
after: (j) => {
|
|
786
833
|
if (j.info.code !== 0) return;
|
|
787
|
-
if (j
|
|
834
|
+
if (rep(j).needsKey) {
|
|
788
835
|
return [{ label: "Enter the password to install a key", run: () => passwordFor(args, base) }];
|
|
789
836
|
}
|
|
790
|
-
const todo = j.
|
|
837
|
+
const todo = (rep(j).checks ?? []).filter((ch) => ch.status === "todo").length;
|
|
791
838
|
return [
|
|
792
839
|
{ label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0] }) },
|
|
793
840
|
{ label: "Change options", run: () => setupWizard(base) },
|
|
@@ -900,14 +947,13 @@ function addServer() {
|
|
|
900
947
|
}
|
|
901
948
|
|
|
902
949
|
const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
|
|
903
|
-
const sslip = (name, ip) => `${name}.${ip.replace(/\./g, "-")}.sslip.io`;
|
|
904
950
|
const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
|
|
905
951
|
/** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
|
|
906
952
|
function autoDomain(n, name) {
|
|
907
953
|
const main = mainDomain(n);
|
|
908
954
|
if (main) return `${name}.${main}`;
|
|
909
|
-
const
|
|
910
|
-
return
|
|
955
|
+
const suffix = S.facts[n]?.data?.sslipSuffix;
|
|
956
|
+
return suffix ? `${name}.${suffix}` : "";
|
|
911
957
|
}
|
|
912
958
|
const slugify = (s) => s.toLowerCase().replace(/\.(zip|tgz|tar(\.gz)?)$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "site";
|
|
913
959
|
const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
|
|
@@ -946,6 +992,7 @@ function deployDialog(det, o = {}) {
|
|
|
946
992
|
? `Empty: <name>.${main}. A bare name like blog means blog.${main}, @ means ${main} itself (another site serving it hands it over). Other domains need an A record pointing at the server.`
|
|
947
993
|
: "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
|
|
948
994
|
},
|
|
995
|
+
{ name: "noDomain", label: "No domain: reach it through a tunnel only", type: "check", value: !!p && p.domains?.length === 0, hint: "Nothing is served on the internet. Give it a domain later with Domains… on the site." },
|
|
949
996
|
...(det.kind === "static"
|
|
950
997
|
? [
|
|
951
998
|
{ name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
|
|
@@ -956,7 +1003,7 @@ function deployDialog(det, o = {}) {
|
|
|
956
1003
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
957
1004
|
]
|
|
958
1005
|
: []),
|
|
959
|
-
{ name: "authPassword", label:
|
|
1006
|
+
{ name: "authPassword", label: `Login password (optional, at least ${S.state.rules.minPassword} 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." },
|
|
960
1007
|
{ name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
|
|
961
1008
|
],
|
|
962
1009
|
ok: "Deploy",
|
|
@@ -965,7 +1012,8 @@ function deployDialog(det, o = {}) {
|
|
|
965
1012
|
if (!n) return invalid("name", "Give the site a name");
|
|
966
1013
|
if (!v.server) return invalid("server", "Pick a server");
|
|
967
1014
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
968
|
-
|
|
1015
|
+
if (v.noDomain && domains.length) return invalid("domains", "No domain is ticked below: clear these, or untick it");
|
|
1016
|
+
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
|
|
969
1017
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
970
1018
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
971
1019
|
const dir = v.dir?.trim().replace(/\/+$/, "") || ".";
|
|
@@ -987,7 +1035,7 @@ function deployDialog(det, o = {}) {
|
|
|
987
1035
|
/** After a deploy: offer the address it printed. */
|
|
988
1036
|
function openAfter(j) {
|
|
989
1037
|
if (j.info.code !== 0) return;
|
|
990
|
-
const url = j
|
|
1038
|
+
const url = rep(j).url;
|
|
991
1039
|
if (url) return [{ label: `Open ${url.replace("https://", "")}`, run: () => window.open(url, "_blank", "noopener") }];
|
|
992
1040
|
}
|
|
993
1041
|
|
|
@@ -1146,7 +1194,7 @@ function authDialog(n, m) {
|
|
|
1146
1194
|
: "Visitors have to log in (HTTP basic auth) before they see the site.",
|
|
1147
1195
|
fields: [
|
|
1148
1196
|
{ name: "user", label: "User", value: users[0] ?? "admin" },
|
|
1149
|
-
{ name: "password", label:
|
|
1197
|
+
{ name: "password", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", auto: "new-password" },
|
|
1150
1198
|
...(users.length ? [{ name: "off", label: "Remove the login: the site is public again", type: "check", value: false }] : []),
|
|
1151
1199
|
],
|
|
1152
1200
|
ok: "Save",
|
|
@@ -1155,7 +1203,7 @@ function authDialog(n, m) {
|
|
|
1155
1203
|
const user = v.user.trim() || "admin";
|
|
1156
1204
|
const bad = loginError(user, v.password);
|
|
1157
1205
|
if (bad) return invalid(...bad);
|
|
1158
|
-
run(["auth", m.name, ...sv(n), "-u", user, "
|
|
1206
|
+
run(["auth", m.name, ...sv(n), "-u", user, "--password", v.password], { server: n });
|
|
1159
1207
|
},
|
|
1160
1208
|
});
|
|
1161
1209
|
}
|
|
@@ -1201,14 +1249,8 @@ function forget(n) {
|
|
|
1201
1249
|
});
|
|
1202
1250
|
}
|
|
1203
1251
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
await http(`/api/servers/${encodeURIComponent(n)}/default`, {});
|
|
1207
|
-
await refreshState();
|
|
1208
|
-
toast(`${n} is now the default server`);
|
|
1209
|
-
} catch (e) {
|
|
1210
|
-
toast(e.message);
|
|
1211
|
-
}
|
|
1252
|
+
function makeDefault(n) {
|
|
1253
|
+
run(["server", "use", n], { server: n, after: (j) => (j.info.code === 0 ? void refreshState() : undefined) });
|
|
1212
1254
|
}
|
|
1213
1255
|
|
|
1214
1256
|
// ---- views ---------------------------------------------------------------
|
|
@@ -1494,10 +1536,12 @@ function sitesView(n, o) {
|
|
|
1494
1536
|
.join("")}</ul></dd>` : ""}
|
|
1495
1537
|
</dl></td></tr>`
|
|
1496
1538
|
: "";
|
|
1539
|
+
// A site deployed with no domain is reached by its tunnel.
|
|
1540
|
+
const where = domains.length ? domains.map((d) => domainChip(n, d)).join("") : main && !old ? `<span class="faint">no domain</span> ${tunnelBtn(n, ["tunnel", name, ...sv(n)])}` : "";
|
|
1497
1541
|
return `<tr class="click" ${on(toggle)}>
|
|
1498
1542
|
<td><div class="site-name">${statusDot(status)}<button type="button" class="row-toggle" aria-expanded="${open}" data-f="row:${esc(name)}" ${on(toggle)}><b>${esc(name)}</b> <span class="faint" aria-hidden="true">${open ? "▾" : "▸"}</span></button>${m.auth?.length ? `<span class="lock" title="password protected" role="img" aria-label="password protected">🔒</span>` : ""}</div>
|
|
1499
|
-
<div class="chips show-sm">${
|
|
1500
|
-
<td class="hide-sm"><div class="chips">${
|
|
1543
|
+
<div class="chips show-sm">${where}</div></td>
|
|
1544
|
+
<td class="hide-sm"><div class="chips">${where}</div></td>
|
|
1501
1545
|
<td class="hide-sm"><span class="badge" title="${esc(services.map((x) => x.service).join(", "))}">${esc(kind)}</span>${services.length > 1 ? ` <span class="faint">${services.length} services</span>` : ""}</td>
|
|
1502
1546
|
<td class="hide-sm dim">${cur ? esc(ago(cur.at)) : ""}</td>
|
|
1503
1547
|
<td class="acts">${acts}</td></tr>${detail}`;
|
|
@@ -1744,7 +1788,7 @@ function editRule(n, num, r) {
|
|
|
1744
1788
|
|
|
1745
1789
|
/** Everything an SSH tunnel reaches on server n, and the tunnels open now. */
|
|
1746
1790
|
function tunnelsView(n, o) {
|
|
1747
|
-
if (!o.ready && !o.others.length) return `<div class="card"><div class="body empty"><h2>Nothing to tunnel to yet</h2><p>Set up the server and deploy a site first.</p></div></div>`;
|
|
1791
|
+
if (!o.ready && !o.others.length && !o.unrouted?.length) return `<div class="card"><div class="body empty"><h2>Nothing to tunnel to yet</h2><p>Set up the server and deploy a site first.</p></div></div>`;
|
|
1748
1792
|
const open = openTunnels(n);
|
|
1749
1793
|
const chips = (l) => `<div class="chips">${l.map((d) => `<span class="chip">${esc(d)}</span>`).join("")}</div>`;
|
|
1750
1794
|
const targets = [];
|
|
@@ -1758,6 +1802,8 @@ function tunnelsView(n, o) {
|
|
|
1758
1802
|
else for (const svc of x.services) targets.push({ name: `${x.meta.name} · ${svc.service}`, status: svc.status, serves: svc.service === x.main ? x.domains : [], argv: ["tunnel", x.meta.name, svc.service, ...sv(n)] });
|
|
1759
1803
|
}
|
|
1760
1804
|
for (const e of o.others) targets.push({ name: e.name, status: e.status, serves: e.domains, argv: ["tunnel", e.name, ...sv(n)], other: true });
|
|
1805
|
+
// Databases, workers: by Compose project and service, else by container name.
|
|
1806
|
+
const loose = (o.unrouted ?? []).map((x) => ({ name: x.container, status: x.status, image: x.image, ports: x.ports ?? [], serves: [], argv: ["tunnel", ...(x.project ? [x.project, x.service] : [x.container]), ...sv(n)] }));
|
|
1761
1807
|
const row = (t) => `<tr><td><div class="site-name">${t.status ? statusDot(t.status) : ""}<b>${esc(t.name)}</b>${t.other ? ` <span class="faint">not landfall's</span>` : ""}</div><div class="show-sm">${chips(t.serves)}</div></td>
|
|
1762
1808
|
<td class="hide-sm">${chips(t.serves)}</td>
|
|
1763
1809
|
<td class="acts">${t.argv ? tunnelBtn(n, t.argv) : `<span class="dim">${esc(t.why)}</span> ${btn("Traefik", () => go(n, "traefik"), "sm")}`}</td></tr>`;
|
|
@@ -1766,7 +1812,12 @@ function tunnelsView(n, o) {
|
|
|
1766
1812
|
.join("")}</tbody></table></div>` : ""}
|
|
1767
1813
|
<div class="card scroll"><h2>Tunnel to</h2>
|
|
1768
1814
|
<div class="lede">An SSH tunnel forwards a port on this machine to a container on ${esc(n)}: test a site on localhost with no domain, HTTPS or login. Nothing changes on the server, and closing the tunnel ends it.</div>
|
|
1769
|
-
<table><thead><tr><th>Container</th><th class="hide-sm">Domains</th><th><span class="sr">Tunnel</span></th></tr></thead><tbody>${targets.map(row).join("")}</tbody></table></div
|
|
1815
|
+
<table><thead><tr><th>Container</th><th class="hide-sm">Domains</th><th><span class="sr">Tunnel</span></th></tr></thead><tbody>${targets.map(row).join("")}</tbody></table></div>
|
|
1816
|
+
${loose.length ? `<div class="card scroll"><h2>Other containers <span class="badge">${loose.length}</span></h2>
|
|
1817
|
+
<div class="lede">Running, but no domain routes to them: databases, caches, workers. The tunnel goes to the port their image exposes.</div>
|
|
1818
|
+
<table><thead><tr><th>Container</th><th class="hide-sm">Image</th><th><span class="sr">Tunnel</span></th></tr></thead><tbody>${loose
|
|
1819
|
+
.map((t) => `<tr><td><div class="site-name">${statusDot(t.status)}<b>${esc(t.name)}</b></div><div class="show-sm faint mono">${esc(t.image)}</div></td><td class="hide-sm faint mono">${esc(t.image)}</td><td class="acts">${tunnelBtn(n, t.argv, undefined, t.ports)}</td></tr>`)
|
|
1820
|
+
.join("")}</tbody></table></div>` : ""}`;
|
|
1770
1821
|
}
|
|
1771
1822
|
|
|
1772
1823
|
/** Traefik on server n: its dashboard (domain and tunnel) and how landfall runs it. */
|
|
@@ -1785,7 +1836,7 @@ function traefikView(n) {
|
|
|
1785
1836
|
fields: [
|
|
1786
1837
|
{ 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." },
|
|
1787
1838
|
{ name: "user", label: "Login user", value: "admin", placeholder: "admin" },
|
|
1788
|
-
{ name: "password", label:
|
|
1839
|
+
{ name: "password", label: `Login password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
|
|
1789
1840
|
],
|
|
1790
1841
|
ok: "Apply",
|
|
1791
1842
|
onOk: (v) => {
|
|
@@ -1836,7 +1887,7 @@ function traefikView(n) {
|
|
|
1836
1887
|
<div class="body">${rows([
|
|
1837
1888
|
["domain", dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">none${foreign ? "" : ": only through the tunnel"}</span>`],
|
|
1838
1889
|
["tunnel", dash.tunnel
|
|
1839
|
-
? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim">lf traefik dashboard</span>`
|
|
1890
|
+
? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim mono">lf traefik dashboard</span>`
|
|
1840
1891
|
: foreign ? `<span class="dim">route its dashboard (api@internal) to a domain, or run <span class="mono">lf traefik dashboard --port <its api port></span></span>`
|
|
1841
1892
|
: `<span class="dim">set up by an older landfall</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`],
|
|
1842
1893
|
])}</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import{A as e,C as t,D as n,E as r,M as i,N as a,O as o,S as s,T as c,_ as l,a as u,b as d,c as f,d as ee,f as te,g as ne,h as re,i as ie,j as ae,k as p,l as m,m as h,n as g,o as _,p as v,r as oe,s as se,t as y,u as ce,v as le,w as ue,x as de,y as fe}from"./cli.mjs";import{spawn as pe,spawnSync as b}from"node:child_process";import x from"node:fs";import{homedir as S}from"node:os";import{dirname as C,isAbsolute as me,join as w,resolve as he}from"node:path";import{createHash as T,randomBytes as E,timingSafeEqual as D}from"node:crypto";import{createServer as O}from"node:http";import{Readable as k,Transform as A}from"node:stream";function j(e){let t=he(!e||e===`~`?S():n(e)),r=x.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&M(w(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:C(t)===t?null:C(t),home:S(),dirs:r,detected:v(t)}}const M=e=>{try{return x.statSync(e).isDirectory()}catch{return!1}},N=w(r,`uploads`),P=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function F(e,t){x.mkdirSync(C(e),{recursive:!0}),x.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function I(e){x.mkdirSync(N,{recursive:!0,mode:448});for(let e of x.readdirSync(N)){let t=w(N,e);Date.now()-x.statSync(t).mtimeMs>864e5&&x.rmSync(t,{recursive:!0,force:!0})}let t=x.mkdtempSync(w(N,`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=w(t,`.upload-`+P(n[0].name).pop());await F(e,n[0]);let r=w(t,`files`);x.mkdirSync(r);let i=/\.zip$/i.test(e);if(L(e,i))throw Error(`${n[0].name} contains symlinks or hard links: upload the folder instead`);let a=i?b(`unzip`,[`-q`,e,`-d`,r]):b(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?b(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return x.rmSync(e),R(r),{path:z(r),files:ge(r)}}let i=w(t,`files`);for(let[e,t]of n.entries()){let n=P(r[e]||t.name);n.length&&await F(w(i,...n),t)}return{path:z(i),files:n.length}}function L(e,t){let n=(e,t)=>{let n=b(e,t,{encoding:`utf8`,maxBuffer:256*1024**2});return n.error||n.status!==0?void 0:n.stdout},r=t?n(`zipinfo`,[e])??n(`tar`,[`-tvf`,e]):n(`tar`,[`-tvf`,e]);if(r===void 0)throw Error(`could not read ${e.split(`/`).pop()}`);return r.split(`
|
|
2
|
+
`).some(e=>/^[lh]/.test(e)||/ link to /.test(e))}function R(e){for(let t of x.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&x.rmSync(w(t.parentPath,t.name),{force:!0})}function z(e){for(;;){let t=x.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!M(w(e,t[0])))return e;e=w(e,t[0])}}function ge(e){return x.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!me(e)||!M(e))throw Error(`cwd must be an existing folder`);return e}}const B=()=>[process.execPath,...process.execArgv,process.argv[1]],V=5e3,H=1e4,U=e=>e.length>H?e.slice(0,H)+`…`:e;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,...n.env};n.password&&Object.assign(r,f(n.password));let i=pe(B()[0],[...B().slice(1),...e],{stdio:[n.stdin===void 0?`ignore`:`pipe`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0});i.stdin?.on(`error`,()=>{}).end(n.stdin);let a={argv:e,lines:[],dropped:0,report:{},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(`
|
|
3
|
+
`);e=n.pop(),e.length>H&&(n.push(e),e=``);for(let e of n)if(e.startsWith(``))try{Object.assign(a.report,JSON.parse(e.slice(1)))}catch{}else a.lines.push(U(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));a.lines.length>V&&(a.dropped+=a.lines.splice(0,a.lines.length-V).length),s()},flush:()=>e&&a.lines.push(U(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=x.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),be=512*1024**2,xe=1024**2,W=/* @__PURE__ */ new Map,Se=/* @__PURE__ */ new Set([`ui`,`web`]);function Ce(){let e=w(C(t),`ui-token`);try{let t=x.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let n=E(24).toString(`hex`);return x.mkdirSync(C(e),{recursive:!0,mode:448}),x.writeFileSync(e,n+`
|
|
4
|
+
`,{mode:384}),n}const G=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),K=(e,t=500)=>G({error:e?.message??String(e)},t);function q(e,t){let n=re(),r=/* @__PURE__ */ new Set,{args:i,env:a,stdin:o}=we(e),s=ve(i,()=>r.forEach(e=>e()),{password:t.password,cwd:t.cwd,stdin:o,env:{...a,LANDFALL_RUN:n}}),c={id:n,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:m(e),job:s,listeners:r,opts:t};W.set(n,c);for(let[e,t]of W){if(W.size<=50)break;t.job.code!==void 0&&W.delete(e)}return c}const J=e=>e[0]===`env`||e[0]===`sites`&&e[1]===`env`,Y=/^[A-Za-z_][A-Za-z0-9_]*=/;function we(e){let t=[],n={},r=[];for(let i=0;i<e.length;i++){let a=e[i],o=a.match(/^--((?:[\w-]+-)?password)(?:=(.*))?$/s);o&&(o[2]!==void 0||i+1<e.length)?n[ae(o[1])]=o[2]??e[++i]:J(e)&&Y.test(a)&&!a.includes(`
|
|
5
|
+
`)&&!e.includes(`--env-file`)?r.push(a):t.push(a)}return r.length&&t.push(`--env-file`,`-`),{args:t,env:n,stdin:r.length?r.join(`
|
|
6
|
+
`)+`
|
|
7
|
+
`:void 0}}const Te=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:J(e)&&Y.test(t)?t.replace(/=.*/s,`=***`):t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),X=e=>({id:e.id,argv:Te(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function Z(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),report:e.job.report})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(X(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
|
|
8
|
+
|
|
9
|
+
`),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 Ee(t,n){let[r,i,a]=n,f=t.method;if(r===`state`&&f===`GET`){let e=o();return G({version:y,cwd:process.cwd(),project:p(process.cwd())??null,projects:h(),defaultImage:l,rules:{loginName:c.source,minPassword:8},paths:{root:d,sites:de,traefik:s,journal:le,proxy:fe},servers:ce().map(t=>({...t,target:ee(t.name),saved:e.servers[t.name]??null})),jobs:[...W.values()].reverse().map(X)})}if(r===`servers`&&i){let{server:n}=e(i);if(f===`GET`&&!a)return G(await te(n));if(f===`GET`&&a===`firewall`)return G(await u(n));if(f===`GET`&&a===`config`)return G(await g(i,n));if(f===`GET`&&a===`history`)return G(await _(n));if(f===`GET`&&a===`facts`)return G(await ie(i,n));if(f===`GET`&&a===`updates`)return G(await se(n,new URL(t.url).searchParams.get(`refresh`)===`1`));if(f===`GET`&&a===`dns`){let e=(new URL(t.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>ue.test(e)).slice(0,50);return G(await oe(n,e))}}if(r===`jobs`){if(f===`POST`&&!i){let e=await t.json(),n=e.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||Se.has(n[0]))return K(`argv must be a list of landfall arguments`,400);if([...W.values()].filter(e=>e.job.code===void 0).length>=20)return K(`20 jobs are already running: wait for one to finish or stop one`,429);let r;try{r=_e(e.cwd)}catch(e){return K(e,400)}return G(X(q(n,{server:e.server,password:e.password||void 0,cwd:r})))}let e=i?W.get(i):void 0;if(i&&!e)return K(`no job ${i}`,404);if(e&&f===`GET`&&a===`events`)return Z(e,Number(new URL(t.url).searchParams.get(`from`))||0,t.signal);if(e&&f===`GET`&&!a)return G({...X(e),lines:e.job.lines,from:e.job.dropped});if(e&&f===`POST`&&a===`force`)return e.job.code===void 0||e.argv.includes(`--force`)?K(`only a finished job without --force`,400):[...W.values()].filter(e=>e.job.code===void 0).length>=20?K(`too many jobs running`,429):G(X(q([...e.argv,`--force`],e.opts)));if(e&&f===`POST`&&a===`stop`)return e.job.stop(),G(X(e))}if(r===`fs`&&f===`GET`)return G(j(new URL(t.url).searchParams.get(`path`)??void 0));if(r===`upload`&&f===`POST`){let e=await I(await t.formData());return G({...e,detected:j(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&D(Buffer.from(e),Buffer.from(t)),De=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>T(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function Oe(e){ne(!0);let t=Ce(),n=O(Ae(async e=>{let n=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(n.pathname===`/ping`)return new Response($(t));if(n.pathname===`/login`)return Q(n.searchParams.get(`t`)??``,t)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${t}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!Q(De(e),t))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(n.pathname===`/`)return new Response(ye,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(n.pathname.startsWith(`/api/`)){let t=e.headers.get(`sec-fetch-site`);if(t&&t!==`same-origin`&&t!==`none`)return new Response(`forbidden`,{status:403});try{return await Ee(e,n.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return K(e)}}return new Response(`not found`,{status:404})}));n.requestTimeout=0;let r=e=>new Promise(t=>{n.once(`error`,t),n.listen(e,`127.0.0.1`,()=>(n.off(`error`,t),t(void 0)))}),o=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),s=e.port??4777,c=e.port===void 0?[...Array.from({length:20},(e,t)=>s+t),0]:[s],l;for(let n of c){if(!(l=await r(n)))break;if(l.code!==`EADDRINUSE`)throw l;if(await o(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${i.cyan(r)}`),e.open&&a(r),0}}if(l)return console.error(`${i.red(`error:`)} port ${s} is in use by another program; pick another with --port`),1;let u=n.address().port;u!==s&&console.log(i.dim(`port ${s} is in use by another program: using ${u}`));let d=`http://127.0.0.1:${u}/login?t=${t}`;return console.log(`${i.bold(`lf ui`)} ${i.dim(y)} on ${i.cyan(d)}`),console.log(i.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&a(d),new Promise(e=>{let t=()=>{for(let e of W.values())e.job.stop();n.closeAllConnections(),n.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function ke(e,t){let n=0;return e.pipe(new A({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Ae(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let a=new Headers;for(let[e,n]of Object.entries(t.headers))for(let t of[n??[]].flat())a.append(e,t);let o=t.url?.startsWith(`/api/upload`)?be:xe;if(Number(t.headers[`content-length`])>o){n.writeHead(413).end(`request too large`);return}let s=t.method===`GET`||t.method===`HEAD`?void 0:k.toWeb(ke(t,o));i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:a,body:s,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=k.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{Oe as webUi};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "landfall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Set up servers and deploy sites behind Traefik with automatic HTTPS",
|
|
5
5
|
"author": "ph1p <me@ph1p.dev>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=22"
|
|
25
|
+
"node": ">=22.4"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"bcryptjs": "^3.0.3",
|
package/dist/server-BqVugweK.mjs
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
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};
|