landfall 0.6.1 → 0.6.2
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 +17 -1
- package/dist/cli.mjs +416 -335
- package/dist/index.html +85 -34
- package/dist/{server-D2_Msm-i.mjs → server-Bbkw_Rxx.mjs} +1 -1
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -932,6 +932,7 @@ function openDlg(cls = "") {
|
|
|
932
932
|
const dlg = $("#dlg");
|
|
933
933
|
if (dlg.open) dlg.close();
|
|
934
934
|
dlg.className = cls;
|
|
935
|
+
dlg.site = undefined;
|
|
935
936
|
return dlg;
|
|
936
937
|
}
|
|
937
938
|
const confirmDlg = (o) => dialog({ ...o, onOk: () => o.onOk() });
|
|
@@ -952,13 +953,17 @@ function setupFlow(args, base = args) {
|
|
|
952
953
|
}
|
|
953
954
|
const todo = (rep(j).checks ?? []).filter((ch) => ch.status === "todo").length;
|
|
954
955
|
return [
|
|
955
|
-
{ label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0] }) },
|
|
956
|
+
{ label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0], after: securityAfter(args[0]) }) },
|
|
956
957
|
{ label: "Change options", run: () => setupWizard(base) },
|
|
957
958
|
];
|
|
958
959
|
},
|
|
959
960
|
});
|
|
960
961
|
}
|
|
961
962
|
|
|
963
|
+
/** After a job that ends with security advice: a way to its fixes. */
|
|
964
|
+
const securityAfter = (n) => (j) =>
|
|
965
|
+
j.info.code === 0 && rep(j).advice?.length ? [{ label: `Security: ${plural(rep(j).advice.length, "thing")} to look at`, run: () => go(n, "system") }] : undefined;
|
|
966
|
+
|
|
962
967
|
function setupWizard(base) {
|
|
963
968
|
const saved = S.state.servers.find((s) => s.name === base[0])?.saved ?? {};
|
|
964
969
|
dialog({
|
|
@@ -1057,7 +1062,7 @@ function addServer() {
|
|
|
1057
1062
|
const then = (j) => (j.info.code === 0 ? void (v.setup ? setupWizard([name]) : go(name)) : undefined);
|
|
1058
1063
|
if (v.password) run(["server", "add", ...args], { server: name, password: v.password, after: then });
|
|
1059
1064
|
else if (v.setup) setupWizard(args);
|
|
1060
|
-
else run(["server", "add", ...args], { server: name, after: () => void go(name) });
|
|
1065
|
+
else run(["server", "add", ...args], { server: name, after: (j) => void go(name, rep(j).advice?.length ? "system" : S.tab) });
|
|
1061
1066
|
},
|
|
1062
1067
|
});
|
|
1063
1068
|
}
|
|
@@ -1124,28 +1129,37 @@ function describe(d) {
|
|
|
1124
1129
|
return what + before;
|
|
1125
1130
|
}
|
|
1126
1131
|
|
|
1127
|
-
/**
|
|
1132
|
+
/**
|
|
1133
|
+
* Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no .landfall.json.
|
|
1134
|
+
* site: an existing site ({ server, name, domains, old }) this folder redeploys.
|
|
1135
|
+
*/
|
|
1128
1136
|
function deployDialog(det, o = {}) {
|
|
1129
1137
|
const p = det.project;
|
|
1138
|
+
const re = o.site;
|
|
1130
1139
|
const servers = S.state.servers.map((s) => ({ value: s.name, label: s.name }));
|
|
1131
|
-
const target = p?.server ?? defaultServer();
|
|
1132
|
-
const name = o.name ?? det.name;
|
|
1140
|
+
const target = re?.server ?? p?.server ?? defaultServer();
|
|
1141
|
+
const name = re?.name ?? o.name ?? det.name;
|
|
1133
1142
|
const main = mainDomain(target);
|
|
1143
|
+
// A redeploy keeps the site's domains unless .landfall.json or the field names others.
|
|
1144
|
+
const keeps = re && !p?.domains ? re.domains : null;
|
|
1134
1145
|
dialog({
|
|
1135
|
-
title: `Deploy ${name}`,
|
|
1136
|
-
text: o.upload ? `Uploaded ${plural(o.files ?? 0, "file")}. Not remembered: drop it again to redeploy.` : `From ${tilde(det.path)}${p ? " (settings from .landfall.json)" : ""}
|
|
1146
|
+
title: re ? `Redeploy ${name}` : `Deploy ${name}`,
|
|
1147
|
+
text: (o.upload ? `Uploaded ${plural(o.files ?? 0, "file")}. Not remembered: drop it again to redeploy.` : `From ${tilde(det.path)}${p ? " (settings from .landfall.json)" : ""}`)
|
|
1148
|
+
+ (re ? `\nGoes live as a new release of ${name} on ${re.server}: volumes keep their data${re.old ? "." : ", and Roll back… returns to the current one."}` : ""),
|
|
1137
1149
|
extra: `<div class="detected">${describe(det)}</div>`,
|
|
1138
1150
|
fields: [
|
|
1139
|
-
{ name: "name", label: "Site name", value: name },
|
|
1151
|
+
{ name: "name", label: "Site name", value: name, hint: re ? "Another name deploys a new site instead." : "" },
|
|
1140
1152
|
{ name: "server", label: "Server", type: "select", options: servers, value: target },
|
|
1141
1153
|
{
|
|
1142
1154
|
name: "domains", label: "Domains", value: (p?.domains ?? []).join(" "),
|
|
1143
|
-
placeholder: autoDomain(target, name) || "example.com www.example.com",
|
|
1144
|
-
hint:
|
|
1155
|
+
placeholder: keeps ? keeps.join(" ") || "none" : autoDomain(target, name) || "example.com www.example.com",
|
|
1156
|
+
hint: keeps
|
|
1157
|
+
? `Empty: keeps ${keeps.length ? keeps.join(", ") : "no domain (tunnel only)"}.`
|
|
1158
|
+
: main
|
|
1145
1159
|
? `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.`
|
|
1146
1160
|
: "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
|
|
1147
1161
|
},
|
|
1148
|
-
{ 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." },
|
|
1162
|
+
{ name: "noDomain", label: "No domain: reach it through a tunnel only", type: "check", value: p?.domains ? !p.domains.length : re ? false : !!tunnelOnly(target), hint: "Nothing is served on the internet. Give it a domain later with Domains… on the site." },
|
|
1149
1163
|
...(det.kind === "static"
|
|
1150
1164
|
? [
|
|
1151
1165
|
{ name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
|
|
@@ -1164,14 +1178,14 @@ function deployDialog(det, o = {}) {
|
|
|
1164
1178
|
],
|
|
1165
1179
|
},
|
|
1166
1180
|
],
|
|
1167
|
-
ok: "Deploy",
|
|
1181
|
+
ok: re ? "Redeploy" : "Deploy",
|
|
1168
1182
|
onOk: (v) => {
|
|
1169
1183
|
const n = v.name.trim();
|
|
1170
1184
|
if (!n) return invalid("name", "Give the site a name");
|
|
1171
1185
|
if (!v.server) return invalid("server", "Pick a server");
|
|
1172
1186
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
1173
1187
|
// The server's default would leave it unrouted though the box is unticked.
|
|
1174
|
-
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`);
|
|
1188
|
+
if (!v.noDomain && !domains.length && !p?.domains?.length && !(keeps && n === re.name && v.server === re.server) && tunnelOnly(v.server)) return invalid("domains", `${v.server} gives new sites no domain: name one, or tick No domain`);
|
|
1175
1189
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
|
|
1176
1190
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
1177
1191
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
@@ -1216,8 +1230,11 @@ function deployHere() {
|
|
|
1216
1230
|
browse(S.state.cwd).then((b) => deployDialog(b.detected), (e) => toast(e.message));
|
|
1217
1231
|
}
|
|
1218
1232
|
|
|
1219
|
-
/**
|
|
1220
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* Pick a folder on this machine: recent projects, a folder browser, or drop/choose files to upload.
|
|
1235
|
+
* site: an existing site the folder redeploys (one this machine does not remember a folder for).
|
|
1236
|
+
*/
|
|
1237
|
+
async function folderDialog(path, site) {
|
|
1221
1238
|
let b;
|
|
1222
1239
|
try {
|
|
1223
1240
|
b = await browse(path);
|
|
@@ -1225,12 +1242,15 @@ async function folderDialog(path) {
|
|
|
1225
1242
|
return toast(e.message);
|
|
1226
1243
|
}
|
|
1227
1244
|
const dlg = openDlg("wide");
|
|
1228
|
-
|
|
1245
|
+
// Files dropped while it is open redeploy the site too.
|
|
1246
|
+
dlg.site = site;
|
|
1247
|
+
const recent = site ? [] : (S.state.projects ?? []).slice(0, 8);
|
|
1229
1248
|
const parts = b.path.split("/").filter(Boolean);
|
|
1230
1249
|
const crumbs = `<button type="button" class="ghost sm" data-path="/">/</button>` + parts.map((x, i) =>
|
|
1231
1250
|
`<button type="button" class="ghost sm" data-path="${esc("/" + parts.slice(0, i + 1).join("/"))}">${esc(x)}</button>`).join(`<span class="faint">/</span>`);
|
|
1232
1251
|
dlg.innerHTML = `<form method="dialog">
|
|
1233
|
-
<h2
|
|
1252
|
+
<h2>${site ? `Redeploy ${esc(site.name)}` : "Deploy a folder"}</h2>
|
|
1253
|
+
${site ? `<div class="text">This machine does not know which folder ${esc(site.name)} on ${esc(site.server)} was deployed from. Pick it: this machine remembers it, so the next redeploy is one click. Dropped files are not remembered.</div>` : ""}
|
|
1234
1254
|
${recent.length ? `<fieldset><legend>Recent</legend><div class="list">${recent
|
|
1235
1255
|
.map((r, i) => `<button type="button" class="row" data-recent="${i}" title="Deploy again"><b>${esc(r.name)}</b><span class="dim">→ ${esc(r.server)}${r.domains[0] ? ` · ${esc(r.domains[0])}` : ""}</span><span class="faint mono">${esc(tilde(r.path))}</span></button>`)
|
|
1236
1256
|
.join("")}</div></fieldset>` : ""}
|
|
@@ -1244,24 +1264,24 @@ async function folderDialog(path) {
|
|
|
1244
1264
|
<label>choose a .zip<input type="file" id="pick-zip" accept=".zip,.tar,.tgz,.gz" hidden></label></div>
|
|
1245
1265
|
<div class="btns">
|
|
1246
1266
|
<button type="button" class="ghost" id="dlg-cancel">Cancel</button>
|
|
1247
|
-
<button type="submit" class="primary"
|
|
1267
|
+
<button type="submit" class="primary">${site ? "Redeploy from this folder…" : "Deploy this folder…"}</button>
|
|
1248
1268
|
</div>
|
|
1249
1269
|
</form>`;
|
|
1250
1270
|
$("#dlg-cancel", dlg).onclick = () => dlg.close();
|
|
1251
|
-
$("form", dlg).onsubmit = (e) => (e.preventDefault(), deployDialog(b.detected));
|
|
1271
|
+
$("form", dlg).onsubmit = (e) => (e.preventDefault(), deployDialog(b.detected, { site }));
|
|
1252
1272
|
$("form", dlg).onclick = (e) => {
|
|
1253
1273
|
const el = e.target.closest("[data-path],[data-recent]");
|
|
1254
1274
|
if (!el) return;
|
|
1255
|
-
if (el.dataset.path) return void folderDialog(el.dataset.path);
|
|
1275
|
+
if (el.dataset.path) return void folderDialog(el.dataset.path, site);
|
|
1256
1276
|
browse(recent[el.dataset.recent].path).then((x) => deployDialog(x.detected), (err) => toast(err.message));
|
|
1257
1277
|
};
|
|
1258
1278
|
$("#pick-dir", dlg).onchange = (e) => {
|
|
1259
1279
|
const files = [...e.target.files];
|
|
1260
|
-
upload(files.filter((f) => !skipped(f.webkitRelativePath)).map((f) => [f, f.webkitRelativePath || f.name]), files[0]?.webkitRelativePath.split("/")[0]);
|
|
1280
|
+
upload(files.filter((f) => !skipped(f.webkitRelativePath)).map((f) => [f, f.webkitRelativePath || f.name]), files[0]?.webkitRelativePath.split("/")[0], site);
|
|
1261
1281
|
};
|
|
1262
1282
|
$("#pick-zip", dlg).onchange = (e) => {
|
|
1263
1283
|
const f = e.target.files[0];
|
|
1264
|
-
if (f) upload([[f, f.name]], f.name);
|
|
1284
|
+
if (f) upload([[f, f.name]], f.name, site);
|
|
1265
1285
|
};
|
|
1266
1286
|
if (!dlg.open) dlg.showModal();
|
|
1267
1287
|
}
|
|
@@ -1285,7 +1305,7 @@ async function droppedFiles(roots) {
|
|
|
1285
1305
|
return out;
|
|
1286
1306
|
}
|
|
1287
1307
|
|
|
1288
|
-
async function upload(pairs, label = "site") {
|
|
1308
|
+
async function upload(pairs, label = "site", site) {
|
|
1289
1309
|
if (!pairs.length) return toast("Nothing to upload");
|
|
1290
1310
|
const fd = new FormData();
|
|
1291
1311
|
for (const [f, path] of pairs) fd.append("file", f), fd.append("path", path);
|
|
@@ -1295,7 +1315,7 @@ async function upload(pairs, label = "site") {
|
|
|
1295
1315
|
const data = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
|
|
1296
1316
|
if (!r.ok) throw new Error(data.error);
|
|
1297
1317
|
toast(`Uploaded ${plural(data.files, "file")}`);
|
|
1298
|
-
deployDialog(data.detected, { upload: true, files: data.files, name: slugify(label) });
|
|
1318
|
+
deployDialog(data.detected, { upload: true, files: data.files, name: slugify(label), site });
|
|
1299
1319
|
} catch (e) {
|
|
1300
1320
|
toast(`Upload failed: ${e.message}`);
|
|
1301
1321
|
}
|
|
@@ -1318,7 +1338,9 @@ window.addEventListener("drop", (e) => {
|
|
|
1318
1338
|
const roots = [...e.dataTransfer.items].map((i) => i.webkitGetAsEntry?.()).filter(Boolean);
|
|
1319
1339
|
const label = roots[0]?.name ?? e.dataTransfer.files[0]?.name;
|
|
1320
1340
|
const files = roots.length ? droppedFiles(roots) : Promise.resolve([...e.dataTransfer.files].map((f) => [f, f.name]));
|
|
1321
|
-
|
|
1341
|
+
const dlg = $("#dlg");
|
|
1342
|
+
const site = dlg.open ? dlg.site : undefined;
|
|
1343
|
+
files.then((pairs) => upload(pairs, label, site), (err) => toast(err.message));
|
|
1322
1344
|
});
|
|
1323
1345
|
|
|
1324
1346
|
// ---- domains & login ---------------------------------------------------------
|
|
@@ -1625,7 +1647,7 @@ function renderMain() {
|
|
|
1625
1647
|
{ label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
|
|
1626
1648
|
!k || k.docker ? { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) } : null,
|
|
1627
1649
|
!k || k.admin ? "-" : null,
|
|
1628
|
-
!k || k.admin ? { label: "Reboot…", cls: "danger", hint: "lf reboot", run: () =>
|
|
1650
|
+
!k || k.admin ? { label: "Reboot…", cls: "danger", hint: "lf reboot", run: () => rebootDlg(n) } : null,
|
|
1629
1651
|
s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
|
|
1630
1652
|
s?.registered ? "-" : null,
|
|
1631
1653
|
s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
|
|
@@ -1774,7 +1796,8 @@ function sitesView(n, o) {
|
|
|
1774
1796
|
const acts = [
|
|
1775
1797
|
proj && !m.adopted
|
|
1776
1798
|
? btn("Redeploy", () => confirmDlg({ title: `Redeploy ${name}?`, text: `From ${tilde(proj.path)}${proj.kind === "static" ? " (runs its build first, if it has one)" : ""}.`, ok: "Deploy", onOk: () => run(["deploy", "-y", ...sv(n)], { server: n, cwd: proj.path, after: openAfter }) }), "sm primary", `lf deploy in ${proj.path}`)
|
|
1777
|
-
: ""
|
|
1799
|
+
: m.adopted || m.kind === "image" ? ""
|
|
1800
|
+
: btn("Redeploy…", () => folderDialog(S.state.cwd, { server: n, name, domains: m.domains ?? [], old }), "sm", "Pick the folder it was deployed from"),
|
|
1778
1801
|
old ? "" : btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the logs of all its services"),
|
|
1779
1802
|
menuBtn([
|
|
1780
1803
|
old ? null : { label: "Restart", hint: "lf restart", run: () => run(["restart", name, ...sv(n)]) },
|
|
@@ -1805,7 +1828,7 @@ function sitesView(n, o) {
|
|
|
1805
1828
|
<dt>On ${esc(n)}</dt><dd>${siteFiles(m, state)}</dd>
|
|
1806
1829
|
${m.adopted ? "" : `<dt>Source</dt><dd>${proj
|
|
1807
1830
|
? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· redeploy runs there</span>`
|
|
1808
|
-
: `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved)"}</span>`}</dd>`}
|
|
1831
|
+
: `<span class="dim">not deployed from this machine${m.kind === "image" ? "" : " (or the folder moved): Redeploy… picks the folder"}</span>`}</dd>`}
|
|
1809
1832
|
<dt>Kind</dt><dd>${esc(kind)}${main || !managed ? "" : ` <span class="dim">· domains set in compose.yaml</span>`}</dd>
|
|
1810
1833
|
${managed ? `<dt>Login</dt><dd>${m.auth?.length ? `${esc(m.auth.map((a) => a.split(":")[0]).join(", "))} (basic auth)` : `<span class="dim">public</span>`}</dd>` : ""}
|
|
1811
1834
|
<dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
|
|
@@ -2157,7 +2180,32 @@ function systemView(n) {
|
|
|
2157
2180
|
<tbody>${banned.map((b) => `<tr><td class="mono">${esc(b.ip)}</td><td>${esc(b.jail)}</td><td class="acts">${may(adm, btn("Unban", () => go_(["unban", b.ip]), "sm"))}</td></tr>`).join("") || `<tr><td colspan="3" class="dim">nobody banned right now</td></tr>`}</tbody></table></div></div>`
|
|
2158
2181
|
: `<div class="card"><h2>Bans</h2><div class="body dim">${i.fail2ban === "missing" ? "fail2ban is not installed." : "fail2ban is not running."}</div></div>`;
|
|
2159
2182
|
|
|
2160
|
-
return basics + services + keys + bans;
|
|
2183
|
+
return securityCard(n, i) + basics + services + keys + bans;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** What `lf security` says, with its fixes as buttons. */
|
|
2187
|
+
function rebootDlg(n) {
|
|
2188
|
+
confirmDlg({ title: `Reboot ${n}?`, text: "Its sites are offline for a minute or two. Waits until it is back and checks its containers run again.", ok: "Reboot", danger: true, onOk: () => run(["reboot", ...sv(n), "-y"], { server: n }) });
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function securityCard(n, i) {
|
|
2192
|
+
const adv = i.advice ?? [];
|
|
2193
|
+
const cls = { risk: "bad", warn: "warn", info: "" };
|
|
2194
|
+
const fix = (a) => {
|
|
2195
|
+
const f = a.fix;
|
|
2196
|
+
if (!f) return "";
|
|
2197
|
+
// A key pair for someone else: say where it goes before making it.
|
|
2198
|
+
if (f.user) return btn(`${f.label}…`, () => addKeyDlg(n, f.user, true), "sm");
|
|
2199
|
+
if (f.argv[0] === "reboot") return btn(`${f.label}…`, () => rebootDlg(n), "sm");
|
|
2200
|
+
return btn(f.confirm ? `${f.label}…` : f.label, () => f.confirm
|
|
2201
|
+
? confirmDlg({ title: `${a.title}: ${f.label.toLowerCase()} on ${n}?`, text: f.confirm, ok: f.label, onOk: () => run([...f.argv, "-y"], { server: n }) })
|
|
2202
|
+
: run(f.argv, { server: n }), "sm");
|
|
2203
|
+
};
|
|
2204
|
+
const risks = adv.filter((a) => a.level === "risk").length;
|
|
2205
|
+
return `<div class="card"><h2>Security ${adv.length ? `<span class="badge ${risks ? "bad" : "warn"}">${plural(adv.length, "thing")} to look at</span>` : `<span class="badge ok">nothing to fix</span>`} <span class="sub">lf security</span></h2>
|
|
2206
|
+
${adv.length ? `<div class="scroll"><table><tbody>${adv.map((a) => `<tr><td><span class="badge ${cls[a.level]}">${a.level}</span></td>
|
|
2207
|
+
<td><b>${esc(a.title)}</b><div class="dim">${esc(a.why)}</div></td><td class="acts">${fix(a)}</td></tr>`).join("")}</tbody></table></div>`
|
|
2208
|
+
: `<div class="body dim">SSH takes keys only${i.security.firewall === "active" ? ", the firewall is on" : ""}${i.fail2ban === "running" ? ", fail2ban runs" : ""}${i.security.autoUpdates ? " and security updates install themselves" : ""}.</div>`}</div>`;
|
|
2161
2209
|
}
|
|
2162
2210
|
|
|
2163
2211
|
function renameHost(n, old) {
|
|
@@ -2219,19 +2267,22 @@ function logLimitDlg(n, d) {
|
|
|
2219
2267
|
},
|
|
2220
2268
|
});
|
|
2221
2269
|
}
|
|
2222
|
-
function addKeyDlg(n) {
|
|
2270
|
+
function addKeyDlg(n, user = "", generate = false) {
|
|
2223
2271
|
dialog({
|
|
2224
2272
|
title: `Add an SSH key to ${n}`,
|
|
2225
|
-
text: "Paste a public key (the .pub file, one line). Whoever holds
|
|
2273
|
+
text: "Paste a public key (the .pub file, one line), or make a new pair on this computer. Whoever holds the private key can log in.",
|
|
2226
2274
|
fields: [
|
|
2227
|
-
{ name: "
|
|
2228
|
-
{ name: "
|
|
2275
|
+
{ name: "generate", label: "Make a new key pair", type: "check", value: generate, hint: `Saved as ~/.ssh/${n}_<user>, without a passphrase (add one with ssh-keygen -p). Hand it to whoever logs in as that user.` },
|
|
2276
|
+
{ name: "key", label: "Public key", type: "textarea", value: "", placeholder: "ssh-ed25519 AAAA… name@laptop", hint: "Not needed for a new pair." },
|
|
2277
|
+
{ name: "user", label: "User", value: user, placeholder: "the login user", hint: "Whose account it logs in to. Empty: the user landfall logs in as." },
|
|
2229
2278
|
],
|
|
2230
2279
|
ok: "Add key",
|
|
2231
2280
|
onOk: (v) => {
|
|
2281
|
+
const u = v.user.trim() ? ["--user", v.user.trim()] : [];
|
|
2282
|
+
if (v.generate) return void run(["keys", "add", "--generate", ...u, ...sv(n)], { server: n });
|
|
2232
2283
|
const key = v.key.trim().split("\n")[0];
|
|
2233
2284
|
if (!/^(\S+\s+)?(ssh-|ecdsa-|sk-)\S+ AAAA/.test(key)) return { error: "That does not look like a public key.", field: "key" };
|
|
2234
|
-
run(["keys", "add", key, ...
|
|
2285
|
+
run(["keys", "add", key, ...u, ...sv(n)], { server: n });
|
|
2235
2286
|
},
|
|
2236
2287
|
});
|
|
2237
2288
|
}
|
|
@@ -5,4 +5,4 @@ import{A as e,B as t,C as n,D as r,E as i,F as a,H as o,I as s,L as c,M as l,N a
|
|
|
5
5
|
`)+`
|
|
6
6
|
`:void 0}}const Oe=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:Oe(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function ke(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
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(t,a){let[o,s,l]=a,f=t.method;if(o===`state`&&f===`GET`){let t=c();return G({version:y,cwd:process.cwd(),project:te(process.cwd())??null,projects:n(),defaultImage:i,rules:{loginName:d.source,minPassword:8},paths:{root:ce,sites:e,traefik:se,journal:r,proxy:ee},defaults:me(),servers:b().map(e=>({...e,target:_(e.name),saved:t.servers[e.name]??null})),jobs:[...W.values()].reverse().map(X)})}if(o===`reach`&&f===`GET`)return G(await C(b().map(e=>e.name),new URL(t.url).searchParams.get(`fresh`)===`1`));if(o===`servers`&&s){let{server:e}=w(s);if(f===`GET`&&!l)return G(await S(e));if(f===`GET`&&l===`firewall`)return G(await le(e));if(f===`GET`&&l===`config`)return G(await g(s,e));if(f===`GET`&&l===`history`)return G(await ge(e));if(f===`GET`&&l===`facts`)return G(await v(s,e));if(f===`GET`&&l===`stats`)return G(await re(e));if(f===`GET`&&l===`disk`)return G(await fe(e));if(f===`GET`&&l===`prune`)return G(await ne(e));if(f===`GET`&&l===`system`)return G(await pe(e));if(f===`GET`&&l===`updates`)return G(await ae(e,new URL(t.url).searchParams.get(`refresh`)===`1`));if(f===`GET`&&l===`traffic`)return G(await ue(e,ie(new URL(t.url).searchParams.get(`since`)??`24h`)));if(f===`GET`&&l===`certs`)return G(await oe(e));if(f===`GET`&&l===`dns`){let n=(new URL(t.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>u.test(e)).slice(0,50);return G(await he(e,n))}}if(o===`jobs`){if(f===`POST`&&!s){let e=await t.json(),n=e.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||Te.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=xe(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=s?W.get(s):void 0;if(s&&!e)return K(`no job ${s}`,404);if(e&&f===`GET`&&l===`events`)return ke(e,Number(new URL(t.url).searchParams.get(`from`))||0,t.signal);if(e&&f===`GET`&&!l)return G({...X(e),lines:e.job.lines,from:e.job.dropped});if(e&&f===`POST`&&l===`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`&&l===`stop`)return e.job.stop(),G(X(e))}if(o===`fs`&&f===`GET`)return G(R(new URL(t.url).searchParams.get(`path`)??void 0));if(o===`upload`&&f===`POST`){let e=await _e(await t.formData());return G({...e,detected:R(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&P(Buffer.from(e),Buffer.from(t)),Ae=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>M(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function je(e){p(!0);let t=Ee(),n=F(Ne(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(Ae(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(Se,{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)))}),i=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),a=e.port??4777,s=e.port===void 0?[...Array.from({length:20},(e,t)=>a+t),0]:[a],c;for(let n of s){if(!(c=await r(n)))break;if(c.code!==`EADDRINUSE`)throw c;if(await i(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${m.cyan(r)}`),e.open&&o(r),0}}if(c)return console.error(`${m.red(`error:`)} port ${a} is in use by another program; pick another with --port`),1;let l=n.address().port;l!==a&&console.log(m.dim(`port ${a} is in use by another program: using ${l}`));let u=`http://127.0.0.1:${l}/login?t=${t}`;return console.log(`${m.bold(`lf ui`)} ${m.dim(y)} on ${m.cyan(u)}`),console.log(m.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&o(u),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 Me(e,t){let n=0;return e.pipe(new L({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Ne(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`)?Ce:we;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:I.toWeb(Me(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=I.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{je as webUi};
|
|
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(t,a){let[o,s,l]=a,f=t.method;if(o===`state`&&f===`GET`){let t=c();return G({version:y,cwd:process.cwd(),project:te(process.cwd())??null,projects:n(),defaultImage:i,rules:{loginName:d.source,minPassword:8},paths:{root:ce,sites:e,traefik:se,journal:r,proxy:ee},defaults:me(),servers:b().map(e=>({...e,target:_(e.name),saved:t.servers[e.name]??null})),jobs:[...W.values()].reverse().map(X)})}if(o===`reach`&&f===`GET`)return G(await C(b().map(e=>e.name),new URL(t.url).searchParams.get(`fresh`)===`1`));if(o===`servers`&&s){let{server:e}=w(s);if(f===`GET`&&!l)return G(await S(e));if(f===`GET`&&l===`firewall`)return G(await le(e));if(f===`GET`&&l===`config`)return G(await g(s,e));if(f===`GET`&&l===`history`)return G(await ge(e));if(f===`GET`&&l===`facts`)return G(await v(s,e));if(f===`GET`&&l===`stats`)return G(await re(e));if(f===`GET`&&l===`disk`)return G(await fe(e));if(f===`GET`&&l===`prune`)return G(await ne(e));if(f===`GET`&&l===`system`)return G(await pe(s,e));if(f===`GET`&&l===`updates`)return G(await ae(e,new URL(t.url).searchParams.get(`refresh`)===`1`));if(f===`GET`&&l===`traffic`)return G(await ue(e,ie(new URL(t.url).searchParams.get(`since`)??`24h`)));if(f===`GET`&&l===`certs`)return G(await oe(e));if(f===`GET`&&l===`dns`){let n=(new URL(t.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>u.test(e)).slice(0,50);return G(await he(e,n))}}if(o===`jobs`){if(f===`POST`&&!s){let e=await t.json(),n=e.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||Te.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=xe(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=s?W.get(s):void 0;if(s&&!e)return K(`no job ${s}`,404);if(e&&f===`GET`&&l===`events`)return ke(e,Number(new URL(t.url).searchParams.get(`from`))||0,t.signal);if(e&&f===`GET`&&!l)return G({...X(e),lines:e.job.lines,from:e.job.dropped});if(e&&f===`POST`&&l===`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`&&l===`stop`)return e.job.stop(),G(X(e))}if(o===`fs`&&f===`GET`)return G(R(new URL(t.url).searchParams.get(`path`)??void 0));if(o===`upload`&&f===`POST`){let e=await _e(await t.formData());return G({...e,detected:R(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&P(Buffer.from(e),Buffer.from(t)),Ae=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>M(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function je(e){p(!0);let t=Ee(),n=F(Ne(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(Ae(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(Se,{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)))}),i=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),a=e.port??4777,s=e.port===void 0?[...Array.from({length:20},(e,t)=>a+t),0]:[a],c;for(let n of s){if(!(c=await r(n)))break;if(c.code!==`EADDRINUSE`)throw c;if(await i(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${m.cyan(r)}`),e.open&&o(r),0}}if(c)return console.error(`${m.red(`error:`)} port ${a} is in use by another program; pick another with --port`),1;let l=n.address().port;l!==a&&console.log(m.dim(`port ${a} is in use by another program: using ${l}`));let u=`http://127.0.0.1:${l}/login?t=${t}`;return console.log(`${m.bold(`lf ui`)} ${m.dim(y)} on ${m.cyan(u)}`),console.log(m.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&o(u),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 Me(e,t){let n=0;return e.pipe(new L({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Ne(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`)?Ce:we;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:I.toWeb(Me(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=I.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{je as webUi};
|