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/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 (menu && !e.target.closest("#menu")) {
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 = $("#menu");
427
+ const m = menuEl;
427
428
  if (!m) return;
428
- // Removing the focused item fires focusout, which calls closeMenu again: drop the id first so that call finds nothing.
429
- m.removeAttribute("id");
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
- div.textContent = l.replace(ANSI, "") || " ";
586
- const c = lineClass(div.textContent);
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: "authUser", label: "Login user (optional)", placeholder: "admin", hint: "Visitors log in with this user and password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
915
- { name: "authPassword", label: "Login password (at least 8 characters)", type: "password" },
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 user = v.authUser.trim();
927
- if (user && !/^[A-Za-z0-9._@-]{1,64}$/.test(user)) return invalid("authUser", "Only letters, digits and . _ @ -");
928
- if (user && v.authPassword.length < 8) return invalid("authPassword", "The password needs at least 8 characters");
929
- if (!user && v.authPassword) return invalid("authUser", "Give a user for the password");
930
- if (user) argv.push("--auth-user", user, "--auth-password", v.authPassword);
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
- if (!v.user.trim()) return invalid("user", "Give a user name");
1106
- if (v.password.length < 8) return invalid("password", "The password needs at least 8 characters");
1107
- run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
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
- confirmDlg({
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
- promptDlg(`Traefik dashboard on ${n}`, "Serves the dashboard behind a password (shown in the output). `off` removes it.\nRuns lf traefik setup, which reconfigures landfall's Traefik.", { label: "Domain", type: "text", value: x.traefik.dashboard ?? (x.domains[0] ? `traefik.${x.domains[0]}` : "") }, (d) => {
1689
- if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1690
- }, "Apply");
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
- // Sites behind a Traefik the server already had (proxy.env): lf traefik setup refuses to touch it.
1693
- const proxy = S.overview[n]?.data?.proxy;
1694
- const foreign = proxy && proxy !== "traefik" ? proxy : "";
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
- ["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
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 &lt;its api port&gt;</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" && $("#menu")) return closeMenu();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -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};