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/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
- /** Deploy a local folder (det from /api/fs or an upload). upload: a temporary folder, so no .landfall.json. */
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: main
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
- { name: "authPassword", label: `Login password (optional, at least ${S.state.rules.minPassword} characters)`, type: "password", hint: "Visitors log in with the user below and this password (HTTP basic auth). Empty: no change; set or remove it later with Password… on the site." },
1160
- { name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
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
- /** Pick a folder on this machine: recent projects, a folder browser, or drop/choose files to upload. */
1215
- async function folderDialog(path) {
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
- const recent = (S.state.projects ?? []).slice(0, 8);
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>Deploy a folder</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">Deploy this folder…</button>
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
- files.then((pairs) => upload(pairs, label), (err) => toast(err.message));
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: () => 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 }) }) } : null,
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 its private key can log in.",
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: "key", label: "Public key", type: "textarea", value: "", placeholder: "ssh-ed25519 AAAA… name@laptop" },
2223
- { name: "user", label: "User", value: "", placeholder: "the login user", hint: "Whose account it logs in to. Empty: the user landfall logs in as." },
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, ...(v.user.trim() ? ["--user", v.user.trim()] : []), ...sv(n)], { server: n });
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
- { name: "user", label: "Login user", value: "admin", placeholder: "admin" },
2286
- { name: "password", label: `Login password (at least ${S.state.rules.minPassword} characters)`, type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Set up servers and deploy sites behind Traefik with automatic HTTPS",
5
5
  "author": "ph1p <me@ph1p.dev>",
6
6
  "license": "MIT",