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/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" },
@@ -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
- /** Pick a folder on this machine: recent projects, a folder browser, or drop/choose files to upload. */
1220
- 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) {
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
- 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);
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>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>` : ""}
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">Deploy this folder…</button>
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
- 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));
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: () => 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,
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 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.",
2226
2274
  fields: [
2227
- { name: "key", label: "Public key", type: "textarea", value: "", placeholder: "ssh-ed25519 AAAA… name@laptop" },
2228
- { 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." },
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, ...(v.user.trim() ? ["--user", v.user.trim()] : []), ...sv(n)], { server: n });
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.6.1",
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",