landfall 0.1.0 → 0.2.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 +53 -35
- package/dist/cli.mjs +186 -143
- package/dist/index.html +30 -12
- package/dist/{job-CypEf1ud.mjs → job-i5GF9h93.mjs} +2 -2
- package/dist/server-CqBtraHg.mjs +4 -0
- package/dist/tui-BDvEukfL.mjs +8 -0
- package/package.json +1 -1
- package/dist/server-CkG3c5Za.mjs +0 -4
- package/dist/tui-B0KECXvO.mjs +0 -8
package/dist/cli.mjs
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as ee}from"node:path";import{promises as
|
|
3
|
-
`)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new
|
|
4
|
-
`)}}function
|
|
5
|
-
`)}function
|
|
6
|
-
|
|
7
|
-
`)
|
|
8
|
-
`),a=await Ie(`ssh`,[...Pe(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
|
|
9
|
-
`).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function H(e){let{code:t}=await Ie(`ssh`,[...Pe(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}const ze=[`.git`,`node_modules`,`.DS_Store`,`srv.json`,`.env`,`.env.*`];let Be;async function Ve(){if(!Be){let{stdout:e}=await Ie(`tar`,[`--version`],{capture:!0});Be=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Be}async function He(e,t,r,i=ze){o.existsSync(t)||D(`directory not found: ${t}`);let a=[...await Ve(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${B(r)} && tar -xzof - -C ${B(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...Pe(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const U=(e,t,n=`644`)=>`printf %s ${B(Buffer.from(t).toString(`base64`))} | base64 -d > ${B(e)} && chmod ${n} ${B(e)}`,Ue=process.env.SRV_SSH_CONFIG??p(s(),`.ssh`,`config`),We=/* @__PURE__ */ new Map;function W(e){let n=[...je(e),`-G`,Ae(e)],r=We.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return D(`ssh cannot resolve ${Ae(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>I(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return We.set(n.join(`\0`),s),s}function Ge(e){return(e.key?[I(e.key)]:W(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Ke(e=Ue,t=/* @__PURE__ */ new Set){if(t.has(e)||!o.existsSync(e))return[];t.add(e);let n=[];for(let r of o.readFileSync(e,`utf8`).split(`
|
|
10
|
-
`)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=I(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of qe(i))n.push(...Ke(e,t))}}return[...new Set(n)]}function qe(e){if(!/[*?[]/.test(e))return[e];let t=f(e);return o.existsSync(t)?o.globSync(d(e),{cwd:t}).map(e=>p(t,e)):[]}const Je=e=>[`# >>> srv:${e}`,`# <<< srv:${e}`];function Ye(e){let[t,n]=Je(e.alias);return[t,`Host ${e.alias}`,` HostName ${e.hostname}`,` User ${e.user}`,...e.port===22?[]:[` Port ${e.port}`],` IdentityFile ${e.key}`,` IdentitiesOnly yes`,n].join(`
|
|
11
|
-
`)}function Xe(e,t=Ue){let[n,r]=Je(e.alias),i=!o.existsSync(t),a=i?``:o.readFileSync(t,`utf8`),s=Ye(e),c=RegExp(`${tt(n)}[\\s\\S]*?${tt(r)}\\n?`),l=c.test(a)?a.replace(c,s+`
|
|
12
|
-
`):`${s}\n\n${a}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,l,{mode:384}),We.clear(),{created:i,block:s}}function Ze(e,t=Ue,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
|
|
13
|
-
`);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< srv:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:r[i-1]===Je(e)[0]}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=I(t);for(let t of qe(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=Ze(e,t,n);if(r)return r}}}}function Qe(e,t){let n=Ze(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
|
|
14
|
-
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(!o.existsSync(I(n))||I(n)===t)}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.srv-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
|
|
15
|
-
`)),We.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function $e(e,t=Ue){if(!o.existsSync(t))return!1;let[n,r]=Je(e),i=o.readFileSync(t,`utf8`),a=RegExp(`${tt(n)}[\\s\\S]*?${tt(r)}\\n*`);return a.test(i)?(o.writeFileSync(t,i.replace(a,``)),!0):!1}const et=(e,t=Ue)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).includes(Je(e)[0]),tt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),G=`/opt/srv`,K=`${G}/sites`,q=`${G}/traefik`,nt=`traefik:v3`,J=`${G}/proxy.env`,Y=`${G}/journal.log`,rt={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},it=`"$(sed -n 's/^container=//p' ${J} 2>/dev/null | grep . || echo traefik)"`;function at(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const ot=e=>`# srv routes its sites through this existing traefik; delete the file to let srv run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,X=String.raw`
|
|
2
|
+
import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as ee}from"node:path";import{promises as te}from"node:dns";import{isIP as h}from"node:net";import{randomBytes as g}from"node:crypto";import _ from"bcryptjs";const v=process.stdout.isTTY&&!process.env.NO_COLOR,y=e=>t=>v?`\x1b[${e}m${t}\x1b[0m`:t,b={bold:y(`1`),dim:y(`2`),red:y(`31`),green:y(`32`),yellow:y(`33`),blue:y(`34`),cyan:y(`36`)},x=e=>console.error(`${b.cyan(`==>`)} ${b.bold(e)}`),S=e=>console.error(`${b.green(`✓`)} ${e}`),C=e=>console.error(` ${e}`),w=e=>console.error(`${b.yellow(`!`)} ${e}`);var T=class extends Error{};const E=e=>{throw new T(e)};let D=!1;const O=e=>D=e,ne=()=>!D&&!!process.stdin.isTTY;async function re(e,t){if(D||!process.stdin.isTTY){if(t!==void 0)return t;E(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${b.blue(`?`)} ${e}${t?b.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function k(e){return D||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){D||(process.stdin.isTTY||E(`${e} Pass -y to confirm (no terminal to ask).`),t?await re(`${e} Type ${b.bold(t)} to confirm:`)!==t&&E(`cancelled`):await j(e,!1)||E(`cancelled`))}async function j(e,t=!0){return D?!0:process.stdin.isTTY?(await re(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function M(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const N=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function P(e){(D||!process.stdin.isTTY)&&E(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${b.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
|
|
3
|
+
`)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new T(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
|
|
4
|
+
`)}}function ie(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const F=e=>e,I={type:`string`,short:`s`,desc:`server name or ssh alias`},ae={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function oe(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...ae,...t.options}))o[e]=r;try{let{values:t,positionals:n}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});return{values:t,positionals:n,rest:a}}catch(e){return E(`${e.message}\n\n${se(t)}`)}}function se(e){let t=[`${b.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,b.bold(`Options:`));let n=Object.entries({...e.options,yes:ae.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?b.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?b.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
|
|
5
|
+
`)}function ce(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}let le=!1;const ue=e=>le=e,L=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,de=(e=Xe(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],fe=e=>e.user?`${e.user}@${e.host}`:e.host;function pe(e){let t=[...de(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,Y(e.key),`-o`,`IdentitiesOnly=yes`),t}let me;function he(){return me||(me=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(me,{recursive:!0,mode:448})),me}function ge(e,t={}){let n=[...pe(e),`-o`,`ServerAliveInterval=15`];return(t.batch||le)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${he()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,fe(e)),n}function _e(e){return new Promise(t=>{let r=[...pe(e),`-o`,`ControlPath=${he()}/%C`,`-O`,`exit`,fe(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function ve(e,t,r){return new Promise((i,a)=>{let o=n(e,t,{...r,stdio:[r.input===void 0?`inherit`:`pipe`,r.capture?`pipe`:`inherit`,le?`pipe`:`inherit`]}),s=``,c=``;o.stdout?.on(`data`,e=>s+=e),o.stderr?.on(`data`,e=>c+=e),o.on(`error`,a),o.on(`close`,e=>i({code:e??1,stdout:s,stderr:c})),r.input!==void 0&&o.stdin.end(r.input)})}async function ye(e,t=[]){let{code:n}=await ve(`ssh`,[...ge(e,{tty:process.stdin.isTTY}),...t],{});return n}const be={run:process.env.LANDFALL_RUN||`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function R(e,t,n={}){let r={LANDFALL_RUN:be.run,LANDFALL_ACTOR:be.actor,LANDFALL_CMD:be.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${L(typeof t==`boolean`?+!!t:t)}`).join(`
|
|
6
|
+
`),a=await ve(`ssh`,[...ge(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${n.oldLayout?``:Ke}${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
|
|
7
|
+
`).at(-1);E(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function z(e){let{code:t}=await ve(`ssh`,[...ge(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}const xe=()=>[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,We,`.env`,`.env.*`];let Se;async function Ce(){if(!Se){let{stdout:e}=await ve(`tar`,[`--version`],{capture:!0});Se=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Se}async function we(e,t,r,i=xe()){o.existsSync(t)||E(`directory not found: ${t}`);let a=[...await Ce(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${L(r)} && tar -xzof - -C ${L(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...ge(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const B=(e,t,n=`644`)=>`printf %s ${L(Buffer.from(t).toString(`base64`))} | base64 -d > ${L(e)} && chmod ${n} ${L(e)}`,V=`/opt/landfall`,H=`${V}/sites`,U=`${V}/traefik`,Te=`${U}/dynamic`,Ee=`traefik:v3`,W=`${V}/proxy.env`,G=`${V}/journal.log`,De=`/etc/ssh/sshd_config.d/00-landfall.conf`,Oe={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},ke=`"$(sed -n 's/^container=//p' ${W} 2>/dev/null | grep . || echo traefik)"`;function Ae(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const je=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,K=String.raw`
|
|
16
8
|
log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
|
|
17
9
|
SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
|
18
10
|
# The distro's package manager, and installing with it.
|
|
@@ -63,34 +55,34 @@ ensure_running() {
|
|
|
63
55
|
}
|
|
64
56
|
sshd_reload() { $SUDO systemctl reload ssh 2>/dev/null || $SUDO systemctl reload sshd 2>/dev/null || $SUDO service ssh reload 2>/dev/null || true; }
|
|
65
57
|
|
|
66
|
-
# Journal.
|
|
58
|
+
# Journal. LANDFALL_RUN/LANDFALL_ACTOR/LANDFALL_CMD come from runScript. Writing it never fails a script.
|
|
67
59
|
_clean() { printf %s "$1" | tr '\t\n' ' '; }
|
|
68
60
|
_journal() { # _journal <do|undo> <what> <undo shell>
|
|
69
|
-
line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$
|
|
61
|
+
line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$LANDFALL_RUN" "$(_clean "$LANDFALL_ACTOR")" "$(id -un)" "$(_clean "$LANDFALL_CMD")" "$1" "$(_clean "$2")")
|
|
70
62
|
line="$line$(printf %s "$3" | base64 | tr -d '\n')"
|
|
71
|
-
if [ -w ${
|
|
72
|
-
printf '%s\n' "$line" >> ${
|
|
63
|
+
if [ -w ${G} ] || { [ ! -e ${G} ] && [ -w ${V} ]; }; then
|
|
64
|
+
printf '%s\n' "$line" >> ${G}
|
|
73
65
|
elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
|
|
74
66
|
else
|
|
75
|
-
{ $SUDO install -d ${
|
|
76
|
-
fi || { log "warning: could not write ${
|
|
67
|
+
{ $SUDO install -d ${V} && printf '%s\n' "$line" | $SUDO tee -a ${G} >/dev/null; } 2>/dev/null
|
|
68
|
+
fi || { log "warning: could not write ${G}"; return 0; }
|
|
77
69
|
# A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
|
|
78
|
-
# append; it belongs to whoever owns ${
|
|
79
|
-
[ -O ${
|
|
70
|
+
# append; it belongs to whoever owns ${V}.
|
|
71
|
+
[ -O ${G} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${V})" ${G} 2>/dev/null || true
|
|
80
72
|
}
|
|
81
73
|
# record <what> [undo shell]: journal a change; no undo shell = not reversible.
|
|
82
74
|
record() { _journal do "$1" "$(shift; printf %s "$*")"; }
|
|
83
|
-
# Run as $SUDO only when <dir> is not writable: deploy users own ${
|
|
75
|
+
# Run as $SUDO only when <dir> is not writable: deploy users own ${V} and need no sudo.
|
|
84
76
|
_as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
|
|
85
77
|
# saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
|
|
86
78
|
saved() {
|
|
87
|
-
d=$(dirname "$1"); b="$d/.$(basename "$1").
|
|
79
|
+
d=$(dirname "$1"); b="$d/.$(basename "$1").landfall-$LANDFALL_RUN-$RANDOM"
|
|
88
80
|
# $(saved ...) does not inherit set -e: fail explicitly so callers stop instead of journaling a missing backup.
|
|
89
81
|
if _as "$d" test -e "$1"; then _as "$d" cp -p "$1" "$b" || return 1; printf 'restore_file %q %q' "$b" "$1"
|
|
90
82
|
else printf '_as %q rm -f %q' "$d" "$1"; fi
|
|
91
83
|
# Keep the newest ${10} backups per file: old ones pile up (and .env copies hold secrets).
|
|
92
84
|
# By ctime: cp -p keeps the original's mtime, so mtime says nothing about when the backup was made.
|
|
93
|
-
_as "$d" ls -1Atc "$d" 2>/dev/null | awk -v p=".$(basename "$1").
|
|
85
|
+
_as "$d" ls -1Atc "$d" 2>/dev/null | awk -v p=".$(basename "$1").landfall-" 'index($0, p) == 1' | tail -n +$((${10} + 1)) \
|
|
94
86
|
| while IFS= read -r f; do _as "$d" rm -f "$d/$f"; done || true
|
|
95
87
|
}
|
|
96
88
|
restore_file() { # restore_file <backup> <file>
|
|
@@ -120,19 +112,19 @@ ufw_enable() {
|
|
|
120
112
|
*inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
|
|
121
113
|
esac
|
|
122
114
|
}
|
|
123
|
-
`,
|
|
115
|
+
`,Me=`/usr/local/lib/docker/cli-plugins/docker-compose`,Ne=String.raw`
|
|
124
116
|
compose_install() {
|
|
125
117
|
arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
|
|
126
|
-
f=${
|
|
118
|
+
f=${Me}
|
|
127
119
|
$SUDO mkdir -p "$(dirname $f)" || return 1
|
|
128
120
|
$SUDO curl -fsSL -o $f.new "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$arch" || { $SUDO rm -f $f.new; return 1; }
|
|
129
121
|
$SUDO chmod 755 $f.new
|
|
130
|
-
if [ -f $f ]; then $SUDO mv $f $f.
|
|
122
|
+
if [ -f $f ]; then $SUDO mv $f $f.landfall-$LANDFALL_RUN; undo="\$SUDO mv $f.landfall-$LANDFALL_RUN $f"; else undo="\$SUDO rm -f $f"; fi
|
|
131
123
|
$SUDO mv $f.new $f
|
|
132
124
|
record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
|
|
133
125
|
}
|
|
134
|
-
`,
|
|
135
|
-
${
|
|
126
|
+
`,Pe=String.raw`
|
|
127
|
+
${Ne}
|
|
136
128
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
|
|
137
129
|
echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
|
|
138
130
|
exit 1
|
|
@@ -174,7 +166,7 @@ if [ -n "$DEPLOY_USER" ]; then
|
|
|
174
166
|
$SUDO useradd -m -s /bin/bash "$DEPLOY_USER"
|
|
175
167
|
# '*' = no password but not locked; sshd without PAM refuses locked accounts even for keys.
|
|
176
168
|
$SUDO usermod -p '*' "$DEPLOY_USER"
|
|
177
|
-
# userdel refuses while the user has processes (a lingering ssh session of
|
|
169
|
+
# userdel refuses while the user has processes (a lingering ssh session of landfall's own).
|
|
178
170
|
record "created user $DEPLOY_USER" "[ \"\$(id -un)\" = $DEPLOY_USER ] || \$SUDO pkill -u $DEPLOY_USER 2>/dev/null || true; for i in 1 2 3 4 5; do \$SUDO userdel -r $DEPLOY_USER 2>/dev/null && break; ! id $DEPLOY_USER >/dev/null 2>&1 && break; sleep 1; done; ! id $DEPLOY_USER >/dev/null 2>&1 || \$SUDO deluser --remove-home $DEPLOY_USER"
|
|
179
171
|
fi
|
|
180
172
|
case " $(id -nG "$DEPLOY_USER") " in
|
|
@@ -187,7 +179,7 @@ if [ -n "$DEPLOY_USER" ]; then
|
|
|
187
179
|
$SUDO touch "$home/.ssh/authorized_keys"
|
|
188
180
|
if ! $SUDO grep -qxF "$PUBKEY" "$home/.ssh/authorized_keys"; then
|
|
189
181
|
printf '%s\n' "$PUBKEY" | $SUDO tee -a "$home/.ssh/authorized_keys" >/dev/null
|
|
190
|
-
record "authorized
|
|
182
|
+
record "authorized landfall's key for $DEPLOY_USER" "drop_line $(printf %q "$home/.ssh/authorized_keys") $(printf %q "$PUBKEY")"
|
|
191
183
|
fi
|
|
192
184
|
$SUDO chown "$DEPLOY_USER:$DEPLOY_USER" "$home/.ssh/authorized_keys"
|
|
193
185
|
$SUDO chmod 600 "$home/.ssh/authorized_keys"
|
|
@@ -196,14 +188,14 @@ else
|
|
|
196
188
|
OWNER="$(id -un)"
|
|
197
189
|
fi
|
|
198
190
|
|
|
199
|
-
log "preparing ${
|
|
200
|
-
new_sites=$([ -d ${
|
|
201
|
-
new_traefik=$([ -d ${
|
|
202
|
-
$SUDO install -d -o "$OWNER" -g "$OWNER" ${
|
|
203
|
-
$SUDO chown "$OWNER:$OWNER" ${
|
|
204
|
-
# ${
|
|
205
|
-
[ -z "$new_sites" ] || record "created ${
|
|
206
|
-
[ -z "$new_traefik" ] || record "created ${
|
|
191
|
+
log "preparing ${V}"
|
|
192
|
+
new_sites=$([ -d ${H} ] || echo 1)
|
|
193
|
+
new_traefik=$([ -d ${U} ] || echo 1)
|
|
194
|
+
$SUDO install -d -o "$OWNER" -g "$OWNER" ${V} ${H} ${U}
|
|
195
|
+
$SUDO chown "$OWNER:$OWNER" ${G} 2>/dev/null || true
|
|
196
|
+
# ${V} itself stays: it holds the journal. Each dir is only removed if setup created it.
|
|
197
|
+
[ -z "$new_sites" ] || record "created ${H}" "_as ${V} rmdir ${H}"
|
|
198
|
+
[ -z "$new_traefik" ] || record "created ${U}" "_as ${V} rm -rf ${U}"
|
|
207
199
|
|
|
208
200
|
DOCKER="$SUDO docker" ensure_network ${`web`}
|
|
209
201
|
|
|
@@ -222,10 +214,10 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
|
|
|
222
214
|
if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
|
|
223
215
|
# 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
|
|
224
216
|
want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
|
|
225
|
-
if [ "$($SUDO cat
|
|
217
|
+
if [ "$($SUDO cat ${De} 2>/dev/null || true)" != "$want" ]; then
|
|
226
218
|
$SUDO install -d /etc/ssh/sshd_config.d
|
|
227
|
-
undo=$(saved
|
|
228
|
-
printf '%s\n' "$want" | $SUDO tee
|
|
219
|
+
undo=$(saved ${De})
|
|
220
|
+
printf '%s\n' "$want" | $SUDO tee ${De} >/dev/null
|
|
229
221
|
if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
|
|
230
222
|
undo="$undo; $(saved /etc/ssh/sshd_config)"
|
|
231
223
|
printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
|
|
@@ -240,7 +232,7 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
|
|
|
240
232
|
fi
|
|
241
233
|
fi
|
|
242
234
|
log "bootstrap done"
|
|
243
|
-
`,
|
|
235
|
+
`,Fe=String.raw`
|
|
244
236
|
set +e
|
|
245
237
|
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
246
238
|
kv() { printf '%s=%s
|
|
@@ -269,17 +261,17 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
|
|
|
269
261
|
else
|
|
270
262
|
kv user 0
|
|
271
263
|
fi
|
|
272
|
-
kv root_dir "$(yn test -d ${
|
|
273
|
-
kv sites "$(ls ${
|
|
264
|
+
kv root_dir "$(yn test -d ${H})"
|
|
265
|
+
kv sites "$(ls ${H} 2>/dev/null | wc -l | tr -d ' ')"
|
|
274
266
|
kv network "$(yn S docker network inspect ${`web`})"
|
|
275
|
-
kv proxy "$(grep -v '^#' ${
|
|
267
|
+
kv proxy "$(grep -v '^#' ${W} 2>/dev/null | tr '\n' ' ')"
|
|
276
268
|
if command -v ufw >/dev/null 2>&1; then kv ufw "$(S ufw status 2>/dev/null | awk 'NR == 1 {print $2}')"; else kv ufw missing; fi
|
|
277
|
-
kv hardened "$(yn test -f
|
|
269
|
+
kv hardened "$(yn test -f ${De})"
|
|
278
270
|
kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
|
|
279
271
|
kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
|
|
280
|
-
f=${
|
|
272
|
+
f=${U}/docker-compose.yml
|
|
281
273
|
if [ ! -f "$f" ]; then kv traefik_compose none
|
|
282
|
-
elif grep -q "managed by
|
|
274
|
+
elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
|
|
283
275
|
else kv traefik_compose foreign; fi
|
|
284
276
|
kv traefik_container "$(S docker inspect -f '{{.State.Status}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' traefik 2>/dev/null)"
|
|
285
277
|
kv publish_80 "$(S docker ps --filter publish=80 --format '{{.Names}}' 2>/dev/null | tr '
|
|
@@ -304,14 +296,14 @@ else
|
|
|
304
296
|
S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
|
|
305
297
|
fi
|
|
306
298
|
exit 0
|
|
307
|
-
`,
|
|
299
|
+
`,Ie=e=>e.replace(/\$/g,`$$$$`),Le=e=>JSON.stringify(e);function Re(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return e.dashboard&&(t.push(`--api.dashboard=true`),n.push(`traefik.enable=true`,`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${Ie(e.dashboard.htpasswd)}`)),`# managed by landfall — regenerate with \`lf traefik setup\`
|
|
308
300
|
services:
|
|
309
301
|
traefik:
|
|
310
302
|
image: ${e.image}
|
|
311
303
|
container_name: traefik
|
|
312
304
|
restart: unless-stopped
|
|
313
305
|
command:
|
|
314
|
-
${t.map(e=>` - ${
|
|
306
|
+
${t.map(e=>` - ${Le(e)}`).join(`
|
|
315
307
|
`)}
|
|
316
308
|
${e.hostNetwork?` network_mode: host
|
|
317
309
|
`:` ports:
|
|
@@ -320,23 +312,24 @@ ${e.hostNetwork?` network_mode: host
|
|
|
320
312
|
`} volumes:
|
|
321
313
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
322
314
|
- ./letsencrypt:/letsencrypt
|
|
315
|
+
- ./dynamic:/etc/traefik/dynamic:ro
|
|
323
316
|
${e.hostNetwork?``:` networks:
|
|
324
317
|
- web
|
|
325
|
-
`}${n.length?` labels:\n${n.map(e=>` - ${
|
|
318
|
+
`}${n.length?` labels:\n${n.map(e=>` - ${Le(e)}`).join(`
|
|
326
319
|
`)}\n`:``}${e.hostNetwork?``:`networks:
|
|
327
320
|
web:
|
|
328
321
|
external: true
|
|
329
|
-
`}`}const
|
|
322
|
+
`}`}const q=e=>`landfall-${e}`;function ze(e,t,n,r=Oe,i=[]){let a=q(e),o=t.map(e=>`Host(\`${e}\`)`).join(` || `),s=i.length?[`traefik.http.middlewares.${a}-auth.basicauth.users=${i.join(`,`)}`,`traefik.http.routers.${a}.middlewares=${a}-auth`]:[];return[`traefik.enable=true`,`traefik.docker.network=${r.network}`,`traefik.http.routers.${a}.rule=${o}`,`traefik.http.routers.${a}.entrypoints=${r.entrypoint}`,`traefik.http.routers.${a}.tls.certresolver=${r.resolver}`,`traefik.http.services.${a}.loadbalancer.server.port=${n}`,...s,`landfall.managed=true`,`landfall.site=${e}`]}function Be(e,t=Oe){let n=e.releases.find(t=>t.id===e.current),r=ze(e.name,e.domains,e.port,t,e.auth).map(e=>` - ${Le(Ie(e))}`),i;return i=e.kind===`static`?` image: nginx:alpine
|
|
330
323
|
volumes:
|
|
331
324
|
- ./releases:/site/releases:ro
|
|
332
|
-
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro`:` image: ${
|
|
325
|
+
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro`:` image: ${Le(n?.image??``)}
|
|
333
326
|
env_file:
|
|
334
|
-
- .env`,`# managed by
|
|
335
|
-
name: ${
|
|
327
|
+
- .env`,`# managed by landfall — do not edit, changes are overwritten on deploy
|
|
328
|
+
name: ${q(e.name)}
|
|
336
329
|
services:
|
|
337
330
|
app:
|
|
338
331
|
${i}
|
|
339
|
-
container_name: ${
|
|
332
|
+
container_name: ${q(e.name)}
|
|
340
333
|
restart: unless-stopped
|
|
341
334
|
networks:
|
|
342
335
|
- ${t.network}
|
|
@@ -346,7 +339,7 @@ ${r.join(`
|
|
|
346
339
|
networks:
|
|
347
340
|
${t.network}:
|
|
348
341
|
external: true
|
|
349
|
-
`}function
|
|
342
|
+
`}function Ve(e,t){return`# managed by landfall
|
|
350
343
|
server {
|
|
351
344
|
listen 80;
|
|
352
345
|
server_name _;
|
|
@@ -370,7 +363,51 @@ server {
|
|
|
370
363
|
error_page 404 /404.html;
|
|
371
364
|
location = /404.html { internal; }
|
|
372
365
|
}
|
|
373
|
-
`}const
|
|
366
|
+
`}function He(e){return process.env[`SRV_${e}`]}function Ue(e,t){let n=p(e,`srv`);!o.existsSync(t)&&o.existsSync(p(n,`config.json`))&&o.renameSync(n,t)}const We=`srv.json`,Ge=e=>p(s(),`.ssh`,`srv_${e}`),J=`/opt/srv`,Ke=`[ ! -d ${J} ] || [ -e ${V} ] || { echo "this server still uses ${J} from when landfall was called srv; run 'lf setup' for it once to move it" >&2; exit 3; }\n`,qe=`moved ${J} to ${V} (landfall was called srv); older changes can no longer be undone`,Je=String.raw`
|
|
367
|
+
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
|
|
368
|
+
echo "moving ${J} needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
|
|
369
|
+
exit 1
|
|
370
|
+
fi
|
|
371
|
+
[ ! -e ${V} ] || { echo "both ${J} and ${V} exist; merge them by hand, then remove ${J}" >&2; exit 1; }
|
|
372
|
+
log "moving ${J} to ${V}"
|
|
373
|
+
$SUDO mv ${J} ${V}
|
|
374
|
+
# Only the older journal steps restored these backups; the .env copies hold secrets.
|
|
375
|
+
$SUDO find ${V} /etc/ssh -name '.*.srv-*' -type f -delete 2>/dev/null || true
|
|
376
|
+
$SUDO rm -f ${Me}.srv-*
|
|
377
|
+
old_conf=/etc/ssh/sshd_config.d/00-srv.conf
|
|
378
|
+
[ ! -f $old_conf ] || $SUDO mv $old_conf ${De}
|
|
379
|
+
# Rewrite a file in place, keeping its owner (deploy users must still write it).
|
|
380
|
+
rewrite() { o=$($SUDO stat -c %u:%g "$1"); $SUDO sed -i -E "$2" "$1"; $SUDO chown "$o" "$1"; }
|
|
381
|
+
dc version >/dev/null 2>&1 || true
|
|
382
|
+
sdc() { $SUDO $DC "$@"; }
|
|
383
|
+
f=${U}/docker-compose.yml
|
|
384
|
+
if [ -f $f ] && grep -q "managed by srv" $f; then
|
|
385
|
+
rewrite $f 's/managed by srv/managed by landfall/'
|
|
386
|
+
log "restarting traefik from ${U}"
|
|
387
|
+
(cd ${U} && sdc up -d --force-recreate --quiet-pull)
|
|
388
|
+
fi
|
|
389
|
+
for d in ${H}/*; do
|
|
390
|
+
n=$(basename "$d"); f="$d/docker-compose.yml"
|
|
391
|
+
[ -f "$f" ] && grep -q "managed by srv" "$f" || continue
|
|
392
|
+
rewrite "$f" 's/managed by srv/managed by landfall/; s/^name: srv-/name: landfall-/; s/container_name: srv-/container_name: landfall-/; s/(routers|services|middlewares)\.srv-/\1.landfall-/g; s/middlewares=srv-/middlewares=landfall-/; s/"srv\.(managed|site)=/"landfall.\1=/'
|
|
393
|
+
[ ! -f "$d/nginx.conf" ] || rewrite "$d/nginx.conf" 's/managed by srv/managed by landfall/'
|
|
394
|
+
# A stopped site stays stopped.
|
|
395
|
+
if [ "$($SUDO docker inspect -f '{{.State.Running}}' "srv-$n" 2>/dev/null)" = true ]; then act="up -d"; else act=create; fi
|
|
396
|
+
log "moving srv-$n to landfall-$n"
|
|
397
|
+
if (cd "$d" && sdc $act --quiet-pull >/dev/null); then $SUDO docker rm -f "srv-$n" >/dev/null 2>&1 || true
|
|
398
|
+
else log "landfall-$n did not start, srv-$n keeps running; redeploy $n"; fi
|
|
399
|
+
done
|
|
400
|
+
record "${qe}"
|
|
401
|
+
`;async function Ye(e,t){let{stdout:n}=await R(e,`[ ! -d ${J} ] || echo old`,{capture:!0,oldLayout:!0});return n.trim()!==`old`||(w(`${J} is from when landfall was called srv: its sites, traefik and journal move to ${V}`),C(b.dim(`sites keep running; traefik restarts once; changes made before the move can no longer be undone`)),t.check||!await j(`move /opt/srv to /opt/landfall?`)?!1:(await R(e,K+Je,{oldLayout:!0}),S(`moved to ${V}`),!0))}const Xe=e=>process.env[`LANDFALL_${e}`]??He(e),Ze=process.env.XDG_CONFIG_HOME??p(s(),`.config`),Qe=Xe(`CONFIG_DIR`)??p(Ze,`landfall`),$e=p(Qe,`config.json`);Xe(`CONFIG_DIR`)||Ue(Ze,Qe);const Y=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,et=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function X(){try{let e=JSON.parse(o.readFileSync($e,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:E(`cannot read ${$e}: ${e.message}`)}}function tt(e){o.mkdirSync(f($e),{recursive:!0,mode:448}),o.writeFileSync($e,JSON.stringify(e,null,2)+`
|
|
402
|
+
`,{mode:384})}function Z(e){let t=X();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?E("no server given. Use --server <ssh-alias> or `lf server add`"):E(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const nt=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,rt=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return nt.test(t)||E(`not a domain: ${e}`),t},it=/^[a-z0-9][a-z0-9._\-/:@]*$/i,at=e=>it.test(e)?e:E(`not an image: ${e}`);function ot(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&E(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const st=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,ct=e=>e.domains?.[0];function lt(e,t){let n=ct(t);return e===`@`?n??E("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const ut=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),dt=e=>{let t=e.toLowerCase().split(`.`),n=t.length>=3&&t.at(-1).length===2&&t.at(-2).length<=3?3:2;return t.slice(-n).join(`.`)};function ft(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!nt.test(n))continue;let e=dt(n);ut.has(e)||t.set(e,(t.get(e)??0)+1)}return[...t].filter(([,e])=>e>=2).sort((e,t)=>t[1]-e[1]).map(([e,t])=>({domain:e,hosts:t}))}const pt=`.landfall.json`,mt=e=>{let t=p(e,pt);return o.existsSync(t)||!o.existsSync(p(e,`srv.json`))?t:p(e,We)};function ht(e){try{return JSON.parse(o.readFileSync(mt(e),`utf8`))}catch{return}}function gt(e,t){o.writeFileSync(p(e,pt),JSON.stringify(t,null,2)+`
|
|
403
|
+
`),o.rmSync(p(e,We),{force:!0})}const _t=Xe(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),vt=/* @__PURE__ */ new Map;function Q(e){let n=[...pe(e),`-G`,fe(e)],r=vt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return E(`ssh cannot resolve ${fe(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>Y(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return vt.set(n.join(`\0`),s),s}function yt(e){return(e.key?[Y(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function bt(e=_t,t=/* @__PURE__ */ new Set){if(t.has(e)||!o.existsSync(e))return[];t.add(e);let n=[];for(let r of o.readFileSync(e,`utf8`).split(`
|
|
404
|
+
`)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=Y(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of xt(i))n.push(...bt(e,t))}}return[...new Set(n)]}function xt(e){if(!/[*?[]/.test(e))return[e];let t=f(e);return o.existsSync(t)?o.globSync(d(e),{cwd:t}).map(e=>p(t,e)):[]}const St=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],Ct=(e,t)=>RegExp(`# >>> (?:landfall|srv):${jt(e)}\\n[\\s\\S]*?# <<< (?:landfall|srv):${jt(e)}${t}`),wt=(e,t)=>e===`# >>> landfall:${t}`||e===`# >>> srv:${t}`;function Tt(e){let[t,n]=St(e.alias);return[t,`Host ${e.alias}`,` HostName ${e.hostname}`,` User ${e.user}`,...e.port===22?[]:[` Port ${e.port}`],` IdentityFile ${e.key}`,` IdentitiesOnly yes`,n].join(`
|
|
405
|
+
`)}function Et(e,t=_t){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=Tt(e),a=Ct(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
|
|
406
|
+
`):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),vt.clear(),{created:n,block:i}}function Dt(e,t=_t,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
|
|
407
|
+
`);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!(/* @__PURE__ */ RegExp(`^# <<< (landfall|srv):`)).test(r[n]);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:wt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=Y(t);for(let t of xt(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=Dt(e,t,n);if(r)return r}}}}function Ot(e,t){let n=Dt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
|
|
408
|
+
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(!o.existsSync(Y(n))||Y(n)===t)}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
|
|
409
|
+
`)),vt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function kt(e,t=_t){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=Ct(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const At=(e,t=_t)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
|
|
410
|
+
`).some(t=>wt(t,e)),jt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Mt=String.raw`
|
|
374
411
|
set +e
|
|
375
412
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
376
413
|
PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
|
|
@@ -392,28 +429,27 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
|
|
|
392
429
|
kv ip6 "$(ip -6 route get 2606:4700:4700::1111 2>/dev/null | sed -n 's/.* src \([0-9a-fA-F:]*\).*/\1/p' | head -n1)"
|
|
393
430
|
kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
|
|
394
431
|
exit 0
|
|
395
|
-
`;function
|
|
396
|
-
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:
|
|
397
|
-
`,{mode:384})}function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
--
|
|
401
|
-
|
|
402
|
-
P=${it}
|
|
432
|
+
`;function Nt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
|
|
433
|
+
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:It(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Pt(e){let{stdout:t}=await R(e,K+Mt,{capture:!0});return Nt(t)}const Ft={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`};function It(e,t,n,r=/* @__PURE__ */ new Date){if(n===`none`)return{level:`unsupported`,note:`no apt, dnf, yum, apk or zypper: setup cannot install packages`};let i=t.split(`.`)[0],a=t.split(`.`).slice(0,2).join(`.`),o=Ft[`${e} ${t}`]??Ft[`${e} ${a}`]??Ft[`${e} ${i}`];if(!o)return{level:`ok`};let s=(Date.parse(o)-r.getTime())/864e5;return s<0?{level:`eol`,note:`${e} ${t} reached end of life on ${o}: no more security updates, upgrade the release`}:s<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${o}`}:{level:`ok`}}const Lt=/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/,Rt=e=>h(e)===4&&!Lt.test(e);async function zt(e,t){let n=Q(e).hostname,r=h(n)?[n]:await te.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Bt=e=>e.find(Rt)??e[0],Vt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Ht(e,t){return Promise.all(e.map(async e=>{let n=await te.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Ut=e=>{let t=[`B`,`K`,`M`,`G`,`T`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)}${t[n]}`},Wt=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);return t?`${t}d ${n}h`:n?`${n}h ${Math.floor(e%3600/60)}m`:`${Math.floor(e/60)}m`},Gt=[`dist`,`build`,`out`,`public`,`_site`,`site`];function Kt(e){return o.existsSync(p(e,`index.html`))?`.`:Gt.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function qt(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Jt=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Yt(e){let t=ht(e),n=t?.name??Jt(d(e));if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let r=t=>o.existsSync(p(e,t)),i=t?.build;if(!i&&r(`package.json`))try{JSON.parse(o.readFileSync(p(e,`package.json`),`utf8`)).scripts?.build&&(i=r(`bun.lock`)||r(`bun.lockb`)?`bun run build`:r(`pnpm-lock.yaml`)?`pnpm build`:r(`yarn.lock`)?`yarn build`:`npm run build`)}catch{}if(!t?.static&&r(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??qt(p(e,`Dockerfile`))??3e3,project:t};let a=t?.dir??Kt(e);return{path:e,name:n,kind:`static`,dir:a!==`.`||r(`index.html`)?a:void 0,build:i,project:t}}const Xt=p(Qe,`projects.json`);function Zt(){try{return JSON.parse(o.readFileSync(Xt,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Qt(e){let t=ee(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Zt().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(Qe,{recursive:!0,mode:448}),o.writeFileSync(Xt,JSON.stringify(n,null,2)+`
|
|
434
|
+
`,{mode:384})}function $t(e,t,n){let r=Zt();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=ht(i.path);e?.name===t&>(i.path,{...e,domains:n})}r.length&&o.writeFileSync(Xt,JSON.stringify(r,null,2)+`
|
|
435
|
+
`,{mode:384})}const $=e=>`${H}/${e}`;function en(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||E(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const tn=`
|
|
436
|
+
--8<--landfall--8<--
|
|
437
|
+
`;async function nn(e,t){let n=$(t),{stdout:r}=await R(e,[...[`${n}/site.json`,`${n}/.env`,W].map(e=>`cat ${L(e)} 2>/dev/null || true`),`[ ! -d ${L(n)} ] || echo 1`].join(`; printf %s ${L(tn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(tn),c;if(i.trim())try{c=JSON.parse(i)}catch{E(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ae(o)??Oe,exists:s.trim()===`1`}}async function rn(e,t){let n=await nn(e,t);return n.meta||E(`no site "${t}" on ${e.host}. See \`lf sites list\``),n}const an=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),on=(e,t,n)=>ye(e,[`docker logs --tail ${L(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);async function sn(e){let{stdout:t}=await R(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
|
|
438
|
+
P=${ke}
|
|
403
439
|
printf 'TRAEFIK\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P"
|
|
404
|
-
for f in ${
|
|
405
|
-
docker ps -a --filter label=
|
|
406
|
-
`)){let[t,o=``,s]=a.split(` `);if(t===`READY`&&(i.ready=o===`1`),t===`TRAEFIK`&&(i.traefik=o,i.proxy=s||`traefik`),t===`SITE`)try{r.push(JSON.parse(a.slice(t.length+1)))}catch{
|
|
407
|
-
`),
|
|
440
|
+
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
|
|
441
|
+
docker ps -a --filter label=landfall.managed=true --format 'CTR\\t{{.Names}}\\t{{.Status}}' 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=[],i={ready:!1,traefik:``,proxy:`traefik`,sites:[]};for(let a of t.split(`
|
|
442
|
+
`)){let[t,o=``,s]=a.split(` `);if(t===`READY`&&(i.ready=o===`1`),t===`TRAEFIK`&&(i.traefik=o,i.proxy=s||`traefik`),t===`SITE`)try{r.push(JSON.parse(a.slice(t.length+1)))}catch{w(`skipped a corrupt site.json on ${e.host}: ${a.slice(5,80)}`)}t===`CTR`&&n.set(o,s)}return i.sites=r.map(e=>({meta:e,status:n.get(q(e.name))??`not running`})),i}const cn=async e=>(await sn(e)).sites;function ln(e,t,n={}){let r=$(e.name),i=e.releases.find(t=>t.id===e.current),a=[`mkdir -p ${L(r)} && cd ${L(r)}`,B(`site.json`,JSON.stringify(e,null,2)+`
|
|
443
|
+
`),B(`docker-compose.yml`,Be(e,t)),n.env===void 0?`touch .env && chmod 600 .env`:B(`.env`,n.env,`600`)];return e.kind===`static`&&a.push(B(`nginx.conf`,Ve(e.current,!!e.spa))),e.kind!==`image`&&a.push(`ln -sfn ${L(`releases/`+e.current)} current`),n.build&&e.kind===`dockerfile`&&a.push(`log "building image ${i.image}"`,`docker build -q -t ${L(i.image)} ${L(`releases/`+e.current)} >/dev/null`),n.pull&&e.kind===`image`&&a.push(`log "pulling ${i.image}"`,`dc pull -q`),a.push(`log "starting ${q(e.name)}"`,`{ dc up -d --remove-orphans --quiet-pull && ensure_running ${q(e.name)}; } || exit 1`),e.kind===`static`&&a.push(`docker exec ${q(e.name)} nginx -s reload >/dev/null 2>&1 || true`),a}const un=(e,t)=>[`rm -rf ${L(`${$(e.name)}/releases/${t.id}`)}`,...e.kind===`dockerfile`&&t.image?[`docker image rm ${L(t.image)} >/dev/null 2>&1 || true`]:[]];async function dn(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=an(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[K],u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${L(c+`/.env`)})`),!r.prev)u=`[ ! -d ${L(c)} ] || {\n${pn(t,r.existed).join(`
|
|
408
444
|
`)}\n`,d=`
|
|
409
|
-
}`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${
|
|
410
|
-
`+[...
|
|
411
|
-
`)}l.push(`record ${
|
|
412
|
-
`))}async function
|
|
413
|
-
`))}function
|
|
445
|
+
}`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${L(c)} ] || { log ${L(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
|
|
446
|
+
`+[...ln(i,n),...a.flatMap(e=>un(t,e))].join(`
|
|
447
|
+
`)}l.push(`record ${L(r.what)} ${L(u)}${f?`"$u"`:``}${L(d)}`),l.push(...ln(t,n,r));for(let e of s)l.push(...un(t,e));await R(e,l.join(`
|
|
448
|
+
`))}async function fn(e,t,n,r=``){await R(e,`${K}\ncd ${L($(t))}\n${r}\ndc ${n}`)}function pn(e,t=!1){let n=$(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[],i=[`site.json`,`docker-compose.yml`,`nginx.conf`,`current`,...e.releases.map(e=>`releases/${e.id}`)].map(L).join(` `);return[`cd ${L(n)}`,`dc down --remove-orphans >/dev/null 2>&1 || docker rm -f ${q(e.name)} >/dev/null 2>&1 || true`,...r.map(e=>`docker image rm ${L(e)} >/dev/null 2>&1 || true`),...t?[`rm -rf ${i} 2>/dev/null || $SUDO rm -rf ${i}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rm -rf ${L(n)} 2>/dev/null || $SUDO rm -rf ${L(n)}`]]}async function mn(e,t){await R(e,[K,...pn(t),`record ${L(`removed site ${t.name} (${t.domains.join(`, `)})`)}`].join(`
|
|
449
|
+
`))}function hn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
|
|
414
450
|
`)){let e=t.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1];e&&n.includes(e)||(e&&i.has(e)?(r.push(`${e}=${i.get(e)}`),i.delete(e)):(t.trim()||r.length)&&r.push(t))}for(;r.length&&!r[r.length-1].trim();)r.pop();for(let[e,t]of i)r.push(`${e}=${t}`);return r.length?r.join(`
|
|
415
451
|
`)+`
|
|
416
|
-
`:``}function
|
|
452
|
+
`:``}function gn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&E(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function _n(e,t){let{stdout:n}=await R(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r={...e,user:`root`};return Q(e).user!==`root`&&await z(r)?r:E(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const vn=String.raw`
|
|
417
453
|
set +e
|
|
418
454
|
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
419
455
|
m=$(pkg_mgr); echo "mgr=$m"
|
|
@@ -433,8 +469,8 @@ case "$m" in
|
|
|
433
469
|
esac
|
|
434
470
|
[ -f /var/run/reboot-required ] && echo reboot=1
|
|
435
471
|
exit 0
|
|
436
|
-
`;function
|
|
437
|
-
`)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function
|
|
472
|
+
`;function yn(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
|
|
473
|
+
`)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function bn(e,t=!0){let{stdout:n}=await R(e,K+vn,{capture:!0,env:{REFRESH:t}});return yn(n)}const xn=String.raw`
|
|
438
474
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
|
|
439
475
|
keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
|
|
440
476
|
case "$(pkg_mgr)" in
|
|
@@ -454,8 +490,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
|
|
|
454
490
|
# Detached, so this ssh session can end cleanly first.
|
|
455
491
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
456
492
|
fi
|
|
457
|
-
`;async function
|
|
458
|
-
${
|
|
493
|
+
`;async function Sn(e,t){await R(e,K+xn,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Cn(e){let[t,n]=e.split(`@`),r=`latest`,i=t.lastIndexOf(`:`);i>t.lastIndexOf(`/`)&&(r=t.slice(i+1),t=t.slice(0,i));let a=t.split(`/`)[0],o=`registry-1.docker.io`;return t.includes(`/`)&&(a.includes(`.`)||a.includes(`:`)||a===`localhost`)&&(o=a===`docker.io`?o:a,t=t.slice(a.length+1)),o===`registry-1.docker.io`&&!t.includes(`/`)&&(t=`library/${t}`),{registry:o,repo:t,tag:r,digest:n}}async function wn(e,t,n=`GET`){let r=t.startsWith(`https://`)?t:`https://${e.registry}/v2/${e.repo}/${t}`,i=AbortSignal.timeout(1e4),a=e=>fetch(r,{method:n,signal:i,headers:{accept:`application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json`,...e?{authorization:e}:{}}});try{let t=await a();if(t.status===401){let n=t.headers.get(`www-authenticate`)??``,r=Object.fromEntries([...n.matchAll(/(\w+)="([^"]*)"/g)].map(e=>[e[1],e[2]]));if(!/^bearer/i.test(n)||!r.realm)return;let o=new URL(r.realm);r.service&&o.searchParams.set(`service`,r.service),o.searchParams.set(`scope`,r.scope??`repository:${e.repo}:pull`);let s=await fetch(o,{signal:i}).then(e=>e.ok?e.json():void 0),c=s?.token??s?.access_token;if(!c)return;t=await a(`Bearer ${c}`)}return t.ok?t:void 0}catch{return}}async function Tn(e){let t=Cn(e);return t.digest?t.digest:(await wn(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function En(e){let t=Cn(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await wn(t,r);if(!e)break;n.push(...(await e.json().catch(()=>({}))).tags??[]);let i=e.headers.get(`link`)?.match(/<([^>]+)>;\s*rel="next"/)?.[1];r=i?`https://${t.registry}${i}`:void 0}return n}function Dn(e,t){let n=e.replace(/^v/,``).split(/[.+-]/).map(Number),r=t.replace(/^v/,``).split(/[.+-]/).map(Number);for(let e=0;e<Math.max(n.length,r.length);e++){let t=(n[e]||0)-(r[e]||0);if(t)return t}return 0}const On=/^(v?)(\d+(?:\.\d+){0,2})$/;function kn(e,t){let n=On.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=On.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Dn(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const An=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,jn=/* @__PURE__ */ new Map;function Mn(e){return jn.has(e)||jn.set(e,fetch(`https://github.com/${e}/releases/latest`,{method:`HEAD`,redirect:`manual`,signal:AbortSignal.timeout(1e4)}).then(e=>e.headers.get(`location`)?.match(/\/tag\/(?:docker-)?v?([^/]+)$/)?.[1]).catch(()=>void 0)),jn.get(e)}const Nn=()=>Mn(`moby/moby`),Pn=()=>Mn(`docker/compose`);async function Fn(e){let t=String.raw`
|
|
494
|
+
${K}
|
|
459
495
|
set +e
|
|
460
496
|
printf 'docker=%s
|
|
461
497
|
' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
|
@@ -465,60 +501,67 @@ printf 'path=%s
|
|
|
465
501
|
' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
|
|
466
502
|
printf 'ce=%s
|
|
467
503
|
' "$(pkg_has docker-ce && echo 1)"
|
|
468
|
-
exit 0`,[{stdout:n},r,i]=await Promise.all([
|
|
469
|
-
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function
|
|
470
|
-
`)}const
|
|
504
|
+
exit 0`,[{stdout:n},r,i]=await Promise.all([R(e,t,{capture:!0}),Nn(),Pn()]),a=Object.fromEntries(n.split(`
|
|
505
|
+
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function In(e){let t=(e,t)=>!!e&&!!t&&Dn(e,t)<0,n=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`}`:void 0,r=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`;return{dockerNote:n,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${r?``:e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:r}}async function Ln(e){await R(e,K+Ne+`compose_install || { log "could not download docker compose"; exit 1; }
|
|
506
|
+
`)}const Rn=String.raw`
|
|
471
507
|
set +e
|
|
472
|
-
P=${
|
|
508
|
+
P=${ke}
|
|
473
509
|
img() { # img <kind> <name> <container> <dir>
|
|
474
510
|
ref=$(docker inspect -f '{{.Config.Image}}' "$3" 2>/dev/null) || { printf 'IMG\t%s\t%s\t%s\t%s\t\t\n' "$1" "$2" "$3" "$4"; return 0; }
|
|
475
511
|
id=$(docker inspect -f '{{.Image}}' "$3" 2>/dev/null)
|
|
476
512
|
printf 'IMG\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$ref" "$(docker image inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$id" 2>/dev/null)"
|
|
477
513
|
}
|
|
478
|
-
if [ "$P" = traefik ] && grep -q "managed by
|
|
514
|
+
if [ "$P" = traefik ] && grep -q "managed by landfall" ${U}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${U}
|
|
479
515
|
else img proxy "$P" "$P" ""; fi
|
|
480
|
-
for f in ${
|
|
516
|
+
for f in ${H}/*/site.json; do
|
|
481
517
|
[ -f "$f" ] || continue
|
|
482
518
|
d=$(dirname "$f"); n=$(basename "$d")
|
|
483
|
-
if grep -q '"kind": *"dockerfile"' "$f"; then printf 'IMG\tbuilt\t%s\
|
|
484
|
-
img site "$n" "
|
|
519
|
+
if grep -q '"kind": *"dockerfile"' "$f"; then printf 'IMG\tbuilt\t%s\t${q(`%s`)}\t%s\t\t\n' "$n" "$n" "$d"; continue; fi
|
|
520
|
+
img site "$n" "${q(`$n`)}" "$d"
|
|
485
521
|
done
|
|
486
522
|
exit 0
|
|
487
|
-
`;async function
|
|
488
|
-
`).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!
|
|
489
|
-
`))}function
|
|
490
|
-
`)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(
|
|
523
|
+
`;async function zn(e){let{stdout:t}=await R(e,Rn,{capture:!0}),n=t.split(`
|
|
524
|
+
`).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!On.test(Cn(e).tag))return;i.has(t)||i.set(t,En(e));let n=kn(Cn(e).tag,await i.get(t));return n&&An(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``])=>{let c={name:t,container:n,dir:i,ref:o};if(e===`built`)return{...c,kind:`site`,state:`rebuild`,note:`built from its Dockerfile: redeploy to rebuild`};if(!o)return{...c,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...c,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(o.includes(`@`))return{...c,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(o)||r.set(o,Tn(o));let[l,u]=await Promise.all([r.get(o),a(o)]),d=e,f=e=>Cn(e).tag.replace(/^v/,``).split(`.`)[0],p=d===`traefik`?`lf traefik setup --image ${u}`:`redeploy with it`,m=u?{newer:u,note:`newer version ${u} (${f(u)===f(o)?``:`new major, read its upgrade notes; `}${p})`}:{};return l?{...c,kind:d,state:s.split(` `).some(e=>e.endsWith(`@${l}`))?`current`:`update`,...m}:{...c,kind:d,state:`unknown`,note:`registry did not answer (private image?)`,...m}}))}async function Bn(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[K];for(let[e,t]of n){r.push(`log ${L(`pulling ${e}`)}`,`docker pull -q ${L(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${L(e)})`);for(let n of t){let t=`docker tag $old ${L(e)} && cd ${L(n.dir)} && dc up -d`;r.push(`old=$(docker inspect -f '{{.Image}}' ${L(n.container)})`,`if [ "$old" = "$new" ]; then log ${L(`${n.name} already runs the newest ${e}`)}; else`,` record ${L(`updated ${n.name} to the newest ${e}`)} "${t.replace(/"/g,`\\"`)}"`,` log ${L(`recreating ${n.container}`)}`,` (cd ${L(n.dir)} && dc up -d --quiet-pull) && ensure_running ${L(n.container)} || exit 1`,`fi`)}}await R(e,r.join(`
|
|
525
|
+
`))}function Vn(e){let t=e.split(` `),n=t[0]===`udp`?`udp`:`tcp`;/^(tcp|udp)/.test(t[0])&&t.shift();let[r,i=``]=t,a=r.lastIndexOf(`:`);return{proto:n,addr:r.slice(0,a),port:Number(r.slice(a+1)),proc:i.match(/"([^"]+)"/)?.[1]??i.split(`/`)[1]??(i||`?`)}}function Hn(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
|
|
526
|
+
`)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(Vn(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function Un(e,t={}){let{stdout:n}=await R(e,K+Fe,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return Hn(n)}const Wn=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),Gn=e=>e.includes(`:`),Kn=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,qn=[68,546,5353];function Jn(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=tr(t?t[1]:e),r=e=>n.includes(e)?n[n.indexOf(e)+1]:void 0,i=[`allow`,`deny`,`reject`,`limit`].find(e=>e===n[0])??`allow`,a=r(`port`),o=r(`proto`);if(!n.includes(`to`)&&!n.includes(`from`)){let e=n.find(e=>/^[\d,:]+(\/(tcp|udp))?$/.test(e));[a,o]=e?.split(`/`)??[]}let s=r(`from`);return{spec:e,action:i,port:a,proto:o===`tcp`||o===`udp`?o:void 0,from:s&&s!==`any`?s:void 0,comment:t?.[2],out:n[1]===`out`}}function Yn(e,t){let n=e.findIndex(e=>!e.out&&(!e.proto||e.proto===t.proto)&&!!e.port?.split(`,`).some(e=>{let[n,r=n]=e.split(`:`).map(Number);return t.port>=n&&t.port<=r}));return n<0?void 0:{rule:e[n],n:n+1}}const Xn=(e,t)=>{let n=Yn(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Zn=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Kn(e),t(e)])).values()];function Qn(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=$n(e,[t.sshPort,80,443]);return r.length?n(`conflict`,`enabling ufw would block ${r.join(`, `)}`):n(`todo`,`enable ufw (ssh/80/443)${i.length?`; docker keeps ${i.join(` `)} open`:``}`)}function $n(e,t){let n=e.values,r=e.rules.map(Jn),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&qn.includes(e.port)||Xn(r,e),a=e=>!Wn(e.addr)&&!i(e),o=e.listen.filter(e=>e.proc!==`docker-proxy`&&a(e)),s=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)].filter(a),c=n.docker_fw===`nftables`,l=s.filter(e=>n[Gn(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Zn(o,e=>`${Kn(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Zn(l,e=>`${Kn(e)} (docker${Gn(e.addr)?`, IPv6`:``})`)],kept:c?[]:Zn(s.filter(e=>!l.includes(e)),Kn)}}function er(e){let t=e.values,n=e.rules.map(Jn),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[Gn(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(Wn(e.addr))return{state:`local`,why:`localhost only`};if(!r)return{state:`open`,why:`open, firewall off`};if(a&&i(e))return{state:`open`,why:`open, docker skips ufw`};let o=Yn(n,e);if(o){let{rule:e,n:t}=o;return e.action===`deny`||e.action===`reject`?{state:`blocked`,why:`blocked by rule ${t}`}:e.from?{state:`limited`,why:`only from ${e.from} (rule ${t})`}:{state:`open`,why:`allowed by rule ${t}${e.action===`limit`?`, rate-limited`:``}`}}return e.proto===`udp`&&qn.includes(e.port)?{state:`open`,why:`ufw default rule`}:t.ufw_in===`ACCEPT`?{state:`open`,why:`default policy accepts`}:{state:`blocked`,why:`blocked, no rule`}},o=[...e.published,...e.listen.filter(e=>e.proc===`docker-proxy`)],s=[...e.listen.filter(e=>e.proc!==`docker-proxy`).map(e=>({l:e,docker:!1})),...o.map(e=>({l:e,docker:!0}))],c=/* @__PURE__ */ new Map;for(let{l:e,docker:t}of s.sort((e,t)=>e.l.port-t.l.port||e.l.proto.localeCompare(t.l.proto))){let n=`${Kn(e)} ${t?`docker`:e.proc}`,r={port:Kn(e),proc:t?`docker`:e.proc,...a(e,t)};c.has(n)&&c.get(n).state!==`local`||c.set(n,r)}return[...c.values()]}const tr=(e=``)=>e.split(/\s+/).filter(Boolean);function nr(e){let t=(e,t=``)=>({part:`docker`,label:`docker`,status:e,detail:t});if(!e.docker)return t(`todo`,`install docker and compose`);let n=e.docker.match(/\d+(\.\d+)+/)?.[0]??e.docker;if(e.docker_podman===`1`)return t(`conflict`,`${e.docker} is podman's docker shim; landfall needs Docker Engine (remove podman-docker, then re-run setup)`);let r=[e.docker_running===`1`?``:`start it`,Number(e.compose?.split(`.`)[0])>=2?``:`install compose`].filter(Boolean);if(r.length)return t(`todo`,`docker ${n}, ${r.join(` + `)}`);let i=(e,t=``)=>t&&Dn(e,t)<0?` (${t} is current)`:``;return t(`done`,`docker ${n}${i(n,e.docker_latest)}, compose ${e.compose}${i(e.compose,e.compose_latest)}`)}function rr(e,t){let n=e.values,r=(e,t,n,r=``)=>({part:e,label:t,status:n,detail:r}),i=[],a=n.pkgs_missing??``;i.push(n.pkg===`none`?r(`packages`,`base packages`,`done`,`no supported package manager, skipped`):a.trim()?r(`packages`,`base packages`,`todo`,`install ${a.trim()}`):r(`packages`,`base packages`,`done`)),i.push(nr(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,V,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(Qn(e,t)):i.push(r(`firewall`,`firewall`,`off`)),!t.harden)i.push(r(`harden`,`ssh hardening`,`off`));else if(n.hardened===`1`)i.push(r(`harden`,`ssh hardening`,`done`));else{let e=tr(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(or(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function ir(e=``){let[t=``,n=``,r=``,i=``]=e.split(`|`);if(!/traefik/.test(n))return;let a=t.replace(/^\//,``),o=e=>i.match(e)?.[1],s=o(/--entrypoints\.([\w-]+)\.address=[^\s]*:443\b/),c=r.split(`,`).filter(e=>e&&e!==`bridge`&&e!==`host`),l={container:a,network:o(/--providers\.docker\.network=(\S+)/)??(c.length===1?c[0]:void 0),entrypoint:s,resolver:(s&&o(RegExp(`--entrypoints\\.${s}\\.http\\.tls\\.certresolver=(\\S+)`)))??o(/--certificatesresolvers\.([\w-]+)\./)},u=[`network`,`entrypoint`,`resolver`].filter(e=>!l[e]);return{name:a,proxy:u.length?void 0:l,missing:u}}const ar=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function or(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ae(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${ar(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=tr(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=ir(t.foreign_proxy);if(!e||e.name!==`traefik`)return n(`conflict`,`a container named traefik exists that landfall did not create`)}for(let r of[80,443]){let i=tr(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=ir(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${ar(e.proxy)}`,e.proxy):e?n(`conflict`,`traefik ${e.name} owns 80/443, its flags don't name the ${e.missing.join(`, `)}: lf traefik use ${e.name} ${e.missing.map(e=>`--${e} …`).join(` `)}`):n(`conflict`,`port ${r} is published by container ${i.join(`, `)}`)}let a=e.listen.find(e=>e.port===r&&e.proc!==`docker-proxy`&&!(o&&e.proc===`traefik`));if(a)return n(`conflict`,`port ${r} is in use by ${a.proc}`)}return o&&i===`running`?n(`done`,`running (config refreshed)`):n(`todo`,o?`${i}, start`:`install`)}function sr(e){let t={done:b.green(`✓`),todo:b.cyan(`→`),conflict:b.yellow(`!`),off:b.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?b.dim(`disabled`):r.status===`conflict`?b.yellow(r.detail):b.dim(r.detail);C(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function cr(e,t){return`${e}:${(await _.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}function lr(e,t){return{email:t.email??e.email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork)}}const ur=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function dr(e,t,n){let r=lr(t,n);at(r.image);let{email:i,staging:a}=r;x(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?lt(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){nt.test(c)||E(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??g(12).toString(`base64url`),o={host:c,user:e,htpasswd:await cr(e,s)}}let l=Re({...r,dashboard:o});await R(t,`${K}
|
|
491
527
|
ensure_network web
|
|
492
|
-
[ -w ${
|
|
493
|
-
mkdir -p ${
|
|
528
|
+
[ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
|
|
529
|
+
mkdir -p ${U}/letsencrypt ${Te} && cd ${U}
|
|
530
|
+
${B(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
|
|
531
|
+
tls:
|
|
532
|
+
options:
|
|
533
|
+
default:
|
|
534
|
+
# Refuse the handshake for a name no certificate covers: no 404, no warning about a bad certificate.
|
|
535
|
+
sniStrict: true
|
|
536
|
+
`)}
|
|
494
537
|
if [ -e docker-compose.yml ]; then
|
|
495
|
-
${
|
|
538
|
+
${B(`.docker-compose.yml.new`,l)}
|
|
496
539
|
# Same config again: nothing to journal.
|
|
497
540
|
if cmp -s .docker-compose.yml.new docker-compose.yml; then rm -f .docker-compose.yml.new
|
|
498
541
|
else
|
|
499
542
|
u=$(saved docker-compose.yml)
|
|
500
543
|
mv .docker-compose.yml.new docker-compose.yml
|
|
501
|
-
record ${
|
|
544
|
+
record ${L(`reconfigured traefik (${ur(r)})`)} "cd ${U} && $u && dc up -d --remove-orphans"
|
|
502
545
|
fi
|
|
503
546
|
else
|
|
504
|
-
${
|
|
547
|
+
${B(`docker-compose.yml`,l)}
|
|
505
548
|
# Certificates in letsencrypt/ stay, a later install reuses them.
|
|
506
|
-
record ${
|
|
549
|
+
record ${L(`installed traefik (${ur(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
|
|
507
550
|
fi
|
|
508
551
|
log "starting traefik"
|
|
509
552
|
dc up -d --remove-orphans --quiet-pull
|
|
510
553
|
sleep 2 # traefik exits shortly after start on a bad config
|
|
511
|
-
ensure_running traefik`);let u=
|
|
554
|
+
ensure_running traefik`);let u=X();u.servers[e]&&(Object.assign(u.servers[e],{email:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,dashboard:o}),tt(u)),S(`traefik running (${ur(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&C(b.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(S(`dashboard: https://${o.host}`),C(s?`user ${b.bold(o.user)} password ${b.bold(s)}`:b.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function fr(e,t){x(`using traefik ${b.bold(t.container)} for lf sites`),await R(e,`${K}
|
|
512
555
|
docker network inspect ${t.network} >/dev/null 2>&1 || { echo "docker network ${t.network} does not exist" >&2; exit 1; }
|
|
513
|
-
[ -w ${
|
|
514
|
-
u=$(saved ${
|
|
515
|
-
${
|
|
516
|
-
record ${
|
|
556
|
+
[ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
|
|
557
|
+
u=$(saved ${W})
|
|
558
|
+
${B(W,je(t))}
|
|
559
|
+
record ${L(`sites use traefik ${t.container}`)} "$u"`),S(`sites join network ${b.bold(t.network)} with entrypoint ${b.bold(t.entrypoint)} and resolver ${b.bold(t.resolver)}`),C(b.dim(`redeploy existing sites to switch them over; delete ${W} to let landfall run its own traefik again`))}const pr={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},mr=F({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:I,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${Ee})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:`admin`},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=or(await Un(r));i.status===`conflict`&&E(`not touching ${n}: ${i.detail}`),i.proxy&&E(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await dr(n,r,{email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`]})}}),hr=F({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
|
|
517
560
|
|
|
518
561
|
landfall reads network, entrypoint and cert resolver from the container's flags;
|
|
519
|
-
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:
|
|
562
|
+
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:I,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=Z(t.server),i=ir((await Un(r)).values.foreign_proxy),a=e??i?.name;a||E(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&E(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await fr(r,s)}}),gr=F({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`P=${ke}
|
|
520
563
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
|
|
521
|
-
[ -f ${
|
|
564
|
+
[ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
|
|
522
565
|
docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
|
|
523
566
|
echo
|
|
524
567
|
echo "routes:"
|
|
@@ -542,44 +585,44 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
|
|
|
542
585
|
printf " %s %s (%s)\\n", n, x, s
|
|
543
586
|
}
|
|
544
587
|
}' || true
|
|
545
|
-
done | sort`)}}),
|
|
546
|
-
`));let c=
|
|
547
|
-
record ${
|
|
548
|
-
${
|
|
588
|
+
done | sort`)}}),_r=F({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:I,...pr},async run(e,t){return on(Z(t.server).server,ke,t)}}),vr=F({name:`traefik restart`,summary:`Restart traefik.`,options:{server:I},async run(e,t){let{server:n}=Z(t.server);await R(n,`${K}\ndocker restart ${ke} >/dev/null\nrecord "restarted traefik"`),S(`traefik restarted`)}});function yr(e,t){let n=ht(process.cwd()),r=e??n?.name;return r||E(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...Z(t??n?.server)}}const br=e=>e.startsWith(`Up`)?b.green(e):b.red(e),xr=F({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:I,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server??ht(process.cwd())?.server),i=await cn(r);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return console.log(`no sites on ${n} yet — ${b.bold(`lf deploy --domain example.com`)}`);M([[b.dim(`NAME`),b.dim(`KIND`),b.dim(`DOMAINS`),b.dim(`RELEASE`),b.dim(`UPDATED`),b.dim(`STATUS`)],...i.map(({meta:e,status:t})=>[b.bold(e.name),e.kind,e.domains.join(`, `),e.current,N(e.updatedAt),br(t)])])}}),Sr=F({name:`sites info`,args:`[name]`,summary:`Show a site's details and release history.`,options:{server:I},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i}=await rn(r,n),{stdout:a}=await R(r,`docker ps -a --filter name=^${q(n)}$ --format '{{.Status}}'`,{capture:!0});console.log(`${b.bold(i.name)} ${i.kind} ${br(a.trim()||`not running`)}`);for(let e of i.domains)console.log(` ${b.cyan(`https://`+e)}`);console.log(b.dim(` dir ${$(n)} port ${i.port}${i.spa?` spa`:``}`)),console.log(`\n${b.bold(`Releases`)}`);for(let e of an(i)){let t=e.id===i.current?b.green(`● `):` `;console.log(`${t}${e.id} ${b.dim(N(e.at))}${e.image?` `+e.image:``}`)}}}),Cr=F({name:`logs`,args:`[name]`,summary:`Show a site's container logs.`,options:{server:I,...pr},async run([e],t){let{site:n,server:r}=yr(e,t.server);return on(r,q(n),t)}}),wr=(e,t,n,r,i)=>F({name:e,args:`[name]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's container.`,options:{server:I},async run([a],o){let{site:s,server:c}=yr(a,o.server);await rn(c,s);let l=`record ${L(`${e} ${s}`)}`,u=`[ "$(docker inspect -f '{{.State.Running}}' ${q(s)} 2>/dev/null)" = true ]`;await fn(c,s,t,r?`if ${i?``:`! `}${u}; then ${l} ${L(`cd ${$(s)} && dc ${r}`)}; else ${l}; fi`:l),S(`${s} ${n}`)}}),Tr=wr(`restart`,`restart`,`restarted`),Er=wr(`stop`,`stop`,`stopped`,`up -d`,!0),Dr=wr(`start`,`up -d`,`started`,`stop`,!1),Or=F({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:I,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i,proxy:a}=await rn(r,n),o=an(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&E(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||E(t.to?`no release ${t.to}`:`no release older than ${i.current}`);let l=structuredClone(i);i.current=c.id,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await dn(r,i,a,{keep:i.releases.length,prev:l,what:`rollback ${n} from ${l.current} to ${c.id}`}),S(`${n} rolled back to ${b.bold(c.id)}`)}}),kr=F({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete all its files and releases from the server.`,options:{server:I},async run([e],t){e||E(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=yr(e,t.server),{meta:a}=await rn(r,n);await A(`Delete ${n} (${a.domains.join(`, `)}) from ${i}? This cannot be undone.`,n),await mn(r,a),S(`removed ${n}`)}}),Ar=F({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change environment variables of an image/dockerfile site; the container is recreated.`,options:{server:I,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`}},async run(e,t){let n=e.filter(e=>e.includes(`=`)),[r]=e.filter(e=>!e.includes(`=`)),{site:i,server:a}=yr(r,t.server),{meta:o,env:s}=await rn(a,i);if(o.kind===`static`&&E(`${i} is a static site; env vars only reach image and Dockerfile sites`),!n.length&&!t.unset?.length)return void process.stdout.write(s||b.dim(`(empty)
|
|
589
|
+
`));let c=hn(s,gn(n),t.unset??[]),l=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await fn(a,i,`up -d --force-recreate --quiet-pull`,`u=$(saved .env)
|
|
590
|
+
record ${L(`env ${i} ${l.join(` `)}`)} "cd ${$(i)} && $u && dc up -d --force-recreate"
|
|
591
|
+
${B(`.env`,c,`600`)}`),S(`updated env of ${i}`)}});async function jr(e){try{return{status:(await fetch(`https://${e}`,{method:`HEAD`,signal:AbortSignal.timeout(8e3),redirect:`manual`})).status}}catch(e){return{error:e?.cause?.code??e?.code??e?.message??String(e)}}}async function Mr(e,t,n,r,i,a){let o=(await cn(e)).map(e=>e.meta).filter(e=>e.name!==n&&e.domains.some(e=>r.includes(e)));for(let e of o){let t=e.domains.filter(e=>r.includes(e));a&&E(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`),t.length===e.domains.length&&E(`${t.join(`, `)} ${t.length>1?`are`:`is`} all ${e.name} serves: give it another domain first (lf domains ${e.name} --add <domain>) or remove it (lf sites rm ${e.name})`),await A(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?`)}return async()=>{for(let a of o){let o=structuredClone(a);a.domains=a.domains.filter(e=>!r.includes(e)),a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await dn(e,a,i,{keep:a.releases.length,prev:o,what:`moved ${o.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`}),$t(t,a.name,a.domains),C(b.dim(`${a.name} keeps ${a.domains.join(`, `)}`))}}}const Nr=F({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
|
|
549
592
|
|
|
550
593
|
Without flags it lists and checks; --add/--rm/--set change the site (undoable)
|
|
551
|
-
and update
|
|
594
|
+
and update .landfall.json of remembered projects.`,options:{server:I,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=yr(e,t.server),{meta:a,proxy:o}=await rn(i,n);if(!t.add&&!t.rm&&!t.set){let e=await zt(i),[r,o]=await Promise.all([Ht(a.domains,e),Promise.all(a.domains.map(jr))]),s=r.map((e,t)=>({...e,https:o[t]}));if(t.json)return void console.log(JSON.stringify({site:n,serverIps:e,domains:s},null,2));for(let e of s){let t=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`),n=e.https.status?(e.https.status<400?b.green:e.https.status===401?b.cyan:b.yellow)(`https ${e.https.status}`):b.red(`https ${e.https.error}`);console.log(`${b.cyan(`https://${e.domain}`)} ${t} ${n}`)}s.some(e=>!e.ok)&&console.log(b.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}let s=e=>(e??[]).map(e=>lt(e.trim(),i)).filter(Boolean),c=t.set?s(t.set):[...a.domains];c=[.../* @__PURE__ */ new Set([...c,...s(t.add)])].filter(e=>!s(t.rm).includes(e));let l=c.filter(e=>!nt.test(e));l.length&&E(`not a domain: ${l.join(`, `)}`),c.length||E(`a site needs at least one domain`);let u=s(t.rm).filter(e=>!a.domains.includes(e));if(u.length&&w(`${u.join(`, `)} not on ${n}`),c.join()===a.domains.join())return S(`nothing to change`);let d=a.domains.filter(e=>!c.includes(e));d.length&&await A(`Remove ${d.join(`, `)} from ${n}? ${d.length>1?`They stop`:`It stops`} serving it.`);let f=await Mr(i,r,n,c.filter(e=>!a.domains.includes(e)),o),p=structuredClone(a);a.domains=c,a.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await f(),await dn(i,a,o,{keep:a.releases.length,prev:p,what:`domains of ${n}: ${c.join(`, `)}`}),$t(r,n,c);let m=ht(process.cwd());m?.name===n&>(process.cwd(),{...m,domains:c}),S(`${n} serves ${c.map(e=>b.cyan(`https://${e}`)).join(`, `)}`);let ee=await zt(i);for(let e of await Ht(c.filter(e=>!p.domains.includes(e)),ee))e.ok||w(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Pr=F({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
|
|
552
595
|
|
|
553
596
|
lf auth my-site --user anna add or change a login (asks the password)
|
|
554
597
|
lf auth my-site --rm-user anna remove a login
|
|
555
598
|
lf auth my-site --off remove all logins: the site is public again
|
|
556
|
-
lf auth my-site show who can log in`,options:{server:
|
|
599
|
+
lf auth my-site show who can log in`,options:{server:I,user:{type:`string`,short:`u`,desc:`login to add or change`},password:{type:`string`,short:`p`,desc:`its password (asked when missing)`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i,proxy:a}=await rn(r,n),o=(i.auth??[]).map(e=>e.slice(0,e.indexOf(`:`)));if(!t.user&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${b.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>b.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||w(`no login ${e} on ${n}`);if(t.user){/^[A-Za-z0-9._@-]{1,64}$/.test(t.user)||E(`user may only have letters, digits and . _ @ -`);let e=t.password??await P(`Password for ${t.user}:`);e.length<8&&E(`use a password of at least 8 characters`),c.add(t.user),s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`)))),s.push(await cr(t.user,e))}else s=s.filter(e=>!c.has(e.slice(0,e.indexOf(`:`))));if(s.join(`
|
|
557
600
|
`)===(i.auth??[]).join(`
|
|
558
|
-
`))return
|
|
601
|
+
`))return S(`nothing to change`);let l=s.map(e=>e.slice(0,e.indexOf(`:`))),u=o.filter(e=>!l.includes(e));l.length?u.length&&await A(`Remove the login${u.length>1?`s`:``} ${u.join(`, `)} from ${n}?`):await A(`Remove every login from ${n}? Anyone can open it then.`);let d=structuredClone(i);i.auth=s.length?s:void 0,i.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await dn(r,i,a,{keep:i.releases.length,prev:d,what:l.length?`login for ${n}: ${l.join(`, `)}`:`removed the login of ${n}`}),S(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),Fr=F({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:I},async run([e],t){let{site:n,server:r}=yr(e,t.server),{meta:i}=await rn(r,n),a=`https://${i.domains[0]}`;console.log(b.cyan(a)),ie(a)}}),Ir=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Lr(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Ir(new Date(n)));)n+=1e3;return Ir(new Date(n))}const Rr=F({name:`deploy`,args:`[dir]`,summary:`Deploy a static folder, Dockerfile or image with HTTPS.
|
|
559
602
|
|
|
560
603
|
Kinds:
|
|
561
604
|
static a folder of files (default; auto-detects dist/, build/, out/, public/)
|
|
562
605
|
dockerfile the folder has a Dockerfile: uploaded and built on the server
|
|
563
606
|
image --image: run a prebuilt image (e.g. ghcr.io/me/app:1.2)
|
|
564
607
|
|
|
565
|
-
Settings are saved to
|
|
566
|
-
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function
|
|
567
|
-
printf '%s\\n' "$
|
|
568
|
-
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),
|
|
569
|
-
`)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==
|
|
608
|
+
Settings are saved to ./.landfall.json, so later deploys are just \`lf deploy\`.`,options:{server:I,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback`,default:`5`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0}},async run([e],t){let n=process.cwd(),i=ht(n)??{};i.name&&C(b.dim(`using ${pt}`));let{name:a,server:s}=Z(t.server??i.server),c=en(t.name??i.name??Jt(d(n))),l=!t.domain?.length&&!!i.domains?.length,u=t.domain?.length?t.domain:i.domains??[];if(!u.length){let e=ct(s),t=e?void 0:Bt(await zt(s));u=(await re(`Domain(s) to serve (comma separated):`,e?`${c}.${e}`:t?Vt(c,t):void 0)).split(/[\s,]+/).filter(Boolean)}u=u.map(e=>lt(e,s)),u.length||E(`at least one --domain is required`);let f=u.filter(e=>!nt.test(e));f.length&&E(`not a domain: ${f.join(`, `)}`);let te=ot(t.keep,`--keep`,1),h=t.build??i.build;if(h){x(`building locally: ${h}`);let e=r(h,{shell:!0,stdio:`inherit`});e.status!==0&&E(`build failed (exit ${e.status})`)}let g=t.static??i.static,_=t.image??(g?void 0:i.image),v=_&&at(_),y=ee(n,e??i.dir??(v?`.`:Kt(n))),T=v?`image`:!g&&o.existsSync(p(y,`Dockerfile`))?`dockerfile`:`static`;T===`static`&&!o.existsSync(p(y,`index.html`))&&w(`no index.html in ${m(n,y)||`.`}`);let D=T===`static`?80:ot(t.port??i.port??(T===`dockerfile`?qt(p(y,`Dockerfile`)):void 0)??3e3,`--port`,1,65535);x(`deploying ${b.bold(c)} (${T}) to ${b.bold(a)}`);let{meta:O,env:ne,proxy:k,exists:A}=await nn(s,c);O&&O.kind!==T&&C(b.dim(`site was ${O.kind}, now ${T}`));let j=await Mr(s,a,c,u,k,l?`${pt} lists it; deploy with -d to move it`:void 0),M=Lr((O?.releases??[]).map(e=>e.id)),N={id:M,at:(/* @__PURE__ */ new Date()).toISOString()};T===`image`&&(N.image=v),T===`dockerfile`&&(N.image=`${q(c)}:${M}`),T!==`image`&&(x(`uploading ${m(n,y)||`.`}`),await we(s,y,`${$(c)}/releases/${M}`));let P;(t.env?.length||t[`env-file`])&&(P=ne,t[`env-file`]&&(P=hn(P,gn(zr(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(P=hn(P,gn(t.env))));let ie=(/* @__PURE__ */ new Date()).toISOString(),F={name:c,kind:T,domains:u,port:D,spa:T===`static`?!!(t.spa??i.spa):void 0,auth:O?.auth,current:M,releases:[...O?.releases??[],N],createdAt:O?.createdAt??ie,updatedAt:ie};if(await j(),await dn(s,F,k,{env:P,build:T===`dockerfile`,pull:T===`image`,keep:te,prev:O,existed:!O&&A,what:`deploy ${c} release ${M}${O?``:` (new site, ${u.join(`, `)})`}`}),t.save){let e={name:c,server:a,domains:u};if(T===`image`)Object.assign(e,{image:v,port:D});else{let t=m(n,y)||`.`;t!==Kt(n)&&(e.dir=t),T===`dockerfile`&&(e.port=D),F.spa&&(e.spa=!0),g&&T===`static`&&(e.static=!0)}h&&(e.build=h),gt(n,e),Qt({path:n,name:c,server:a,domains:u,kind:T})}S(`release ${b.bold(M)} live`);for(let e of u)console.log(` ${b.cyan(`https://${e}`)}`);await Br(u[0])}});function zr(e){return e.split(`
|
|
609
|
+
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function Br(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?S(`https://${e} responded ${t.status}`):w(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);w(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Vr=async e=>{let t=``;for await(let n of e)t+=n;return t};let Hr;function Ur(){if(Hr)return Hr;let e=o.mkdtempSync(p(l(),`landfall-`));return Hr=p(e,`askpass`),o.writeFileSync(Hr,`#!/bin/sh
|
|
610
|
+
printf '%s\\n' "$LANDFALL_PASSWORD"
|
|
611
|
+
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Hr}const Wr=e=>({SSH_ASKPASS:Ur(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Gr(e){if(e){let e=(await Vr(process.stdin)).replace(/\r?\n$/,``);return e||E(`--password-stdin: nothing on stdin`),e}return Xe(`PASSWORD`)||void 0}const Kr=e=>{let t=p(s(),`.ssh`,`landfall_${e}`),n=Ge(e);return o.existsSync(t)||!o.existsSync(n)?t:n};function qr(e){let t=Kr(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(p(s(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&E(`ssh-keygen failed`),{path:t,created:!0})}async function Jr(e,t,n){let i=Q(t),a=t.key?Y(t.key):void 0;if(a&&!o.existsSync(a)&&E(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&E(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await z(t);if(!s&&!t.key&&o.existsSync(Kr(e))&&await z({...t,key:Kr(e)})&&(t.key=a=Kr(e),s=!0),s)S(`key login works`),a??=yt(t);else if(n.check){w("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=qr(e);a=t.path,c=t.created,c&&S(`generated key ${et(a)}`)}let l=t.key&&Y(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await z(t))){x(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Wr(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...pe(t),fe(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await z(t)||E(`could not log in with the key — check host, user and password`),S(`key installed`)}return a}const Yr=e=>{for(let t of e)console.log(b.dim(` `)+t)};function Xr(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Et({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});S(`${r.created?`created`:`updated`} ${et(_t)} with ${b.bold(`Host ${e}`)} — try \`ssh ${e}\``),Yr(r.block.split(`
|
|
612
|
+
`)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==yt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Ot(e,n):void 0;s?(S(`added the key to your ${b.bold(`Host ${e}`)} in ${et(s.file)} ${b.dim(`(backup: ${et(s.backup)})`)}`),Yr([...s.removed.map(e=>b.red(`- ${e}`)),b.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=et(n)),r.ownAlias&&C(b.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Zr=F({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
|
|
570
613
|
|
|
571
614
|
Without --host, <name> is used as an ssh alias from ~/.ssh/config,
|
|
572
615
|
so \`lf server add het\` works whenever \`ssh het\` works.
|
|
573
616
|
|
|
574
617
|
With --host lf logs in with --key, or installs its own key
|
|
575
|
-
(~/.ssh/
|
|
618
|
+
(~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
|
|
576
619
|
to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
|
|
577
|
-
The password comes from the prompt, --password-stdin or $
|
|
578
|
-
Any ~/.ssh/config alias works too (--all to list them).`)))}})
|
|
579
|
-
`)}:null}}}const
|
|
620
|
+
The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||E(`usage: lf server add <name> [--host ...]`);let n=t.domain&&rt(t.domain),r=X(),i=r.servers[e],a=bt().includes(e)&&!At(e);t.host&&a&&E(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await Gr(t[`password-stdin`]),c=await Jr(e,o,{check:!s&&!ne(),password:s}),l=c?Xr(e,o,c,{existing:i,sshConfig:t[`ssh-config`],ownAlias:a,port:t.port,keyGiven:!!t.key}):{...i,...o};l.key||delete l.key,l.user||delete l.user,l.port||delete l.port,t.email&&(l.email=t.email),n&&(l.domains=st(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),tt(r),S(`added ${b.bold(e)}${r.default===e?b.dim(` (default)`):``}`),C(c?b.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${b.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Qr=F({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`}},async run(e,t){let n=X(),r=[[b.dim(`NAME`),b.dim(`TARGET`),b.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?b.green(` *`):``),i,`landfall`])}if(t.all)for(let e of bt())n.servers[e]||r.push([e,e,At(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${b.bold(`lf server add <ssh-alias>`)} or ${b.bold(`lf setup <name> --host <ip>`)}`):(M(r),t.all||console.log(b.dim(`
|
|
621
|
+
Any ~/.ssh/config alias works too (--all to list them).`)))}}),$r=F({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||E(`usage: lf server rm <name>`);let n=X();!n.servers[e]&&!At(e)&&E(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),delete n.servers[e],n.default===e&&delete n.default,tt(n),t[`ssh-config`]&&kt(e)&&S(`removed Host ${e} from ~/.ssh/config`),S(`removed ${e}`)}}),ei=F({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||E(`usage: lf server use <name>`);let t=X();t.servers[e]??={host:e},t.default=e,tt(t),S(`default server: ${b.bold(e)}`)}}),ti=F({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=Z(e);return ye(r,n)}}),ni=F({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=Z(e),i=await Pt(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await zt(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(b.bold(n)+b.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:b.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${Wt(i.uptime)}${i.reboot?b.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Ut(o)} / ${Ut(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Ut(i.disk.used)} / ${Ut(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${b.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:w)(i.support.note),i.docker&&(console.log(),await R(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function ri(e){let t=X(),n=t.servers[e],r=n??{host:e},i=Q(r),a=Dt(r.host),s=lr(r,{});return{name:e,isDefault:t.default===e,configFile:et($e),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:ur(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:et(e),exists:o.existsSync(Y(e))})),block:a?{file:et(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
|
|
622
|
+
`)}:null}}}const ii=F({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
|
|
580
623
|
the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
|
|
581
|
-
and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n}=
|
|
582
|
-
`))console.log(` ${e}`)}else console.log(`\n${
|
|
624
|
+
and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n}=Z(e),r=ri(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${b.dim(e.padEnd(10))} ${t}`);if(console.log(`${b.bold(n)}${r.isDefault?b.dim(` (default)`):``}`),console.log(`\n${b.bold(`landfall`)} ${b.dim(r.configFile)}`),!r.saved)i(`entry`,b.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,b.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${b.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${b.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:b.dim(`off`)),console.log(`\n${b.bold(`ssh`)} ${b.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${b.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${b.bold(`ssh config`)} ${b.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
|
|
625
|
+
`))console.log(` ${e}`)}else console.log(`\n${b.bold(`ssh config`)} ${b.dim(`no Host block; ssh uses its defaults`)}`)}});async function ai(e,t){let n=await zt(e),[r,i]=await Ht([t,`landfall-${g(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function oi(e){let{stdout:t}=await R(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const si=async e=>ft(await oi(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),ci=F({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
|
|
583
626
|
|
|
584
627
|
The first main domain is the primary: \`lf deploy\` serves a new site at
|
|
585
628
|
<site>.<primary>, \`--domain blog\` means blog.<primary>, and
|
|
@@ -590,18 +633,18 @@ More main domains are listed and DNS-checked; use them with full names
|
|
|
590
633
|
|
|
591
634
|
Domains given as arguments replace the list (the first becomes primary).
|
|
592
635
|
Without any, it lists them and, if there are none, suggests the base domains
|
|
593
|
-
the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=
|
|
636
|
+
the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=X(),r=e.length>0&&!(e=>!!n.servers[e]||bt().includes(e))(e[0])&&nt.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=Z(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(rt),i=t.clear?[]:a.length?a.map(rt):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(rt)])].filter(e=>!r.includes(e)),i=st(i,t.primary&&rt(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&w(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await A(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,tt(n),s.domains=e.domains,S(i.length?`main domains of ${b.bold(o)}: ${i.map((e,t)=>t?e:b.bold(e)).join(`, `)}${i.length>1?b.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await si(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));C(`${o} has no main domain yet`);for(let t of e)C(`${t.hosts} routed hosts use ${b.bold(t.domain)} — ${b.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||C(`set one: ${b.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>ai(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?b.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(b.dim(`
|
|
594
637
|
add these DNS records at your DNS provider:
|
|
595
638
|
`)+d.join(`
|
|
596
|
-
`));let f=
|
|
597
|
-
record ${
|
|
639
|
+
`));let f=ct(s);C(b.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}});async function li(e){let t=e,n=await Un(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Q(e).user!==`root`&&await z(r)||E(`the firewall needs root or passwordless sudo; ${Q(e).user} has no sudo and root's key login does not work`),t=r,n=await Un(t)}let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Jn),ports:er(n),blocked:$n(n,[i]).blocked,probe:n}}const ui=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,di=/^[0-9a-fA-F.:]+(\/\d+)?$/;function fi(e){ui.test(e.port)||E(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&E(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&E(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!di.test(e.from)&&E(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const pi=e=>`ufw_add ${e.map(L).join(` `)}`,mi=e=>e[0]===`deny`?`${pi([`prepend`,...e])} 2>/dev/null || ${pi(e)}`:pi(e),hi=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
|
|
640
|
+
record ${L(`ufw delete ${e.spec}`)} ${L(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,gi=e=>Jn(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),_i=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function vi(e,t,n){if(e.ufw!==`active`)return;let r=Yn(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await k(`${i}. Do it anyway?`)||E(`${i} (--force to do it anyway)`)}function yi(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||E(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const bi={server:I,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function xi(e){return F({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...bi,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||E(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=fi({action:e,port:t,from:n.from,comment:n.comment}),i=await li(Z(n.server).server);e===`deny`&&await vi(i,[gi(r),...i.rules],n.force),await R(i.login,`${K}\n${_i}\n${mi(r)}`),S(`${r.join(` `)}${i.ufw===`active`?``:b.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Si=xi(`allow`),Ci=xi(`deny`),wi=F({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:I,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=await li(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?b.green(`on`):i.ufw===`missing`?b.yellow(`not installed`):b.yellow(`off`);x(`firewall on ${n}: ${a}${i.ufw===`active`?b.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${b.bold(`Rules`)}`),i.rules.length||C(b.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?b.green(e.action):b.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${b.bold(`Listening ports`)}`);let o={open:b.green,limited:b.yellow,blocked:b.red,local:b.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?w(`enabling would block ${i.blocked.join(`, `)}: allow them first`):C(b.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Ti=F({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:I,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=await li(r);if(i.ufw===`active`)return S(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await k(`${e}. Turn it on anyway?`)||E(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await vi({...i,ufw:`active`},[...i.rules,gi([`allow`,`${i.sshPort}/tcp`])],t.force),await R(i.login,`${K}
|
|
598
641
|
command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
|
|
599
|
-
${
|
|
600
|
-
ufw_enable`),
|
|
601
|
-
${
|
|
602
|
-
${
|
|
603
|
-
${
|
|
604
|
-
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function
|
|
642
|
+
${pi([`allow`,`${i.sshPort}/tcp`])}
|
|
643
|
+
ufw_enable`),S(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Ei=F({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:I},async run(e,t){let{name:n,server:r}=Z(t.server),i=await li(r);if(i.ufw!==`active`)return S(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||E(`cancelled`),await R(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),S(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Di=F({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:I,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=Z(t.server),i=await li(r),{rule:a,n:o}=yi(i,e);await vi(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await R(i.login,`${K}\n${_i}\n${hi(a)}`),S(`removed rule ${o}: ${a.spec}`)}}),Oi=F({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...bi,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await li(Z(t.server).server),{rule:r,n:i}=yi(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||E(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||E(`rule ${i} (${r.spec}) names no port; pass --port`);let s=fi({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=n.rules.filter(e=>e!==r);await vi(n,a===`deny`?[gi(s),...c]:[...c,gi(s)],t.force),await R(n.login,`${K}
|
|
644
|
+
${_i}
|
|
645
|
+
${hi(r)}
|
|
646
|
+
${mi(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),S(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),ki=e=>e.steps.filter(e=>e.undo&&!e.undone);function Ai(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
|
|
647
|
+
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else if(u===qe)for(let e of n.values())delete e.undo;else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function ji(e){let{stdout:t}=await R(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return Ai(t)}function Mi(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||E(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||E(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Ni(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await R(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await z(i)?{login:i}:E(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Pi=String.raw`
|
|
605
648
|
undo_step() { # undo_step <line> <undo shell, base64>
|
|
606
649
|
set +e
|
|
607
650
|
( set -e; eval "$(printf %s "$2" | base64 -d)" )
|
|
@@ -609,12 +652,12 @@ undo_step() { # undo_step <line> <undo shell, base64>
|
|
|
609
652
|
set -e
|
|
610
653
|
[ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
|
|
611
654
|
_journal undo "$1" ""
|
|
612
|
-
}`;async function
|
|
613
|
-
`),{sudo:c}),r.push(s),t=i}return n.length}function
|
|
614
|
-
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=
|
|
655
|
+
}`;async function Fi(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${L(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Ni(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(_e)),await R(s,[K,Pi,...o].join(`
|
|
656
|
+
`),{sudo:c}),r.push(s),t=i}return n.length}function Ii(e,t){let n=b.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${b.dim(`↩ `+e.what)}`:e.undone?`${n} ${b.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${b.cyan(`↺`)} ${e.what}`:`${n} ${b.dim(`· `+e.what+` (not reversible)`)}`}const Li=e=>e.trim().replace(/\\(.)/g,`$1`),Ri=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function zi(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await li(e),i=[...r.rules],a=r.ufw;for(let e of[...t].sort((e,t)=>t.line-e.line))for(let t of e.undo.split(`
|
|
657
|
+
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Jn(Li(e[1])),n=i.findIndex(e=>Ri(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Jn(Li(n[2])),...i]:[...i,Jn(Li(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await vi({...r,ufw:a},i,n)}const Bi=e=>`${b.bold(`#${e.n}`.padEnd(5))} ${b.dim(N(e.time).padEnd(9))} ${b.bold(e.cmd||`?`)} ${b.dim(`${e.actor} · ${e.id}`)}`,Vi=F({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:I,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=Z(t.server),i=(await ji(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return C(`nothing in ${G} on ${n} yet`);for(let e of i)console.log(Bi(e)),e.steps.forEach((e,t)=>console.log(Ii(e,t)));let a=i.find(e=>ki(e).length);a&&console.log(b.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Hi=F({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:I,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){let{name:n,server:r}=Z(t.server),i=await ji(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?Mi(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>ki(e.run).length);if(!t)return S(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${N(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&ki(e).length);r.length&&w(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return S(`nothing left to undo in ${o}`);x(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${b.dim(`· ${e.what} stays (not reversible)`)}`);await zi(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await A(l,n):await j(l,!1)||E(`cancelled`),await Fi(r,s),S(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&C(b.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),Ui=F({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||E(`usage: lf keygen <name>`);let{path:t,created:n}=qr(e);S(`${n?`created`:`key exists:`} ${et(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Wi(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?w(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await k(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):w(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Gi(e,t){x(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await re(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(C(b.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await j(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await re(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=ct(e);t.dashboard??=await re(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}const Ki=F({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
|
|
615
658
|
|
|
616
659
|
Turns a fresh box into a deploy target (safe to re-run):
|
|
617
|
-
1. create a dedicated SSH key (~/.ssh/
|
|
660
|
+
1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
|
|
618
661
|
2. install Docker, firewall (ufw: ssh/80/443), fail2ban, unattended-upgrades
|
|
619
662
|
3. create a deploy user with docker access, disable SSH password login
|
|
620
663
|
4. write \`Host <name>\` to ~/.ssh/config so \`ssh <name>\` just works
|
|
@@ -631,11 +674,11 @@ missing. Steps that would break services already running there are skipped:
|
|
|
631
674
|
a Traefik already running there is used instead (lf traefik use)
|
|
632
675
|
Use --check to only see that report, --force to apply firewall/hardening anyway.
|
|
633
676
|
In a terminal, a first Traefik install asks for image, host network, e-mail and
|
|
634
|
-
dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${
|
|
635
|
-
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on`,default:`4777`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-
|
|
677
|
+
dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${Ee})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||E(`usage: lf setup <name> [--host <ip>]`);let n=X(),r=n.servers[e],i=bt().includes(e)&&!At(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};!t.user&&t[`deploy-user`]&&Q(a).user===t[`deploy-user`]&&await z({...a,user:`root`})&&(a.user=`root`);let s=Q(a);x(`setting up ${b.bold(e)} ${b.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>x(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Jr(e,a,{check:t.check,password:await Gr(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=u?o.readFileSync(`${u}.pub`,`utf8`).trim():``,f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f};if(!await Ye(a,t))return;let[m,ee,te]=await Promise.all([Un(a,p),Nn(),Pn()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let h=Number(m.values.ssh_port)||s.port,g=rr(m,{sshPort:h,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),_=g.find(e=>e.part===`traefik`),v={email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:`admin`},y=_&&!_.proxy&&_.status!==`conflict`&&_.status!==`off`;y&&(_.detail+=b.dim(` · ${ur(lr(r??a,v))}`));let T=It(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(C(`os: ${m.values.os??`unknown`}${m.values.pkg?b.dim(` · ${m.values.pkg}`):``}`),T.note&&(T.level===`ok`?C:w)(T.note),sr(g),t.check||!u)return;let D=await Wi(g,t.force),O=y&&_.status===`todo`&&ne(),k=t.domain?rt(t.domain):r&&ct(r);if(k===void 0&&O){let e=await re(`main domain for sites, e.g. example.com (optional)`,``);k=e.trim()?rt(e):void 0}O&&(await Gi({...r??a,domains:st(r?.domains,k)},v),C(`traefik: ${ur(lr(r??a,v))}${v.dashboard?`, dashboard ${v.dashboard}`:``}`));let A=g.filter(e=>e.status!==`done`&&D.has(e.part)).length;if(A&&!await j(`apply the ${A} change${A===1?``:`s`} above?`))return;let M=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];g.some(e=>M.includes(e.part)&&e.status!==`done`&&D.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await R(a,K+Pe,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:h,HARDEN:D.has(`harden`),FIREWALL:D.has(`firewall`)}}),S(`server bootstrapped`)):(l(`bootstrap`),S(`already bootstrapped, nothing to change`));let N={...a};if(f){let e={...a,user:f};await z(e)?(S(`deploy user ${b.bold(f)} ready`),N=e):w(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let P=Xr(e,N,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});k&&(P.domains=st(P.domains,k)),n.servers[e]=P,n.default??=e,tt(n),l(`traefik`),_?.proxy?D.has(`traefik`)&&_.status===`todo`?await fr(P,_.proxy):S(_.detail):D.has(`traefik`)?await dr(e,P,v):C(b.dim(`skipped`)),k?(console.log(`\n${b.green(`Done.`)} Point ${b.bold(k)} and ${b.bold(`*.${k}`)} (A records) at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${b.dim(`# https://blog.${k}`)}`),console.log(` lf server domain ${e} ${b.dim(`# check the DNS`)}\n`)):(console.log(`\n${b.green(`Done.`)} Point a domain's A record at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),qi=F({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
|
|
678
|
+
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on`,default:`4777`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-CqBtraHg.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),Ji={current:b.green,update:b.yellow,unknown:b.dim,pinned:b.dim,rebuild:b.dim,foreign:b.dim,missing:b.red};function Yi(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return C(`packages: no supported package manager`);let r=e.updates.length?`${b.bold(String(e.updates.length))} package updates${n?`, ${b.yellow(`${n} security`)}`:``}`:b.green(`packages up to date`);console.log(`${r}${e.reboot?b.yellow(` · reboot required`):``}${e.refreshed?``:b.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,b.dim(e.from??``),`→ `+e.to,e.security?b.yellow(`security`):``])),i.length<e.updates.length&&console.log(b.dim(` … ${e.updates.length-i.length} more (--all)`))}function Xi(e){e.length&&(console.log(`\n${b.bold(`Images`)}`),M(e.map(e=>[` `+e.name,b.dim(e.ref||`-`),Ji[e.state](e.state===`update`?`newer image`:e.state),b.dim(e.note??``)])))}function Zi(e){let t=In(e);console.log(`\n${b.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||b.red(`missing`)}`:b.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+b.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(b.dim(` newest releases`))}const Qi=F({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:I,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=Z(t.server),r=await bn(n,t.refresh),i=await zn(n),a=await Fn(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...In(a)}},null,2));Yi(r,t.all),Zi(a),Xi(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(b.dim(`\napply with: lf update${o}`)),In(a).composeUpdate&&console.log(b.dim(`newest compose: lf update --compose${o}`))}}),$i=F({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
|
|
636
679
|
|
|
637
680
|
Without --packages/--images both are updated. Package upgrades cannot be undone;
|
|
638
681
|
image updates can (\`lf undo\` puts the old image back). --compose replaces the
|
|
639
|
-
compose plugin setup installed with the newest release (undoable too).`,options:{server:
|
|
640
|
-
`)[0]}`)}}t.push(``,
|
|
641
|
-
`))}async function
|
|
682
|
+
compose plugin setup installed with the newest release (undoable too).`,options:{server:I,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=Z(t.server);if(t.compose){(e.length||t.packages||t.images)&&E(`--compose goes alone`);let i=await _n(r,`updating docker compose`),a=await Fn(i),o=In(a);if(!o.composeUpdate)return void(o.composeNote?C(o.composeNote):S(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(x(`downloading docker compose`),await Ln(i),S(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&E(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await _n(r,`upgrading packages`),i=await bn(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?w(`no supported package manager, skipping packages`):a.length?(Yi({...i,updates:a},!1),t.security&&i.mgr===`apk`&&E(`apk has no security-only upgrades; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(x(`upgrading packages on ${n}`),await Sn(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),S(`packages upgraded`),t.reboot&&C(`rebooting if required; give it a minute`))):S(t.security?`no security updates`:`packages up to date`)}if(o){let t=await zn(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&E(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))C(b.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Xi(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(x(`updating images`),await Bn(r,a),S(`images updated (lf undo puts the old ones back)`))):S(`images up to date`)}}});var ea=`0.2.0`;const ta=[Ki,Rr,xr,Sr,Cr,Ar,Or,Tr,Er,Dr,kr,Nr,Pr,Fr,Qi,$i,Zr,Qr,ni,ii,ci,ei,$r,ti,mr,hr,gr,_r,vr,wi,Ti,Ei,Si,Ci,Oi,Di,Vi,Hi,Ui,qi],na={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,upgrade:`update`},ra=new Map(ta.map(e=>[e.name,e]));function ia(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=ra.get(na[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function aa(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik logs`,`traefik restart`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${b.bold(`landfall`)} ${b.dim(ea)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${b.bold(`Usage:`)} lf ${b.dim(`web dashboard in your browser (same as lf ui)`)}`,` lf tui ${b.dim(`the dashboard in the terminal instead`)}`,` lf <command> [options] ${b.dim(`lf <command> --help for details`)}`];for(let[n,r]of e){t.push(``,b.bold(n));for(let e of r){let n=ra.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
|
|
683
|
+
`)[0]}`)}}t.push(``,b.bold(`Quick start`),` lf setup het --check ${b.dim(`# what is on het, what setup would change`)}`,` lf setup het ${b.dim("# docker + traefik on a server you reach with `ssh het`")}`,` lf setup box --host 203.0.113.7 ${b.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s het ${b.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${b.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${b.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${b.dim(`# put a password in front of a site`)}`,` lf updates -s het && lf update -s het ${b.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${b.dim(`# what runs where`)}`,` lf history -s het && lf undo -s het ${b.dim(`# what landfall changed, and revert the last of it`)}`,``,b.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
|
|
684
|
+
`))}async function oa(e){if(e[0]===`tui`&&e.length===1){process.stdin.isTTY&&process.stdout.isTTY||E("the terminal dashboard needs a terminal; `lf ui` opens the web one"),process.env.NODE_ENV??=`production`;let{dashboard:e}=await import(`./tui-BDvEukfL.mjs`);return e(sa)}if(!e.length&&process.stdin.isTTY&&process.stdout.isTTY&&(e=[`ui`]),!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return aa(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(ea),0;let{cmd:t,rest:n}=ia(e);if(!t)return console.error(`${b.red(`error:`)} unknown command "${e.join(` `)}"\n`),aa(),2;let{values:r,positionals:i,rest:a}=oe(t,n);if(r.help)return console.log(se(t)),0;O(!!r.yes),be.cmd=ce(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function sa(e){try{return await oa(e)}catch(e){return e instanceof T?console.error(`${b.red(`error:`)} ${e.message}`):console.error(e),1}}sa(process.argv.slice(2)).then(e=>process.exit(e));export{ht as A,N as B,Q as C,Qe as D,nt as E,W as F,ie as H,V as I,H as L,tt as M,Ee as N,Y as O,G as P,U as R,zt as S,$e as T,b as V,Yt as _,ai as a,Pt as b,Wr as c,Fn as d,zn as f,$ as g,sn as h,li as i,Z as j,X as k,jr as l,an as m,ki as n,ri as o,bn as p,ji as r,si as s,ea as t,In as u,Zt as v,bt as w,Bt as x,Ht as y,ue as z};
|