landfall 0.4.2 → 0.4.3

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
@@ -583,8 +583,13 @@ function appendOutput(j) {
583
583
  const frag = document.createDocumentFragment();
584
584
  for (const l of j.lines.slice(out.textContent ? shown : 0)) {
585
585
  const div = document.createElement("div");
586
- div.textContent = l.replace(ANSI, "") || " ";
587
- const c = lineClass(div.textContent);
586
+ const text = l.replace(ANSI, "") || " ";
587
+ // Addresses (a tunnel's localhost one, a deployed site) open in a new tab.
588
+ for (const [i, part] of text.split(/(https?:\/\/[^\s)]+)/).entries()) {
589
+ if (!(i % 2)) div.append(part);
590
+ else div.append(Object.assign(document.createElement("a"), { href: part, textContent: part, target: "_blank", rel: "noopener" }));
591
+ }
592
+ const c = lineClass(text);
588
593
  if (c) div.className = c;
589
594
  frag.append(div);
590
595
  }
@@ -719,6 +724,11 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
719
724
  (first || (danger ? $("#dlg-cancel", dlg) : submit)).focus();
720
725
  }
721
726
  const invalid = (field, error) => ({ field, error });
727
+ /** What is wrong with a login (lf newLogin's rules), as [field, message]. */
728
+ const loginError = (user, password) =>
729
+ !/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
730
+ : password.length < 8 ? ["password", "The password needs at least 8 characters"]
731
+ : null;
722
732
  /**
723
733
  * Passwords here are for servers and sites, not for this page: a masked text field, so browsers
724
734
  * and password managers do not offer to save them. Browsers without text masking get a password field.
@@ -780,6 +790,7 @@ function setupWizard(base) {
780
790
  { name: "image", label: "Image", value: saved.traefikImage ?? S.state.defaultImage },
781
791
  { name: "email", label: "Let's Encrypt e-mail (optional)", value: saved.email ?? "" },
782
792
  { name: "dashboard", label: "Dashboard domain (optional)", value: "", placeholder: "traefik.example.com", hint: "Behind basic auth; the password is shown after setup." },
793
+ { name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: saved.http3 !== false, hint: "Browsers that can't use it fall back to HTTP/2." },
783
794
  ],
784
795
  },
785
796
  { name: "firewall", label: "Enable ufw: deny incoming except ssh, 80 and 443", type: "check", value: true, hint: "Skipped automatically if it would block ports in use." },
@@ -794,6 +805,7 @@ function setupWizard(base) {
794
805
  if (v.image.trim()) f.push(`--image=${v.image.trim()}`);
795
806
  if (v.email.trim()) f.push(`--email=${v.email.trim()}`);
796
807
  if (v.dashboard.trim()) f.push(`--dashboard=${v.dashboard.trim()}`);
808
+ f.push(v.http3 ? "--http3" : "--no-http3");
797
809
  }
798
810
  if (!v.firewall) f.push("--no-firewall");
799
811
  if (!v.harden) f.push("--no-harden");
@@ -878,7 +890,7 @@ function describe(d) {
878
890
  d.kind === "compose" ? `Compose project <code>${esc(d.compose ?? "compose.yaml")}</code>: built and started on the server; x-landfall picks the routed service`
879
891
  : d.kind === "image" ? `Docker image <code>${esc(d.project?.image ?? "")}</code> (from .landfall.json)`
880
892
  : d.kind === "dockerfile" ? `Dockerfile: built on the server, served on port ${esc(d.port)}`
881
- : d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.spa ? " (single-page app)" : ""}${built}`
893
+ : d.dir ? `Static files from <code>${esc(d.dir === "." ? "./" : d.dir + "/")}</code>${d.tool ? ` (where ${esc(d.tool)} builds the site)` : ""}${d.spa ? " (single-page app)" : ""}${built}`
882
894
  : d.build ? `Static site, built with <code>${esc(d.build)}</code> first`
883
895
  : `<span style="color:var(--warn)">No compose.yaml, Dockerfile, index.html or build script here.</span>`;
884
896
  const before = d.project?.server ? ` <span class="dim">· deployed before to ${esc(d.project.server)}</span>` : "";
@@ -909,11 +921,15 @@ function deployDialog(det, o = {}) {
909
921
  ...(det.kind === "static"
910
922
  ? [
911
923
  { name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
924
+ {
925
+ name: "dir", label: "Folder to publish", value: det.dir ?? ".", placeholder: ".",
926
+ hint: det.tool ? `Detected: ${det.tool} writes the site to ${det.dir}/ (it need not exist before the build).` : "Relative to the project; it needs an index.html after the build.",
927
+ },
912
928
  { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
913
929
  ]
914
930
  : []),
915
- { name: "authUser", label: "Login user (optional)", placeholder: "admin", 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." },
916
- { name: "authPassword", label: "Login password (at least 8 characters)", type: "password" },
931
+ { name: "authPassword", label: "Login password (optional, at least 8 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." },
932
+ { name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
917
933
  ],
918
934
  ok: "Deploy",
919
935
  onOk: (v) => {
@@ -924,11 +940,16 @@ function deployDialog(det, o = {}) {
924
940
  const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...domains.flatMap((d) => ["-d", d])];
925
941
  if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
926
942
  if (v.spa && !p?.spa) argv.push("--spa");
927
- const user = v.authUser.trim();
928
- if (user && !/^[A-Za-z0-9._@-]{1,64}$/.test(user)) return invalid("authUser", "Only letters, digits and . _ @ -");
929
- if (user && v.authPassword.length < 8) return invalid("authPassword", "The password needs at least 8 characters");
930
- if (!user && v.authPassword) return invalid("authUser", "Give a user for the password");
931
- if (user) argv.push("--auth-user", user, "--auth-password", v.authPassword);
943
+ const dir = v.dir?.trim().replace(/\/+$/, "") || ".";
944
+ if (det.kind === "static" && (/^[/-]/.test(dir) || dir.split("/").includes(".."))) return invalid("dir", "A folder inside the project");
945
+ if (det.kind === "static" && dir !== (det.dir ?? ".")) argv.push(dir);
946
+ // The login counts once there is a password; the user defaults to admin.
947
+ if (v.authPassword) {
948
+ const user = v.authUser.trim() || "admin";
949
+ const bad = loginError(user, v.authPassword);
950
+ if (bad) return invalid(bad[0] === "user" ? "authUser" : "authPassword", bad[1]);
951
+ argv.push("--auth-user", user, "--auth-password", v.authPassword);
952
+ }
932
953
  if (o.upload) argv.push("--no-save");
933
954
  run(argv, { server: v.server, cwd: det.path, after: openAfter });
934
955
  },
@@ -1103,9 +1124,10 @@ function authDialog(n, m) {
1103
1124
  ok: "Save",
1104
1125
  onOk: (v) => {
1105
1126
  if (v.off) return void run(["auth", m.name, ...sv(n), "--off", "-y"], { server: n });
1106
- if (!v.user.trim()) return invalid("user", "Give a user name");
1107
- if (v.password.length < 8) return invalid("password", "The password needs at least 8 characters");
1108
- run(["auth", m.name, ...sv(n), "-u", v.user.trim(), "-p", v.password], { server: n });
1127
+ const user = v.user.trim() || "admin";
1128
+ const bad = loginError(user, v.password);
1129
+ if (bad) return invalid(...bad);
1130
+ run(["auth", m.name, ...sv(n), "-u", user, "-p", v.password], { server: n });
1109
1131
  },
1110
1132
  });
1111
1133
  }
@@ -1396,6 +1418,7 @@ function sitesView(n, o) {
1396
1418
  ? { label: "Roll back…", hint: "lf rollback", title: `To ${older[0].id} (${ago(older[0].at)})`, run: () => confirmDlg({ title: `Roll back ${name}?`, text: `From ${m.current} to ${older[0].id} (${ago(older[0].at)}). Volumes keep their data.`, ok: "Roll back", onOk: () => run(["rollback", name, ...sv(n), "-y"]) }) }
1397
1419
  : null,
1398
1420
  "-",
1421
+ old ? null : { label: "Open via tunnel", hint: "lf tunnel", title: "An SSH tunnel straight to the container: test it on localhost, no domain needed", run: () => run(["tunnel", name, ...sv(n)], { server: n }) },
1399
1422
  main ? { label: "Domains…", hint: "lf domains", run: () => domainsDialog(n, m) } : null,
1400
1423
  managed ? { label: m.auth?.length ? "Login…" : "Password…", hint: "lf auth", title: "Protect the site with a user and password", run: () => authDialog(n, m) } : null,
1401
1424
  managed ? { label: "Environment…", hint: "lf env", run: () => envDialog(n, name) } : null,
@@ -1687,13 +1710,58 @@ function settingsView(n) {
1687
1710
  if (d) run(["server", "domain", n, "--add", d], reload);
1688
1711
  }, "Add");
1689
1712
  const dashboard = () =>
1690
- promptDlg(`Traefik dashboard on ${n}`, "Serves the dashboard behind a password (shown in the output). `off` removes it.\nRuns lf traefik setup, which reconfigures landfall's Traefik.", { label: "Domain", type: "text", value: x.traefik.dashboard ?? (x.domains[0] ? `traefik.${x.domains[0]}` : "") }, (d) => {
1691
- if (d) run(["traefik", "setup", ...sv(n), "--dashboard", d], reload);
1692
- }, "Apply");
1713
+ dialog({
1714
+ title: `Traefik dashboard on ${n}`,
1715
+ 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.",
1716
+ fields: [
1717
+ { 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." },
1718
+ { name: "user", label: "Login user", value: "admin", placeholder: "admin" },
1719
+ { name: "password", label: "Login password (at least 8 characters)", type: "password", hint: x.traefik.dashboard ? "Empty: keeps the current one." : "Empty: a generated one, shown in the output." },
1720
+ ],
1721
+ ok: "Apply",
1722
+ onOk: (v) => {
1723
+ const d = v.domain.trim();
1724
+ const user = v.user.trim() || "admin";
1725
+ if (!d && !x.traefik.dashboard) return invalid("domain", "Give a domain, or use the tunnel");
1726
+ const bad = d && loginError(user, v.password || "generated");
1727
+ if (bad) return invalid(...bad);
1728
+ const argv = ["traefik", "setup", ...sv(n), "--dashboard", d || "off"];
1729
+ if (d) argv.push("--dashboard-user", user, ...(v.password ? ["--dashboard-password", v.password] : []));
1730
+ run(argv, reload);
1731
+ },
1732
+ });
1733
+ const traefikDialog = () =>
1734
+ dialog({
1735
+ title: `Traefik on ${n}`,
1736
+ text: "Runs lf traefik setup, which reconfigures landfall's Traefik and restarts it (a few seconds without traffic). The settings are remembered.",
1737
+ fields: [
1738
+ { name: "http3", label: "HTTP/3 (QUIC on 443/udp)", type: "check", value: x.traefik.http3, hint: "Faster on lossy and mobile connections; browsers that can't use it fall back to HTTP/2." },
1739
+ {
1740
+ name: "mode", label: "Ports", type: "select", value: x.traefik.hostNetwork ? "host" : "docker",
1741
+ options: [
1742
+ { value: "docker", label: "Published by Docker (default)" },
1743
+ { value: "host", label: "Host network: real client IPs, ufw guards 80/443" },
1744
+ ],
1745
+ },
1746
+ { name: "image", label: "Image", value: x.traefik.image, placeholder: S.state.defaultImage },
1747
+ { name: "email", label: "Let's Encrypt e-mail (optional)", value: x.traefik.email ?? "", hint: "Only the account contact; Let's Encrypt no longer mails expiry warnings." },
1748
+ { name: "staging", label: "Let's Encrypt staging CA (test certificates, browsers warn)", type: "check", value: x.traefik.staging },
1749
+ ],
1750
+ ok: "Apply",
1751
+ onOk: (v) => {
1752
+ const argv = ["traefik", "setup", ...sv(n), v.http3 ? "--http3" : "--no-http3", v.mode === "host" ? "--host-network" : "--no-host-network", v.staging ? "--staging" : "--no-staging"];
1753
+ if (v.image.trim()) argv.push("--image", v.image.trim());
1754
+ if (v.email.trim()) argv.push("--email", v.email.trim());
1755
+ run(argv, reload);
1756
+ },
1757
+ });
1758
+ const tunnel = () => run(["traefik", "dashboard", ...sv(n)], { server: n });
1693
1759
  const sites = S.overview[n]?.data?.sites ?? [];
1694
1760
  // lf traefik setup refuses to touch a Traefik the server already had.
1695
1761
  const o = S.overview[n]?.data;
1696
1762
  const foreign = o?.adopted ? o.proxy : "";
1763
+ const dash = o?.dashboard ?? { hosts: x.traefik.dashboard ? [x.traefik.dashboard] : [], tunnel: false };
1764
+ const link = (h) => `<a href="https://${esc(h)}" target="_blank" rel="noopener">${esc(h)}</a>`;
1697
1765
  const external = (d) => S.overview[n]?.data?.others?.find((e) => e.domains.includes(d))?.name;
1698
1766
  const servedBy = (d) => sites.find((s) => s.domains.includes(d))?.meta.name;
1699
1767
  const serve = (d) => {
@@ -1730,11 +1798,15 @@ function settingsView(n) {
1730
1798
  ${x.suggest.length ? `<div class="note">${x.domains.length ? "Other domains" : "Domains"} your sites on this server already use: ${x.suggest.map((g) => `<b>${esc(g.domain)}</b> <span class="faint">(${g.hosts})</span> ${btn("Add", () => run(["server", "domain", n, "--add", g.domain], reload), "sm")}`).join(" ")}</div>` : ""}</div>
1731
1799
  </div>
1732
1800
  <div class="card">
1733
- <h2>Traefik <span class="actions">${foreign ? "" : btn("Dashboard…", dashboard, "sm")}</span></h2>
1801
+ <h2>Traefik <span class="actions">${dash.tunnel ? btn("Open via tunnel", tunnel, "sm", "lf traefik dashboard: an SSH tunnel to the dashboard, until you stop it") : ""}${foreign ? "" : btn("Dashboard…", dashboard, "sm") + btn("Settings…", traefikDialog, "sm")}</span></h2>
1734
1802
  <div class="body">${foreign ? `<div class="note">Sites go through <b class="mono">${esc(foreign)}</b>, a Traefik landfall did not install and never changes: its dashboard is set up in its own config.</div>` : ""}${rows([
1735
1803
  ["image", esc(x.traefik.summary)],
1736
1804
  ["e-mail", esc(x.traefik.email ?? "none")],
1737
- ["dashboard", x.traefik.dashboard ? `<a href="https://${esc(x.traefik.dashboard)}" target="_blank" rel="noopener">${esc(x.traefik.dashboard)}</a>` : "off"],
1805
+ ["HTTP/3", x.traefik.http3 ? `on <span class="dim">(443/udp)</span>` : `<span class="dim">off</span>`],
1806
+ ["dashboard", `${dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">no domain</span>`}${
1807
+ dash.tunnel ? ` · <span class="dim">tunnel: lf traefik dashboard</span>`
1808
+ : foreign ? ` · <span class="dim">tunnel: lf traefik dashboard --port &lt;its api port&gt;</span>`
1809
+ : ` · <span class="dim">no tunnel yet</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`}`],
1738
1810
  ["files", `<span class="mono">${esc(P.traefik)}/docker-compose.yml</span>`],
1739
1811
  ["certificates", `<span class="mono">${esc(P.traefik)}/letsencrypt/</span> <span class="dim">(kept on reinstall)</span>`],
1740
1812
  ])}<p class="faint">Used when landfall installs or reconfigures its own Traefik. With a Traefik that was already there, landfall only writes <span class="mono">${esc(P.proxy)}</span>.</p></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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",