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/README.md +90 -41
- package/dist/cli.mjs +234 -210
- package/dist/index.html +38 -30
- package/dist/server-DA-e5nav.mjs +9 -0
- package/package.json +2 -2
- package/dist/server-c9LxeAd1.mjs +0 -5
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
790
|
-
: password.length <
|
|
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
|
|
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.
|
|
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
|
|
942
|
-
return
|
|
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:
|
|
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
|
|
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:
|
|
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, "
|
|
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
|
-
|
|
1239
|
-
|
|
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:
|
|
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 <its api port></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.
|
|
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",
|
package/dist/server-c9LxeAd1.mjs
DELETED
|
@@ -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};
|