landfall 0.5.2 → 0.5.4

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
@@ -73,13 +73,32 @@ nav.servers { border-right: 1px solid var(--line); background: var(--bg); overfl
73
73
  #navlist { padding: 0 8px 16px; }
74
74
  nav h3 { margin: 14px 10px 4px; font-size: var(--fs-xs); color: var(--faint); font-weight: 500; display: flex; justify-content: space-between; }
75
75
  nav h3 span { font-weight: 500; }
76
- .srv { display: block; width: 100%; text-align: left; border: 0; background: none; box-shadow: none; padding: 6px 10px; border-radius: var(--r-sm); }
76
+ .srv { display: grid; grid-template-columns: 8px minmax(0, 1fr); column-gap: 10px; align-items: center; width: 100%; text-align: left; border: 0; background: none; box-shadow: none; padding: 7px 10px; border-radius: var(--r-sm); }
77
+ .srv > .dot { margin: 0; }
78
+ .srv .n, .srv .t { grid-column: 2; }
79
+ .srv.off:not(.on) .n { color: var(--dim); }
80
+ .srv .t .refused { color: var(--warn); }
77
81
  .srv:hover { background: var(--hover); }
78
82
  .srv.on { background: var(--panel); box-shadow: 0 0 0 1px var(--line), var(--shadow); }
79
83
  .srv.on .n { color: var(--text); }
80
84
  .srv.on .t { color: var(--dim); }
81
85
  .srv .n { font-weight: 550; display: flex; align-items: center; gap: 6px; }
82
- .srv .t { color: var(--faint); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
86
+ .tile .n .dot { margin-right: 0; flex: none; }
87
+ nav h3 .grp { all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
88
+ nav h3 .grp:hover { color: var(--dim); }
89
+ nav h3 .grp:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
90
+ nav h3 .caret { width: 0; height: 0; border-left: 5px solid currentColor; border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform .15s; }
91
+ nav h3 .grp[aria-expanded="true"] .caret { transform: rotate(90deg); }
92
+ .nav-foot { position: sticky; bottom: 0; margin-top: auto; display: flex; align-items: center; gap: 8px; padding: 10px 12px 12px 18px; border-top: 1px solid var(--line); background: var(--bg); font-size: var(--fs-sm); }
93
+ .nav-foot .what { flex: 1; min-width: 0; color: var(--dim); }
94
+ .nav-foot .counts { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--text); }
95
+ .nav-foot .counts .dot { margin-right: 5px; }
96
+ .nav-foot .when { color: var(--faint); font-size: var(--fs-xs); margin-top: 2px; }
97
+ .nav-foot .recheck { flex: none; display: inline-flex; align-items: center; gap: 6px; }
98
+ .nav-foot .recheck .spin { width: 10px; height: 10px; }
99
+ .srv .t { color: var(--faint); font-size: var(--fs-sm); display: flex; min-width: 0; white-space: nowrap; }
100
+ .srv .t .h { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
101
+ .srv .t .x { flex: none; white-space: pre; }
83
102
  .nav-none { color: var(--faint); font-size: var(--fs-sm); padding: 6px 10px; }
84
103
  .srv .t .live { color: var(--ok); }
85
104
  /* The open server's sections, under it in the list. */
@@ -176,6 +195,9 @@ td.acts button { margin-left: 4px; }
176
195
  .note.info { background: var(--accent-bg); color: var(--accent); }
177
196
  .kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; font-size: var(--fs); margin: 0; }
178
197
  .kv dt { color: var(--faint); } .kv dd { margin: 0; }
198
+ details.where summary { cursor: pointer; color: var(--faint); font-size: var(--fs-sm); }
199
+ details.where h3 { font-size: var(--fs-sm); margin: 12px 0 6px; }
200
+ details.where dl { margin: 0; font-size: var(--fs-sm); } details.where dt { overflow-wrap: anywhere; margin-top: 6px; } details.where dd { margin: 0; }
179
201
  pre.block { background: var(--hover); border: 1px solid var(--line); padding: 10px 12px; border-radius: var(--r-sm); overflow-x: auto; font-size: var(--fs-sm); margin: 6px 0 0; }
180
202
  .detail td { background: color-mix(in srgb, var(--hover) 60%, transparent); }
181
203
  .releases, .services { margin: 0; padding: 0; list-style: none; }
@@ -285,11 +307,13 @@ body.dragging::after {
285
307
  <header>
286
308
  <a class="brand" href="#/"><svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="4" fill="var(--accent)"/><path d="M4 5h8M4 8h8M4 11h5" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>landfall <small id="version"></small></a>
287
309
  <div class="project" id="project"></div>
310
+ <button type="button" class="sm ghost" id="app-settings" title="landfall's defaults for every server (lf config)">Defaults</button>
288
311
  </header>
289
312
  <!-- The filter stays put across renders, so typing keeps its focus. -->
290
313
  <nav class="servers" id="nav">
291
314
  <div class="nav-top"><input id="filter" type="search" placeholder="Filter servers" autocomplete="off" spellcheck="false" aria-label="Filter servers"><button type="button" id="add-server" title="Register an ssh alias or set up a fresh machine">+</button></div>
292
315
  <div id="navlist"></div>
316
+ <div class="nav-foot" id="navfoot" hidden></div>
293
317
  </nav>
294
318
  <div class="server-pick" id="pick"></div>
295
319
  <main id="main"></main>
@@ -328,6 +352,9 @@ const S = {
328
352
  dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https}]}}
329
353
  open: new Set(), // expanded site rows
330
354
  filter: "", // server list filter
355
+ reach: {}, // name -> {state: ok|login|down, detail, ms} from /api/reach
356
+ reaching: false,
357
+ reachedAt: null, // when the last check finished
331
358
  jobs: new Map(), // id -> {info, lines, after, actions, es}
332
359
  job: null, // shown job id
333
360
  };
@@ -508,6 +535,30 @@ async function refreshState() {
508
535
  S.state = await http("/api/state");
509
536
  render();
510
537
  }
538
+ /** Which servers take landfall's ssh login; the server caches each answer for 30s unless fresh. */
539
+ async function loadReach(fresh = false) {
540
+ if (S.reaching) return;
541
+ S.reaching = true;
542
+ renderNav();
543
+ try {
544
+ S.reach = await http(`/api/reach${fresh ? "?fresh=1" : ""}`);
545
+ S.reachedAt = new Date();
546
+ } catch (e) {
547
+ toast(`Checking servers failed: ${e.message}`);
548
+ } finally {
549
+ S.reaching = false;
550
+ // The home tiles show the dots too; an open server's view stays as it is.
551
+ S.sel ? renderNav() : render();
552
+ }
553
+ }
554
+ const REACH_DOT = { ok: ["up", "reachable"], login: ["warn", "login refused"], down: ["down", "unreachable"] };
555
+ function reachDot(n) {
556
+ const r = S.reach[n];
557
+ if (!r) return `<span class="dot" role="img" aria-label="not checked yet" title="${S.reaching ? "Checking…" : "Not checked yet"}"></span>`;
558
+ const [cls, word] = REACH_DOT[r.state];
559
+ const tip = r.state === "ok" ? `Key login works (${r.ms} ms)` : r.state === "login" ? `sshd answers but refuses the login: ${r.detail}` : `Unreachable: ${r.detail}`;
560
+ return `<span class="dot ${cls}" role="img" aria-label="${word}" title="${esc(tip)}"></span>`;
561
+ }
511
562
 
512
563
  // ---- jobs ----------------------------------------------------------------
513
564
 
@@ -743,7 +794,7 @@ function renderJob() {
743
794
  * onOk(values) runs on submit; return false to keep it open, or {field, error} to keep it open and show why.
744
795
  * typeToConfirm: a name the user types before the (dangerous) action is enabled.
745
796
  */
746
- function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", typeToConfirm = "" }) {
797
+ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", after = "", typeToConfirm = "" }) {
747
798
  const dlg = openDlg();
748
799
  const field = (f) => {
749
800
  const id = `f-${f.name}`;
@@ -766,6 +817,7 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
766
817
  ${text ? `<div class="text">${esc(text)}</div>` : ""}
767
818
  ${extra}
768
819
  ${fields.map(group).join("")}
820
+ ${after}
769
821
  ${typeToConfirm ? `<label>Type <b class="mono">${esc(typeToConfirm)}</b> to confirm<input id="dlg-confirm" autocomplete="off" spellcheck="false" autocapitalize="off"></label>` : ""}
770
822
  <div class="note bad dlg-err" id="dlg-err" role="alert" hidden></div>
771
823
  <div class="btns">
@@ -958,6 +1010,43 @@ function addServer() {
958
1010
 
959
1011
  const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
960
1012
  const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
1013
+ /** The server deploys new sites with no domain unless one is given: its own setting, else landfall's default. */
1014
+ const tunnelOnly = (n) => S.state.servers.find((s) => s.name === n)?.saved?.tunnelOnly ?? S.state.defaults.tunnelOnly;
1015
+
1016
+ /** landfall's defaults for every server (lf config); a server's own setting wins. */
1017
+ function defaultsDialog() {
1018
+ const d = S.state.defaults;
1019
+ dialog({
1020
+ title: "landfall defaults",
1021
+ text: `For every server, unless it sets its own. Saved in ${d.configFile}.`,
1022
+ fields: [
1023
+ { name: "default", label: "Default server", type: "select", value: d.default ?? "", options: [{ value: "", label: "none" }, ...S.state.servers.map((s) => ({ value: s.name, label: s.name }))], hint: "Commands in a terminal use it without --server." },
1024
+ { name: "tunnelOnly", label: "New sites get no domain: tunnel only", type: "check", value: d.tunnelOnly, hint: "Nothing is served on the internet until a site gets a domain. A server's Settings can override it." },
1025
+ { name: "email", label: "Let's Encrypt e-mail (optional)", value: d.email ?? "", hint: "For servers that set none, when landfall installs Traefik." },
1026
+ { name: "keep", label: "Releases to keep for rollback", value: String(d.keep), placeholder: "5" },
1027
+ ],
1028
+ after: whereList(d.locations),
1029
+ ok: "Save",
1030
+ onOk: (v) => {
1031
+ const argv = ["config"];
1032
+ if (v.default !== (d.default ?? "")) argv.push(...(v.default ? ["--default", v.default] : ["--unset", "default"]));
1033
+ if (v.tunnelOnly !== d.tunnelOnly) argv.push(v.tunnelOnly ? "--tunnel-only" : "--no-tunnel-only");
1034
+ const email = v.email.trim();
1035
+ if (email !== (d.email ?? "")) argv.push(...(email ? ["--email", email] : ["--unset", "email"]));
1036
+ const keep = v.keep.trim();
1037
+ if (keep && (!/^\d+$/.test(keep) || !Number(keep))) return invalid("keep", "A whole number from 1 up");
1038
+ if (keep !== String(d.keep)) argv.push(...(keep ? ["--keep", keep] : ["--unset", "keep"]));
1039
+ if (argv.length === 1) return;
1040
+ run(argv, { after: (j) => (j.info.code === 0 ? void (S.sel && load(S.sel, "settings", true), refreshState()) : undefined) });
1041
+ },
1042
+ });
1043
+ }
1044
+ /** Every place landfall writes to, from `lf config`: nothing it installs is hidden. */
1045
+ const whereList = (loc) => {
1046
+ const kv = (list) => `<dl>${list.map((l) => `<dt class="mono">${esc(l.path)}</dt><dd class="dim">${esc(l.what)}</dd>`).join("")}</dl>`;
1047
+ return `<details class="where"><summary>Where landfall puts things</summary>
1048
+ <h3>On this machine</h3>${kv(loc.local)}<h3>On each server</h3>${kv(loc.server)}</details>`;
1049
+ };
961
1050
  /** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
962
1051
  function autoDomain(n, name) {
963
1052
  const main = mainDomain(n);
@@ -1002,7 +1091,7 @@ function deployDialog(det, o = {}) {
1002
1091
  ? `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.`
1003
1092
  : "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
1004
1093
  },
1005
- { 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." },
1094
+ { name: "noDomain", label: "No domain: reach it through a tunnel only", type: "check", value: p?.domains ? !p.domains.length : !!tunnelOnly(target), hint: "Nothing is served on the internet. Give it a domain later with Domains… on the site." },
1006
1095
  ...(det.kind === "static"
1007
1096
  ? [
1008
1097
  { name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
@@ -1022,6 +1111,8 @@ function deployDialog(det, o = {}) {
1022
1111
  if (!n) return invalid("name", "Give the site a name");
1023
1112
  if (!v.server) return invalid("server", "Pick a server");
1024
1113
  const domains = v.domains.split(/[\s,]+/).filter(Boolean);
1114
+ // The server's default would leave it unrouted though the box is unticked.
1115
+ if (!v.noDomain && !domains.length && !p?.domains?.length && tunnelOnly(v.server)) return invalid("domains", `${v.server} gives new sites no domain: name one, or tick No domain`);
1025
1116
  const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
1026
1117
  if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
1027
1118
  if (v.spa && !p?.spa) argv.push("--spa");
@@ -1278,31 +1369,75 @@ function makeDefault(n) {
1278
1369
 
1279
1370
  function renderNav() {
1280
1371
  const st = S.state;
1281
- const list = st.servers;
1282
- const own = list.filter((s) => s.registered);
1283
- const aliases = list.filter((s) => !s.registered);
1372
+ // Reachable first, then refused, then down; unchecked last. sort() is stable, so ties keep config order.
1373
+ const list = st.servers.slice().sort((a, b) => reachRank(a.name) - reachRank(b.name));
1374
+ // Down servers sit in their own group, closed until opened, so the rest stay in view.
1375
+ const isDown = (s) => S.reach[s.name]?.state === "down";
1376
+ const own = list.filter((s) => s.registered && !isDown(s));
1377
+ const aliases = list.filter((s) => !s.registered && !isDown(s));
1378
+ const down = list.filter(isDown);
1284
1379
  const q = S.filter.toLowerCase();
1285
1380
  const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
1286
1381
  const item = (s) => {
1287
1382
  const tunnels = openTunnels(s.name).length;
1288
- const head = `<button type="button" class="srv ${s.name === S.sel ? "on" : ""}" data-f="srv:${esc(s.name)}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
1289
- <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1290
- <div class="t">${esc(s.target)}${tunnels ? ` · <span class="live">${plural(tunnels, "tunnel")}</span>` : ""}</div></button>`;
1383
+ const r = S.reach[s.name];
1384
+ const why = r?.state === "login" ? `<span class="x"> · <span class="refused">login refused</span></span>` : "";
1385
+ const head = `<button type="button" class="srv ${s.name === S.sel ? "on" : ""} ${r?.state === "down" ? "off" : ""}" data-f="srv:${esc(s.name)}" ${on(() => go(s.name, S.sel === s.name ? S.tab : "sites"))}>
1386
+ ${reachDot(s.name)}<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1387
+ <div class="t"><span class="h">${esc(s.target)}</span>${why}${tunnels ? `<span class="x"> · <span class="live">${plural(tunnels, "tunnel")}</span></span>` : ""}</div></button>`;
1291
1388
  return s.name === S.sel ? `${head}<div class="sections" role="list" aria-label="${esc(s.name)}">${sectionButtons(s.name, "sec")}</div>` : head;
1292
1389
  };
1293
- const group = (title, l) => {
1390
+ const group = (key, title, l) => {
1391
+ if (!l.length) return "";
1294
1392
  const shown = l.filter(hit);
1295
- return l.length ? `<h3>${title}${q ? `<span>${shown.length}/${l.length}</span>` : ""}</h3>${shown.map(item).join("") || `<div class="nav-none">no match</div>`}` : "";
1393
+ // A closed group still shows the open server; a filter opens every group.
1394
+ const closed = navClosed.has(key) && !q;
1395
+ const rows = closed ? shown.filter((s) => s.name === S.sel) : shown;
1396
+ return `<h3><button type="button" class="grp" aria-expanded="${!closed}" ${on(() => toggleGroup(key))}><span class="caret" aria-hidden="true"></span>${title}</button><span>${q ? `${shown.length}/` : ""}${l.length}</span></h3>
1397
+ ${rows.map(item).join("") || (closed ? "" : `<div class="nav-none">no match</div>`)}`;
1296
1398
  };
1297
1399
  $("#filter").hidden = list.length < 8;
1298
1400
  $("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
1299
- $("#navlist").innerHTML = `${group("Servers", own)}${group("From ~/.ssh/config", aliases)}
1401
+ $("#navlist").innerHTML = `${group("own", "Servers", own)}${group("ssh", "From ~/.ssh/config", aliases)}${group("down", "Unreachable", down)}
1300
1402
  ${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
1403
+ $("#navfoot").hidden = !list.length;
1404
+ $("#navfoot").innerHTML = reachSummary(list);
1301
1405
  $("#pick").innerHTML = `<select aria-label="Server" onchange="go(this.value || null)">
1302
- <option value="">Pick a server…</option>${list.map((s) => `<option value="${esc(s.name)}" ${s.name === S.sel ? "selected" : ""}>${esc(s.name)} · ${esc(s.target)}</option>`).join("")}
1406
+ <option value="">Pick a server…</option>${list.map((s) => `<option value="${esc(s.name)}" ${s.name === S.sel ? "selected" : ""}>${esc(s.name)} · ${esc(s.target)}${S.reach[s.name] && S.reach[s.name].state !== "ok" ? ` · ${REACH_DOT[S.reach[s.name].state][1]}` : ""}</option>`).join("")}
1303
1407
  </select>`;
1304
1408
  }
1305
1409
 
1410
+ const reachRank = (n) => ({ ok: 0, login: 1, down: 2 })[S.reach[n]?.state] ?? 3;
1411
+
1412
+ /** The sidebar's foot: what the login check is doing, or what it found and when. */
1413
+ function reachSummary(list) {
1414
+ const btnHtml = `<button type="button" class="sm ghost recheck" ${S.reaching ? "disabled" : ""} title="Try landfall's ssh login on every server again (lf server list --check)" ${on(() => loadReach(true))}><span class="${S.reaching ? "spin" : "ico"}" aria-hidden="true">${S.reaching ? "" : "↻"}</span>${S.reaching ? "Checking" : "Check"}</button>`;
1415
+ if (S.reaching) return `<div class="what" role="status">Trying ssh login on ${plural(list.length, "server")}…</div>${btnHtml}`;
1416
+ if (!S.reachedAt) return `<div class="what">Not checked yet</div>${btnHtml}`;
1417
+ const count = { ok: 0, login: 0, down: 0 };
1418
+ for (const s of list) if (S.reach[s.name]) count[S.reach[s.name].state]++;
1419
+ const part = (k, word) => (count[k] ? `<span title="${esc(word)}"><span class="dot ${REACH_DOT[k][0]}"></span>${count[k]} ${word}</span>` : "");
1420
+ const at = S.reachedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1421
+ return `<div class="what" role="status"><div class="counts">${part("ok", "reachable")}${part("login", "refused")}${part("down", "down")}</div>
1422
+ <div class="when">checked ${at} · again every minute</div></div>${btnHtml}`;
1423
+ }
1424
+
1425
+ // Unreachable starts folded on every load; only the other groups remember their state.
1426
+ const navClosed = new Set([...(() => {
1427
+ try {
1428
+ return JSON.parse(localStorage.getItem("lf.navClosed") ?? "[]");
1429
+ } catch {
1430
+ return [];
1431
+ }
1432
+ })(), "down"]);
1433
+ function toggleGroup(key) {
1434
+ navClosed.has(key) ? navClosed.delete(key) : navClosed.add(key);
1435
+ try {
1436
+ localStorage.setItem("lf.navClosed", JSON.stringify([...navClosed].filter((k) => k !== "down")));
1437
+ } catch {}
1438
+ renderNav();
1439
+ }
1440
+
1306
1441
  /** The open server's sections as buttons, each with its count. */
1307
1442
  function sectionButtons(n, f) {
1308
1443
  const o = S.overview[n]?.data;
@@ -1435,14 +1570,14 @@ function renderMain() {
1435
1570
  /** No server open: the servers to pick from, and how the same works in a terminal. */
1436
1571
  function homeView() {
1437
1572
  const list = S.state.servers;
1438
- const own = list.filter((s) => s.registered);
1573
+ const own = list.filter((s) => s.registered).sort((a, b) => reachRank(a.name) - reachRank(b.name));
1439
1574
  const tile = (s) => `<button type="button" class="tile" ${on(() => go(s.name, "sites"))}>
1440
- <div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1575
+ <div class="n">${reachDot(s.name)}${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
1441
1576
  <div class="t">${esc(s.target)}</div></button>`;
1442
1577
  const def = list.find((s) => s.isDefault)?.name;
1443
1578
  return `<div class="home">
1444
1579
  <h1>${own.length ? "Your servers" : "Pick a server"}</h1>
1445
- <p>landfall connects only when you open one. Servers are ssh targets: any ~/.ssh/config alias works${own.length < list.length ? ` (${list.length - own.length} in the list on the left)` : ""}.</p>
1580
+ <p>The dot shows whether landfall's ssh login works (hover for why not); nothing else connects until you open one. Servers are ssh targets: any ~/.ssh/config alias works${own.length < list.length ? ` (${list.length - own.length} in the list on the left)` : ""}.</p>
1446
1581
  <div class="grid">${own.map(tile).join("")}<button type="button" class="tile add" ${on(addServer)}>+ Add server</button></div>
1447
1582
  <div class="cli">Everything here runs an <span class="mono">lf</span> command, shown above its output, so you can do the same in a terminal:
1448
1583
  <pre>lf deploy ./dist -d blog <span># a folder, Dockerfile project or image, with HTTPS</span>
@@ -1956,6 +2091,9 @@ function settingsView(n) {
1956
2091
  const P = S.state.paths;
1957
2092
  const ip = x.dns[0]?.ips[0] ?? "<server ip>";
1958
2093
  const records = x.dns.flatMap((r) => [r.apex, r.wildcard]).filter((d) => !d.ok).map((d) => `${esc(d.domain)} A ${esc(ip)}`);
2094
+ const tunnel = x.tunnelOnly;
2095
+ const setTunnel = () =>
2096
+ run(["server", "domain", n, tunnel ? "--no-tunnel-only" : "--tunnel-only"], { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), refreshState()) : undefined) });
1959
2097
  const saved = x.saved ? Object.entries(x.saved).filter(([k, v]) => v !== undefined && k !== "dashboard").map(([k, v]) => [k, `<span class="mono">${esc(String(v))}</span>`]) : [];
1960
2098
  return `
1961
2099
  <div class="card">
@@ -1971,6 +2109,10 @@ function settingsView(n) {
1971
2109
  <p class="faint">New sites get <span class="mono">&lt;site&gt;.${esc(x.domains[0])}</span>; a bare name like <span class="mono">blog</span> means <span class="mono">blog.${esc(x.domains[0])}</span>, and <span class="mono">@</span> means <span class="mono">${esc(x.domains[0])}</span> itself.</p>
1972
2110
  ${records.length ? `<div class="note">Add at your DNS provider:<div class="mono">${records.join("<br>")}</div></div>` : ""}`
1973
2111
  : `<p class="dim">No main domain set in landfall. New sites get a free <span class="mono">&lt;site&gt;.&lt;ip&gt;.sslip.io</span> address until you set one.</p>`}
2112
+ <div class="drow">
2113
+ <div>New sites: ${tunnel ? `<b>no domain</b> <span class="dim">· tunnel only until you give one (Domains… on the site, or deploy with a domain)</span>` : `<b>get a domain</b> <span class="dim">· unless you tick “No domain” when deploying</span>`}${x.tunnelOnlyOwn ? "" : ` <span class="faint">(landfall default)</span>`}</div>
2114
+ <div class="actions">${btn(tunnel ? "Give them a domain" : "Make them tunnel only", setTunnel, "sm", tunnel ? "lf server domain --no-tunnel-only" : "lf server domain --tunnel-only")}</div>
2115
+ </div>
1974
2116
  ${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>
1975
2117
  </div>
1976
2118
  <div class="card">
@@ -2084,10 +2226,14 @@ $("#filter").addEventListener("input", (e) => {
2084
2226
  renderNav();
2085
2227
  });
2086
2228
  $("#add-server").onclick = () => addServer();
2229
+ // Servers go up and down: check again each minute while the page is visible.
2230
+ setInterval(() => document.hidden || loadReach(), 60_000);
2231
+ $("#app-settings").onclick = () => defaultsDialog();
2087
2232
 
2088
2233
  (async () => {
2089
2234
  try {
2090
2235
  await refreshState();
2236
+ loadReach();
2091
2237
  // Jobs started before a reload keep running on the server: list them, and show one still running.
2092
2238
  const old = [];
2093
2239
  for (const info of S.state.jobs.slice().reverse()) {
@@ -0,0 +1,9 @@
1
+ import{A as e,C as t,D as n,E as r,F as i,M as a,N as o,O as s,P as c,S as l,T as u,_ as d,a as ee,b as te,c as ne,d as f,f as re,g as ie,h as p,i as m,j as h,k as g,l as _,m as v,n as y,o as ae,p as oe,r as se,s as ce,t as b,u as le,v as ue,w as de,x as fe,y as pe}from"./cli.mjs";import{spawn as me,spawnSync as x}from"node:child_process";import S from"node:fs";import{homedir as C}from"node:os";import{dirname as w,isAbsolute as he,join as T,resolve as E}from"node:path";import{createHash as D,randomBytes as O,timingSafeEqual as k}from"node:crypto";import{createServer as A}from"node:http";import{Readable as j,Transform as M}from"node:stream";function N(e){let t=E(!e||e===`~`?C():g(e)),n=S.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&P(T(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:w(t)===t?null:w(t),home:C(),dirs:n,detected:p(t)}}const P=e=>{try{return S.statSync(e).isDirectory()}catch{return!1}},F=T(s,`uploads`),I=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function L(e,t){S.mkdirSync(w(e),{recursive:!0}),S.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function R(e){S.mkdirSync(F,{recursive:!0,mode:448});for(let e of S.readdirSync(F)){let t=T(F,e);Date.now()-S.statSync(t).mtimeMs>864e5&&S.rmSync(t,{recursive:!0,force:!0})}let t=S.mkdtempSync(T(F,`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=T(t,`.upload-`+I(n[0].name).pop());await L(e,n[0]);let r=T(t,`files`);S.mkdirSync(r);let i=/\.zip$/i.test(e);if(ge(e,i))throw Error(`${n[0].name} contains symlinks or hard links: upload the folder instead`);let a=i?x(`unzip`,[`-q`,e,`-d`,r]):x(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?x(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return S.rmSync(e),_e(r),{path:z(r),files:ve(r)}}let i=T(t,`files`);for(let[e,t]of n.entries()){let n=I(r[e]||t.name);n.length&&await L(T(i,...n),t)}return{path:z(i),files:n.length}}function ge(e,t){let n=(e,t)=>{let n=x(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 _e(e){for(let t of S.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&S.rmSync(T(t.parentPath,t.name),{force:!0})}function z(e){for(;;){let t=S.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!P(T(e,t[0])))return e;e=T(e,t[0])}}function ve(e){return S.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function ye(e){if(e!=null&&e!==``){if(typeof e!=`string`||!he(e)||!P(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 be(e,t,n={}){let r={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,...n.env};n.password&&Object.assign(r,_(n.password));let i=me(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 xe=S.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),Se=512*1024**2,Ce=1024**2,W=/* @__PURE__ */ new Map,we=/* @__PURE__ */ new Set([`ui`,`web`]);function Te(){let e=T(w(u),`ui-token`);try{let t=S.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=O(24).toString(`hex`);return S.mkdirSync(w(e),{recursive:!0,mode:448}),S.writeFileSync(e,t+`
4
+ `,{mode:384}),t}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=d(),r=/* @__PURE__ */ new Set,{args:i,env:a,stdin:o}=Ee(e),s=be(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:le(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 Ee(e){let t=[],n={},r=[];for(let i=0;i<e.length;i++){let a=e[i],s=a.match(/^--((?:[\w-]+-)?password)(?:=(.*))?$/s);s&&(s[2]!==void 0||i+1<e.length)?n[o(s[1])]=s[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 X=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***`)),Z=e=>({id:e.id,argv:X(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function De(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(Z(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 Oe(i,o){let[s,c,u]=o,d=i.method;if(s===`state`&&d===`GET`){let r=e();return G({version:b,cwd:process.cwd(),project:h(process.cwd())??null,projects:ie(),defaultImage:pe,rules:{loginName:n.source,minPassword:8},paths:{root:l,sites:t,traefik:de,journal:te,proxy:fe},defaults:y(),servers:f().map(e=>({...e,target:oe(e.name),saved:r.servers[e.name]??null})),jobs:[...W.values()].reverse().map(Z)})}if(s===`reach`&&d===`GET`)return G(await re(f().map(e=>e.name),new URL(i.url).searchParams.get(`fresh`)===`1`));if(s===`servers`&&c){let{server:e}=a(c);if(d===`GET`&&!u)return G(await v(e));if(d===`GET`&&u===`firewall`)return G(await ae(e));if(d===`GET`&&u===`config`)return G(await se(c,e));if(d===`GET`&&u===`history`)return G(await ce(e));if(d===`GET`&&u===`facts`)return G(await ee(c,e));if(d===`GET`&&u===`updates`)return G(await ne(e,new URL(i.url).searchParams.get(`refresh`)===`1`));if(d===`GET`&&u===`dns`){let t=(new URL(i.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>r.test(e)).slice(0,50);return G(await m(e,t))}}if(s===`jobs`){if(d===`POST`&&!c){let e=await i.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||we.has(t[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 n;try{n=ye(e.cwd)}catch(e){return K(e,400)}return G(Z(q(t,{server:e.server,password:e.password||void 0,cwd:n})))}let e=c?W.get(c):void 0;if(c&&!e)return K(`no job ${c}`,404);if(e&&d===`GET`&&u===`events`)return De(e,Number(new URL(i.url).searchParams.get(`from`))||0,i.signal);if(e&&d===`GET`&&!u)return G({...Z(e),lines:e.job.lines,from:e.job.dropped});if(e&&d===`POST`&&u===`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(Z(q([...e.argv,`--force`],e.opts)));if(e&&d===`POST`&&u===`stop`)return e.job.stop(),G(Z(e))}if(s===`fs`&&d===`GET`)return G(N(new URL(i.url).searchParams.get(`path`)??void 0));if(s===`upload`&&d===`POST`){let e=await R(await i.formData());return G({...e,detected:N(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&k(Buffer.from(e),Buffer.from(t)),ke=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>D(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function Ae(e){ue(!0);let t=Te(),n=A(Me(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(ke(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(xe,{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 Oe(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)))}),a=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),o=e.port??4777,s=e.port===void 0?[...Array.from({length:20},(e,t)=>o+t),0]:[o],l;for(let n of s){if(!(l=await r(n)))break;if(l.code!==`EADDRINUSE`)throw l;if(await a(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${c.cyan(r)}`),e.open&&i(r),0}}if(l)return console.error(`${c.red(`error:`)} port ${o} is in use by another program; pick another with --port`),1;let u=n.address().port;u!==o&&console.log(c.dim(`port ${o} is in use by another program: using ${u}`));let d=`http://127.0.0.1:${u}/login?t=${t}`;return console.log(`${c.bold(`lf ui`)} ${c.dim(b)} on ${c.cyan(d)}`),console.log(c.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&i(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 je(e,t){let n=0;return e.pipe(new M({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Me(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`)?Se:Ce;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:j.toWeb(je(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=j.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{Ae as webUi};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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,9 +0,0 @@
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};