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/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
- info = await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
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
- if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
549
- const why = j.lines.map((l) => l.replace(ANSI, "").trim()).filter(Boolean).slice(-4).join("\n");
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
- const tunnelUrl = (j) => j.lines.map((l) => l.replace(ANSI, "").match(/http:\/\/localhost:\d+\S*/)?.[0]).find(Boolean);
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
- const j = openTunnels(n).find((t) => t.info.argv.join(" ") === argv.join(" "));
590
- if (!j) return btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`);
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
- return `${url ? `<a class="tunnel-url" href="${esc(url)}" target="_blank" rel="noopener">${esc(url.replace("http://", ""))}</a>` : `<span class="spin" role="img" aria-label="opening"></span>`}
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
- !/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
758
- : password.length < 8 ? ["password", "The password needs at least 8 characters"]
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.lines.some((l) => l.includes("no key login yet"))) {
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.lines.filter((l) => /^\s+→ /.test(l)).length;
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 ip = S.facts[n]?.data?.sslipIp;
910
- return ip ? sslip(name, ip) : "";
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: "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." },
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
- const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
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.lines.map((l) => l.replace(ANSI, "").match(/https:\/\/[^\s)]+/)?.[0]).filter(Boolean).pop();
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: "Password (at least 8 characters)", type: "password", auto: "new-password" },
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, "-p", v.password], { server: n });
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
- async function makeDefault(n) {
1205
- try {
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">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
1500
- <td class="hide-sm"><div class="chips">${domains.map((d) => domainChip(n, d)).join("")}</div></td>
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 ? `&nbsp;<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: "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." },
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 &lt;its api port&gt;</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.4.5",
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",
@@ -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};