landfall 0.5.2 → 0.5.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
@@ -176,6 +176,9 @@ td.acts button { margin-left: 4px; }
176
176
  .note.info { background: var(--accent-bg); color: var(--accent); }
177
177
  .kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; font-size: var(--fs); margin: 0; }
178
178
  .kv dt { color: var(--faint); } .kv dd { margin: 0; }
179
+ details.where summary { cursor: pointer; color: var(--faint); font-size: var(--fs-sm); }
180
+ details.where h3 { font-size: var(--fs-sm); margin: 12px 0 6px; }
181
+ details.where dl { margin: 0; font-size: var(--fs-sm); } details.where dt { overflow-wrap: anywhere; margin-top: 6px; } details.where dd { margin: 0; }
179
182
  pre.block { background: var(--hover); border: 1px solid var(--line); padding: 10px 12px; border-radius: var(--r-sm); overflow-x: auto; font-size: var(--fs-sm); margin: 6px 0 0; }
180
183
  .detail td { background: color-mix(in srgb, var(--hover) 60%, transparent); }
181
184
  .releases, .services { margin: 0; padding: 0; list-style: none; }
@@ -285,6 +288,7 @@ body.dragging::after {
285
288
  <header>
286
289
  <a class="brand" href="#/"><svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="4" fill="var(--accent)"/><path d="M4 5h8M4 8h8M4 11h5" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>landfall <small id="version"></small></a>
287
290
  <div class="project" id="project"></div>
291
+ <button type="button" class="sm ghost" id="app-settings" title="landfall's defaults for every server (lf config)">Defaults</button>
288
292
  </header>
289
293
  <!-- The filter stays put across renders, so typing keeps its focus. -->
290
294
  <nav class="servers" id="nav">
@@ -743,7 +747,7 @@ function renderJob() {
743
747
  * onOk(values) runs on submit; return false to keep it open, or {field, error} to keep it open and show why.
744
748
  * typeToConfirm: a name the user types before the (dangerous) action is enabled.
745
749
  */
746
- function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", typeToConfirm = "" }) {
750
+ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk, extra = "", after = "", typeToConfirm = "" }) {
747
751
  const dlg = openDlg();
748
752
  const field = (f) => {
749
753
  const id = `f-${f.name}`;
@@ -766,6 +770,7 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
766
770
  ${text ? `<div class="text">${esc(text)}</div>` : ""}
767
771
  ${extra}
768
772
  ${fields.map(group).join("")}
773
+ ${after}
769
774
  ${typeToConfirm ? `<label>Type <b class="mono">${esc(typeToConfirm)}</b> to confirm<input id="dlg-confirm" autocomplete="off" spellcheck="false" autocapitalize="off"></label>` : ""}
770
775
  <div class="note bad dlg-err" id="dlg-err" role="alert" hidden></div>
771
776
  <div class="btns">
@@ -958,6 +963,43 @@ function addServer() {
958
963
 
959
964
  const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
960
965
  const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
966
+ /** The server deploys new sites with no domain unless one is given: its own setting, else landfall's default. */
967
+ const tunnelOnly = (n) => S.state.servers.find((s) => s.name === n)?.saved?.tunnelOnly ?? S.state.defaults.tunnelOnly;
968
+
969
+ /** landfall's defaults for every server (lf config); a server's own setting wins. */
970
+ function defaultsDialog() {
971
+ const d = S.state.defaults;
972
+ dialog({
973
+ title: "landfall defaults",
974
+ text: `For every server, unless it sets its own. Saved in ${d.configFile}.`,
975
+ fields: [
976
+ { name: "default", label: "Default server", type: "select", value: d.default ?? "", options: [{ value: "", label: "none" }, ...S.state.servers.map((s) => ({ value: s.name, label: s.name }))], hint: "Commands in a terminal use it without --server." },
977
+ { name: "tunnelOnly", label: "New sites get no domain: tunnel only", type: "check", value: d.tunnelOnly, hint: "Nothing is served on the internet until a site gets a domain. A server's Settings can override it." },
978
+ { name: "email", label: "Let's Encrypt e-mail (optional)", value: d.email ?? "", hint: "For servers that set none, when landfall installs Traefik." },
979
+ { name: "keep", label: "Releases to keep for rollback", value: String(d.keep), placeholder: "5" },
980
+ ],
981
+ after: whereList(d.locations),
982
+ ok: "Save",
983
+ onOk: (v) => {
984
+ const argv = ["config"];
985
+ if (v.default !== (d.default ?? "")) argv.push(...(v.default ? ["--default", v.default] : ["--unset", "default"]));
986
+ if (v.tunnelOnly !== d.tunnelOnly) argv.push(v.tunnelOnly ? "--tunnel-only" : "--no-tunnel-only");
987
+ const email = v.email.trim();
988
+ if (email !== (d.email ?? "")) argv.push(...(email ? ["--email", email] : ["--unset", "email"]));
989
+ const keep = v.keep.trim();
990
+ if (keep && (!/^\d+$/.test(keep) || !Number(keep))) return invalid("keep", "A whole number from 1 up");
991
+ if (keep !== String(d.keep)) argv.push(...(keep ? ["--keep", keep] : ["--unset", "keep"]));
992
+ if (argv.length === 1) return;
993
+ run(argv, { after: (j) => (j.info.code === 0 ? void (S.sel && load(S.sel, "settings", true), refreshState()) : undefined) });
994
+ },
995
+ });
996
+ }
997
+ /** Every place landfall writes to, from `lf config`: nothing it installs is hidden. */
998
+ const whereList = (loc) => {
999
+ const kv = (list) => `<dl>${list.map((l) => `<dt class="mono">${esc(l.path)}</dt><dd class="dim">${esc(l.what)}</dd>`).join("")}</dl>`;
1000
+ return `<details class="where"><summary>Where landfall puts things</summary>
1001
+ <h3>On this machine</h3>${kv(loc.local)}<h3>On each server</h3>${kv(loc.server)}</details>`;
1002
+ };
961
1003
  /** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
962
1004
  function autoDomain(n, name) {
963
1005
  const main = mainDomain(n);
@@ -1002,7 +1044,7 @@ function deployDialog(det, o = {}) {
1002
1044
  ? `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.`
1003
1045
  : "Empty: a free <name>.<ip>.sslip.io address, no DNS needed. Own domains need an A record pointing at the server.",
1004
1046
  },
1005
- { name: "noDomain", label: "No domain: reach it through a tunnel only", type: "check", value: !!p && p.domains?.length === 0, hint: "Nothing is served on the internet. Give it a domain later with Domains… on the site." },
1047
+ { 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." },
1006
1048
  ...(det.kind === "static"
1007
1049
  ? [
1008
1050
  { name: "build", label: "Build command (runs here, before upload)", value: det.build ?? "", placeholder: "none" },
@@ -1022,6 +1064,8 @@ function deployDialog(det, o = {}) {
1022
1064
  if (!n) return invalid("name", "Give the site a name");
1023
1065
  if (!v.server) return invalid("server", "Pick a server");
1024
1066
  const domains = v.domains.split(/[\s,]+/).filter(Boolean);
1067
+ // The server's default would leave it unrouted though the box is unticked.
1068
+ 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`);
1025
1069
  const argv = ["deploy", "-y", ...sv(v.server), "-n", n, ...(v.noDomain ? ["--no-domain"] : domains.flatMap((d) => ["-d", d]))];
1026
1070
  if (v.build?.trim() && v.build.trim() !== p?.build) argv.push("--build", v.build.trim());
1027
1071
  if (v.spa && !p?.spa) argv.push("--spa");
@@ -1956,6 +2000,9 @@ function settingsView(n) {
1956
2000
  const P = S.state.paths;
1957
2001
  const ip = x.dns[0]?.ips[0] ?? "<server ip>";
1958
2002
  const records = x.dns.flatMap((r) => [r.apex, r.wildcard]).filter((d) => !d.ok).map((d) => `${esc(d.domain)} A ${esc(ip)}`);
2003
+ const tunnel = x.tunnelOnly;
2004
+ const setTunnel = () =>
2005
+ run(["server", "domain", n, tunnel ? "--no-tunnel-only" : "--tunnel-only"], { server: n, after: (j) => (j.info.code === 0 ? void (load(n, "settings", true), refreshState()) : undefined) });
1959
2006
  const saved = x.saved ? Object.entries(x.saved).filter(([k, v]) => v !== undefined && k !== "dashboard").map(([k, v]) => [k, `<span class="mono">${esc(String(v))}</span>`]) : [];
1960
2007
  return `
1961
2008
  <div class="card">
@@ -1971,6 +2018,10 @@ function settingsView(n) {
1971
2018
  <p class="faint">New sites get <span class="mono">&lt;site&gt;.${esc(x.domains[0])}</span>; a bare name like <span class="mono">blog</span> means <span class="mono">blog.${esc(x.domains[0])}</span>, and <span class="mono">@</span> means <span class="mono">${esc(x.domains[0])}</span> itself.</p>
1972
2019
  ${records.length ? `<div class="note">Add at your DNS provider:<div class="mono">${records.join("<br>")}</div></div>` : ""}`
1973
2020
  : `<p class="dim">No main domain set in landfall. New sites get a free <span class="mono">&lt;site&gt;.&lt;ip&gt;.sslip.io</span> address until you set one.</p>`}
2021
+ <div class="drow">
2022
+ <div>New sites: ${tunnel ? `<b>no domain</b> <span class="dim">· tunnel only until you give one (Domains… on the site, or deploy with a domain)</span>` : `<b>get a domain</b> <span class="dim">· unless you tick “No domain” when deploying</span>`}${x.tunnelOnlyOwn ? "" : ` <span class="faint">(landfall default)</span>`}</div>
2023
+ <div class="actions">${btn(tunnel ? "Give them a domain" : "Make them tunnel only", setTunnel, "sm", tunnel ? "lf server domain --no-tunnel-only" : "lf server domain --tunnel-only")}</div>
2024
+ </div>
1974
2025
  ${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>
1975
2026
  </div>
1976
2027
  <div class="card">
@@ -2084,6 +2135,7 @@ $("#filter").addEventListener("input", (e) => {
2084
2135
  renderNav();
2085
2136
  });
2086
2137
  $("#add-server").onclick = () => addServer();
2138
+ $("#app-settings").onclick = () => defaultsDialog();
2087
2139
 
2088
2140
  (async () => {
2089
2141
  try {
@@ -0,0 +1,9 @@
1
+ import{A as e,C as t,D as n,E as r,M as i,N as a,O as o,P as s,S as c,T as l,_ as u,a as d,b as f,c as ee,d as te,f as ne,g as re,h as ie,i as p,j as ae,k as oe,l as se,m as ce,n as m,o as h,p as le,r as ue,s as de,t as g,u as _,v,w as y,x as b,y as fe}from"./cli.mjs";import{spawn as pe,spawnSync as x}from"node:child_process";import S from"node:fs";import{homedir as C}from"node:os";import{dirname as w,isAbsolute as me,join as T,resolve as E}from"node:path";import{createHash as D,randomBytes as O,timingSafeEqual as k}from"node:crypto";import{createServer as A}from"node:http";import{Readable as j,Transform as M}from"node:stream";function N(e){let t=E(!e||e===`~`?C():o(e)),n=S.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&P(T(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:w(t)===t?null:w(t),home:C(),dirs:n,detected:ce(t)}}const P=e=>{try{return S.statSync(e).isDirectory()}catch{return!1}},F=T(n,`uploads`),I=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function L(e,t){S.mkdirSync(w(e),{recursive:!0}),S.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function R(e){S.mkdirSync(F,{recursive:!0,mode:448});for(let e of S.readdirSync(F)){let t=T(F,e);Date.now()-S.statSync(t).mtimeMs>864e5&&S.rmSync(t,{recursive:!0,force:!0})}let t=S.mkdtempSync(T(F,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=T(t,`.upload-`+I(n[0].name).pop());await L(e,n[0]);let r=T(t,`files`);S.mkdirSync(r);let i=/\.zip$/i.test(e);if(z(e,i))throw Error(`${n[0].name} contains symlinks or hard links: upload the folder instead`);let a=i?x(`unzip`,[`-q`,e,`-d`,r]):x(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?x(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return S.rmSync(e),he(r),{path:B(r),files:ge(r)}}let i=T(t,`files`);for(let[e,t]of n.entries()){let n=I(r[e]||t.name);n.length&&await L(T(i,...n),t)}return{path:B(i),files:n.length}}function z(e,t){let n=(e,t)=>{let n=x(e,t,{encoding:`utf8`,maxBuffer:256*1024**2});return n.error||n.status!==0?void 0:n.stdout},r=t?n(`zipinfo`,[e])??n(`tar`,[`-tvf`,e]):n(`tar`,[`-tvf`,e]);if(r===void 0)throw Error(`could not read ${e.split(`/`).pop()}`);return r.split(`
2
+ `).some(e=>/^[lh]/.test(e)||/ link to /.test(e))}function he(e){for(let t of S.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&S.rmSync(T(t.parentPath,t.name),{force:!0})}function B(e){for(;;){let t=S.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!P(T(e,t[0])))return e;e=T(e,t[0])}}function ge(e){return S.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!me(e)||!P(e))throw Error(`cwd must be an existing folder`);return e}}const V=()=>[process.execPath,...process.execArgv,process.argv[1]],H=5e3,U=1e4,W=e=>e.length>U?e.slice(0,U)+`…`:e;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,...n.env};n.password&&Object.assign(r,se(n.password));let i=pe(V()[0],[...V().slice(1),...e],{stdio:[n.stdin===void 0?`ignore`:`pipe`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0});i.stdin?.on(`error`,()=>{}).end(n.stdin);let a={argv:e,lines:[],dropped:0,report:{},stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
3
+ `);e=n.pop(),e.length>U&&(n.push(e),e=``);for(let e of n)if(e.startsWith(``))try{Object.assign(a.report,JSON.parse(e.slice(1)))}catch{}else a.lines.push(W(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));a.lines.length>H&&(a.dropped+=a.lines.splice(0,a.lines.length-H).length),s()},flush:()=>e&&a.lines.push(W(e))}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const ye=S.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),be=512*1024**2,xe=1024**2,G=/* @__PURE__ */ new Map,Se=/* @__PURE__ */ new Set([`ui`,`web`]);function Ce(){let e=T(w(y),`ui-token`);try{let t=S.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=O(24).toString(`hex`);return S.mkdirSync(w(e),{recursive:!0,mode:448}),S.writeFileSync(e,t+`
4
+ `,{mode:384}),t}const K=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),q=(e,t=500)=>K({error:e?.message??String(e)},t);function J(e,t){let n=re(),r=/* @__PURE__ */ new Set,{args:i,env:a,stdin:o}=we(e),s=ve(i,()=>r.forEach(e=>e()),{password:t.password,cwd:t.cwd,stdin:o,env:{...a,LANDFALL_RUN:n}}),c={id:n,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:_(e),job:s,listeners:r,opts:t};G.set(n,c);for(let[e,t]of G){if(G.size<=50)break;t.job.code!==void 0&&G.delete(e)}return c}const Y=e=>e[0]===`env`||e[0]===`sites`&&e[1]===`env`,X=/^[A-Za-z_][A-Za-z0-9_]*=/;function we(e){let t=[],n={},r=[];for(let a=0;a<e.length;a++){let o=e[a],s=o.match(/^--((?:[\w-]+-)?password)(?:=(.*))?$/s);s&&(s[2]!==void 0||a+1<e.length)?n[i(s[1])]=s[2]??e[++a]:Y(e)&&X.test(o)&&!o.includes(`
5
+ `)&&!e.includes(`--env-file`)?r.push(o):t.push(o)}return r.length&&t.push(`--env-file`,`-`),{args:t,env:n,stdin:r.length?r.join(`
6
+ `)+`
7
+ `:void 0}}const Te=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:Y(e)&&X.test(t)?t.replace(/=.*/s,`=***`):t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Z=e=>({id:e.id,argv:Te(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function Ee(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(Z(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
8
+
9
+ `),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 De(n,i){let[a,o,s]=i,u=n.method;if(a===`state`&&u===`GET`){let n=oe();return K({version:g,cwd:process.cwd(),project:e(process.cwd())??null,projects:ie(),defaultImage:v,rules:{loginName:r.source,minPassword:8},paths:{root:b,sites:c,traefik:t,journal:fe,proxy:f},defaults:m(),servers:te().map(e=>({...e,target:ne(e.name),saved:n.servers[e.name]??null})),jobs:[...G.values()].reverse().map(Z)})}if(a===`servers`&&o){let{server:e}=ae(o);if(u===`GET`&&!s)return K(await le(e));if(u===`GET`&&s===`firewall`)return K(await h(e));if(u===`GET`&&s===`config`)return K(await ue(o,e));if(u===`GET`&&s===`history`)return K(await de(e));if(u===`GET`&&s===`facts`)return K(await d(o,e));if(u===`GET`&&s===`updates`)return K(await ee(e,new URL(n.url).searchParams.get(`refresh`)===`1`));if(u===`GET`&&s===`dns`){let t=(new URL(n.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>l.test(e)).slice(0,50);return K(await p(e,t))}}if(a===`jobs`){if(u===`POST`&&!o){let e=await n.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||Se.has(t[0]))return q(`argv must be a list of landfall arguments`,400);if([...G.values()].filter(e=>e.job.code===void 0).length>=20)return q(`20 jobs are already running: wait for one to finish or stop one`,429);let r;try{r=_e(e.cwd)}catch(e){return q(e,400)}return K(Z(J(t,{server:e.server,password:e.password||void 0,cwd:r})))}let e=o?G.get(o):void 0;if(o&&!e)return q(`no job ${o}`,404);if(e&&u===`GET`&&s===`events`)return Ee(e,Number(new URL(n.url).searchParams.get(`from`))||0,n.signal);if(e&&u===`GET`&&!s)return K({...Z(e),lines:e.job.lines,from:e.job.dropped});if(e&&u===`POST`&&s===`force`)return e.job.code===void 0||e.argv.includes(`--force`)?q(`only a finished job without --force`,400):[...G.values()].filter(e=>e.job.code===void 0).length>=20?q(`too many jobs running`,429):K(Z(J([...e.argv,`--force`],e.opts)));if(e&&u===`POST`&&s===`stop`)return e.job.stop(),K(Z(e))}if(a===`fs`&&u===`GET`)return K(N(new URL(n.url).searchParams.get(`path`)??void 0));if(a===`upload`&&u===`POST`){let e=await R(await n.formData());return K({...e,detected:N(e.path).detected})}return q(`not found`,404)}const Q=(e,t)=>e.length===t.length&&k(Buffer.from(e),Buffer.from(t)),Oe=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>D(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function ke(e){u(!0);let t=Ce(),n=A(je(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(Oe(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(ye,{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 De(e,n.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return q(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),o=e.port??4777,c=e.port===void 0?[...Array.from({length:20},(e,t)=>o+t),0]:[o],l;for(let n of c){if(!(l=await r(n)))break;if(l.code!==`EADDRINUSE`)throw l;if(await i(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${a.cyan(r)}`),e.open&&s(r),0}}if(l)return console.error(`${a.red(`error:`)} port ${o} is in use by another program; pick another with --port`),1;let d=n.address().port;d!==o&&console.log(a.dim(`port ${o} is in use by another program: using ${d}`));let f=`http://127.0.0.1:${d}/login?t=${t}`;return console.log(`${a.bold(`lf ui`)} ${a.dim(g)} on ${a.cyan(f)}`),console.log(a.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&s(f),new Promise(e=>{let t=()=>{for(let e of G.values())e.job.stop();n.closeAllConnections(),n.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function Ae(e,t){let n=0;return e.pipe(new M({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function je(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`)?be:xe;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:j.toWeb(Ae(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=j.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{ke as webUi};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.5.2",
3
+ "version": "0.5.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",
@@ -1,9 +0,0 @@
1
- import{A as e,C as t,D as n,E as r,M as i,N as a,O as o,S as s,T as c,_ as l,a as u,b as d,c as f,d as ee,f as te,g as ne,h as re,i as ie,j as ae,k as p,l as m,m as h,n as g,o as _,p as v,r as oe,s as se,t as y,u as ce,v as le,w as ue,x as de,y as fe}from"./cli.mjs";import{spawn as pe,spawnSync as b}from"node:child_process";import x from"node:fs";import{homedir as S}from"node:os";import{dirname as C,isAbsolute as me,join as w,resolve as he}from"node:path";import{createHash as T,randomBytes as E,timingSafeEqual as D}from"node:crypto";import{createServer as O}from"node:http";import{Readable as k,Transform as A}from"node:stream";function j(e){let t=he(!e||e===`~`?S():n(e)),r=x.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&M(w(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:C(t)===t?null:C(t),home:S(),dirs:r,detected:v(t)}}const M=e=>{try{return x.statSync(e).isDirectory()}catch{return!1}},N=w(r,`uploads`),P=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function F(e,t){x.mkdirSync(C(e),{recursive:!0}),x.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function I(e){x.mkdirSync(N,{recursive:!0,mode:448});for(let e of x.readdirSync(N)){let t=w(N,e);Date.now()-x.statSync(t).mtimeMs>864e5&&x.rmSync(t,{recursive:!0,force:!0})}let t=x.mkdtempSync(w(N,`u-`)),n=e.getAll(`file`).filter(e=>e instanceof File);if(!n.length)throw Error(`no files in the upload`);let r=e.getAll(`path`).map(String);if(n.length===1&&/\.(zip|tar|tgz|tar\.gz)$/i.test(n[0].name)){let e=w(t,`.upload-`+P(n[0].name).pop());await F(e,n[0]);let r=w(t,`files`);x.mkdirSync(r);let i=/\.zip$/i.test(e);if(L(e,i))throw Error(`${n[0].name} contains symlinks or hard links: upload the folder instead`);let a=i?b(`unzip`,[`-q`,e,`-d`,r]):b(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?b(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return x.rmSync(e),R(r),{path:z(r),files:ge(r)}}let i=w(t,`files`);for(let[e,t]of n.entries()){let n=P(r[e]||t.name);n.length&&await F(w(i,...n),t)}return{path:z(i),files:n.length}}function L(e,t){let n=(e,t)=>{let n=b(e,t,{encoding:`utf8`,maxBuffer:256*1024**2});return n.error||n.status!==0?void 0:n.stdout},r=t?n(`zipinfo`,[e])??n(`tar`,[`-tvf`,e]):n(`tar`,[`-tvf`,e]);if(r===void 0)throw Error(`could not read ${e.split(`/`).pop()}`);return r.split(`
2
- `).some(e=>/^[lh]/.test(e)||/ link to /.test(e))}function R(e){for(let t of x.readdirSync(e,{withFileTypes:!0,recursive:!0}))t.isSymbolicLink()&&x.rmSync(w(t.parentPath,t.name),{force:!0})}function z(e){for(;;){let t=x.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!M(w(e,t[0])))return e;e=w(e,t[0])}}function ge(e){return x.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function _e(e){if(e!=null&&e!==``){if(typeof e!=`string`||!me(e)||!M(e))throw Error(`cwd must be an existing folder`);return e}}const B=()=>[process.execPath,...process.execArgv,process.argv[1]],V=5e3,H=1e4,U=e=>e.length>H?e.slice(0,H)+`…`:e;function ve(e,t,n={}){let r={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,...n.env};n.password&&Object.assign(r,f(n.password));let i=pe(B()[0],[...B().slice(1),...e],{stdio:[n.stdin===void 0?`ignore`:`pipe`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0});i.stdin?.on(`error`,()=>{}).end(n.stdin);let a={argv:e,lines:[],dropped:0,report:{},stop(){if(a.code===void 0){a.stopped=!0;try{process.kill(-i.pid,`SIGTERM`)}catch{}}}},o,s=()=>o??=setTimeout(()=>(o=void 0,t()),50),c=()=>{let e=``;return{push(t){let n=(e+t).split(`
3
- `);e=n.pop(),e.length>H&&(n.push(e),e=``);for(let e of n)if(e.startsWith(``))try{Object.assign(a.report,JSON.parse(e.slice(1)))}catch{}else a.lines.push(U(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));a.lines.length>V&&(a.dropped+=a.lines.splice(0,a.lines.length-V).length),s()},flush:()=>e&&a.lines.push(U(e))}},l=c(),u=c();return i.stdout.setEncoding(`utf8`).on(`data`,l.push),i.stderr.setEncoding(`utf8`).on(`data`,u.push),i.on(`error`,e=>{a.lines.push(`error: ${e.message}`),a.code=1,t()}),i.on(`close`,e=>{l.flush(),u.flush(),a.code=a.stopped?null:e??1,clearTimeout(o),t()}),a}const ye=x.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),be=512*1024**2,xe=1024**2,W=/* @__PURE__ */ new Map,Se=/* @__PURE__ */ new Set([`ui`,`web`]);function Ce(){let e=w(C(t),`ui-token`);try{let t=x.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let n=E(24).toString(`hex`);return x.mkdirSync(C(e),{recursive:!0,mode:448}),x.writeFileSync(e,n+`
4
- `,{mode:384}),n}const G=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),K=(e,t=500)=>G({error:e?.message??String(e)},t);function q(e,t){let n=re(),r=/* @__PURE__ */ new Set,{args:i,env:a,stdin:o}=we(e),s=ve(i,()=>r.forEach(e=>e()),{password:t.password,cwd:t.cwd,stdin:o,env:{...a,LANDFALL_RUN:n}}),c={id:n,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:m(e),job:s,listeners:r,opts:t};W.set(n,c);for(let[e,t]of W){if(W.size<=50)break;t.job.code!==void 0&&W.delete(e)}return c}const J=e=>e[0]===`env`||e[0]===`sites`&&e[1]===`env`,Y=/^[A-Za-z_][A-Za-z0-9_]*=/;function we(e){let t=[],n={},r=[];for(let i=0;i<e.length;i++){let a=e[i],o=a.match(/^--((?:[\w-]+-)?password)(?:=(.*))?$/s);o&&(o[2]!==void 0||i+1<e.length)?n[ae(o[1])]=o[2]??e[++i]:J(e)&&Y.test(a)&&!a.includes(`
5
- `)&&!e.includes(`--env-file`)?r.push(a):t.push(a)}return r.length&&t.push(`--env-file`,`-`),{args:t,env:n,stdin:r.length?r.join(`
6
- `)+`
7
- `:void 0}}const Te=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:Te(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped,report:e.job.report});function Z(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
8
-
9
- `),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 Ee(t,n){let[r,i,a]=n,f=t.method;if(r===`state`&&f===`GET`){let e=o();return G({version:y,cwd:process.cwd(),project:p(process.cwd())??null,projects:h(),defaultImage:l,rules:{loginName:c.source,minPassword:8},paths:{root:d,sites:de,traefik:s,journal:le,proxy:fe},servers:ce().map(t=>({...t,target:ee(t.name),saved:e.servers[t.name]??null})),jobs:[...W.values()].reverse().map(X)})}if(r===`servers`&&i){let{server:n}=e(i);if(f===`GET`&&!a)return G(await te(n));if(f===`GET`&&a===`firewall`)return G(await u(n));if(f===`GET`&&a===`config`)return G(await g(i,n));if(f===`GET`&&a===`history`)return G(await _(n));if(f===`GET`&&a===`facts`)return G(await ie(i,n));if(f===`GET`&&a===`updates`)return G(await se(n,new URL(t.url).searchParams.get(`refresh`)===`1`));if(f===`GET`&&a===`dns`){let e=(new URL(t.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>ue.test(e)).slice(0,50);return G(await oe(n,e))}}if(r===`jobs`){if(f===`POST`&&!i){let e=await t.json(),n=e.argv;if(!Array.isArray(n)||!n.length||!n.every(e=>typeof e==`string`)||Se.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=_e(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=i?W.get(i):void 0;if(i&&!e)return K(`no job ${i}`,404);if(e&&f===`GET`&&a===`events`)return Z(e,Number(new URL(t.url).searchParams.get(`from`))||0,t.signal);if(e&&f===`GET`&&!a)return G({...X(e),lines:e.job.lines,from:e.job.dropped});if(e&&f===`POST`&&a===`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`&&a===`stop`)return e.job.stop(),G(X(e))}if(r===`fs`&&f===`GET`)return G(j(new URL(t.url).searchParams.get(`path`)??void 0));if(r===`upload`&&f===`POST`){let e=await I(await t.formData());return G({...e,detected:j(e.path).detected})}return K(`not found`,404)}const Q=(e,t)=>e.length===t.length&&D(Buffer.from(e),Buffer.from(t)),De=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``,$=e=>T(`sha256`).update(`landfall-ui:${e}`).digest(`hex`);async function Oe(e){ne(!0);let t=Ce(),n=O(Ae(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(De(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(ye,{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 Ee(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)))}),o=e=>fetch(`http://127.0.0.1:${e}/ping`).then(async e=>e.ok&&await e.text()===$(t),()=>!1),s=e.port??4777,c=e.port===void 0?[...Array.from({length:20},(e,t)=>s+t),0]:[s],l;for(let n of c){if(!(l=await r(n)))break;if(l.code!==`EADDRINUSE`)throw l;if(await o(n)){let r=`http://127.0.0.1:${n}/login?t=${t}`;return console.log(`lf ui already runs: ${i.cyan(r)}`),e.open&&a(r),0}}if(l)return console.error(`${i.red(`error:`)} port ${s} is in use by another program; pick another with --port`),1;let u=n.address().port;u!==s&&console.log(i.dim(`port ${s} is in use by another program: using ${u}`));let d=`http://127.0.0.1:${u}/login?t=${t}`;return console.log(`${i.bold(`lf ui`)} ${i.dim(y)} on ${i.cyan(d)}`),console.log(i.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&a(d),new Promise(e=>{let t=()=>{for(let e of W.values())e.job.stop();n.closeAllConnections(),n.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function ke(e,t){let n=0;return e.pipe(new A({transform(e,r,i){n+=e.length,i(n>t?/* @__PURE__ */ Error(`request too large`):null,e)}}))}function Ae(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`)?be:xe;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:k.toWeb(ke(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=k.fromWeb(i.body);n.on(`close`,()=>o.destroy()),o.pipe(n)}}export{Oe as webUi};