landfall 0.4.6 → 0.5.0

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
@@ -265,6 +265,8 @@ body.dragging::after {
265
265
  main { padding: 14px 16px 40px; }
266
266
  .project .path { display: none; }
267
267
  aside.job { inset: 52px 0 0 0; width: 100vw; }
268
+ td.acts { white-space: normal; }
269
+ td.acts button { margin: 2px 0 2px 4px; }
268
270
  }
269
271
  </style>
270
272
  </head>
@@ -506,7 +508,10 @@ async function refreshState() {
506
508
  async function run(argv, o = {}) {
507
509
  let info;
508
510
  try {
509
- info = await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
511
+ // A retry runs the job's own argv on the server: the page only has it with passwords masked.
512
+ info = o.retry
513
+ ? await http(`/api/jobs/${encodeURIComponent(o.retry)}/force`, {})
514
+ : await http("/api/jobs", { argv, server: o.server ?? S.sel ?? undefined, password: o.password, cwd: o.cwd });
510
515
  } catch (e) {
511
516
  return toast(e.message);
512
517
  }
@@ -522,7 +527,8 @@ function stream(j) {
522
527
  const es = new EventSource(`/api/jobs/${j.info.id}/events?from=${j.lines.length}`);
523
528
  j.es = es;
524
529
  es.onmessage = (e) => {
525
- const { from, lines } = JSON.parse(e.data);
530
+ const { from, lines, report } = JSON.parse(e.data);
531
+ if (report) j.report = report;
526
532
  j.lines.length = Math.min(j.lines.length, from);
527
533
  j.lines.push(...lines);
528
534
  if (S.job === j.info.id) appendOutput(j);
@@ -532,6 +538,7 @@ function stream(j) {
532
538
  es.addEventListener("done", (e) => {
533
539
  es.close();
534
540
  j.info = JSON.parse(e.data);
541
+ j.report = j.info.report;
535
542
  finished(j);
536
543
  });
537
544
  // Dropped connection while it still runs: pick up where it left off.
@@ -546,12 +553,12 @@ function finished(j) {
546
553
  const failed = info.code !== 0 && info.code !== null;
547
554
  j.actions = (j.after && j.after(j)) || [];
548
555
  // Safety checks refuse what would lock out ssh or break running services; --force overrides after reading why.
549
- if (failed && !info.argv.includes("--force") && j.lines.some((l) => l.includes("--force"))) {
550
- const why = j.lines.map((l) => l.replace(ANSI, "").trim()).filter(Boolean).slice(-4).join("\n");
556
+ const why = rep(j).force;
557
+ if (failed && !info.argv.includes("--force") && why) {
551
558
  j.actions.push({
552
559
  label: "Do it anyway (--force)…",
553
560
  cls: "danger",
554
- run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server }) }),
561
+ run: () => confirmDlg({ title: "Override the safety check?", text: why, ok: "Run with --force", danger: true, onOk: () => run([...info.argv, "--force"], { server: info.server, retry: info.id }) }),
555
562
  });
556
563
  }
557
564
  if (info.undoable && info.server && info.code !== null) {
@@ -583,9 +590,11 @@ const runningJobs = () => [...S.jobs.values()].filter((j) => j.info.code === und
583
590
  const isTunnel = (info) => info.argv[0] === "tunnel" || (info.argv[0] === "traefik" && info.argv[1] === "dashboard");
584
591
  /** Tunnels the UI opened to server n that are still open. */
585
592
  const openTunnels = (n) => runningJobs().filter((j) => j.info.server === n && isTunnel(j.info));
586
- const tunnelUrl = (j) => j.lines.map((l) => l.replace(ANSI, "").match(/\b[a-z][a-z0-9+.-]*:\/\/localhost:\d+\S*/)?.[0]).find(Boolean);
593
+ /** What the job reported through the CLI's report(): never parsed from its text output. */
594
+ const rep = (j) => j.report ?? j.info.report ?? {};
595
+ const tunnelUrl = (j) => rep(j).url;
587
596
  /** With --lan: the addresses other devices on the network open it at. */
588
- const lanUrls = (j) => j.lines.map((l) => l.replace(ANSI, "")).find((l) => /on your network:/.test(l))?.match(/\b[a-z][a-z0-9+.-]*:\/\/\S+/g) ?? [];
597
+ const lanUrls = (j) => rep(j).lan ?? [];
589
598
  const closeTunnel = (j) => http(`/api/jobs/${j.info.id}/stop`, {}).catch((e) => toast(e.message));
590
599
  /** argv plus --port, --local and --lan, picked in a dialog. ports: the ones the container listens on, if known. */
591
600
  function tunnelOptions(n, argv, ports = []) {
@@ -615,7 +624,13 @@ function tunnelOptions(n, argv, ports = []) {
615
624
  function tunnelBtn(n, argv, label = "Open tunnel", ports = []) {
616
625
  // Opened with options, the job's argv goes on after argv.
617
626
  const j = openTunnels(n).find((t) => t.info.argv.slice(0, argv.length).join(" ") === argv.join(" "));
618
- if (!j) return `${btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`)}${btn("…", () => tunnelOptions(n, argv, ports), "sm ghost", "Pick the ports, or make it reachable from your network")}`;
627
+ if (!j)
628
+ return `${btn(label, () => run(argv, { server: n }), "sm", `lf ${argv.join(" ")}: until you close it`)}${btn(
629
+ "On network",
630
+ () => run([...argv, "--lan"], { server: n }),
631
+ "sm",
632
+ "Open it for every device on your network too (a phone, a colleague): anyone there reaches it at this machine's address",
633
+ )}${btn("…", () => tunnelOptions(n, argv, ports), "sm", "Container port, local port, network")}`;
619
634
  const url = tunnelUrl(j);
620
635
  // A database's address is for a client, not the browser.
621
636
  const link = (u) => u.startsWith("http://") ? `<a class="tunnel-url" href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace("http://", ""))}</a>` : `<code class="tunnel-url" title="Connect a client to it">${esc(u)}</code>`;
@@ -785,9 +800,9 @@ function dialog({ title, text = "", fields = [], ok = "OK", danger = false, onOk
785
800
  }
786
801
  const invalid = (field, error) => ({ field, error });
787
802
  /** What is wrong with a login (lf newLogin's rules), as [field, message]. */
788
- const loginError = (user, password) =>
789
- !/^[A-Za-z0-9._@-]{1,64}$/.test(user) ? ["user", "Only letters, digits and . _ @ -"]
790
- : password.length < 8 ? ["password", "The password needs at least 8 characters"]
803
+ const loginError = (user, password, { loginName, minPassword } = S.state.rules) =>
804
+ !new RegExp(loginName).test(user) ? ["user", "Only letters, digits and . _ @ -"]
805
+ : password.length < minPassword ? ["password", `The password needs at least ${minPassword} characters`]
791
806
  : null;
792
807
  /**
793
808
  * Passwords here are for servers and sites, not for this page: a masked text field, so browsers
@@ -816,10 +831,10 @@ function setupFlow(args, base = args) {
816
831
  server: args[0],
817
832
  after: (j) => {
818
833
  if (j.info.code !== 0) return;
819
- if (j.lines.some((l) => l.includes("no key login yet"))) {
834
+ if (rep(j).needsKey) {
820
835
  return [{ label: "Enter the password to install a key", run: () => passwordFor(args, base) }];
821
836
  }
822
- const todo = j.lines.filter((l) => /^\s+→ /.test(l)).length;
837
+ const todo = (rep(j).checks ?? []).filter((ch) => ch.status === "todo").length;
823
838
  return [
824
839
  { label: todo ? `Apply ${plural(todo, "change")}` : "Finish setup (nothing to install)", run: () => run(["setup", ...args, "-y"], { server: args[0] }) },
825
840
  { label: "Change options", run: () => setupWizard(base) },
@@ -932,14 +947,13 @@ function addServer() {
932
947
  }
933
948
 
934
949
  const defaultServer = () => S.sel ?? S.state.servers.find((s) => s.isDefault)?.name;
935
- const sslip = (name, ip) => `${name}.${ip.replace(/\./g, "-")}.sslip.io`;
936
950
  const mainDomain = (n) => S.state.servers.find((s) => s.name === n)?.saved?.domains?.[0];
937
951
  /** What `lf deploy` picks without a domain: <site>.<main domain>, else the free sslip.io address. */
938
952
  function autoDomain(n, name) {
939
953
  const main = mainDomain(n);
940
954
  if (main) return `${name}.${main}`;
941
- const ip = S.facts[n]?.data?.sslipIp;
942
- return ip ? sslip(name, ip) : "";
955
+ const suffix = S.facts[n]?.data?.sslipSuffix;
956
+ return suffix ? `${name}.${suffix}` : "";
943
957
  }
944
958
  const slugify = (s) => s.toLowerCase().replace(/\.(zip|tgz|tar(\.gz)?)$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "site";
945
959
  const tilde = (p) => p.replace(/^\/(Users|home)\/[^/]+/, "~");
@@ -989,7 +1003,7 @@ function deployDialog(det, o = {}) {
989
1003
  { name: "spa", label: "Single-page app (unknown paths serve index.html)", type: "check", value: !!p?.spa },
990
1004
  ]
991
1005
  : []),
992
- { 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." },
1006
+ { 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." },
993
1007
  { name: "authUser", label: "Login user", value: "admin", placeholder: "admin" },
994
1008
  ],
995
1009
  ok: "Deploy",
@@ -1021,7 +1035,7 @@ function deployDialog(det, o = {}) {
1021
1035
  /** After a deploy: offer the address it printed. */
1022
1036
  function openAfter(j) {
1023
1037
  if (j.info.code !== 0) return;
1024
- const url = j.lines.map((l) => l.replace(ANSI, "").match(/https:\/\/[^\s)]+/)?.[0]).filter(Boolean).pop();
1038
+ const url = rep(j).url;
1025
1039
  if (url) return [{ label: `Open ${url.replace("https://", "")}`, run: () => window.open(url, "_blank", "noopener") }];
1026
1040
  }
1027
1041
 
@@ -1180,7 +1194,7 @@ function authDialog(n, m) {
1180
1194
  : "Visitors have to log in (HTTP basic auth) before they see the site.",
1181
1195
  fields: [
1182
1196
  { name: "user", label: "User", value: users[0] ?? "admin" },
1183
- { name: "password", label: "Password (at least 8 characters)", type: "password", auto: "new-password" },
1197
+ { name: "password", label: `Password (at least ${S.state.rules.minPassword} characters)`, type: "password", auto: "new-password" },
1184
1198
  ...(users.length ? [{ name: "off", label: "Remove the login: the site is public again", type: "check", value: false }] : []),
1185
1199
  ],
1186
1200
  ok: "Save",
@@ -1189,7 +1203,7 @@ function authDialog(n, m) {
1189
1203
  const user = v.user.trim() || "admin";
1190
1204
  const bad = loginError(user, v.password);
1191
1205
  if (bad) return invalid(...bad);
1192
- run(["auth", m.name, ...sv(n), "-u", user, "-p", v.password], { server: n });
1206
+ run(["auth", m.name, ...sv(n), "-u", user, "--password", v.password], { server: n });
1193
1207
  },
1194
1208
  });
1195
1209
  }
@@ -1235,14 +1249,8 @@ function forget(n) {
1235
1249
  });
1236
1250
  }
1237
1251
 
1238
- async function makeDefault(n) {
1239
- try {
1240
- await http(`/api/servers/${encodeURIComponent(n)}/default`, {});
1241
- await refreshState();
1242
- toast(`${n} is now the default server`);
1243
- } catch (e) {
1244
- toast(e.message);
1245
- }
1252
+ function makeDefault(n) {
1253
+ run(["server", "use", n], { server: n, after: (j) => (j.info.code === 0 ? void refreshState() : undefined) });
1246
1254
  }
1247
1255
 
1248
1256
  // ---- views ---------------------------------------------------------------
@@ -1828,7 +1836,7 @@ function traefikView(n) {
1828
1836
  fields: [
1829
1837
  { 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." },
1830
1838
  { name: "user", label: "Login user", value: "admin", placeholder: "admin" },
1831
- { 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." },
1839
+ { 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." },
1832
1840
  ],
1833
1841
  ok: "Apply",
1834
1842
  onOk: (v) => {
@@ -1879,7 +1887,7 @@ function traefikView(n) {
1879
1887
  <div class="body">${rows([
1880
1888
  ["domain", dash.hosts.length ? `${dash.hosts.map(link).join(", ")} <span class="dim">(with its login)</span>` : `<span class="dim">none${foreign ? "" : ": only through the tunnel"}</span>`],
1881
1889
  ["tunnel", dash.tunnel
1882
- ? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim">lf traefik dashboard</span>`
1890
+ ? `${tunnelBtn(n, dashArgv, "Open via tunnel")} <span class="dim mono">lf traefik dashboard</span>`
1883
1891
  : foreign ? `<span class="dim">route its dashboard (api@internal) to a domain, or run <span class="mono">lf traefik dashboard --port &lt;its api port&gt;</span></span>`
1884
1892
  : `<span class="dim">set up by an older landfall</span> ${btn("Enable tunnel", () => run(["traefik", "setup", ...sv(n)], reload), "sm", "Re-runs lf traefik setup, which adds it")}`],
1885
1893
  ])}</div>
@@ -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,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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "landfall",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
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",
@@ -22,7 +22,7 @@
22
22
  "dist"
23
23
  ],
24
24
  "engines": {
25
- "node": ">=22"
25
+ "node": ">=22.4"
26
26
  },
27
27
  "dependencies": {
28
28
  "bcryptjs": "^3.0.3",
@@ -1,5 +0,0 @@
1
- import{A as e,B as t,C as n,D as r,E as i,F as a,I as o,L as s,M as c,N as l,O as u,P as d,R as ee,S as f,T as te,_ as p,a as m,b as h,c as g,d as ne,f as _,g as v,h as y,i as b,j as x,k as S,l as re,m as ie,n as ae,o as oe,p as se,r as ce,s as le,t as C,u as ue,v as de,w as fe,x as w,y as pe,z as T}from"./cli.mjs";import{spawn as me,spawnSync as E}from"node:child_process";import D from"node:fs";import{homedir as O}from"node:os";import{dirname as k,isAbsolute as A,join as j,resolve as M}from"node:path";import{randomBytes as N,timingSafeEqual as P}from"node:crypto";import{createServer as F}from"node:http";import{Readable as I}from"node:stream";const L=/^(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org)$/;function R(){let e=a(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=f().filter(t=>!e.servers[t]&&!L.test(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}function z(e){try{let t=w(s(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function he(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}function B(e){let t=M(!e||e===`~`?O():d(e)),n=D.readdirSync(t,{withFileTypes:!0}).filter(e=>!e.name.startsWith(`.`)&&(e.isDirectory()||e.isSymbolicLink()&&V(j(t,e.name)))).map(e=>e.name).sort((e,t)=>e.localeCompare(t));return{path:t,parent:k(t)===t?null:k(t),home:O(),dirs:n,detected:y(t)}}const V=e=>{try{return D.statSync(e).isDirectory()}catch{return!1}},H=j(l,`uploads`),U=e=>e.split(/[\\/]+/).filter(e=>e&&e!==`.`&&e!==`..`);async function W(e,t){D.mkdirSync(k(e),{recursive:!0}),D.writeFileSync(e,Buffer.from(await t.arrayBuffer()))}async function ge(e){D.mkdirSync(H,{recursive:!0,mode:448});for(let e of D.readdirSync(H)){let t=j(H,e);Date.now()-D.statSync(t).mtimeMs>864e5&&D.rmSync(t,{recursive:!0,force:!0})}let t=D.mkdtempSync(j(H,`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=j(t,`.upload-`+U(n[0].name).pop());await W(e,n[0]);let r=j(t,`files`);D.mkdirSync(r);let i=/\.zip$/i.test(e),a=i?E(`unzip`,[`-q`,e,`-d`,r]):E(`tar`,[`-xf`,e,`-C`,r]);if(a.error||a.status!==0){let t=i?E(`tar`,[`-xf`,e,`-C`,r]):a;if(t.error||t.status!==0)throw Error(`could not unpack ${n[0].name}`)}return D.rmSync(e),{path:G(r),files:_e(r)}}let i=j(t,`files`);for(let[e,t]of n.entries()){let n=U(r[e]||t.name);n.length&&await W(j(i,...n),t)}return{path:G(i),files:n.length}}function G(e){for(;;){let t=D.readdirSync(e).filter(e=>e!==`__MACOSX`&&e!==`.DS_Store`);if(t.length!==1||!V(j(e,t[0])))return e;e=j(e,t[0])}}function _e(e){return D.readdirSync(e,{withFileTypes:!0,recursive:!0}).filter(e=>e.isFile()).length}function ve(e){if(e!=null&&e!==``){if(typeof e!=`string`||!A(e)||!V(e))throw Error(`cwd must be an existing folder`);return e}}const K=()=>[process.execPath,...process.execArgv,process.argv[1]],q=5e3;function ye(e,t,n={}){let r={...process.env,NO_COLOR:`1`,...n.env};n.password&&Object.assign(r,g(n.password));let i=me(K()[0],[...K().slice(1),...e],{stdio:[`ignore`,`pipe`,`pipe`],env:r,cwd:n.cwd,detached:!0}),a={argv:e,lines:[],dropped:0,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(`
2
- `);e=n.pop();for(let e of n)a.lines.push(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``));a.lines.length>q&&(a.dropped+=a.lines.splice(0,a.lines.length-q).length),s()},flush:()=>e&&a.lines.push(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 be=D.readFileSync(new URL(`./index.html`,import.meta.url),`utf8`),J=/* @__PURE__ */ new Map,xe=/* @__PURE__ */ new Set([`ui`,`web`]);function Se(){let e=j(k(x),`ui-token`);try{let t=D.readFileSync(e,`utf8`).trim();if(t.length>=32)return t}catch{}let t=N(24).toString(`hex`);return D.mkdirSync(k(e),{recursive:!0,mode:448}),D.writeFileSync(e,t+`
3
- `,{mode:384}),t}const Y=(e,t=200)=>Response.json(e,{status:t,headers:{"cache-control":`no-store`}}),X=(e,t=500)=>Y({error:e?.message??String(e)},t);function Z(e,t){let r=n(),i=/* @__PURE__ */ new Set,a=ye(e,()=>i.forEach(e=>e()),{password:t.password,cwd:t.cwd,env:{LANDFALL_RUN:r}}),o={id:r,argv:e,server:t.server,startedAt:(/* @__PURE__ */ new Date()).toISOString(),undoable:he(e),job:a,listeners:i};J.set(r,o);for(let[e,t]of J){if(J.size<=50)break;t.job.code!==void 0&&J.delete(e)}return o}const Ce=e=>e.map((t,n)=>/^(-p|--([\w-]+-)?password)$/.test(e[n-1])?`***`:t.replace(/^(--([\w-]+-)?password=).*/,`$1***`)),Q=e=>({id:e.id,argv:Ce(e.argv),server:e.server,startedAt:e.startedAt,undoable:e.undoable,code:e.job.code,stopped:!!e.job.stopped});function we(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)})}\n\n`),i=n+t.length),r!==void 0&&(o(`event: done\ndata: ${JSON.stringify(Q(e))}\n\n`),a())},c=setInterval(()=>o(`: beat
4
-
5
- `),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 Te(n,l){let[d,f,g]=l,y=n.method;if(d===`state`&&y===`GET`){let t=a();return Y({version:C,cwd:process.cwd(),project:o(process.cwd())??null,projects:v(),defaultImage:te,paths:{root:u,sites:S,traefik:e,journal:i,proxy:r},servers:R().map(e=>({...e,target:z(e.name),saved:t.servers[e.name]??null})),jobs:[...J.values()].reverse().map(Q)})}if(d===`servers`&&f){let{server:e}=s(f);if(y===`GET`&&!g)return Y(await ie(e));if(y===`GET`&&g===`firewall`){let{login:t,probe:n,...r}=await m(e);return Y({...r,asRoot:w(t).user===`root`})}if(y===`GET`&&g===`config`){let t=ce(f),[n,r]=await Promise.all([Promise.all(t.domains.map(t=>ae(e,t))),b(e)]);return Y({...t,dns:n,suggest:r})}if(y===`GET`&&g===`history`){let t=(await le(e)).reverse();return Y(t.map(e=>({...e,pending:oe(e).length})))}if(y===`GET`&&g===`facts`){let t=await de(e),n=await h(e,t);return Y({...t,ips:n,sslipIp:pe(n)??null})}if(y===`GET`&&g===`updates`){let t=new URL(n.url).searchParams.get(`refresh`)===`1`,r=await se(e,t),i=await ne(e);return Y({packages:r,images:await _(e),engine:{...i,...ue(i)}})}if(y===`GET`&&g===`dns`){let t=(new URL(n.url).searchParams.get(`domains`)??``).split(`,`).filter(e=>c.test(e)).slice(0,50),r=await h(e),[i,a]=await Promise.all([p(t,r),Promise.all(t.map(re))]);return Y({ips:r,domains:i.map((e,t)=>({...e,https:a[t]}))})}if(y===`POST`&&g==="default")return ee(f),Y({ok:!0})}if(d===`jobs`){if(y===`POST`&&!f){let e=await n.json(),t=e.argv;if(!Array.isArray(t)||!t.length||!t.every(e=>typeof e==`string`)||xe.has(t[0]))return X(`argv must be a list of landfall arguments`,400);let r;try{r=ve(e.cwd)}catch(e){return X(e,400)}return Y(Q(Z(t,{server:e.server,password:e.password||void 0,cwd:r})))}let e=f?J.get(f):void 0;if(f&&!e)return X(`no job ${f}`,404);if(e&&y===`GET`&&g===`events`)return we(e,Number(new URL(n.url).searchParams.get(`from`))||0,n.signal);if(e&&y===`GET`&&!g)return Y({...Q(e),lines:e.job.lines,from:e.job.dropped});if(e&&y===`POST`&&g===`stop`)return e.job.stop(),Y(Q(e))}if(d===`fs`&&y===`GET`)return Y(B(new URL(n.url).searchParams.get(`path`)??void 0));if(d===`projects`&&y===`GET`)return Y(v());if(d===`upload`&&y===`POST`){let e=await ge(await n.formData());return Y({...e,detected:B(e.path).detected})}if(d===`open`&&y===`POST`){let{url:e}=await n.json();return!e||!/^https?:\/\//.test(e)?X(`not a web address`,400):(t(e),Y({ok:!0}))}return X(`not found`,404)}const $=(e,t)=>e.length===t.length&&P(Buffer.from(e),Buffer.from(t)),Ee=e=>(e.headers.get(`cookie`)??``).split(/;\s*/).find(e=>e.startsWith(`landfall=`))?.slice(9)??``;async function De(e){fe(!0);let n=Se(),r=F(Oe(async e=>{let t=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(t.pathname===`/login`)return $(t.searchParams.get(`t`)??``,n)?new Response(null,{status:303,headers:{location:`/`,"set-cookie":`landfall=${n}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`}}):new Response(`wrong or missing token, use the link lf ui printed`,{status:403});if(!$(Ee(e),n))return new Response("Open the link `lf ui` printed (it logs this browser in).",{status:401,headers:{"content-type":`text/plain`}});if(t.pathname===`/`)return new Response(be,{headers:{"content-type":`text/html; charset=utf-8`,"cache-control":`no-store`}});if(t.pathname.startsWith(`/api/`))try{return await Te(e,t.pathname.slice(5).split(`/`).filter(Boolean).map(decodeURIComponent))}catch(e){return X(e)}return new Response(`not found`,{status:404})}));r.requestTimeout=0;let i=e=>new Promise(t=>{r.once(`error`,t),r.listen(e,`127.0.0.1`,()=>(r.off(`error`,t),t(void 0)))}),a=e=>fetch(`http://127.0.0.1:${e}/api/state`,{headers:{cookie:`landfall=${n}`}}).then(e=>e.ok,()=>!1),o=e.port??4777,s=e.port===void 0?[...Array.from({length:20},(e,t)=>o+t),0]:[o],c;for(let r of s){if(!(c=await i(r)))break;if(c.code!==`EADDRINUSE`)throw c;if(await a(r)){let i=`http://127.0.0.1:${r}/login?t=${n}`;return console.log(`lf ui already runs: ${T.cyan(i)}`),e.open&&t(i),0}}if(c)return console.error(`${T.red(`error:`)} port ${o} is in use by another program; pick another with --port`),1;let l=r.address().port;l!==o&&console.log(T.dim(`port ${o} is in use by another program: using ${l}`));let u=`http://127.0.0.1:${l}/login?t=${n}`;return console.log(`${T.bold(`lf ui`)} ${T.dim(C)} on ${T.cyan(u)}`),console.log(T.dim(`Only this machine can reach it, and only with that link. Ctrl-C stops it (and any running commands).`)),e.open&&t(u),new Promise(e=>{let t=()=>{for(let e of J.values())e.job.stop();r.closeAllConnections(),r.close(),e(0)};process.once(`SIGINT`,t),process.once(`SIGTERM`,t)})}function Oe(e){return async(t,n)=>{let r=new AbortController;n.on(`close`,()=>r.abort());let i;try{let n=new Headers;for(let[e,r]of Object.entries(t.headers))for(let t of[r??[]].flat())n.append(e,t);let a=t.method===`GET`||t.method===`HEAD`?void 0:I.toWeb(t);i=await e(new Request(`http://${t.headers.host??`127.0.0.1`}${t.url}`,{method:t.method,headers:n,body:a,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{De as webUi};