landfall 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/cli.mjs +93 -85
- package/dist/index.html +177 -23
- package/dist/server-QFQ0pmqK.mjs +8 -0
- package/package.json +1 -1
- package/dist/server-BxTFpFmw.mjs +0 -9
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:
|
|
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
|
-
.
|
|
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. */
|
|
@@ -141,7 +160,32 @@ button.more { padding-inline: 8px; letter-spacing: .05em; }
|
|
|
141
160
|
.tile .t { color: var(--faint); font-size: var(--fs-sm); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
142
161
|
.tile.add { border: 1.5px dashed var(--line-strong); background: none; box-shadow: none; color: var(--dim); display: grid; place-items: center; min-height: 68px; }
|
|
143
162
|
.tile.add:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); transform: none; box-shadow: none; }
|
|
144
|
-
.
|
|
163
|
+
.home-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; color: var(--dim); font-size: var(--fs); margin: 0 0 20px; }
|
|
164
|
+
.home-sub .sep { width: 1px; height: 14px; background: var(--line-strong); }
|
|
165
|
+
.home-sub .spin { width: 10px; height: 10px; vertical-align: -1px; }
|
|
166
|
+
.home-note { font-size: var(--fs-sm); margin: 10px 0 0; }
|
|
167
|
+
.tile .w { font-size: var(--fs-xs); margin-top: 4px; color: var(--faint); }
|
|
168
|
+
.tile .w.login { color: var(--warn); } .tile .w.down { color: var(--bad); }
|
|
169
|
+
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; margin: 28px 0 20px; align-items: start; }
|
|
170
|
+
.cols > .card + .card { margin-top: 0; }
|
|
171
|
+
.recent { list-style: none; margin: 0; padding: 0; }
|
|
172
|
+
.recent li { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
|
|
173
|
+
.recent li:last-child { border-bottom: 0; }
|
|
174
|
+
.recent li > div { flex: 1; min-width: 0; }
|
|
175
|
+
.recent li .mono { font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
176
|
+
.where-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
|
177
|
+
.where-cols > div { padding: 14px 18px 16px; }
|
|
178
|
+
.where-cols > div + div { border-left: 1px solid var(--line); }
|
|
179
|
+
@container (max-width: 720px) { .where-cols > div + div { border-left: 0; border-top: 1px solid var(--line); } }
|
|
180
|
+
.where-cols h3 { margin: 0 0 8px; font-size: var(--fs-xs); font-weight: 500; color: var(--faint); text-transform: uppercase; letter-spacing: .04em; }
|
|
181
|
+
.paths { margin: 0; font-size: var(--fs-sm); }
|
|
182
|
+
.paths dt { overflow-wrap: anywhere; color: var(--text); margin-top: 8px; }
|
|
183
|
+
.paths dt:first-child { margin-top: 0; }
|
|
184
|
+
.paths dd { margin: 1px 0 0; color: var(--dim); }
|
|
185
|
+
.nav-home { margin: 6px 0 2px; grid-template-columns: 14px minmax(0, 1fr); column-gap: 4px; }
|
|
186
|
+
.nav-home .ico { width: 14px; height: 14px; color: var(--faint); }
|
|
187
|
+
.nav-home.on .ico { color: var(--accent); }
|
|
188
|
+
.cli { margin-top: 20px; padding: 16px 18px; border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--hover); font-size: var(--fs); color: var(--dim); }
|
|
145
189
|
.cli pre { margin: 10px 0 0; font: 12.5px/1.7 var(--mono); color: var(--text); white-space: pre-wrap; }
|
|
146
190
|
.cli pre span { color: var(--faint); }
|
|
147
191
|
|
|
@@ -288,12 +332,12 @@ body.dragging::after {
|
|
|
288
332
|
<header>
|
|
289
333
|
<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>
|
|
290
334
|
<div class="project" id="project"></div>
|
|
291
|
-
<button type="button" class="sm ghost" id="app-settings" title="landfall's defaults for every server (lf config)">Defaults</button>
|
|
292
335
|
</header>
|
|
293
336
|
<!-- The filter stays put across renders, so typing keeps its focus. -->
|
|
294
337
|
<nav class="servers" id="nav">
|
|
295
338
|
<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>
|
|
296
339
|
<div id="navlist"></div>
|
|
340
|
+
<div class="nav-foot" id="navfoot" hidden></div>
|
|
297
341
|
</nav>
|
|
298
342
|
<div class="server-pick" id="pick"></div>
|
|
299
343
|
<main id="main"></main>
|
|
@@ -332,6 +376,9 @@ const S = {
|
|
|
332
376
|
dns: {}, // name -> {data: {ips, domains: [{domain, ips, ok, https}]}}
|
|
333
377
|
open: new Set(), // expanded site rows
|
|
334
378
|
filter: "", // server list filter
|
|
379
|
+
reach: {}, // name -> {state: ok|login|down, detail, ms} from /api/reach
|
|
380
|
+
reaching: false,
|
|
381
|
+
reachedAt: null, // when the last check finished
|
|
335
382
|
jobs: new Map(), // id -> {info, lines, after, actions, es}
|
|
336
383
|
job: null, // shown job id
|
|
337
384
|
};
|
|
@@ -512,6 +559,30 @@ async function refreshState() {
|
|
|
512
559
|
S.state = await http("/api/state");
|
|
513
560
|
render();
|
|
514
561
|
}
|
|
562
|
+
/** Which servers take landfall's ssh login; the server caches each answer for 30s unless fresh. */
|
|
563
|
+
async function loadReach(fresh = false) {
|
|
564
|
+
if (S.reaching) return;
|
|
565
|
+
S.reaching = true;
|
|
566
|
+
renderNav();
|
|
567
|
+
try {
|
|
568
|
+
S.reach = await http(`/api/reach${fresh ? "?fresh=1" : ""}`);
|
|
569
|
+
S.reachedAt = new Date();
|
|
570
|
+
} catch (e) {
|
|
571
|
+
toast(`Checking servers failed: ${e.message}`);
|
|
572
|
+
} finally {
|
|
573
|
+
S.reaching = false;
|
|
574
|
+
// The home tiles show the dots too; an open server's view stays as it is.
|
|
575
|
+
S.sel ? renderNav() : render();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const REACH_DOT = { ok: ["up", "reachable"], login: ["warn", "login refused"], down: ["down", "unreachable"] };
|
|
579
|
+
function reachDot(n) {
|
|
580
|
+
const r = S.reach[n];
|
|
581
|
+
if (!r) return `<span class="dot" role="img" aria-label="not checked yet" title="${S.reaching ? "Checking…" : "Not checked yet"}"></span>`;
|
|
582
|
+
const [cls, word] = REACH_DOT[r.state];
|
|
583
|
+
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}`;
|
|
584
|
+
return `<span class="dot ${cls}" role="img" aria-label="${word}" title="${esc(tip)}"></span>`;
|
|
585
|
+
}
|
|
515
586
|
|
|
516
587
|
// ---- jobs ----------------------------------------------------------------
|
|
517
588
|
|
|
@@ -1322,31 +1393,75 @@ function makeDefault(n) {
|
|
|
1322
1393
|
|
|
1323
1394
|
function renderNav() {
|
|
1324
1395
|
const st = S.state;
|
|
1325
|
-
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1396
|
+
// Reachable first, then refused, then down; unchecked last. sort() is stable, so ties keep config order.
|
|
1397
|
+
const list = st.servers.slice().sort((a, b) => reachRank(a.name) - reachRank(b.name));
|
|
1398
|
+
// Down servers sit in their own group, closed until opened, so the rest stay in view.
|
|
1399
|
+
const isDown = (s) => S.reach[s.name]?.state === "down";
|
|
1400
|
+
const own = list.filter((s) => s.registered && !isDown(s));
|
|
1401
|
+
const aliases = list.filter((s) => !s.registered && !isDown(s));
|
|
1402
|
+
const down = list.filter(isDown);
|
|
1328
1403
|
const q = S.filter.toLowerCase();
|
|
1329
1404
|
const hit = (s) => !q || s.name.toLowerCase().includes(q) || s.target.toLowerCase().includes(q) || s.name === S.sel;
|
|
1330
1405
|
const item = (s) => {
|
|
1331
1406
|
const tunnels = openTunnels(s.name).length;
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1407
|
+
const r = S.reach[s.name];
|
|
1408
|
+
const why = r?.state === "login" ? `<span class="x"> · <span class="refused">login refused</span></span>` : "";
|
|
1409
|
+
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"))}>
|
|
1410
|
+
${reachDot(s.name)}<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1411
|
+
<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>`;
|
|
1335
1412
|
return s.name === S.sel ? `${head}<div class="sections" role="list" aria-label="${esc(s.name)}">${sectionButtons(s.name, "sec")}</div>` : head;
|
|
1336
1413
|
};
|
|
1337
|
-
const group = (title, l) => {
|
|
1414
|
+
const group = (key, title, l) => {
|
|
1415
|
+
if (!l.length) return "";
|
|
1338
1416
|
const shown = l.filter(hit);
|
|
1339
|
-
|
|
1417
|
+
// A closed group still shows the open server; a filter opens every group.
|
|
1418
|
+
const closed = navClosed.has(key) && !q;
|
|
1419
|
+
const rows = closed ? shown.filter((s) => s.name === S.sel) : shown;
|
|
1420
|
+
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>
|
|
1421
|
+
${rows.map(item).join("") || (closed ? "" : `<div class="nav-none">no match</div>`)}`;
|
|
1340
1422
|
};
|
|
1341
1423
|
$("#filter").hidden = list.length < 8;
|
|
1342
1424
|
$("#add-server").textContent = list.length < 8 ? "+ Add server" : "+";
|
|
1343
|
-
$("#navlist").innerHTML =
|
|
1425
|
+
$("#navlist").innerHTML = `<button type="button" class="srv nav-home ${S.sel ? "" : "on"}"${S.sel ? "" : ` aria-current="page"`} data-f="home" ${on(() => go(null))}><svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 2.5h4.5v4.5h-4.5zM9 2.5h4.5v4.5h-4.5zM2.5 9h4.5v4.5h-4.5zM9 9h4.5v4.5h-4.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg><div class="n">Overview</div></button>${group("own", "Servers", own)}${group("ssh", "From ~/.ssh/config", aliases)}${group("down", "Unreachable", down)}
|
|
1344
1426
|
${list.length ? "" : `<div class="nav-none">No servers yet.</div>`}`;
|
|
1427
|
+
$("#navfoot").hidden = !list.length;
|
|
1428
|
+
$("#navfoot").innerHTML = reachSummary(list);
|
|
1345
1429
|
$("#pick").innerHTML = `<select aria-label="Server" onchange="go(this.value || null)">
|
|
1346
|
-
<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("")}
|
|
1430
|
+
<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("")}
|
|
1347
1431
|
</select>`;
|
|
1348
1432
|
}
|
|
1349
1433
|
|
|
1434
|
+
const reachRank = (n) => ({ ok: 0, login: 1, down: 2 })[S.reach[n]?.state] ?? 3;
|
|
1435
|
+
|
|
1436
|
+
/** The sidebar's foot: what the login check is doing, or what it found and when. */
|
|
1437
|
+
function reachSummary(list) {
|
|
1438
|
+
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>`;
|
|
1439
|
+
if (S.reaching) return `<div class="what" role="status">Trying ssh login on ${plural(list.length, "server")}…</div>${btnHtml}`;
|
|
1440
|
+
if (!S.reachedAt) return `<div class="what">Not checked yet</div>${btnHtml}`;
|
|
1441
|
+
const count = { ok: 0, login: 0, down: 0 };
|
|
1442
|
+
for (const s of list) if (S.reach[s.name]) count[S.reach[s.name].state]++;
|
|
1443
|
+
const part = (k, word) => (count[k] ? `<span title="${esc(word)}"><span class="dot ${REACH_DOT[k][0]}"></span>${count[k]} ${word}</span>` : "");
|
|
1444
|
+
const at = S.reachedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1445
|
+
return `<div class="what" role="status"><div class="counts">${part("ok", "reachable")}${part("login", "refused")}${part("down", "down")}</div>
|
|
1446
|
+
<div class="when">checked ${at} · again every minute</div></div>${btnHtml}`;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Unreachable starts folded on every load; only the other groups remember their state.
|
|
1450
|
+
const navClosed = new Set([...(() => {
|
|
1451
|
+
try {
|
|
1452
|
+
return JSON.parse(localStorage.getItem("lf.navClosed") ?? "[]");
|
|
1453
|
+
} catch {
|
|
1454
|
+
return [];
|
|
1455
|
+
}
|
|
1456
|
+
})(), "down"]);
|
|
1457
|
+
function toggleGroup(key) {
|
|
1458
|
+
navClosed.has(key) ? navClosed.delete(key) : navClosed.add(key);
|
|
1459
|
+
try {
|
|
1460
|
+
localStorage.setItem("lf.navClosed", JSON.stringify([...navClosed].filter((k) => k !== "down")));
|
|
1461
|
+
} catch {}
|
|
1462
|
+
renderNav();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1350
1465
|
/** The open server's sections as buttons, each with its count. */
|
|
1351
1466
|
function sectionButtons(n, f) {
|
|
1352
1467
|
const o = S.overview[n]?.data;
|
|
@@ -1476,23 +1591,60 @@ function renderMain() {
|
|
|
1476
1591
|
else view.innerHTML = historyView(n);
|
|
1477
1592
|
}
|
|
1478
1593
|
|
|
1479
|
-
/** No server open: the servers
|
|
1594
|
+
/** No server open: the servers, recent deploys, landfall's defaults and every place it writes to. */
|
|
1480
1595
|
function homeView() {
|
|
1481
|
-
const
|
|
1482
|
-
const
|
|
1596
|
+
const st = S.state;
|
|
1597
|
+
const list = st.servers;
|
|
1598
|
+
const own = list.filter((s) => s.registered).sort((a, b) => reachRank(a.name) - reachRank(b.name));
|
|
1599
|
+
const why = (n) => {
|
|
1600
|
+
const r = S.reach[n];
|
|
1601
|
+
return r && r.state !== "ok" ? `<div class="w ${r.state}">${REACH_DOT[r.state][1]}</div>` : "";
|
|
1602
|
+
};
|
|
1483
1603
|
const tile = (s) => `<button type="button" class="tile" ${on(() => go(s.name, "sites"))}>
|
|
1484
|
-
<div class="n">${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1485
|
-
<div class="t">${esc(s.target)}</div
|
|
1486
|
-
const
|
|
1604
|
+
<div class="n">${reachDot(s.name)}${esc(s.name)}${s.isDefault ? `<span class="star" title="default server">★</span>` : ""}</div>
|
|
1605
|
+
<div class="t">${esc(s.target)}</div>${why(s.name)}</button>`;
|
|
1606
|
+
const d = st.defaults;
|
|
1607
|
+
const def = d.default;
|
|
1608
|
+
const count = { ok: 0, login: 0, down: 0 };
|
|
1609
|
+
for (const s of list) if (S.reach[s.name]) count[S.reach[s.name].state]++;
|
|
1610
|
+
const stat = (k, word) => (count[k] ? `<span><span class="dot ${REACH_DOT[k][0]}"></span>${count[k]} ${word}</span>` : "");
|
|
1611
|
+
const status = S.reaching ? `<span><span class="spin"></span> checking ssh login…</span>` : S.reachedAt ? `${stat("ok", "reachable")}${stat("login", "login refused")}${stat("down", "unreachable")}` : "";
|
|
1612
|
+
const recent = (st.projects ?? []).slice(0, 6);
|
|
1613
|
+
const recentCard = `<div class="card"><h2>Recent deploys ${recent.length ? `<span class="badge">${(st.projects ?? []).length}</span>` : ""}
|
|
1614
|
+
<span class="actions">${btn("Deploy folder…", () => folderDialog(st.cwd), "sm", "Pick a folder, a recent project, or drop one here")}</span></h2>
|
|
1615
|
+
${recent.length
|
|
1616
|
+
? `<ul class="recent">${recent
|
|
1617
|
+
.map((r) => `<li><div><b>${esc(r.name)}</b> <span class="dim">→ ${esc(r.server)}${r.domains[0] ? ` · ${esc(r.domains[0])}` : " · tunnel only"}</span>
|
|
1618
|
+
<div class="faint mono" title="${esc(r.path)}">${esc(tilde(r.path))} · ${ago(r.at)}</div></div>
|
|
1619
|
+
${btn("Deploy again", () => browse(r.path).then((x) => deployDialog(x.detected), (err) => toast(err.message)), "sm", `lf deploy from ${r.path}`)}</li>`)
|
|
1620
|
+
.join("")}</ul>`
|
|
1621
|
+
: `<div class="body dim">Nothing deployed from this machine yet. Folders you deploy show up here to deploy again.</div>`}</div>`;
|
|
1622
|
+
const row = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
1623
|
+
const defaultsCard = `<div class="card"><h2>Defaults <span class="sub">for every server, unless it sets its own</span>
|
|
1624
|
+
<span class="actions">${btn("Edit…", defaultsDialog, "sm", "lf config")}</span></h2>
|
|
1625
|
+
<div class="body"><dl class="kv">
|
|
1626
|
+
${row("Default server", def ? `<a href="#/${encodeURIComponent(def)}">${esc(def)}</a>` : `<span class="dim">none: commands need -s</span>`)}
|
|
1627
|
+
${row("New sites", d.tunnelOnly ? "no domain, tunnel only" : "get a domain")}
|
|
1628
|
+
${row("Let's Encrypt e-mail", d.email ? `<span class="mono">${esc(d.email)}</span>` : `<span class="dim">none</span>`)}
|
|
1629
|
+
${row("Releases kept", esc(String(d.keep)))}
|
|
1630
|
+
${row("Saved in", `<span class="mono">${esc(d.configFile)}</span>`)}
|
|
1631
|
+
</dl></div></div>`;
|
|
1632
|
+
const paths = (l) => `<dl class="paths">${l.map((x) => `<dt class="mono">${esc(x.path)}</dt><dd>${esc(x.what)}</dd>`).join("")}</dl>`;
|
|
1633
|
+
const whereCard = `<div class="card"><h2>Where landfall puts things <span class="sub">nothing it installs is hidden</span></h2>
|
|
1634
|
+
<div class="where-cols"><div><h3>On this machine</h3>${paths(d.locations.local)}</div><div><h3>On each server</h3>${paths(d.locations.server)}</div></div></div>`;
|
|
1487
1635
|
return `<div class="home">
|
|
1488
|
-
<h1
|
|
1489
|
-
<
|
|
1636
|
+
<h1>Overview</h1>
|
|
1637
|
+
<div class="home-sub">${plural(own.length, "server")}${own.length < list.length ? ` <span class="faint">+ ${list.length - own.length} from ~/.ssh/config</span>` : ""}${status ? `<span class="sep"></span>${status}` : ""}</div>
|
|
1490
1638
|
<div class="grid">${own.map(tile).join("")}<button type="button" class="tile add" ${on(addServer)}>+ Add server</button></div>
|
|
1639
|
+
<p class="faint home-note">The dot shows whether landfall's ssh login works (hover for why not); nothing else connects until you open a server. Any ~/.ssh/config alias works as a server.</p>
|
|
1640
|
+
<div class="cols">${recentCard}${defaultsCard}</div>
|
|
1641
|
+
${whereCard}
|
|
1491
1642
|
<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:
|
|
1492
1643
|
<pre>lf deploy ./dist -d blog <span># a folder, Dockerfile project or image, with HTTPS</span>
|
|
1493
1644
|
lf ls${def ? "" : " -s <server>"} <span># sites, and what else Traefik routes</span>
|
|
1494
1645
|
lf logs blog -f <span># follow a site's logs</span>
|
|
1495
1646
|
lf undo <span># revert the last change</span>
|
|
1647
|
+
lf config <span># these defaults, and where landfall puts things</span>
|
|
1496
1648
|
lf help <span># every command</span></pre></div>
|
|
1497
1649
|
</div>`;
|
|
1498
1650
|
}
|
|
@@ -2135,11 +2287,13 @@ $("#filter").addEventListener("input", (e) => {
|
|
|
2135
2287
|
renderNav();
|
|
2136
2288
|
});
|
|
2137
2289
|
$("#add-server").onclick = () => addServer();
|
|
2138
|
-
|
|
2290
|
+
// Servers go up and down: check again each minute while the page is visible.
|
|
2291
|
+
setInterval(() => document.hidden || loadReach(), 60_000);
|
|
2139
2292
|
|
|
2140
2293
|
(async () => {
|
|
2141
2294
|
try {
|
|
2142
2295
|
await refreshState();
|
|
2296
|
+
loadReach();
|
|
2143
2297
|
// Jobs started before a reload keep running on the server: list them, and show one still running.
|
|
2144
2298
|
const old = [];
|
|
2145
2299
|
for (const info of S.state.jobs.slice().reverse()) {
|
|
@@ -0,0 +1,8 @@
|
|
|
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 p,g as m,h,i as g,j as _,k as v,l as y,m as b,n as x,o as re,p as ie,r as ae,s as oe,t as S,u as se,v as ce,w as le,x as ue,y as de}from"./cli.mjs";import{spawnSync as C}from"node:child_process";import w from"node:fs";import{homedir as T}from"node:os";import{dirname as E,isAbsolute as D,join as O,resolve as k}from"node:path";import{createHash as A,randomBytes as j,timingSafeEqual as M}from"node:crypto";import{createServer as N}from"node:http";import{Readable as P,Transform as F}from"node:stream";function I(e){let t=k(!e||e===`~`?T():v(e)),n=w.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&L(O(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:E(t)===t?null:E(t),home:T(),dirs:n,detected:h(t)}}const L=e=>{try{return w.statSync(e).isDirectory()}catch{return!1}},R=O(s,`uploads`),z=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function B(e,t){w.mkdirSync(E(e),{recursive:!0}),w.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function V(e){w.mkdirSync(R,{recursive:!0,mode:448});for(let e of w.readdirSync(R)){let t=O(R,e);Date.now()-w.statSync(t).mtimeMs>864e5&&w.rmSync(t,{recursive:!0,force:!0})}let t=w.mkdtempSync(O(R,`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=O(t,`.upload-`+z(n[0].name).pop());await B(e,n[0]);let r=O(t,`files`);w.mkdirSync(r);let i=/\.zip$/i.test(e);if(H(e,i))throw Error(`${n[0].name} contains symlinks or hard links: upload the folder instead`);let a=i?C(`unzip`,[`-q`,e,`-d`,r]):C(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?C(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return w.rmSync(e),fe(r),{path:U(r),files:pe(r)}}let i=O(t,`files`);for(let[e,t]of n.entries()){let n=z(r[e]||t.name);n.length&&await B(O(i,...n),t)}return{path:U(i),files:n.length}}function H(e,t){let n=(e,t)=>{let n=C(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 fe(e){for(let t of w.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&w.rmSync(O(t.parentPath,t.name),{force:!0})}function U(e){for(;;){let t=w.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!L(O(e,t[0])))return e;e=O(e,t[0])}}function pe(e){return w.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function me(e){if(e!=null&&e!==``){if(typeof e!=`string`||!D(e)||!L(e))throw Error(`cwd must be an existing folder`);return e}}const he=w.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),ge=512*1024**2,_e=1024**2,W=/* @__PURE__ */ new Map,ve=/* @__PURE__ */ new Set([`ui`,`web`]);function ye(){let e=O(E(u),`ui-token`);try{let t=w.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=j(24).toString(`hex`);return w.mkdirSync(E(e),{recursive:!0,mode:448}),w.writeFileSync(e,t+`
|
|
3
|
+
`,{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}=be(e),s=x(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:se(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 be(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(`
|
|
4
|
+
`)&&!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(`
|
|
5
|
+
`)+`
|
|
6
|
+
`:void 0}}const xe=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:xe(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function Se(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
|
|
7
|
+
|
|
8
|
+
`),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 Z(i,o){let[s,c,u]=o,d=i.method;if(s===`state`&&d===`GET`){let r=e();return G({version:S,cwd:process.cwd(),project:_(process.cwd())??null,projects:m(),defaultImage:de,rules:{loginName:n.source,minPassword:8},paths:{root:l,sites:t,traefik:le,journal:te,proxy:ue},defaults:ae(),servers:f().map(e=>({...e,target:ie(e.name),saved:r.servers[e.name]??null})),jobs:[...W.values()].reverse().map(X)})}if(s===`reach`&&d===`GET`)return G(await p(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 b(e));if(d===`GET`&&u===`firewall`)return G(await oe(e));if(d===`GET`&&u===`config`)return G(await g(c,e));if(d===`GET`&&u===`history`)return G(await ne(e));if(d===`GET`&&u===`facts`)return G(await re(c,e));if(d===`GET`&&u===`updates`)return G(await y(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 ee(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`)||ve.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=me(e.cwd)}catch(e){return K(e,400)}return G(X(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 Se(e,Number(new URL(i.url).searchParams.get(`from`))||0,i.signal);if(e&&d===`GET`&&!u)return G({...X(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(X(q([...e.argv,`--force`],e.opts)));if(e&&d===`POST`&&u===`stop`)return e.job.stop(),G(X(e))}if(s===`fs`&&d===`GET`)return G(I(new URL(i.url).searchParams.get(`path`)??void 0));if(s===`upload`&&d===`POST`){let e=await V(await i.formData());return G({...e,detected:I(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&M(Buffer.from(e),Buffer.from(t)),Ce=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>A(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function we(e){ce(!0);let t=ye(),n=N(Ee(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(Ce(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(he,{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 Z(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(S)} 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)};for(let e of[`SIGINT`,`SIGTERM`,`SIGHUP`])process.once(e,t);if(process.execArgv.some(e=>e.includes(`tsx`))){let e=process.ppid;setInterval(()=>process.ppid!==e&&t(),1e3).unref()}})}function Te(e,t){let n=0;return e.pipe(new F({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Ee(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`)?ge:_e;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:P.toWeb(Te(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=P.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{we as webUi};
|
package/package.json
CHANGED
package/dist/server-BxTFpFmw.mjs
DELETED
|
@@ -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,P as s,S as c,T as l,_ as u,a as d,b as f,c as ee,d as te,f as ne,g as re,h as ie,i as p,j as ae,k as oe,l as se,m as ce,n as m,o as h,p as le,r as ue,s as de,t as g,u as _,v,w as y,x as b,y as fe}from"./cli.mjs";import{spawn as pe,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 me,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():o(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:ce(t)}}const P=e=>{try{return S.statSync(e).isDirectory()}catch{return!1}},F=T(n,`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(z(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),he(r),{path:B(r),files:ge(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:B(i),files:n.length}}function z(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 he(e){for(let t of S.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&S.rmSync(T(t.parentPath,t.name),{force:!0})}function B(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 ge(e){return S.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!me(e)||!P(e))throw Error(`cwd must be an existing folder`);return e}}const V=()=>[process.execPath,...process.execArgv,process.argv[1]],H=5e3,U=1e4,W=e=>e.length>U?e.slice(0,U)+`…`:e;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,...n.env};n.password&&Object.assign(r,se(n.password));let i=pe(V()[0],[...V().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>U&&(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(W(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));a.lines.length>H&&(a.dropped+=a.lines.splice(0,a.lines.length-H).length),s()},flush:()=>e&&a.lines.push(W(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=S.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),be=512*1024**2,xe=1024**2,G=/* @__PURE__ */ new Map,Se=/* @__PURE__ */ new Set([`ui`,`web`]);function Ce(){let e=T(w(y),`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 K=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),q=(e,t=500)=>K({error:e?.message??String(e)},t);function J(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:_(e),job:s,listeners:r,opts:t};G.set(n,c);for(let[e,t]of G){if(G.size<=50)break;t.job.code!==void 0&&G.delete(e)}return c}const Y=e=>e[0]===`env`||e[0]===`sites`&&e[1]===`env`,X=/^[A-Za-z_][A-Za-z0-9_]*=/;function we(e){let t=[],n={},r=[];for(let a=0;a<e.length;a++){let o=e[a],s=o.match(/^--((?:[\w-]+-)?password)(?:=(.*))?$/s);s&&(s[2]!==void 0||a+1<e.length)?n[i(s[1])]=s[2]??e[++a]:Y(e)&&X.test(o)&&!o.includes(`
|
|
5
|
-
`)&&!e.includes(`--env-file`)?r.push(o):t.push(o)}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])?`***`:Y(e)&&X.test(t)?t.replace(/=.*/s,`=***`):t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Z=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 Ee(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 De(n,i){let[a,o,s]=i,u=n.method;if(a===`state`&&u===`GET`){let n=oe();return K({version:g,cwd:process.cwd(),project:e(process.cwd())??null,projects:ie(),defaultImage:v,rules:{loginName:r.source,minPassword:8},paths:{root:b,sites:c,traefik:t,journal:fe,proxy:f},defaults:m(),servers:te().map(e=>({...e,target:ne(e.name),saved:n.servers[e.name]??null})),jobs:[...G.values()].reverse().map(Z)})}if(a===`servers`&&o){let{server:e}=ae(o);if(u===`GET`&&!s)return K(await le(e));if(u===`GET`&&s===`firewall`)return K(await h(e));if(u===`GET`&&s===`config`)return K(await ue(o,e));if(u===`GET`&&s===`history`)return K(await de(e));if(u===`GET`&&s===`facts`)return K(await d(o,e));if(u===`GET`&&s===`updates`)return K(await ee(e,new URL(n.url).searchParams.get(`refresh`)===`1`));if(u===`GET`&&s===`dns`){let t=(new URL(n.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>l.test(e)).slice(0,50);return K(await p(e,t))}}if(a===`jobs`){if(u===`POST`&&!o){let e=await n.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||Se.has(t[0]))return q(`argv must be a list of landfall arguments`,400);if([...G.values()].filter(e=>e.job.code===void 0).length>=20)return q(`20 jobs are already running: wait for one to finish or stop one`,429);let r;try{r=_e(e.cwd)}catch(e){return q(e,400)}return K(Z(J(t,{server:e.server,password:e.password||void 0,cwd:r})))}let e=o?G.get(o):void 0;if(o&&!e)return q(`no job ${o}`,404);if(e&&u===`GET`&&s===`events`)return Ee(e,Number(new URL(n.url).searchParams.get(`from`))||0,n.signal);if(e&&u===`GET`&&!s)return K({...Z(e),lines:e.job.lines,from:e.job.dropped});if(e&&u===`POST`&&s===`force`)return e.job.code===void 0||e.argv.includes(`--force`)?q(`only a finished job without --force`,400):[...G.values()].filter(e=>e.job.code===void 0).length>=20?q(`too many jobs running`,429):K(Z(J([...e.argv,`--force`],e.opts)));if(e&&u===`POST`&&s===`stop`)return e.job.stop(),K(Z(e))}if(a===`fs`&&u===`GET`)return K(N(new URL(n.url).searchParams.get(`path`)??void 0));if(a===`upload`&&u===`POST`){let e=await R(await n.formData());return K({...e,detected:N(e.path).detected})}return q(`not found`,404)}const Q=(e,t)=>e.length===t.length&&k(Buffer.from(e),Buffer.from(t)),Oe=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 ke(e){u(!0);let t=Ce(),n=A(je(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(Oe(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 De(e,n.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return q(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)))}),i=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),o=e.port??4777,c=e.port===void 0?[...Array.from({length:20},(e,t)=>o+t),0]:[o],l;for(let n of c){if(!(l=await r(n)))break;if(l.code!==`EADDRINUSE`)throw l;if(await i(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${a.cyan(r)}`),e.open&&s(r),0}}if(l)return console.error(`${a.red(`error:`)} port ${o} is in use by another program; pick another with --port`),1;let d=n.address().port;d!==o&&console.log(a.dim(`port ${o} is in use by another program: using ${d}`));let f=`http://127.0.0.1:${d}/login?t=${t}`;return console.log(`${a.bold(`lf ui`)} ${a.dim(g)} on ${a.cyan(f)}`),console.log(a.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&s(f),new Promise(e=>{let t=()=>{for(let e of G.values())e.job.stop();n.closeAllConnections(),n.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function Ae(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 je(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:j.toWeb(Ae(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{ke as webUi};
|