landfall 0.6.0 → 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 +23 -4
- package/dist/cli.mjs +426 -345
- package/dist/index.html +99 -38
- 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" },
|
|
@@ -1156,17 +1170,22 @@ function deployDialog(det, o = {}) {
|
|
|
1156
1170
|
{ name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
|
|
1157
1171
|
]
|
|
1158
1172
|
: []),
|
|
1159
|
-
{
|
|
1160
|
-
|
|
1173
|
+
{
|
|
1174
|
+
legend: "Login (optional)",
|
|
1175
|
+
fields: [
|
|
1176
|
+
{ name: "authUser", label: "User", value: "admin", placeholder: "admin" },
|
|
1177
|
+
{ name: "authPassword", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: "Visitors log in with this user and password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
|
|
1178
|
+
],
|
|
1179
|
+
},
|
|
1161
1180
|
],
|
|
1162
|
-
ok: "Deploy",
|
|
1181
|
+
ok: re ? "Redeploy" : "Deploy",
|
|
1163
1182
|
onOk: (v) => {
|
|
1164
1183
|
const n = v.name.trim();
|
|
1165
1184
|
if (!n) return invalid("name", "Give the site a name");
|
|
1166
1185
|
if (!v.server) return invalid("server", "Pick a server");
|
|
1167
1186
|
const domains = v.domains.split(/[\s,]+/).filter(Boolean);
|
|
1168
1187
|
// The server's default would leave it unrouted though the box is unticked.
|
|
1169
|
-
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`);
|
|
1170
1189
|
const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
|
|
1171
1190
|
if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
|
|
1172
1191
|
if (v.spa && !p?.spa) argv.push("--spa");
|
|
@@ -1211,8 +1230,11 @@ function deployHere() {
|
|
|
1211
1230
|
browse(S.state.cwd).then((b) => deployDialog(b.detected), (e) => toast(e.message));
|
|
1212
1231
|
}
|
|
1213
1232
|
|
|
1214
|
-
/**
|
|
1215
|
-
|
|
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) {
|
|
1216
1238
|
let b;
|
|
1217
1239
|
try {
|
|
1218
1240
|
b = await browse(path);
|
|
@@ -1220,12 +1242,15 @@ async function folderDialog(path) {
|
|
|
1220
1242
|
return toast(e.message);
|
|
1221
1243
|
}
|
|
1222
1244
|
const dlg = openDlg("wide");
|
|
1223
|
-
|
|
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);
|
|
1224
1248
|
const parts = b.path.split("/").filter(Boolean);
|
|
1225
1249
|
const crumbs = `<button type="button" class="ghost sm" data-path="/">/</button>` + parts.map((x, i) =>
|
|
1226
1250
|
`<button type="button" class="ghost sm" data-path="${esc("/" + parts.slice(0, i + 1).join("/"))}">${esc(x)}</button>`).join(`<span class="faint">/</span>`);
|
|
1227
1251
|
dlg.innerHTML = `<form method="dialog">
|
|
1228
|
-
<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>` : ""}
|
|
1229
1254
|
${recent.length ? `<fieldset><legend>Recent</legend><div class="list">${recent
|
|
1230
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>`)
|
|
1231
1256
|
.join("")}</div></fieldset>` : ""}
|
|
@@ -1239,24 +1264,24 @@ async function folderDialog(path) {
|
|
|
1239
1264
|
<label>choose a .zip<input type="file" id="pick-zip" accept=".zip,.tar,.tgz,.gz" hidden></label></div>
|
|
1240
1265
|
<div class="btns">
|
|
1241
1266
|
<button type="button" class="ghost" id="dlg-cancel">Cancel</button>
|
|
1242
|
-
<button type="submit" class="primary"
|
|
1267
|
+
<button type="submit" class="primary">${site ? "Redeploy from this folder…" : "Deploy this folder…"}</button>
|
|
1243
1268
|
</div>
|
|
1244
1269
|
</form>`;
|
|
1245
1270
|
$("#dlg-cancel", dlg).onclick = () => dlg.close();
|
|
1246
|
-
$("form", dlg).onsubmit = (e) => (e.preventDefault(), deployDialog(b.detected));
|
|
1271
|
+
$("form", dlg).onsubmit = (e) => (e.preventDefault(), deployDialog(b.detected, { site }));
|
|
1247
1272
|
$("form", dlg).onclick = (e) => {
|
|
1248
1273
|
const el = e.target.closest("[data-path],[data-recent]");
|
|
1249
1274
|
if (!el) return;
|
|
1250
|
-
if (el.dataset.path) return void folderDialog(el.dataset.path);
|
|
1275
|
+
if (el.dataset.path) return void folderDialog(el.dataset.path, site);
|
|
1251
1276
|
browse(recent[el.dataset.recent].path).then((x) => deployDialog(x.detected), (err) => toast(err.message));
|
|
1252
1277
|
};
|
|
1253
1278
|
$("#pick-dir", dlg).onchange = (e) => {
|
|
1254
1279
|
const files = [...e.target.files];
|
|
1255
|
-
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);
|
|
1256
1281
|
};
|
|
1257
1282
|
$("#pick-zip", dlg).onchange = (e) => {
|
|
1258
1283
|
const f = e.target.files[0];
|
|
1259
|
-
if (f) upload([[f, f.name]], f.name);
|
|
1284
|
+
if (f) upload([[f, f.name]], f.name, site);
|
|
1260
1285
|
};
|
|
1261
1286
|
if (!dlg.open) dlg.showModal();
|
|
1262
1287
|
}
|
|
@@ -1280,7 +1305,7 @@ async function droppedFiles(roots) {
|
|
|
1280
1305
|
return out;
|
|
1281
1306
|
}
|
|
1282
1307
|
|
|
1283
|
-
async function upload(pairs, label = "site") {
|
|
1308
|
+
async function upload(pairs, label = "site", site) {
|
|
1284
1309
|
if (!pairs.length) return toast("Nothing to upload");
|
|
1285
1310
|
const fd = new FormData();
|
|
1286
1311
|
for (const [f, path] of pairs) fd.append("file", f), fd.append("path", path);
|
|
@@ -1290,7 +1315,7 @@ async function upload(pairs, label = "site") {
|
|
|
1290
1315
|
const data = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
|
|
1291
1316
|
if (!r.ok) throw new Error(data.error);
|
|
1292
1317
|
toast(`Uploaded ${plural(data.files, "file")}`);
|
|
1293
|
-
deployDialog(data.detected, { upload: true, files: data.files, name: slugify(label) });
|
|
1318
|
+
deployDialog(data.detected, { upload: true, files: data.files, name: slugify(label), site });
|
|
1294
1319
|
} catch (e) {
|
|
1295
1320
|
toast(`Upload failed: ${e.message}`);
|
|
1296
1321
|
}
|
|
@@ -1313,7 +1338,9 @@ window.addEventListener("drop", (e) => {
|
|
|
1313
1338
|
const roots = [...e.dataTransfer.items].map((i) => i.webkitGetAsEntry?.()).filter(Boolean);
|
|
1314
1339
|
const label = roots[0]?.name ?? e.dataTransfer.files[0]?.name;
|
|
1315
1340
|
const files = roots.length ? droppedFiles(roots) : Promise.resolve([...e.dataTransfer.files].map((f) => [f, f.name]));
|
|
1316
|
-
|
|
1341
|
+
const dlg = $("#dlg");
|
|
1342
|
+
const site = dlg.open ? dlg.site : undefined;
|
|
1343
|
+
files.then((pairs) => upload(pairs, label, site), (err) => toast(err.message));
|
|
1317
1344
|
});
|
|
1318
1345
|
|
|
1319
1346
|
// ---- domains & login ---------------------------------------------------------
|
|
@@ -1620,7 +1647,7 @@ function renderMain() {
|
|
|
1620
1647
|
{ label: "Server info", hint: "lf server info", run: () => run(["server", "info", n]) },
|
|
1621
1648
|
!k || k.docker ? { label: "Traefik status", hint: "lf traefik status", run: () => run(["traefik", "status", ...sv(n)]) } : null,
|
|
1622
1649
|
!k || k.admin ? "-" : null,
|
|
1623
|
-
!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,
|
|
1624
1651
|
s?.isDefault ? null : { label: "Make default", title: "Commands without -s use it", run: () => makeDefault(n) },
|
|
1625
1652
|
s?.registered ? "-" : null,
|
|
1626
1653
|
s?.registered ? { label: `Forget ${n}…`, cls: "danger", hint: "lf server rm", run: () => forget(n) } : null,
|
|
@@ -1769,7 +1796,8 @@ function sitesView(n, o) {
|
|
|
1769
1796
|
const acts = [
|
|
1770
1797
|
proj && !m.adopted
|
|
1771
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}`)
|
|
1772
|
-
: ""
|
|
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"),
|
|
1773
1801
|
old ? "" : btn("Logs", () => run(["logs", name, ...sv(n), "-f"]), "sm", "Follow the logs of all its services"),
|
|
1774
1802
|
menuBtn([
|
|
1775
1803
|
old ? null : { label: "Restart", hint: "lf restart", run: () => run(["restart", name, ...sv(n)]) },
|
|
@@ -1800,7 +1828,7 @@ function sitesView(n, o) {
|
|
|
1800
1828
|
<dt>On ${esc(n)}</dt><dd>${siteFiles(m, state)}</dd>
|
|
1801
1829
|
${m.adopted ? "" : `<dt>Source</dt><dd>${proj
|
|
1802
1830
|
? `<span class="mono">${esc(tilde(proj.path))}</span> <span class="dim">· redeploy runs there</span>`
|
|
1803
|
-
: `<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>`}
|
|
1804
1832
|
<dt>Kind</dt><dd>${esc(kind)}${main || !managed ? "" : ` <span class="dim">· domains set in compose.yaml</span>`}</dd>
|
|
1805
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>` : ""}
|
|
1806
1834
|
<dt>Created</dt><dd>${esc(ago(m.createdAt))} · updated ${esc(ago(m.updatedAt))}</dd>
|
|
@@ -2152,7 +2180,32 @@ function systemView(n) {
|
|
|
2152
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>`
|
|
2153
2181
|
: `<div class="card"><h2>Bans</h2><div class="body dim">${i.fail2ban === "missing" ? "fail2ban is not installed." : "fail2ban is not running."}</div></div>`;
|
|
2154
2182
|
|
|
2155
|
-
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>`;
|
|
2156
2209
|
}
|
|
2157
2210
|
|
|
2158
2211
|
function renameHost(n, old) {
|
|
@@ -2214,19 +2267,22 @@ function logLimitDlg(n, d) {
|
|
|
2214
2267
|
},
|
|
2215
2268
|
});
|
|
2216
2269
|
}
|
|
2217
|
-
function addKeyDlg(n) {
|
|
2270
|
+
function addKeyDlg(n, user = "", generate = false) {
|
|
2218
2271
|
dialog({
|
|
2219
2272
|
title: `Add an SSH key to ${n}`,
|
|
2220
|
-
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.",
|
|
2221
2274
|
fields: [
|
|
2222
|
-
{ name: "
|
|
2223
|
-
{ 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." },
|
|
2224
2278
|
],
|
|
2225
2279
|
ok: "Add key",
|
|
2226
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 });
|
|
2227
2283
|
const key = v.key.trim().split("\n")[0];
|
|
2228
2284
|
if (!/^(\S+\s+)?(ssh-|ecdsa-|sk-)\S+ AAAA/.test(key)) return { error: "That does not look like a public key.", field: "key" };
|
|
2229
|
-
run(["keys", "add", key, ...
|
|
2285
|
+
run(["keys", "add", key, ...u, ...sv(n)], { server: n });
|
|
2230
2286
|
},
|
|
2231
2287
|
});
|
|
2232
2288
|
}
|
|
@@ -2282,8 +2338,13 @@ function traefikView(n) {
|
|
|
2282
2338
|
text: "Serves the dashboard on a domain behind a login. Without one it stays reachable through an SSH tunnel only (Open via tunnel).\nRuns lf traefik setup, which reconfigures landfall's Traefik.",
|
|
2283
2339
|
fields: [
|
|
2284
2340
|
{ name: "domain", label: "Domain", value: x.traefik.dashboard ?? "", placeholder: x.domains[0] ? `traefik.${x.domains[0]}` : "traefik.example.com", hint: x.traefik.dashboard ? "Empty: take the dashboard off its domain." : "Needs an A record pointing at the server." },
|
|
2285
|
-
{
|
|
2286
|
-
|
|
2341
|
+
{
|
|
2342
|
+
legend: "Login",
|
|
2343
|
+
fields: [
|
|
2344
|
+
{ name: "user", label: "User", value: "admin", placeholder: "admin" },
|
|
2345
|
+
{ name: "password", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
|
|
2346
|
+
],
|
|
2347
|
+
},
|
|
2287
2348
|
],
|
|
2288
2349
|
ok: "Apply",
|
|
2289
2350
|
onOk: (v) => {
|
|
@@ -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};
|