landfall 0.6.3 → 0.6.5
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 +4 -2
- package/dist/cli.mjs +215 -167
- package/dist/index.html +96 -16
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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 s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as re}from"node:net";import{parse as ie}from"yaml";import{X509Certificate as _,createHash as
|
|
3
|
-
`)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new
|
|
4
|
-
`)}}function
|
|
5
|
-
`)}function
|
|
6
|
-
`,{mode:384})}function
|
|
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 s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as re}from"node:net";import{parse as ie}from"yaml";import{X509Certificate as _,createHash as v,randomBytes as ae}from"node:crypto";import y from"bcryptjs";import oe from"node:http";import se from"node:https";const b=async()=>{process.stdin.isTTY&&D(`expected the value on stdin, e.g. printf %s "$SECRET" | lf …`);let e=``;for await(let t of process.stdin)e+=t;return e},ce=process.stdout.isTTY&&!process.env.NO_COLOR,x=e=>t=>ce?`\x1b[${e}m${t}\x1b[0m`:t,S={bold:x(`1`),dim:x(`2`),red:x(`31`),green:x(`32`),yellow:x(`33`),blue:x(`34`),cyan:x(`36`)},C=e=>console.error(`${S.cyan(`==>`)} ${S.bold(e)}`),w=e=>console.error(`${S.green(`✓`)} ${e}`),T=e=>console.error(` ${e}`),E=e=>console.error(`${S.yellow(`!`)} ${e}`);var le=class extends Error{};function ue(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const D=e=>{throw new le(e)};let O=!1;const k=e=>O=e,de=()=>!O&&!!process.stdin.isTTY;async function fe(e,t){if(O||!process.stdin.isTTY){if(t!==void 0)return t;D(`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(`${S.blue(`?`)} ${e}${t?S.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function pe(e){return O||!process.stdin.isTTY?!1:j(e,!1)}async function A(e,t){O||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await fe(`${e} Type ${S.bold(t)} to confirm:`)!==t&&D(`cancelled`):await j(e,!1)||D(`cancelled`))}async function j(e,t=!0){return O?!0:(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),(await fe(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}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 me=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 he(e){(O||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${S.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 le(`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 ge(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const N=e=>e,P={type:`string`,short:`s`,desc:`server name or ssh alias`},_e={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},F=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function ve(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({..._e,...t.options}))o[e]=r;try{let{values:n,positionals:r}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});for(let e of Object.keys(t.options??{}))/(^|-)password$/.test(e)&&n[e]===void 0&&process.env[F(e)]&&(n[e]=process.env[F(e)]);return{values:n,positionals:r,rest:a}}catch(e){return D(`${e.message}\n\n${ye(t)}`)}}function ye(e){let t=[`${S.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,S.bold(`Options:`));let n=Object.entries({...e.options,yes:_e.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?S.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?S.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 be(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(` `)}const xe=()=>R().defaults??{},Se=e=>e.tunnelOnly??xe().tunnelOnly??!1,Ce=e=>process.env[`LANDFALL_${e}`],we=process.env.XDG_CONFIG_HOME??m(c(),`.config`),I=Ce(`CONFIG_DIR`)??m(we,`landfall`),Te=m(I,`config.json`),Ee=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,L=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function R(){try{let e=JSON.parse(o.readFileSync(Te,`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:{}}:D(`cannot read ${Te}: ${e.message}`)}}function De(e){o.mkdirSync(p(Te),{recursive:!0,mode:448}),o.writeFileSync(Te,JSON.stringify(e,null,2)+`
|
|
6
|
+
`,{mode:384})}function Oe(e){let t=R();delete t.servers[e],t.default===e&&delete t.default,De(t)}function ke(e){let t=R();t.servers[e]??={host:e},t.default=e,De(t)}function z(e){let t=R();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?D("no server given. Use --server <ssh-alias> or `lf server add`"):D(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}(!/^[A-Za-z0-9._@:\[\]-]+$/.test(e)||e.startsWith(`-`))&&D(`not a server name: ${e}`);let n=t.servers[e]??{host:e};return{name:e,server:n}}const Ae=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||D(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),je=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Me=/^[A-Za-z0-9._@-]{1,64}$/,Ne=(e,t=`user`)=>(Me.test(e)||D(`${t} may only have letters, digits and . _ @ -`),e),Pe=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||D(`not an e-mail address: ${e}`),e),Fe=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return je.test(t)||D(`not a domain: ${e}`),t},Ie=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Le=e=>Ie.test(e)?e:D(`not an image: ${e}`);function Re(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)&&D(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const ze=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Be=e=>e.domains?.[0];function Ve(e,t){let n=Be(t);return e===`@`?n??D("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const He=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Ue=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 We(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!je.test(n))continue;let e=Ue(n);He.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 Ge=`.landfall.json`,Ke=e=>m(e,Ge);function B(e){try{return JSON.parse(o.readFileSync(Ke(e),`utf8`))}catch{return}}function qe(e,t){o.writeFileSync(m(e,Ge),JSON.stringify(t,null,2)+`
|
|
7
7
|
`)}const V=`/opt/landfall`,H=`${V}/sites`,U=`${V}/traefik`,Je=`${U}/dynamic`,Ye=8080,Xe=`dashboard-tunnel`,Ze=`traefik:v3`,W=`${V}/proxy.env`,G=`${V}/journal.log`,Qe=`/etc/ssh/sshd_config.d/00-landfall.conf`,$e={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},et=`"$(sed -n 's/^container=//p' ${W} 2>/dev/null | grep . || echo traefik)"`;function tt(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 nt=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`,rt=`PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
|
|
8
8
|
# Synology DSM has no os-release, only /etc/VERSION.
|
|
9
9
|
if [ -f /etc/synoinfo.conf ]; then
|
|
@@ -29,6 +29,14 @@ key_users() {
|
|
|
29
29
|
done; return 0
|
|
30
30
|
}
|
|
31
31
|
apt_auto_updates() { dpkg -s unattended-upgrades >/dev/null 2>&1 && apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; }
|
|
32
|
+
# dnf 4's dnf-automatic-install.timer installs; the other timers only download unless automatic.conf says apply_updates.
|
|
33
|
+
dnf_applies() { grep -qiE '^[[:space:]]*apply_updates[[:space:]]*=[[:space:]]*(yes|true|1)' /etc/dnf/automatic.conf 2>/dev/null; }
|
|
34
|
+
dnf_auto_updates() {
|
|
35
|
+
systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null \
|
|
36
|
+
|| { { systemctl is-enabled -q dnf-automatic.timer 2>/dev/null || systemctl is-enabled -q dnf5-automatic.timer 2>/dev/null; } && dnf_applies; }
|
|
37
|
+
}
|
|
38
|
+
# Alpine: crond runs a job that runs apk upgrade (lf harden updates puts one in /etc/periodic/daily).
|
|
39
|
+
apk_auto_updates() { pidof crond >/dev/null && { S cat /etc/periodic/*/* /etc/crontabs/root 2>/dev/null || true; } | grep 'apk .*upgrade' >/dev/null; }
|
|
32
40
|
`,K=String.raw`
|
|
33
41
|
log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
|
|
34
42
|
SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
|
@@ -41,7 +49,8 @@ ${it}
|
|
|
41
49
|
pkg_has() {
|
|
42
50
|
case "$(pkg_mgr)" in
|
|
43
51
|
apt-get) dpkg -s "$1" ;;
|
|
44
|
-
|
|
52
|
+
# --whatprovides: dnf5's plugin provides dnf-automatic under another name.
|
|
53
|
+
dnf|yum|zypper) rpm -q --whatprovides "$1" ;;
|
|
45
54
|
apk) apk info -e "$1" ;;
|
|
46
55
|
*) false ;;
|
|
47
56
|
esac >/dev/null 2>&1
|
|
@@ -388,17 +397,17 @@ ${e.hostNetwork?``:` networks:
|
|
|
388
397
|
`)}\n`:``}${e.hostNetwork?``:`networks:
|
|
389
398
|
web:
|
|
390
399
|
external: true
|
|
391
|
-
`}`}let ht=!1;const gt=e=>ht=e,q=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,_t=(e=
|
|
400
|
+
`}`}let ht=!1;const gt=e=>ht=e,q=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,_t=(e=Ce(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],vt=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&D(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function yt(e){let t=[..._t(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,Ee(e.key),`-o`,`IdentitiesOnly=yes`),t}let bt;function xt(){return bt||(bt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(bt,{recursive:!0,mode:448})),bt}function St(e,t={}){let n=[...yt(e),`-o`,`ServerAliveInterval=15`];return(t.batch||ht)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${xt()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`),n}const Ct=(e,t={})=>[...St(e,t),vt(e)];function wt(e){return new Promise(t=>{let r=[...yt(e),`-o`,`ControlPath=${xt()}/%C`,`-O`,`exit`,vt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function Tt(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`,ht||r.silent?`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)})}const Et=`PATH="$PATH:/usr/local/sbin:/usr/local/bin"`;async function Dt(e,t=[]){let{code:n}=await Tt(`ssh`,[...Ct(e,{tty:process.stdin.isTTY}),...t.length?[`${Et};`,...t]:[]],{});return n}const Ot=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,kt={run:process.env.LANDFALL_RUN||Ot(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function J(e,t,n={}){let r={LANDFALL_RUN:kt.run,LANDFALL_ACTOR:kt.actor,LANDFALL_CMD:kt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${q(typeof t==`boolean`?+!!t:t)}`).join(`
|
|
392
401
|
`),a=await Tt(`ssh`,[...Ct(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${Et}\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
|
|
393
402
|
`).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function At(e){let{code:t}=await Tt(`ssh`,[...Ct(e,{batch:!0,noMux:!0}),`true`],{capture:!0,silent:!0});return t===0}function jt(e,t=5){let r=Date.now(),i=[...yt(e),`-o`,`ConnectTimeout=${t}`,`-o`,`BatchMode=yes`,`-o`,`ControlPath=none`,`-T`,vt(e),`true`];return new Promise(e=>{let a=(t,n)=>e({state:t,detail:n,ms:Date.now()-r}),o=n(`ssh`,i,{stdio:[`ignore`,`ignore`,`pipe`]}),s=``;o.stderr.on(`data`,e=>s+=e);let c=setTimeout(()=>o.kill(),(t+5)*1e3);o.on(`error`,e=>(clearTimeout(c),a(`down`,e.message))),o.on(`close`,e=>{clearTimeout(c);let t=s.trim().split(`
|
|
394
|
-
`).filter(e=>!/^Warning: Permanently added/.test(e)).pop()??``;e===0?a(`ok`,`key login works`):e===null?a(`down`,`no answer`):/resolve|not known|nodename|no address/i.test(t)?a(`down`,`host name not found`):/timed out|refused|no route|unreachable|network is down/i.test(t)?a(`down`,t.replace(/^ssh: /,``)):a(`login`,t||`ssh exited ${e}`)})})}async function Mt(e){let{code:t}=await Tt(`ssh`,[...Ct(e,{batch:!0}),`test -d ${V}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const Nt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`].flatMap(e=>[`--exclude`,e]);let
|
|
395
|
-
`)){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=
|
|
396
|
-
`)}function
|
|
397
|
-
`):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),
|
|
398
|
-
`);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(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:
|
|
399
|
-
`),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&&(
|
|
400
|
-
`)),
|
|
401
|
-
`).some(t=>
|
|
403
|
+
`).filter(e=>!/^Warning: Permanently added/.test(e)).pop()??``;e===0?a(`ok`,`key login works`):e===null?a(`down`,`no answer`):/resolve|not known|nodename|no address/i.test(t)?a(`down`,`host name not found`):/timed out|refused|no route|unreachable|network is down/i.test(t)?a(`down`,t.replace(/^ssh: /,``)):a(`login`,t||`ssh exited ${e}`)})})}async function Mt(e){let{code:t}=await Tt(`ssh`,[...Ct(e,{batch:!0}),`test -d ${V}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const Nt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`],Pt=e=>[.../* @__PURE__ */ new Set([...Nt,...e])].flatMap(e=>[`--exclude`,e]);let Ft;async function It(){if(!Ft){let{stdout:e}=await Tt(`tar`,[`--version`],{capture:!0});Ft=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Ft}let Lt;const Rt=()=>Lt??=Tt(`rsync`,[`--version`],{capture:!0,silent:!0}).then(e=>e.code===0,()=>!1);async function zt(e,t,n,r,i=[]){o.existsSync(t)||D(`directory not found: ${t}`);let a=async r=>(await Bt(e,t,n,i),r);if(!r)return a(`tar`);if(!await Rt())return a(`tar:no-local-rsync`);let{stdout:s}=await J(e,`if ! command -v rsync >/dev/null; then echo no-rsync; elif [ -d ${q(r)} ]; then mkdir -p ${q(n)} && echo ok; fi`,{capture:!0}),c=s.trim();if(c===`no-rsync`)return a(`tar:no-server-rsync`);if(c!==`ok`)return a(`tar`);let l=[`ssh`,...St(e)].map(q).join(` `),{code:u}=await Tt(`rsync`,[`-rlptz`,`--copy-dest=${r}`,...Pt(i),`-e`,l,`${t.replace(/\/*$/,``)}/`,`${vt(e)}:${n}/`],{});if(u!==0)throw Error(`rsync failed (exit ${u})`);return`rsync`}async function Bt(e,t,r,i){let a=[...await It(),`-czf`,`-`,...Pt(i),`-C`,t,`.`],o=`mkdir -p ${q(r)} && tar -xzof - -C ${q(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),s=n(`ssh`,[...Ct(e),o],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(s.stdin),i.on(`error`,r),s.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(s)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Vt=(e,t,n=`644`)=>`printf %s ${q(Buffer.from(t).toString(`base64`))} | base64 -d > ${q(e)} && chmod ${n} ${q(e)}`,Ht=Ce(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Ut=/* @__PURE__ */ new Map;function Y(e){let n=[...yt(e),`-G`,vt(e)],r=Ut.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 ${vt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>Ee(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Ut.set(n.join(`\0`),s),s}function Wt(e){return(e.key?[Ee(e.key)]:Y(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Gt(e=Ht,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=Ee(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of Kt(i))n.push(...Gt(e,t))}}return[...new Set(n)]}function Kt(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const qt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],Jt=(e,t)=>RegExp(`# >>> landfall:${nn(e)}\\n[\\s\\S]*?# <<< landfall:${nn(e)}${t}`),Yt=(e,t)=>e===`# >>> landfall:${t}`;function Xt(e){let[t,n]=qt(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 Zt(e,t=Ht){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);Ae(e.alias);let i=Xt(e),a=Jt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
|
|
406
|
+
`):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Ut.clear(),{created:n,block:i}}function Qt(e,t=Ht,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])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:Yt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=Ee(t);for(let t of Kt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Qt(e,t,n);if(r)return r}}}}function $t(e,t){let n=Qt(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&&(Ee(n)===t||!/[%$]/.test(n)&&!o.existsSync(Ee(n)))}),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
|
+
`)),Ut.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function en(e,t=Ht){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=Jt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const tn=(e,t=Ht)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
|
|
410
|
+
`).some(t=>Yt(t,e)),nn=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),rn=String.raw`
|
|
402
411
|
set +e
|
|
403
412
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
404
413
|
${rt}
|
|
@@ -426,10 +435,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
|
|
|
426
435
|
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)"
|
|
427
436
|
kv reboot "$(reboot_needed && echo 1)"
|
|
428
437
|
exit 0
|
|
429
|
-
`,
|
|
430
|
-
`)){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??``,compose:n.compose??``,podman:n.podman??``,platform:n.platform??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,user:n.user??``,admin:n.admin===`1`,ufw:n.ufw===`1`,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:pn(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function on(e){let{stdout:t}=await J(e,K+nn,{capture:!0});return an(t)}function sn(e,t=!1){let n=dn[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:fn,s=a(`setup`,e.pkg!==`none`,o);a(`admin`,r,`${e.user||`the login`} has no passwordless sudo and root's key login does not work`),a(`packages`,s&&r,s?i.admin:o);let c=e.podman?`only podman ${e.podman} here: landfall needs docker${n?` (${n.docker})`:``}`:n?n.docker:s?`no docker (or no access to it): lf setup installs it`:`no docker (or no access to it)`,l=a(`docker`,!!e.docker,c);return a(`compose`,l&&!!e.compose,l?`docker has no compose plugin (v2)`:i.docker),a(`firewall`,!cn(e.platform,e.pkg,e.ufw)&&r,cn(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(rn.map(e=>[e,!i[e]])),why:i}}function cn(e,t,n){if(dn[e]?.firewall)return dn[e].firewall;if(!n&&t===`none`)return`no ufw, and ${fn} to install it`}async function ln(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await At({...e,user:i})))break;return sn(t,r)}const un={"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`},dn={synology:{name:`Synology DSM manages its packages, users, ssh and firewall itself`,docker:`install Container Manager in Package Center`,firewall:`DSM has its own firewall: Control Panel → Security → Firewall`,bans:`DSM bans addresses that keep failing to log in itself: Control Panel → Security → Protection → Auto Block`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},fn=`no apt, dnf, yum, apk or zypper`;function pn(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=dn[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${fn}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=un[`${e} ${t}`]??un[`${e} ${o}`]??un[`${e} ${a}`];if(!s)return{level:`ok`};let c=(Date.parse(s)-r.getTime())/864e5;return c<0?{level:`eol`,note:`${e} ${t} reached end of life on ${s}: no more security updates, upgrade the release`}:c<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${s}`}:{level:`ok`}}const mn=/^(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])\.)/,hn=e=>re(e)===4&&!mn.test(e);async function gn(e,t){let n=Y(e).hostname,r=re(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const _n=e=>e.find(hn)??e[0],vn=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function yn(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const X=e=>{let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)} ${t[n]}`},bn=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`},xn=e=>e.v!==2,Sn=e=>e.project??e.name,Cn=e=>xn(e)?void 0:e.releases.find(t=>t.id===e.current),wn=e=>!!(e.port||e.own),Tn=e=>!!e.port&&!e.domains&&!e.own,En=e=>e?.services.find(Tn),Dn=e=>e.adopted?void 0:En(Cn(e)),On=e=>`landfall-${e}`,kn=e=>e.adopted?`adopted`:xn(e)?`old`:`managed`,An=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[kn(e)],jn=e=>/^up/i.test(e),Mn=e=>/^exited \(0\)/i.test(e);function Nn(e){let t=Cn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...En(t)?e.domains:[],...n])]}const Pn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Fn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],In=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Ln=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),Rn=/^traefik\.http\.routers\.[^.]+\.rule$/,zn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Bn(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>Rn.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>Ln(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!Me.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(wn(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(wn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Tn);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const Vn=pt,Hn=`landfall-proxy`;function Un(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function Wn(e,t,n=$e){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${Vn(Sn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${Vn(a.name)}:`),a.build&&r.push(` image: ${Vn(Pn(e.name,a.name,t.id))}`);let o={};wn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${Vn(e)}: {}`),` ${Hn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Un(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${Vn(e)}: ${Vn(ft(t))}`))}return i&&r.push(`networks:`,` ${Hn}:`,` name: ${Vn(n.network)}`,` external: true`),r.join(`
|
|
438
|
+
`,an=[`setup`,`packages`,`docker`,`compose`,`firewall`,`services`,`admin`];function on(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
|
|
439
|
+
`)){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??``,compose:n.compose??``,podman:n.podman??``,platform:n.platform??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,user:n.user??``,admin:n.admin===`1`,ufw:n.ufw===`1`,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:mn(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function sn(e){let{stdout:t}=await J(e,K+rn,{capture:!0});return on(t)}function cn(e,t=!1){let n=fn[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:pn,s=a(`setup`,e.pkg!==`none`,o);a(`admin`,r,`${e.user||`the login`} has no passwordless sudo and root's key login does not work`),a(`packages`,s&&r,s?i.admin:o);let c=e.podman?`only podman ${e.podman} here: landfall needs docker${n?` (${n.docker})`:``}`:n?n.docker:s?`no docker (or no access to it): lf setup installs it`:`no docker (or no access to it)`,l=a(`docker`,!!e.docker,c);return a(`compose`,l&&!!e.compose,l?`docker has no compose plugin (v2)`:i.docker),a(`firewall`,!ln(e.platform,e.pkg,e.ufw)&&r,ln(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(an.map(e=>[e,!i[e]])),why:i}}function ln(e,t,n){if(fn[e]?.firewall)return fn[e].firewall;if(!n&&t===`none`)return`no ufw, and ${pn} to install it`}async function un(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await At({...e,user:i})))break;return cn(t,r)}const dn={"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`},fn={synology:{name:`Synology DSM manages its packages, users, ssh and firewall itself`,docker:`install Container Manager in Package Center`,firewall:`DSM has its own firewall: Control Panel → Security → Firewall`,bans:`DSM bans addresses that keep failing to log in itself: Control Panel → Security → Protection → Auto Block`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},pn=`no apt, dnf, yum, apk or zypper`;function mn(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=fn[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${pn}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=dn[`${e} ${t}`]??dn[`${e} ${o}`]??dn[`${e} ${a}`];if(!s)return{level:`ok`};let c=(Date.parse(s)-r.getTime())/864e5;return c<0?{level:`eol`,note:`${e} ${t} reached end of life on ${s}: no more security updates, upgrade the release`}:c<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${s}`}:{level:`ok`}}const hn=/^(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])\.)/,gn=e=>re(e)===4&&!hn.test(e);async function _n(e,t){let n=Y(e).hostname,r=re(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const vn=e=>e.find(gn)??e[0],yn=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function bn(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const X=e=>{let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)} ${t[n]}`},xn=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`},Sn=e=>e.v!==2,Cn=e=>e.project??e.name,wn=e=>Sn(e)?void 0:e.releases.find(t=>t.id===e.current),Tn=e=>!!(e.port||e.own),En=e=>!!e.port&&!e.domains&&!e.own,Dn=e=>e?.services.find(En),On=e=>e.adopted?void 0:Dn(wn(e)),kn=e=>`landfall-${e}`,An=e=>e.adopted?`adopted`:Sn(e)?`old`:`managed`,jn=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[An(e)],Mn=e=>/^up/i.test(e),Nn=e=>/^exited \(0\)/i.test(e);function Pn(e){let t=wn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...Dn(t)?e.domains:[],...n])]}const Fn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,In=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],Ln=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Rn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),zn=/^traefik\.http\.routers\.[^.]+\.rule$/,Bn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Vn(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>zn.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>Rn(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!je.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(Tn(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(Tn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(En);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const Hn=pt,Un=`landfall-proxy`;function Wn(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function Gn(e,t,n=$e){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${Hn(Cn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${Hn(a.name)}:`),a.build&&r.push(` image: ${Hn(Fn(e.name,a.name,t.id))}`);let o={};Tn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${Hn(e)}: {}`),` ${Un}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Wn(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${Hn(e)}: ${Hn(ft(t))}`))}return i&&r.push(`networks:`,` ${Un}:`,` name: ${Hn(n.network)}`,` external: true`),r.join(`
|
|
431
440
|
`)+`
|
|
432
|
-
`}function
|
|
441
|
+
`}function Kn(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:t.node?`Node.js app`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
|
|
433
442
|
image: nginx:alpine
|
|
434
443
|
restart: unless-stopped
|
|
435
444
|
volumes:
|
|
@@ -438,14 +447,15 @@ exit 0
|
|
|
438
447
|
x-landfall:
|
|
439
448
|
port: 80
|
|
440
449
|
`:`${n} app:
|
|
441
|
-
${e===`image`?`image: ${
|
|
442
|
-
restart: unless-stopped
|
|
450
|
+
${e===`image`?`image: ${Hn(t.image)}`:`build: .`}
|
|
451
|
+
restart: unless-stopped${t.node?`
|
|
452
|
+
init: true`:``}
|
|
443
453
|
env_file:
|
|
444
454
|
- path: .env
|
|
445
455
|
required: false
|
|
446
456
|
x-landfall:
|
|
447
457
|
port: ${t.port??3e3}
|
|
448
|
-
`}function
|
|
458
|
+
`}function qn(e){return`# generated by landfall
|
|
449
459
|
server {
|
|
450
460
|
listen 80;
|
|
451
461
|
server_name _;
|
|
@@ -469,11 +479,12 @@ server {
|
|
|
469
479
|
error_page 404 /404.html;
|
|
470
480
|
location = /404.html { internal; }
|
|
471
481
|
}
|
|
472
|
-
`}const
|
|
473
|
-
|
|
474
|
-
`,{mode:384})}
|
|
482
|
+
`}const Jn=[`dist`,`build`,`out`,`public`,`_site`,`site`],Yn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``),Xn=e=>{let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i={};try{i=JSON.parse(n(`package.json`)||`{}`)}catch{}return{has:t,read:n,first:r,pkg:i}},Zn=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`);function Qn(e){let t=e(`bun.lock`)||e(`bun.lockb`)?`bun`:e(`pnpm-lock.yaml`)?`pnpm`:e(`yarn.lock`)?`yarn`:`npm`;return{name:t,run:e=>t===`bun`?`bun run ${e}`:t===`npm`?e===`start`?`npm start`:`npm run ${e}`:`${t} ${e}`}}const $n=[`express`,`fastify`,`koa`,`hono`,`@hono/node-server`,`@nestjs/core`,`h3`,`@adonisjs/core`];function er(e){let{has:t,read:n,first:r,pkg:i}=Xn(e),a=i.scripts??{};if(a.build||a.start){let e=Qn(t),o=a.build?e.run(`build`):void 0,s=e=>e in{...i.dependencies,...i.devDependencies},c=e=>`./node_modules/.bin/${e}`,l=(e,t)=>({tool:e,dir:t,build:o}),u=(e,t)=>({tool:e,build:o,start:t}),d=`.output/server/index.mjs`;if(s(`next`)){let e=r(Zn(`next.config`));return/\boutput\s*:\s*["'`]export["'`]/.test(e)?l(`Next.js (output: 'export')`,Yn(e,`distDir`)??`out`):u(`Next.js`,`${c(`next`)} start`)}if(s(`nuxt`))return/\bnuxt\s+generate\b/.test(a.build??``)?l(`Nuxt (nuxt generate)`,`.output/public`):u(`Nuxt`,`node ${d}`);if([`@tanstack/react-start`,`@tanstack/solid-start`,`@tanstack/start`].some(s))return u(`TanStack Start`,a.start?e.run(`start`):`node ${d}`);if(s(`@solidjs/start`))return u(`SolidStart`,a.start?e.run(`start`):`node ${d}`);if(s(`@react-router/dev`))return/\bssr\s*:\s*false\b/.test(r(Zn(`react-router.config`)))?l(`React Router (ssr: false)`,`build/client`):u(`React Router`,a.start?e.run(`start`):`${c(`react-router-serve`)} ./build/server/index.js`);if(s(`@remix-run/dev`))return u(`Remix`,a.start?e.run(`start`):`${c(`remix-serve`)} ./build/server/index.js`);if(s(`astro`)){let e=r(Zn(`astro.config`)),t=Yn(e,`outDir`)??`dist`;return s(`@astrojs/node`)?u(`Astro (Node adapter)`,`node ./${t}/server/entry.mjs`):l(`Astro`,t)}if(s(`@sveltejs/kit`)){let e=r(Zn(`svelte.config`));return s(`@sveltejs/adapter-node`)||e.includes(`adapter-node`)?u(`SvelteKit (adapter-node)`,`node ${Yn(e,`out`)??`build`}`):l(`SvelteKit (adapter-static)`,Yn(e,`pages`)??`build`)}if(s(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath,i=typeof r==`string`?r:r?.base;if(typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:i&&(e=`${i}/browser`),i&&t?.options?.ssr)return u(`Angular (SSR)`,`node ${i}/server/server.mjs`)}catch{}return l(`Angular`,e)}return a.start&&$n.some(s)?u(`Node.js`,a[`start:prod`]?e.run(`start:prod`):e.run(`start`)):a.build?s(`gatsby`)?l(`Gatsby`,`public`):s(`@docusaurus/core`)?l(`Docusaurus`,`build`):s(`@11ty/eleventy`)?l(`Eleventy`,Yn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):s(`react-scripts`)?l(`Create React App`,`build`):s(`@vue/cli-service`)?l(`Vue CLI`,Yn(n(`vue.config.js`),`outputDir`)??`dist`):s(`vite`)?l(`Vite`,Yn(r(Zn(`vite.config`)),`outDir`)??`dist`):s(`parcel`)?l(`Parcel`,`dist`):{build:o}:{}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:Yn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function tr(e,t){let n=er(e);return t||=n.start,t?{...n,tool:n.start?n.tool:void 0,start:t}:void 0}const nr=[`node_modules`,`.git`,`.next`,`.nuxt`,`.output`,`.svelte-kit`,`.vinxi`,`.react-router`,`.tanstack`,`.astro`];function rr(e,t){let{has:n,read:r,pkg:i}=Xn(e),a=Qn(n),o=(r(`.nvmrc`)||r(`.node-version`)).trim().match(/^v?(\d+)/)?.[1]??`24`,s=[`bun.lock`,`bun.lockb`,`pnpm-lock.yaml`,`yarn.lock`,`package-lock.json`].find(n),c=i.scripts??{},l=!!i.workspaces||[`pnpm-workspace.yaml`,`.yarnrc.yml`,`patches`].some(n)||[`preinstall`,`install`,`postinstall`,`prepare`].some(e=>e in c),u=n(`package.json`)?a.name===`npm`?s?`npm ci`:`npm install`:a.name===`yarn`?`yarn install${s?` --frozen-lockfile`:``}`:`${a.name} install${s?` --frozen-lockfile`:``}`:void 0,d=[`package.json`,s,...[`.npmrc`,`.yarnrc`,`bunfig.toml`].filter(n)].filter(Boolean);return[`# Generated by landfall${t.tool?` for ${t.tool}`:``}: \`lf init\` writes it into the project, to change it there.`,`FROM node:${o}-slim`,`WORKDIR /app`,...a.name===`bun`?[`RUN npm install -g bun`]:a.name===`npm`?[]:[`RUN corepack enable || npm install -g ${a.name}`],...u&&!l?[`COPY ${d.join(` `)} ./`,`RUN ${u}`,`COPY . .`]:[`COPY . .`,...u?[`RUN ${u}`]:[]],...t.build?[`RUN ${t.build}`]:[],`ENV NODE_ENV=production HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT=${t.port}`,`EXPOSE ${t.port}`,`CMD ${t.start}`,``].join(`
|
|
483
|
+
`)}function ir(e,t=er(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:Jn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function ar(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const or=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function sr(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return In.find(t=>o.existsSync(m(e,t)))}function cr(e){let t=B(e),n=t?.name??or(f(e)),r=sr(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??ar(m(e,`Dockerfile`))??3e3,project:t};let a=er(e),s=t?.dir??ir(e,a),c=s!==`.`||i(`index.html`)?s:void 0,l=i(`package.json`),u=t?.static?void 0:tr(e,t?.start);return u?{path:e,name:n,kind:`dockerfile`,...u,build:t?.build??u.build,port:t?.port??3e3,dir:c,node:l,project:t}:{path:e,name:n,kind:`static`,node:l,dir:c,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const lr=m(I,`projects.json`);function ur(){try{return JSON.parse(o.readFileSync(lr,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function dr(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...ur().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(I,{recursive:!0,mode:448}),o.writeFileSync(lr,JSON.stringify(n,null,2)+`
|
|
484
|
+
`,{mode:384})}function fr(e,t,n){let r=ur();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=B(i.path);e?.name===t&&qe(i.path,{...e,domains:n})}r.length&&o.writeFileSync(lr,JSON.stringify(r,null,2)+`
|
|
485
|
+
`,{mode:384})}const Z=e=>`${H}/${e}`;function pr(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const mr=`
|
|
475
486
|
--8<--landfall--8<--
|
|
476
|
-
`;async function
|
|
487
|
+
`;async function hr(e,t){let n=Z(t),{stdout:r}=await J(e,[...[`${n}/site.json`,`${n}/.env`,W].map(e=>`cat ${q(e)} 2>/dev/null || true`),`[ ! -d ${q(n)} ] || echo 1`].join(`; printf %s ${q(mr)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(mr),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:tt(o)??$e,exists:s.trim()===`1`}}async function gr(e,t,n={}){let r=await hr(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Sn(i)&&D(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&D(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const _r=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),vr=(e,t,n)=>Dt(e,[`docker logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),yr=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function br(e){return xr(e).flatMap(e=>e.hosts)}function xr(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:Rn(e[`traefik.http.routers.${t}.rule`]??``)}))}const Sr=e=>e[`traefik.http.routers.${Xe}.service`]===`api@internal`;function Cr(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>zn.test(e)).flatMap(([,e])=>Rn(e));if(!s.length)continue;let c=e||a,l=i.get(c)??{name:c,domains:[],status:``,containers:[],dir:o[`com.docker.compose.project.working_dir`],files:o[`com.docker.compose.project.config_files`]};l.domains=[.../* @__PURE__ */ new Set([...l.domains,...s])],l.containers.push(a);let u=t.get(a)??`not running`;l.status&&Mn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function wr(e,t){if(!t.length)return`not running`;let n=wn(e),r=new Set(n?.services.filter(Tn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!Mn(e.status))??t.find(e=>!r.has(e.service)&&!Mn(e.status)&&!Nn(e.status))??i[0]??t[0]).status}const Tr=e=>Sn(e)?kn(e.name):Cn(e);async function Er(e){let{stdout:t}=await J(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
|
|
477
488
|
P=${et}
|
|
478
489
|
printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${W} ] && echo 1)"
|
|
479
490
|
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
|
|
@@ -481,25 +492,25 @@ docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.com
|
|
|
481
492
|
ids=$(docker ps -aq 2>/dev/null || true)
|
|
482
493
|
# inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
|
|
483
494
|
[ -z "$ids" ] || docker inspect -f 'LABELS{{"\\t"}}{{.Name}}{{"\\t"}}{{json .Config.Labels}}' $ids 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=/* @__PURE__ */ new Map,i=[],a=[],o={ready:!1,traefik:``,proxy:`traefik`,adopted:!1,sites:[],others:[],dashboard:{hosts:[],tunnel:!1},unrouted:[]},s=[];for(let c of t.split(`
|
|
484
|
-
`)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{
|
|
485
|
-
`),{capture:!0}),[c,l=``]=s.split(
|
|
495
|
+
`)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{E(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:yr(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=Cr(a,n,o.proxy,new Set(i.map(Tr))),o.dashboard={hosts:[...new Set(a.flatMap(e=>br(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(Sr(e.labels)||br(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(Tr));return o.unrouted=s.filter(e=>Mn(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(Tr(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:wr(e,t),services:t,domains:Pn(e),main:On(e)?.name,state:An(e),kind:jn(e)}}),o}async function Dr(e,t,n,r={}){let i=`${Z(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&D(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...In,...Ln].filter(e=>e!==`compose.yaml`),{stdout:s}=await J(e,[K,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${q(i)} && cd -P ${q(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Vt(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${q(a)} compose.yaml`]:[],`rm -f ${o.map(q).join(` `)}`,...r.env===void 0?[]:[Vt(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; exit 1; }`,`cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>.config.err) || { cat .config.err >&2; rm -f .config.err; exit 1; }`,`cat .config.err >&2; rm -f .config.err`,`rm -f .env.next`,`pwd -P; printf %s ${q(mr)}; printf '%s\\n' "$cfg"`].join(`
|
|
496
|
+
`),{capture:!0}),[c,l=``]=s.split(mr),u;try{u=ie(l)}catch(e){D(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function Or(e,t){let n=Z(t.name);await J(e,[K,`[ -r ${q(t.adopted)} ] && [ -x ${q(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${q(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${q(n)} && cd ${q(n)}`,`ln -s ${q(t.adopted)} current`,Vt(`site.json`,JSON.stringify(t,null,2)+`
|
|
486
497
|
`),`record ${q(`adopted ${t.project} as site ${t.name}`)} ${q(`rm -rf ${n}`)}`].join(`
|
|
487
|
-
`))}async function
|
|
488
|
-
`)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...
|
|
489
|
-
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${q(c+`/.env`)})`),!r.prev)u=`[ ! -d ${q(c)} ] || {\n${
|
|
498
|
+
`))}async function kr(e,t,n){let r=Z(t);await J(e,`cd / && rm -rf ${q(`${r}/releases/${n}`)}; [ -f ${q(r+`/site.json`)} ] || { rmdir ${q(r+`/releases`)} 2>/dev/null; { [ -s ${q(r+`/.env`)} ] || rm -f ${q(r+`/.env`)}; }; rmdir ${q(r)} 2>/dev/null; } || true`)}const Ar=e=>[`log "starting ${e}"`,`(cd -P current && dc up -d --wait --wait-timeout 300 --remove-orphans --quiet-pull) || { log "${e} did not start:"; (cd -P current && dc ps -a >&2; dc logs --tail 30 >&2) || true; exit 1; }`],jr=e=>`docker image rm ${q(e)} >/dev/null 2>&1 || true`;function Mr(e,t,n={}){let r=Z(e.name),i=wn(e),a=q(`releases/`+e.current),o=Cn(e),s=[`mkdir -p ${q(r)} && cd ${q(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${q(`label=com.docker.compose.project=${o}`)} --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | while read -r d; do case "$d/" in ("$here"*) ;; (*) echo "\${d:-a container without a folder}"; break ;; esac; done || true)`,`[ -z "$other" ] || { echo ${q(`a Compose project named ${o} already runs from`)} "$other:" ${q(`deploying would replace its containers. Pick another site name, or make it a site with lf adopt ${o}`)} >&2; exit 1; }`,n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Vt(`.env`,n.env,`600`),`(cd -P ${a} && ${Vt(`compose.override.yaml`,Gn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&s.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&s.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),s.push(Vt(`site.json`,JSON.stringify(e,null,2)+`
|
|
499
|
+
`)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...Ar(e.name)),s}const Nr=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>Fn(e.name,n.name,t.id)),Pr=(e,t)=>[`rmf ${q(`${Z(e.name)}/releases/${t.id}`)}`,...Nr(e,t).map(jr)];function Fr(e,t){let n=Z(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${q(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${q(kn(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${q(kn(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.filter(e=>e.id!==t).map(e=>`rm -rf ${q(`releases/`+e.id)}`),...r.map(jr)]}async function Ir(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Lr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function Lr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=_r(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=Z(t.name),l=[K];if(r.takeover){l.push(`record ${q(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Fr(r.takeover,t.current),...Mr(t,n,r)),await J(e,l.join(`
|
|
500
|
+
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${q(c+`/.env`)})`),!r.prev)u=`[ ! -d ${q(c)} ] || {\n${Br(t,{keepDir:r.existed}).join(`
|
|
490
501
|
`)}\n`,d=`
|
|
491
502
|
}`;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 ${q(c)} ] || { log ${q(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
|
|
492
|
-
`+[...
|
|
493
|
-
`)}l.push(`record ${q(r.what)} ${q(u)}${f?`"$u"`:``}${q(d)}`),l.push(...
|
|
494
|
-
`))}async function
|
|
495
|
-
`))}function
|
|
503
|
+
`+[...Mr(i,n),...a.flatMap(e=>Pr(t,e))].join(`
|
|
504
|
+
`)}l.push(`record ${q(r.what)} ${q(u)}${f?`"$u"`:``}${q(d)}`),l.push(...Mr(t,n,r));for(let e of s)l.push(...Pr(t,e));await J(e,l.join(`
|
|
505
|
+
`))}async function Rr(e,t,n,r=``){await J(e,`${K}\ncd ${q(Z(t))}\n${r}\ncd -P current && dc ${n}`)}const zr=(e,t,n)=>Dt(e,[`cd -P ${q(Z(t)+`/current`)} && docker compose logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?q(n.service):``}`]);function Br(e,t={}){let n=Z(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Nr(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(q).join(` `);return[`cd ${q(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${q(Tr(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(jr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${q(n)}`]]}async function Vr(e,t,n={}){let r=`removed site ${t.name} (${Pn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await J(e,[K,...Br(t,n),`record ${q(r)}`].join(`
|
|
506
|
+
`))}function Hr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
|
|
496
507
|
`)){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(`
|
|
497
508
|
`)+`
|
|
498
|
-
`:``}const
|
|
499
|
-
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),
|
|
509
|
+
`:``}const Ur=e=>e.split(`
|
|
510
|
+
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),Wr=async e=>Gr(Ur(e===`-`?await b():o.readFileSync(e,`utf8`)));function Gr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&D(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}let Kr;function qr(){if(Kr)return Kr;let e=o.mkdtempSync(m(u(),`landfall-`));return Kr=m(e,`askpass`),o.writeFileSync(Kr,`#!/bin/sh
|
|
500
511
|
printf '%s\\n' "$LANDFALL_PASSWORD"
|
|
501
|
-
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),
|
|
502
|
-
`)),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!==
|
|
512
|
+
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Kr}const Jr=e=>({SSH_ASKPASS:qr(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Yr(e){if(e){let e=(await b()).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return Ce(`PASSWORD`)||void 0}const Xr=e=>m(c(),`.ssh`,`landfall_${Ae(e)}`);function Zr(e){let t=Xr(e);return{path:t,created:Qr(t,`landfall-${e}`)}}function Qr(e,t,n=!1){return!o.existsSync(e)&&(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,e,`-C`,t,...n?[]:[`-N`,``]],{stdio:`inherit`}).status!==0&&D(`ssh-keygen failed`),!0)}async function $r(e,t,n){let i=Y(t),a=t.key?Ee(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`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 At(t);if(!s&&!t.key&&o.existsSync(Xr(e))&&await At({...t,key:Xr(e)})&&(t.key=a=Xr(e),s=!0),s)w(`key login works`),a??=Wt(t);else if(n.check){E("no key login yet — `lf setup` would install a key first"),ue({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Zr(e);a=t.path,c=t.created,c&&w(`generated key ${L(a)}`)}let l=t.key&&Ee(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await At(t))){C(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Jr(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...yt(t),vt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await At(t)||D(`could not log in with the key — check host, user and password`),w(`key installed`)}return a}const ei=e=>{for(let t of e)console.log(S.dim(` `)+t)};function ti(e,t,n,r){let i=Y(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Zt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});w(`${r.created?`created`:`updated`} ${L(Ht)} with ${S.bold(`Host ${e}`)} — try \`ssh ${e}\``),ei(r.block.split(`
|
|
513
|
+
`)),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!==Wt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?$t(e,n):void 0;s?(w(`added the key to your ${S.bold(`Host ${e}`)} in ${L(s.file)} ${S.dim(`(backup: ${L(s.backup)})`)}`),ei([...s.removed.map(e=>S.red(`- ${e}`)),S.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=L(n)),r.ownAlias&&T(S.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const ni=String.raw`
|
|
503
514
|
set +e
|
|
504
515
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
505
516
|
SUDO=""; R=1
|
|
@@ -554,11 +565,10 @@ ${it}
|
|
|
554
565
|
kv reboot "$(reboot_needed && echo 1)"
|
|
555
566
|
if command -v apt-get >/dev/null 2>&1; then
|
|
556
567
|
if apt_auto_updates; then kv auto_updates on; else kv auto_updates off; fi
|
|
568
|
+
elif command -v apk >/dev/null 2>&1; then
|
|
569
|
+
if apk_auto_updates; then kv auto_updates on; else kv auto_updates off; fi
|
|
557
570
|
elif command -v dnf >/dev/null 2>&1 && [ "$init" = systemd ]; then
|
|
558
|
-
|
|
559
|
-
if systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null \
|
|
560
|
-
|| { { systemctl is-enabled -q dnf-automatic.timer 2>/dev/null || systemctl is-enabled -q dnf5-automatic.timer 2>/dev/null; } \
|
|
561
|
-
&& grep -qiE '^[[:space:]]*apply_updates[[:space:]]*=[[:space:]]*(yes|true|1)' /etc/dnf/automatic.conf 2>/dev/null; }; then kv auto_updates on; else kv auto_updates off; fi
|
|
571
|
+
if dnf_auto_updates; then kv auto_updates on; else kv auto_updates off; fi
|
|
562
572
|
fi
|
|
563
573
|
# Keys of every user with a home: /root and /home/* (deploy users live there).
|
|
564
574
|
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
|
@@ -568,25 +578,25 @@ for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
|
|
568
578
|
done
|
|
569
579
|
done
|
|
570
580
|
exit 0
|
|
571
|
-
`,
|
|
572
|
-
`)){let e=o.split(` `);if(e[0]===`SWAP`)r.swap.push({file:e[1],type:e[2],size:+e[3]||0,used:+e[4]||0});else if(e[0]===`DAEMON`)a=e.slice(1).join(` `);else if(e[0]===`SVC`&&e[1])r.services.push({unit:e[1],load:e[2],active:e[3],sub:e[4],description:e.slice(5).join(` `)});else if(e[0]===`UF`&&e[1])i.set(e[1],e[2]);else if(e[0]===`JAIL`)r.jails.push({name:e[1],banned:+e[2]||0,total:+e[3]||0,failed:+e[4]||0,ips:(e[5]??``).split(/\s+/).filter(Boolean)});else if(e[0]===`KEY`){let n=
|
|
573
|
-
`))}function
|
|
581
|
+
`,ri=[`docker`,`containerd`,`ssh`,`sshd`,`fail2ban`,`ufw`,`unattended-upgrades`,`cron`,`crond`,`systemd-timesyncd`,`chronyd`,`chrony`],ii=e=>e.replace(/\.service$/,``),ai=/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-\S+@openssh\.com)$/;function oi(e){let t=[],n=``,r=!1;for(let i of e.trim())i===`"`&&(r=!r),/\s/.test(i)&&!r?(n&&t.push(n),n=``):n+=i;n&&t.push(n);let i=t.findIndex(e=>ai.test(e));if(i<0||!t[i+1])return;let a=Buffer.from(t[i+1],`base64`);if(a.length)return{type:t[i],fingerprint:`SHA256:${v(`sha256`).update(a).digest(`base64`).replace(/=+$/,``)}`,comment:t.slice(i+2).join(` `),options:t.slice(0,i).join(` `),line:e.trim()}}function si(e){let t=/* @__PURE__ */ new Set;for(let n of Y(e).identityFiles)try{let e=oi(o.readFileSync(`${n}.pub`,`utf8`));e&&t.add(e.fingerprint)}catch{}return t}function ci(e,t=/* @__PURE__ */ new Set){let n={},r={root:!1,user:``,hostname:``,timezone:``,time:``,init:`other`,swap:[],diskFree:0,mem:0,services:[],fail2ban:`missing`,jails:[],keys:[],security:{reboot:!1}},i=/* @__PURE__ */ new Map,a;for(let o of e.split(`
|
|
582
|
+
`)){let e=o.split(` `);if(e[0]===`SWAP`)r.swap.push({file:e[1],type:e[2],size:+e[3]||0,used:+e[4]||0});else if(e[0]===`DAEMON`)a=e.slice(1).join(` `);else if(e[0]===`SVC`&&e[1])r.services.push({unit:e[1],load:e[2],active:e[3],sub:e[4],description:e.slice(5).join(` `)});else if(e[0]===`UF`&&e[1])i.set(e[1],e[2]);else if(e[0]===`JAIL`)r.jails.push({name:e[1],banned:+e[2]||0,total:+e[3]||0,failed:+e[4]||0,ips:(e[5]??``).split(/\s+/).filter(Boolean)});else if(e[0]===`KEY`){let n=oi(e.slice(2).join(` `));n&&r.keys.push({user:e[1],...n,...t.has(n.fingerprint)?{mine:!0}:{}})}else{let e=/^(\w+)=(.*)$/.exec(o);e&&(n[e[1]]=e[2])}}for(let e of r.services)i.has(e.unit)&&(e.enabled=i.get(e.unit));Object.assign(r,{root:n.root===`1`,user:n.user??``,hostname:n.hostname??``,timezone:n.timezone||`UTC`,ntp:n.ntp===`yes`||n.ntp!==`no`&&void 0,time:n.time??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,swappiness:n.swappiness?+n.swappiness:void 0,diskFree:+n.disk_free||0,mem:+n.mem||0,fail2ban:[`running`,`stopped`].includes(n.fail2ban)?n.fail2ban:`missing`});let o=[n.sshd_passwordauthentication,n.sshd_kbdinteractiveauthentication].filter(Boolean);if(r.security={passwordLogin:o.length?o.includes(`yes`):void 0,rootLogin:n.sshd_permitrootlogin||void 0,passwordUsers:n.password_users?.split(/\s+/).filter(Boolean),pkg:n.pkg||void 0,firewall:[`active`,`inactive`,`missing`].includes(n.ufw)?n.ufw:void 0,autoUpdates:n.auto_updates?n.auto_updates===`on`:void 0,reboot:n.reboot===`1`,platform:n.platform||void 0},n.platform===`synology`&&(r.managed=`DSM keeps this itself: change it in Control Panel (Regional Options, Info Center, Container Manager)`),n.log_driver!==void 0){let e={},t;try{e=a?.trim()?JSON.parse(a):{}}catch(e){t=`/etc/docker/daemon.json is not valid JSON: ${e.message}`}let i=e[`log-opts`]??{};r.docker={driver:n.log_driver||String(e[`log-driver`]??`json-file`),...i[`max-size`]?{maxSize:i[`max-size`]}:{},...i[`max-file`]?{maxFile:Number(i[`max-file`])}:{},daemon:e,...t?{daemonError:t}:{}}}return r}async function li(e){let{stdout:t}=await J(e,ni,{capture:!0});return ci(t,si(e))}async function ui(e){let t=await li(e);if(t.root)return t;let n=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==n&&await At({...e,user:t}))return li({...e,user:t});return t}const di=/^[A-Za-z0-9@_.:-]+$/;async function fi(e,t,n){di.test(n)||D(`not a service name: ${n}`);let r=t===`start`?`stop`:t===`stop`?`start`:``,i=`${t} service ${n}`;await J(e,[K,`svc ${t} ${q(n)}`,`record ${q(i)}${r?` ${q(`svc ${r} ${q(n)}`)}`:``}`].join(`
|
|
583
|
+
`))}function pi(e,t){di.test(e)||D(`not a service name: ${e}`);let n=`$([ "$(id -u)" = 0 ] || echo sudo -n)`,r=String(t.lines);return t.follow?`${n} journalctl -u ${q(e)} -n ${r} -f --no-pager 2>/dev/null || ${n} tail -n ${r} -f /var/log/${q(ii(e))}.log /var/log/messages`:`${n} journalctl -u ${q(e)} -n ${r} --no-pager 2>/dev/null || ${n} tail -n ${r} /var/log/${q(ii(e))}.log /var/log/messages 2>/dev/null`}const mi=`home=$(getent passwd "$KEY_USER" 2>/dev/null | cut -d: -f6); [ -n "$home" ] || home=$(awk -F: -v u="$KEY_USER" '$1 == u { print $6 }' /etc/passwd)
|
|
574
584
|
[ -n "$home" ] || { log "no user $KEY_USER"; exit 1; }
|
|
575
|
-
f="$home/.ssh/authorized_keys"`;async function
|
|
576
|
-
${
|
|
585
|
+
f="$home/.ssh/authorized_keys"`;async function hi(e,t,n){oi(n)||D(`not a public key: expected a line like ssh-ed25519 AAAA… comment`);let r=String.raw`
|
|
586
|
+
${mi}
|
|
577
587
|
if $SUDO grep -qxF -- "$KEY" "$f" 2>/dev/null; then log "already there"; exit 0; fi
|
|
578
588
|
$SUDO mkdir -p "$home/.ssh"; $SUDO chmod 700 "$home/.ssh"
|
|
579
589
|
printf '%s\n' "$KEY" | $SUDO tee -a "$f" >/dev/null
|
|
580
590
|
g=$(id -gn "$KEY_USER" 2>/dev/null || echo "$KEY_USER")
|
|
581
591
|
$SUDO chown "$KEY_USER:$g" "$home/.ssh" "$f"; $SUDO chmod 600 "$f"
|
|
582
592
|
record "authorized key $WHAT for $KEY_USER" "drop_line $(printf %q "$f") $(printf %q "$KEY")"
|
|
583
|
-
`,i=
|
|
584
|
-
${
|
|
593
|
+
`,i=oi(n);await J(e,K+r,{env:{KEY_USER:t,KEY:i.line,WHAT:i.comment||i.fingerprint}})}async function gi(e,t){let n=String.raw`
|
|
594
|
+
${mi}
|
|
585
595
|
$SUDO grep -qxF -- "$KEY" "$f" || { log "key not found in $f"; exit 1; }
|
|
586
596
|
drop_line "$f" "$KEY"
|
|
587
597
|
record "removed key $WHAT of $KEY_USER" "printf '%s\n' $(printf %q "$KEY") | \$SUDO tee -a $(printf %q "$f") >/dev/null"
|
|
588
|
-
`;await J(e,K+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const
|
|
589
|
-
`))}function
|
|
598
|
+
`;await J(e,K+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const _i=/^[0-9a-fA-F.:]+$/;async function vi(e,t,n){_i.test(t)||D(`not an IP address: ${t}`);let r=n.map(e=>`\\$SUDO fail2ban-client set ${q(e)} banip ${q(t)} >/dev/null`).join(`; `);await J(e,[K,...n.map(e=>`$SUDO fail2ban-client set ${q(e)} unbanip ${q(t)} >/dev/null`),`record ${q(`unbanned ${t}`)} "${r}"`].join(`
|
|
599
|
+
`))}function yi(e){let t=/^(\d+(?:\.\d+)?)\s*([mg])i?b?$/i.exec(e.trim());return t||D(`not a size: ${e} (e.g. 512M, 2G)`),Math.round(Number(t[1])*(t[2].toLowerCase()===`g`?1024:1))*1024*1024}const bi=`/swapfile none swap sw 0 0`,xi=e=>String.raw`
|
|
590
600
|
if [ -e /swapfile ]; then log "/swapfile exists already: lf swap off first"; exit 1; fi
|
|
591
601
|
mk() { # fallocate is instant but makes files swapon refuses on some filesystems (btrfs, older xfs): dd then
|
|
592
602
|
{ $SUDO fallocate -l ${e}M /swapfile 2>/dev/null || $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none; } &&
|
|
@@ -598,26 +608,26 @@ if ! mk; then
|
|
|
598
608
|
|| { $SUDO rm -f /swapfile; log "could not turn on swap (a container, or a filesystem without swap files?)"; exit 1; }
|
|
599
609
|
fi
|
|
600
610
|
record "created ${e} MB swap at /swapfile" '$SUDO swapoff /swapfile 2>/dev/null; $SUDO rm -f /swapfile'
|
|
601
|
-
if ! grep -qxF '${
|
|
602
|
-
printf '%s\n' '${
|
|
603
|
-
record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${
|
|
611
|
+
if ! grep -qxF '${bi}' /etc/fstab 2>/dev/null; then
|
|
612
|
+
printf '%s\n' '${bi}' | $SUDO tee -a /etc/fstab >/dev/null
|
|
613
|
+
record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${bi}'"
|
|
604
614
|
fi
|
|
605
|
-
`,
|
|
615
|
+
`,Si=(e,t)=>J(e,K+xi(Math.round(t/1024/1024))),Ci=String.raw`
|
|
606
616
|
[ -e /swapfile ] || { log "no /swapfile: landfall only removes the swap file it made"; exit 1; }
|
|
607
617
|
$SUDO swapoff /swapfile
|
|
608
618
|
$SUDO rm -f /swapfile
|
|
609
|
-
drop_line /etc/fstab '${
|
|
619
|
+
drop_line /etc/fstab '${bi}'
|
|
610
620
|
record "removed the swap file /swapfile"
|
|
611
|
-
`,
|
|
612
|
-
`))}const
|
|
613
|
-
`))}const
|
|
621
|
+
`,wi=e=>J(e,K+Ci),Ti=/^(?=.{1,63}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,Ei=e=>`$SUDO hostnamectl set-hostname ${q(e)} 2>/dev/null || { printf '%s\\n' ${q(e)} | $SUDO tee /etc/hostname >/dev/null && $SUDO hostname ${q(e)}; }`;async function Di(e,t,n){Ti.test(n)||D(`not a host name: ${n} (letters, digits and -, up to 63)`),await J(e,[K,Ei(n),`record ${q(`hostname ${t} → ${n}`)} ${q(Ei(t))}`,`if grep -qE '^127\\.0\\.1\\.1[[:space:]]' /etc/hosts; then`,` undo=$(saved /etc/hosts) || exit 1`,` $SUDO sed -i 's/^127\\.0\\.1\\.1[[:space:]].*/127.0.1.1\\t'${q(n)}'/' /etc/hosts`,` record "named ${n} in /etc/hosts" "$undo"`,`fi`].join(`
|
|
622
|
+
`))}const Oi=/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/,ki=e=>`$SUDO timedatectl set-timezone ${q(e)} 2>/dev/null || { $SUDO ln -sf /usr/share/zoneinfo/${q(e)} /etc/localtime && printf '%s\\n' ${q(e)} | $SUDO tee /etc/timezone >/dev/null; }`;async function Ai(e,t,n){Oi.test(n)||D(`not a time zone: ${n} (e.g. Europe/Berlin, UTC)`),await J(e,[K,`[ -e /usr/share/zoneinfo/${q(n)} ] || { log ${q(`unknown time zone ${n} (or tzdata is not installed)`)}; exit 1; }`,ki(n),`record ${q(`time zone ${t} → ${n}`)} ${q(ki(t))}`].join(`
|
|
623
|
+
`))}const ji=/^\d+[kmg]$/;function Mi(e,t,n){let r={...e[`log-opts`]??{}};t?r[`max-size`]=t:delete r[`max-size`],t&&n?r[`max-file`]=String(n):delete r[`max-file`];let i={...e,"log-opts":r};return Object.keys(r).length||delete i[`log-opts`],i}const Ni=`$SUDO systemctl restart docker 2>/dev/null || $SUDO rc-service docker restart 2>/dev/null || $SUDO service docker restart`;async function Pi(e,t,n,r){let i=JSON.stringify(t,null,2)+`
|
|
614
624
|
`,a=String.raw`
|
|
615
625
|
$SUDO install -d /etc/docker
|
|
616
626
|
undo=$(saved /etc/docker/daemon.json) || exit 1
|
|
617
627
|
printf %s ${q(Buffer.from(i).toString(`base64`))} | base64 -d | $SUDO tee /etc/docker/daemon.json >/dev/null
|
|
618
|
-
${r?`log "restarting docker"\n${
|
|
619
|
-
`;await J(e,K+a)}const
|
|
620
|
-
`).map(e=>e.split(/\s+/).filter(Boolean));return
|
|
628
|
+
${r?`log "restarting docker"\n${Ni}\nrecord ${q(n)} "$undo; ${Ni.replace(/\$/g,`\\$`)}"`:`record ${q(n)} "$undo"`}
|
|
629
|
+
`;await J(e,K+a)}const Fi=[`ssh`,`fail2ban`,`updates`],Ii=e=>e.length>1?`${e.slice(0,-1).join(`, `)} and ${e.at(-1)}`:e[0],Li=e=>`${Ii(e)} ${e.length===1?`has`:`have`} no key yet and would be locked out: add one first (lf keys add <file.pub> -u ${e[0]}, or --generate to make a pair), then lf harden ssh.`,Ri=(e,t,n)=>e.filter(e=>e!==`root`&&!n.includes(e)&&!t.includes(e)),zi=(e,t)=>Ri(e.security.passwordUsers??[],e.keys.map(e=>e.user),[t]);async function Bi(e,t){let{stdout:n}=await J(e,`${K}\necho "$(password_users)"; echo "$(key_users)"`,{capture:!0}),[r=[],i=[]]=n.split(`
|
|
630
|
+
`).map(e=>e.split(/\s+/).filter(Boolean));return Ri(r,i,[t])}function Vi(e,t){let n=e.security,r=[`-s`,t.name],i=[],a=n.pkg===`apt-get`||n.pkg===`apk`,o=n.platform?fn[n.platform]:void 0;if(n.passwordLogin){let a=zi(e,t.loginUser);i.push({id:`password-login`,level:`risk`,title:n.rootLogin===`yes`?`SSH accepts passwords, root's too`:`SSH accepts passwords`,why:`Bots try passwords on every public server all day: one weak or reused password lets them in. A key cannot be guessed.${a.length?` ${Li(a)}`:``}`,fix:a.length?{label:`Make a key for ${a[0]}`,argv:[`keys`,`add`,`--generate`,`-u`,a[0],...r],user:a[0]}:{label:`Keys only`,argv:[`harden`,`ssh`,...r],confirm:`Only keys log in afterwards; lf undo turns passwords back on.`}})}n.firewall===`inactive`||n.firewall===`missing`&&a?i.push({id:`firewall`,level:`warn`,title:n.firewall===`missing`?`No firewall`:`Firewall is off`,why:`Every port a program opens is reachable from the internet. ufw lets in ssh, 80 and 443, and what you allow.`,fix:{label:`Turn it on`,argv:[`firewall`,`enable`,...r]}}):n.firewall===`missing`&&i.push({id:`firewall`,level:`info`,title:o?.firewall?`Firewall: the system's own`:`No ufw`,why:o?.firewall?`${o.firewall}. Let in only ssh, 80 and 443.`:`Use firewalld or your provider's firewall to let in only ssh, 80 and 443.`}),e.root&&e.fail2ban!==`running`&&(e.fail2ban===`stopped`||a)?i.push({id:`fail2ban`,level:n.passwordLogin?`warn`:`info`,title:e.fail2ban===`missing`?`No fail2ban`:`fail2ban is not running`,why:`It bans addresses that keep failing to log in: it slows password guessing and keeps the logs readable.`,fix:{label:e.fail2ban===`missing`?`Install it`:`Start it`,argv:[`harden`,`fail2ban`,...r]}}):e.fail2ban===`missing`&&o?.bans&&i.push({id:`fail2ban`,level:`info`,title:`Login bans: the system's own`,why:o.bans}),n.autoUpdates===!1&&i.push({id:`auto-updates`,level:`warn`,title:`No automatic security updates`,why:`Holes in OpenSSH, the kernel or libraries get fixed within days; without automatic updates they stay open until someone runs lf update.`,fix:{label:`Turn them on`,argv:[`harden`,`updates`,...r]}}),n.reboot&&i.push({id:`reboot`,level:`info`,title:`Reboot pending`,why:`Installed updates (a new kernel or libc) only take effect after a reboot.`,fix:{label:`Reboot`,argv:[`reboot`,...r]}});let s={risk:0,warn:1,info:2};return i.sort((e,t)=>s[e.level]-s[t.level])}function Hi(e){for(let t of e){let e=t.level===`risk`?S.red(`!`):t.level===`warn`?S.yellow(`!`):S.dim(`·`);console.log(`${e} ${S.bold(t.title)}\n ${S.dim(t.why)}${t.fix?`\n ${S.cyan(`lf ${t.fix.argv.join(` `)}`)}`:``}`)}}async function Ui(e,t,n){let r=n?await li(n):await ui(t);return{...r,advice:Vi(r,{name:e,loginUser:Y(t).user})}}async function Wi(e,t,n){try{let r=(await Ui(e,t,n)).advice;if(ue({advice:r}),!r.length)return;console.log(`\n${S.bold(`Security`)}`),Hi(r)}catch{}}const Gi=String.raw`
|
|
621
631
|
if [ "$H_SSH" = 1 ]; then
|
|
622
632
|
if [ -d /etc/ssh ]; then
|
|
623
633
|
${st}
|
|
@@ -655,20 +665,47 @@ if [ "$H_UPDATES" = 1 ]; then
|
|
|
655
665
|
printf '%s\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Unattended-Upgrade "1";' | $SUDO tee $f >/dev/null
|
|
656
666
|
record "turned on automatic security updates" "$undo"
|
|
657
667
|
fi ;;
|
|
668
|
+
apk)
|
|
669
|
+
if ! apk_auto_updates; then
|
|
670
|
+
log "turning on automatic updates"
|
|
671
|
+
# A stable Alpine branch only gets fixes, so upgrading all of it is what apt's security updates do.
|
|
672
|
+
f=/etc/periodic/daily/landfall-upgrade
|
|
673
|
+
if [ ! -e $f ]; then
|
|
674
|
+
printf '#!/bin/sh\napk upgrade --no-cache --quiet\n' | $SUDO tee $f >/dev/null && $SUDO chmod 755 $f
|
|
675
|
+
record "turned on automatic updates" "\$SUDO rm -f $f"
|
|
676
|
+
fi
|
|
677
|
+
if ! pidof crond >/dev/null; then
|
|
678
|
+
{ svc enable crond; svc start crond; } >/dev/null 2>&1 || { echo "could not start crond, which runs $f" >&2; exit 1; }
|
|
679
|
+
record "started crond" 'svc stop crond; svc disable crond'
|
|
680
|
+
fi
|
|
681
|
+
fi ;;
|
|
658
682
|
dnf)
|
|
659
683
|
pkg_has dnf-automatic || { log "installing dnf-automatic"; pkg_install dnf-automatic && record "installed dnf-automatic"; }
|
|
660
|
-
if
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
fi
|
|
684
|
+
if dnf_auto_updates; then :
|
|
685
|
+
elif systemctl cat dnf-automatic-install.timer >/dev/null 2>&1; then
|
|
686
|
+
$SUDO systemctl enable --now dnf-automatic-install.timer >/dev/null
|
|
687
|
+
record "turned on automatic updates" '$SUDO systemctl disable --now dnf-automatic-install.timer'
|
|
665
688
|
else
|
|
666
|
-
|
|
689
|
+
# dnf5 (Fedora 41+): one timer, and automatic.conf says whether it installs.
|
|
690
|
+
log "turning on automatic updates"
|
|
691
|
+
f=/etc/dnf/automatic.conf
|
|
692
|
+
if ! dnf_applies; then
|
|
693
|
+
undo=$(saved $f)
|
|
694
|
+
if grep -q '^[[:space:]]*apply_updates' $f 2>/dev/null; then $SUDO sed -i 's/^[[:space:]]*apply_updates.*/apply_updates = yes/' $f
|
|
695
|
+
elif grep -q '^\[commands\]' $f 2>/dev/null; then $SUDO sed -i '/^\[commands\]/a apply_updates = yes' $f
|
|
696
|
+
else printf '[commands]\napply_updates = yes\n' | $SUDO tee -a $f >/dev/null; fi
|
|
697
|
+
record "automatic updates install, not only download" "$undo"
|
|
698
|
+
fi
|
|
699
|
+
t=dnf5-automatic.timer; systemctl cat $t >/dev/null 2>&1 || t=dnf-automatic.timer
|
|
700
|
+
if ! systemctl is-enabled -q $t 2>/dev/null; then
|
|
701
|
+
$SUDO systemctl enable --now $t >/dev/null
|
|
702
|
+
record "turned on automatic updates" "\$SUDO systemctl disable --now $t"
|
|
703
|
+
fi
|
|
667
704
|
fi ;;
|
|
668
|
-
*) echo "automatic updates: landfall knows apt and dnf; on $(pkg_mgr) set them up by hand" >&2; exit 1 ;;
|
|
705
|
+
*) echo "automatic updates: landfall knows apt, apk and dnf; on $(pkg_mgr) set them up by hand" >&2; exit 1 ;;
|
|
669
706
|
esac
|
|
670
707
|
fi
|
|
671
|
-
`;async function
|
|
708
|
+
`;async function Ki(e,t){await J(e,K+Gi,{env:{H_SSH:t.includes(`ssh`),H_F2B:t.includes(`fail2ban`),H_UPDATES:t.includes(`updates`)}})}function qi(e,t){/^[a-z_][a-z0-9_.-]*$/i.test(t)||D(`not a user name: ${t}`);let n=m(c(),`.ssh`,`${Ae(e)}_${t}`),r=de(),i=Qr(n,`${t}@${e}`,r);return o.existsSync(`${n}.pub`)||D(`${n} exists without ${n}.pub; move it away or pass the public key`),{path:n,created:i,passphrase:i&&r,pub:o.readFileSync(`${n}.pub`,`utf8`).trim()}}async function Q(e,t){let{stdout:n}=await J(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==r&&await At({...e,user:t}))return{...e,user:t};return D(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const Ji=String.raw`
|
|
672
709
|
set +e
|
|
673
710
|
m=$(pkg_mgr); echo "mgr=$m"
|
|
674
711
|
refresh() { [ "$REFRESH" = 1 ] && S true 2>/dev/null && "$@" >/dev/null 2>&1 && echo refreshed=1; }
|
|
@@ -690,8 +727,8 @@ case "$m" in
|
|
|
690
727
|
esac
|
|
691
728
|
reboot_needed && echo reboot=1
|
|
692
729
|
exit 0
|
|
693
|
-
`;function
|
|
694
|
-
`)){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
|
|
730
|
+
`;function Yi(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
|
|
731
|
+
`)){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 Xi(e,t=!0){let{stdout:n}=await J(e,K+Ji,{capture:!0,env:{REFRESH:t}});return Yi(n)}const Zi=String.raw`
|
|
695
732
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
|
|
696
733
|
keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
|
|
697
734
|
case "$(pkg_mgr)" in
|
|
@@ -711,7 +748,7 @@ if [ "$REBOOT" = 1 ] && reboot_needed; then
|
|
|
711
748
|
# Detached, so this ssh session can end cleanly first.
|
|
712
749
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
713
750
|
fi
|
|
714
|
-
`;async function
|
|
751
|
+
`;async function Qi(e,t){await J(e,K+Zi,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function $i(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 ea(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 ta(e){let t=$i(e);return t.digest?t.digest:(await ea(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function na(e){let t=$i(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await ea(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 ra(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 ia=/^(v?)(\d+(?:\.\d+){0,2})$/;function aa(e,t){let n=ia.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=ia.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&ra(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const oa=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,sa=/* @__PURE__ */ new Map;function ca(e){return sa.has(e)||sa.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)),sa.get(e)}const la=()=>ca(`moby/moby`),ua=()=>ca(`docker/compose`);async function da(e){let t=String.raw`
|
|
715
752
|
${K}
|
|
716
753
|
set +e
|
|
717
754
|
printf 'docker=%s
|
|
@@ -724,9 +761,9 @@ printf 'ce=%s
|
|
|
724
761
|
' "$(pkg_has docker-ce && echo 1)"
|
|
725
762
|
printf 'syno=%s
|
|
726
763
|
' "$([ -f /etc/synoinfo.conf ] && echo 1)"
|
|
727
|
-
exit 0`,[{stdout:n},r,i]=await Promise.all([J(e,t,{capture:!0}),
|
|
728
|
-
`).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`,synology:a.syno===`1`,latestDocker:r,latestCompose:i}}function
|
|
729
|
-
`)}const
|
|
764
|
+
exit 0`,[{stdout:n},r,i]=await Promise.all([J(e,t,{capture:!0}),la(),ua()]),a=Object.fromEntries(n.split(`
|
|
765
|
+
`).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`,synology:a.syno===`1`,latestDocker:r,latestCompose:i}}function fa(e){let t=(e,t)=>!!e&&!!t&&ra(e,t)<0,n=e.synology?`comes with Container Manager updates in Package Center`:void 0,r=t(e.docker,e.latestDocker)?n??(e.dockerCe?`comes with the package upgrades`:`distro package: switch to Docker's repo for the newest`):void 0,i=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`&&!e.synology,a=t(e.compose,e.latestCompose)&&!i?n??(e.dockerCe?`comes with the package upgrades`:`from ${e.composePath||`the distro`}`):void 0;return{dockerNote:r&&`docker ${e.docker}, ${e.latestDocker} is current (${r})`,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${a?` (${a})`:``}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:i,dockerHow:r,composeHow:a}}async function pa(e){await J(e,K+ct+`compose_install || { log "could not download docker compose"; exit 1; }
|
|
766
|
+
`)}const ma=String.raw`
|
|
730
767
|
set +e
|
|
731
768
|
P=${et}
|
|
732
769
|
img() { # img <kind> <name> <container> <dir> [service]
|
|
@@ -750,15 +787,15 @@ for f in ${H}/*/site.json; do
|
|
|
750
787
|
printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
|
|
751
788
|
done
|
|
752
789
|
exit 0
|
|
753
|
-
`;async function
|
|
754
|
-
`).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(!
|
|
755
|
-
`))}function
|
|
756
|
-
`)){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(
|
|
757
|
-
[ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function
|
|
790
|
+
`;async function ha(e){let{stdout:t}=await J(e,ma,{capture:!0}),n=t.split(`
|
|
791
|
+
`).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(!ia.test($i(e).tag))return;i.has(t)||i.set(t,na(e));let n=aa($i(e).tag,await i.get(t));return n&&oa(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``,c=``])=>{let l={name:t,container:n,dir:i,ref:s,service:o||void 0};if(e===`site`&&o&&s.startsWith(Fn(t,o,``)))return{...l,kind:`site`,state:`rebuild`,note:`built on the server: redeploy to rebuild`};if(!s)return{...l,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...l,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(s.includes(`@`))return{...l,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(s)||r.set(s,ta(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>$i(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,ee=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...ee}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...ee}}))}async function ga(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 ${q(`pulling ${e}`)}`,`docker pull -q ${q(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${q(e)})`);for(let n of t){let t=n.service?` ${q(n.service)}`:``,i=`docker tag $old ${q(e)} && cd -P ${q(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${q(n.container)})`,`if [ "$old" = "$new" ]; then log ${q(`${n.name} already runs the newest ${e}`)}; else`,` record ${q(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${q(`recreating ${n.container}`)}`,` (cd -P ${q(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${q(n.container)} || exit 1`,`fi`)}}await J(e,r.join(`
|
|
792
|
+
`))}function _a(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 va(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
|
|
793
|
+
`)){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(_a(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function ya(e,t={}){let{stdout:n}=await J(e,K+dt,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return va(n)}const ba=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),xa=e=>e.includes(`:`),Sa=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Ca=[68,546,5353];function wa(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=ja(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 Ta(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 Ea=(e,t)=>{let n=Ta(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Da=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[Sa(e),t(e)])).values()];function Oa(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=ka(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 ka(e,t){let n=e.values,r=e.rules.map(wa),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Ca.includes(e.port)||Ea(r,e),a=e=>!ba(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[xa(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Da(o,e=>`${Sa(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Da(l,e=>`${Sa(e)} (docker${xa(e.addr)?`, IPv6`:``})`)],kept:c?[]:Da(s.filter(e=>!l.includes(e)),Sa)}}function Aa(e){let t=e.values,n=e.rules.map(wa),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[xa(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(ba(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=Ta(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`&&Ca.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=`${Sa(e)} ${t?`docker`:e.proc}`,r={port:Sa(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 ja=(e=``)=>e.split(/\s+/).filter(Boolean);function Ma(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&&ra(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 Na(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(Ma(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(Oa(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=Ri(ja(n.password_users),ja(n.key_users),[t.loginUser,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(Ia(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Pa(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 Fa=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Ia(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=tt(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Fa(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=ja(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Pa(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=ja(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Pa(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Fa(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 La(e){let t={done:S.green(`✓`),todo:S.cyan(`→`),conflict:S.yellow(`!`),off:S.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?S.dim(`disabled`):r.status===`conflict`?S.yellow(r.detail):S.dim(r.detail);T(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Ra(e,t){let{stdout:n}=await J(e,`id=$(docker ps -q ${t.map(e=>`--filter ${q(e)}`).join(` `)} | head -n1)
|
|
794
|
+
[ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function za(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||D(`${e.name} has no IP address to tunnel to`),n}function Ba(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const Va={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},Ha=e=>Va[e]??`http`;async function Ua(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=g.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const Wa=e=>new Promise(t=>{let n=g.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function Ga(e,t,n,r){let i=n.tls?se:oe,a=oe.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const Ka=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function qa(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:Re(i.local,`--local`,1,65535),s=await Ua(o??(r>=1024?r:8080),a);o&&s!==o&&D(`local port ${o} is taken; pick another with --local`);let c=i.as?await Ua():s,l=[...yt(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,vt(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`,`SIGHUP`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await Wa(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await Ga(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;w(`${i.what}: ${S.cyan(t(`localhost`))}`);let n=i.lan?Ka():[];ue({url:t(`localhost`),lan:n.map(t)}),i.lan&&(T(n.length?`on your network: ${n.map(e=>S.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),E(`anyone on that network reaches it too: close it when you are done`)),i.note&&T(S.dim(i.note)),T(S.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&D(`the tunnel closed (ssh exit ${m})`)}async function Ja(e,t){return`${e}:${(await y.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const Ya=e=>e.slice(0,e.indexOf(`:`)),Xa=`admin`;async function Za(e,t,n=`user`){Ne(e,n);let r=t??await he(`Password for ${e}:`);return r.length<8&&D(`use a password of at least 8 characters`),Ja(e,r)}function Qa(e,t){return{email:t.email??e.email??xe().email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const $a=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function eo(e,t,n){let r=Qa(t,n);Le(r.image),r.email&&Pe(r.email);let{email:i,staging:a}=r;C(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Ve(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){je.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Ne(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??ae(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Ja(e,s)}}let l=mt({...r,dashboard:o});r.hostNetwork&&await ro(t,r.http3),await J(t,`${K}
|
|
758
795
|
ensure_network web
|
|
759
796
|
[ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
|
|
760
797
|
mkdir -p ${U}/letsencrypt ${Je} && cd ${U}
|
|
761
|
-
${
|
|
798
|
+
${Vt(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
|
|
762
799
|
tls:
|
|
763
800
|
options:
|
|
764
801
|
default:
|
|
@@ -769,7 +806,7 @@ tls:
|
|
|
769
806
|
gw=$(docker network inspect web -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\\n' | grep -m1 '[.]' || true)
|
|
770
807
|
printf 'LANDFALL_GATEWAY=%s\\n' "\${gw:-127.0.0.1}" > .env
|
|
771
808
|
if [ -e docker-compose.yml ]; then
|
|
772
|
-
${
|
|
809
|
+
${Vt(`.docker-compose.yml.new`,l)}
|
|
773
810
|
# .landfall.sha256: what landfall wrote last. A different file was edited by hand.
|
|
774
811
|
if [ -f .landfall.sha256 ] && ! sha256sum -c --status .landfall.sha256 2>/dev/null && ! cmp -s .docker-compose.yml.new docker-compose.yml && [ -z "${n.force?`1`:``}" ]; then
|
|
775
812
|
diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
|
|
@@ -781,31 +818,31 @@ if [ -e docker-compose.yml ]; then
|
|
|
781
818
|
else
|
|
782
819
|
u=$(saved docker-compose.yml)
|
|
783
820
|
mv .docker-compose.yml.new docker-compose.yml
|
|
784
|
-
record ${q(`reconfigured traefik (${
|
|
821
|
+
record ${q(`reconfigured traefik (${$a(r)})`)} "cd ${U} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
|
|
785
822
|
fi
|
|
786
823
|
else
|
|
787
|
-
${
|
|
824
|
+
${Vt(`docker-compose.yml`,l)}
|
|
788
825
|
# Certificates in letsencrypt/ stay, a later install reuses them.
|
|
789
|
-
record ${q(`installed traefik (${
|
|
826
|
+
record ${q(`installed traefik (${$a(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
|
|
790
827
|
fi
|
|
791
828
|
# Written now: a start that fails below must not make landfall's own file look edited by hand.
|
|
792
829
|
sha256sum docker-compose.yml > .landfall.sha256
|
|
793
830
|
log "starting traefik"
|
|
794
831
|
dc up -d --remove-orphans --quiet-pull
|
|
795
832
|
sleep 2 # traefik exits shortly after start on a bad config
|
|
796
|
-
ensure_running traefik`);let u=
|
|
833
|
+
ensure_running traefik`);let u=R();u.servers[e]&&(Object.assign(u.servers[e],{email:i===xe().email?void 0:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),De(u)),w(`traefik running (${$a(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&T(S.dim(`host network: ufw now decides who reaches 80/443`)),o&&(w(`dashboard: https://${o.host}`),T(s?`user ${S.bold(o.user)} password ${S.bold(s)}`:S.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function to(e,t){C(`using traefik ${S.bold(t.container)} for lf sites`),await J(e,`${K}
|
|
797
834
|
docker network inspect ${q(t.network)} >/dev/null 2>&1 || { echo ${q(`docker network ${t.network} does not exist`)} >&2; exit 1; }
|
|
798
835
|
[ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
|
|
799
836
|
u=$(saved ${W})
|
|
800
|
-
${
|
|
801
|
-
record ${q(`sites use traefik ${t.container}`)} "$u"`),
|
|
837
|
+
${Vt(W,nt(t))}
|
|
838
|
+
record ${q(`sites use traefik ${t.container}`)} "$u"`),w(`sites join network ${S.bold(t.network)} with entrypoint ${S.bold(t.entrypoint)} and resolver ${S.bold(t.resolver)}`),T(S.dim(`redeploy existing sites to switch them over; delete ${W} to let landfall run its own traefik again`))}async function no(e){let{stdout:t}=await J(e,`[ ! -f ${W} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${W})"
|
|
802
839
|
f=${U}/docker-compose.yml
|
|
803
840
|
[ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
|
|
804
841
|
for d in ${H}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
|
|
805
|
-
`)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function
|
|
842
|
+
`)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function ro(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await Q(e,`checking ufw`)}catch{return E(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await J(r,`${K}
|
|
806
843
|
command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" || exit 0
|
|
807
844
|
${n.map(e=>`ufw_add allow ${e}`).join(`
|
|
808
|
-
`)}`)}async function
|
|
845
|
+
`)}`)}async function io(e,t={}){C(`removing traefik`),await J(e,`${K}
|
|
809
846
|
cd ${U}
|
|
810
847
|
log "stopping traefik"
|
|
811
848
|
dc down --remove-orphans
|
|
@@ -817,10 +854,10 @@ u="$u1; $u2; $u3"
|
|
|
817
854
|
rm -f docker-compose.yml .landfall.sha256 ${Je}/landfall.yml
|
|
818
855
|
${t.certs?`rmf letsencrypt`:``}
|
|
819
856
|
# Certificates removed with --certs come back from Let's Encrypt on the next start.
|
|
820
|
-
record ${q(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${U} && dc up -d --remove-orphans"`)}const
|
|
857
|
+
record ${q(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${U} && dc up -d --remove-orphans"`)}const ao={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},oo=N({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:P,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: ${Ze})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},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:Xa},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await ya(r),a=Ia(i);if(a.status===`conflict`&&D(`not touching ${n}: ${a.detail}`),a.proxy&&D(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),Qa(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&D(`port ${Ye} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await eo(n,r,{email:t.email&&Pe(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),so=N({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
|
|
821
858
|
|
|
822
859
|
landfall reads network, entrypoint and cert resolver from the container's flags;
|
|
823
|
-
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:
|
|
860
|
+
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:P,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=Pa((await ya(r)).values.foreign_proxy),a=e??i?.name;a||D(`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&&D(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await to(r,s)}}),co=N({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:P,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await no(r);if(i.proxy)return await j(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||D(`cancelled`),await J(r,`${K}\nu=$(saved ${W})\n_as ${V} rm -f ${W}\nrecord ${q(`sites stop using traefik ${i.proxy}`)} "$u"`),w(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),T(S.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&D(`${U}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return w(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await A(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await io(r,{certs:t.certs}),w(`traefik removed${t.certs?``:S.dim(` (certificates stay for a later install; --certs deletes them)`)}`),T(S.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),lo=N({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:P},async run(e,t){let{server:n}=z(t.server);await J(n,`P=${et}
|
|
824
861
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
|
|
825
862
|
[ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
|
|
826
863
|
docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
|
|
@@ -846,34 +883,34 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
|
|
|
846
883
|
printf " %s %s (%s)\\n", n, x, s
|
|
847
884
|
}
|
|
848
885
|
}' || true
|
|
849
|
-
done | sort`)}}),
|
|
850
|
-
Requests are left out unless --access: lf traffic sums them up.`,options:{server:
|
|
886
|
+
done | sort`)}}),uo=N({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).
|
|
887
|
+
Requests are left out unless --access: lf traffic sums them up.`,options:{server:P,...ao,access:{type:`boolean`,desc:`include the access log (a JSON line per request)`}},async run(e,t){let{server:n}=z(t.server);if(t.access)return vr(n,et,t);let r=Re(t.tail,`--tail`,1),i=`awk '!/^\\{.*"DownstreamStatus":/ { print; fflush() }'`;return Dt(n,[t.follow?`docker logs --tail ${r} -f ${et} 2>&1 | ${i}`:`docker logs --tail ${Math.max(r*50,5e3)} ${et} 2>&1 | ${i} | tail -n ${r}`])}}),fo=N({name:`traefik restart`,summary:`Restart traefik.`,options:{server:P},async run(e,t){let{server:n}=z(t.server);await J(n,`${K}\ndocker restart ${et} >/dev/null\nrecord "restarted traefik"`),w(`traefik restarted`)}}),po=N({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
|
|
851
888
|
|
|
852
889
|
No domain or password needed: landfall's Traefik serves the dashboard to the server
|
|
853
890
|
itself, and only there. To also serve it on a domain behind a password:
|
|
854
891
|
lf traefik setup --dashboard traefik (traefik.<main domain>).
|
|
855
892
|
|
|
856
893
|
A Traefik of your own works too when it routes the dashboard to a domain: the tunnel
|
|
857
|
-
goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:
|
|
894
|
+
goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:P,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=z(t.server),i=await no(r),a=await Ra(r,[`name=^${i.proxy??`traefik`}$`]);a||D(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=br(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?Re(t.port,`--port`,1,65535):!i.proxy&&Sr(a.labels)?Ye:void 0,l=`traefik dashboard of ${n}`;if(c)return qa(r,za(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=xr(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:Ba(a,e.router)}]:[]).find(e=>e.at);u||D(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await qa(r,za(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function mo(e,t){let n=B(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...z(t??n?.server)}}const ho=e=>Mn(e)?S.green(e):Nn(e)?S.dim(e):S.red(e),go=N({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=await Er(r),{sites:a,others:o}=i;if(t.json)return console.log(JSON.stringify(t.others?i:a,null,2));if(o.length&&(console.log(S.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),M([[S.dim(`NAME`),S.dim(`DOMAINS`),S.dim(`FOLDER`),S.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??S.dim(e.containers.join(`, `)),ho(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${S.bold(`lf deploy --domain example.com`)}`);M([[S.dim(`NAME`),S.dim(`KIND`),S.dim(`DOMAINS`),S.dim(`SERVICES`),S.dim(`RELEASE`),S.dim(`UPDATED`),S.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[S.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||S.dim(`-`),e.current||S.dim(`-`),me(e.updatedAt),ho(t)])])}}),_o=N({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:P},async run([e],t){let{site:n,server:r}=mo(e,t.server),i=(await Er(r)).sites.find(e=>e.meta.name===n);i||D(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${S.bold(a.name)} ${i.kind} ${ho(i.status)}`);for(let e of i.domains)console.log(` ${S.cyan(`https://`+e)}`);if(console.log(S.dim(` ${a.adopted??Z(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${S.bold(`Services`)}`),M(i.services.map(e=>[` ${e.service}`,S.dim(e.container),ho(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${S.bold(`Releases`)}`);for(let e of _r(a)){let t=e.id===a.current?S.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${S.dim(me(e.at))} ${S.dim(e.services.map(e=>e.name).join(`, `))}${n.length?S.dim(` → ${n.join(`, `)}`):``}`)}}}}),vo=N({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:P,...ao},async run([e,t],n){let{site:r,server:i}=mo(e,n.server);return zr(i,r,{...n,service:t})}}),yo=(e,t,n,r,i)=>N({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:P},async run([a,o],s){let{site:c,server:l}=mo(a,s.server);await gr(l,c);let u=o?` ${q(o)}`:``,d=`record ${q(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${q(`cd -P ${Z(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Rr(l,c,t+u,p),w(`${o?`${c} ${o}`:c} ${n}`)}}),bo=yo(`restart`,`restart`,`restarted`),xo=yo(`stop`,`stop`,`stopped`,`start`,!0),So=yo(`start`,`up -d --wait`,`started`,`stop`,!1),Co=N({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:P,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=mo(e,t.server),{meta:i,proxy:a}=await gr(r,n,{deployed:`roll back`}),o=_r(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&D(`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||D(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await Ir(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),w(`${n} rolled back to ${S.bold(c.id)}`)}}),wo=N({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:P,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||D("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=z(t.server),i=await Er(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Cn(t.meta)===e);D(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||D(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=pr(t.name??or(e));i.sites.some(e=>e.meta.name===s)&&D(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...In,...Ln].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&D(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await Or(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),w(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(S.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),To=N({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
|
|
858
895
|
|
|
859
896
|
Named volumes (databases, uploads) stay, so deploying the site again finds its data;
|
|
860
|
-
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:
|
|
861
|
-
`));let s=
|
|
897
|
+
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:P,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=mo(e,t.server),{meta:a}=await hr(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Pn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await A(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await Vr(r,o,{volumes:t.volumes}),w(`removed ${n}${t.volumes?` and its volumes`:S.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),Eo=N({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:P,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`},"env-file":{type:`string`,desc:`also set the KEY=VALUE lines of this file; - reads stdin (keeps values out of ps)`}},async run(e,t){let[n]=e.filter(e=>!e.includes(`=`)),r={...t[`env-file`]?await Wr(t[`env-file`]):{},...Gr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=mo(n,t.server),{env:o}=await gr(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||S.dim(`(empty)
|
|
898
|
+
`));let s=Hr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await Rr(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
|
|
862
899
|
record ${q(`env ${i} ${c.join(` `)}`)} "cd ${Z(i)} && $u && cd -P current && dc up -d"
|
|
863
|
-
${
|
|
900
|
+
${Vt(`.env`,s,`600`)}`),w(`updated env of ${i}`)}}),Do=N({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
|
|
864
901
|
|
|
865
902
|
Nothing changes on the server: no port is published and its domains stay as they are,
|
|
866
903
|
so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
|
|
867
904
|
Works for any Compose project or container on the server too, not only landfall's sites.
|
|
868
|
-
The tunnel stays open until Ctrl-C.`,options:{server:
|
|
869
|
-
record ${q(`ufw delete ${e.spec}`)} ${q(
|
|
905
|
+
The tunnel stays open until Ctrl-C.`,options:{server:P,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=mo(e,n.server),{meta:a,proxy:o}=await hr(i,r);if(!a)return Oo(i,r,t,o.network,n);Sn(a)&&D(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=wn(a),c=t?s?.services.find(e=>e.name===t):On(a)??s?.services.find(Tn);t&&s&&!c&&D(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||D(`which service of ${r}? lf tunnel ${r} <service>`);let u=await Ra(i,[`label=com.docker.compose.project=${Cn(a)}`,`label=com.docker.compose.service=${l}`]);u||D(`${l} of ${r} is not running (lf start ${r})`),await ko(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function Oo(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await Ra(e,[...a,`label=traefik.enable=true`])??await Ra(e,a)??(n?void 0:await Ra(e,[`name=^/?${t}$`]));o||D(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await ko(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function ko(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>Bn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&D(`which port of ${t.name}? pass --port`);let s=Re(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&T(S.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=Ha(s);await qa(e,za(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function Ao(e){let t=e,n=await ya(t);n.values.sudo!==`1`&&(t=await Q(e,`the firewall`),n=await ya(t));let r=n.values,i=Number(r.ssh_port)||Y(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(wa),ports:Aa(n),blocked:ka(n,[i]).blocked,probe:n}}const jo=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,Mo=/^[0-9a-fA-F.:]+(\/\d+)?$/;function No(e){jo.test(e.port)||D(`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)&&D(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&D(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Mo.test(e.from)&&D(`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 Po=e=>`ufw_add ${e.map(q).join(` `)}`,Fo=e=>e[0]===`deny`?`${Po([`prepend`,...e])} 2>/dev/null || ${Po(e)}`:Po(e),Io=(e,t)=>`${t?`$SUDO ufw insert ${t} ${e.spec} >/dev/null 2>&1 || `:``}$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`,Lo=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
|
|
906
|
+
record ${q(`ufw delete ${e.spec}`)} ${q(Io(e,t))}`,Ro=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,zo=e=>wa(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `));function Bo(e){let t=ln(e.probe.values.platform??``,e.probe.values.pkg??``,e.ufw!==`missing`);t&&D(t)}const Vo=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Ho(e,t,n){if(e.ufw!==`active`)return;let r=Ta(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 pe(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function Uo(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||D(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const Wo={server:P,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 Go(e){return N({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:{...Wo,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||D(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=No({action:e,port:t,from:n.from,comment:n.comment}),i=await Ao(z(n.server).server);Bo(i),e===`deny`&&await Ho(i,[zo(r),...i.rules],n.force),await J(i.login,`${K}\n${Vo}\n${Fo(r)}`),w(`${r.join(` `)}${i.ufw===`active`?``:S.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Ko=Go(`allow`),qo=Go(`deny`),Jo=N({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=z(t.server);if(t.json)return void console.log(JSON.stringify(await Ac(r),null,2));let i=await Ao(r),a=i.ufw===`active`?S.green(`on`):i.ufw===`missing`?S.yellow(`not installed`):S.yellow(`off`);C(`firewall on ${n}: ${a}${i.ufw===`active`?S.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${S.bold(`Rules`)}`),i.rules.length||T(S.dim(`none`)),M(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?S.green(e.action):S.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${S.bold(`Listening ports`)}`);let o={open:S.green,limited:S.yellow,blocked:S.red,local:S.dim};M(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)]));let s=ln(i.probe.values.platform??``,i.probe.values.pkg??``,i.ufw!==`missing`);s?(console.log(),T(S.dim(s))):i.ufw!==`active`&&(console.log(),i.blocked.length?E(`enabling would block ${i.blocked.join(`, `)}: allow them first`):T(S.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Yo=N({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:P,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 Ao(r);if(Bo(i),i.ufw===`active`)return w(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await pe(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Ho({...i,ufw:`active`},[...i.rules,zo([`allow`,`${i.sshPort}/tcp`])],t.force),await J(i.login,`${K}
|
|
870
907
|
command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
|
|
871
|
-
${
|
|
872
|
-
ufw_enable`),
|
|
873
|
-
${
|
|
874
|
-
${
|
|
875
|
-
${u} || { ${
|
|
876
|
-
`).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()]}const
|
|
908
|
+
${Po([`allow`,`${i.sshPort}/tcp`])}
|
|
909
|
+
ufw_enable`),w(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Xo=N({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:P},async run(e,t){let{name:n,server:r}=z(t.server),i=await Ao(r);if(i.ufw!==`active`)return w(`firewall on ${n} is already off`);await j(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await J(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),w(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Zo=N({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:P,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=z(t.server),i=await Ao(r),{rule:a,n:o}=Uo(i,e);await Ho(i,i.rules.filter(e=>e!==a),t.force),await A(`Remove rule ${o} (${a.spec}) on ${n}?`),await J(i.login,`${K}\n${Vo}\n${Lo(a,Ro(i,o))}`),w(`removed rule ${o}: ${a.spec}`)}}),Qo=N({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:{...Wo,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||D(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await Ao(z(t.server).server),{rule:r,n:i}=Uo(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||D(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||D(`rule ${i} (${r.spec}) names no port; pass --port`);let s=No({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=Ro(n,i),l=n.rules.filter(e=>e!==r);await Ho(n,c?n.rules.map(e=>e===r?zo(s):e):a===`deny`?[zo(s),...l]:[...l,zo(s)],t.force);let u=c&&c<n.rules.length?`${Po([`insert`,String(c),...s])} || ${Fo(s)}`:Fo(s);await J(n.login,`${K}
|
|
910
|
+
${Vo}
|
|
911
|
+
${Lo(r,c)}
|
|
912
|
+
${u} || { ${Io(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),w(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),$o=e=>e.steps.filter(e=>e.undo&&!e.undone);function es(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
|
|
913
|
+
`).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()]}const ts=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),ns=RegExp(`${ts(H)}/[A-Za-z0-9._-]+|${ts(U)}`,`g`);function rs(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(ns))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function is(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...rs(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=$o(n).flatMap(e=>[...rs(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function as(e){let{stdout:t}=await J(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return es(t)}function os(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||D(`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||D(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function ss(e,t){let n=Y(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await J(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 At(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const cs=String.raw`
|
|
877
914
|
undo_step() { # undo_step <line> <undo shell, base64>
|
|
878
915
|
# Two undos at once must not both revert a step: the second waits here, then finds it done.
|
|
879
916
|
[ -e ${V}/.undo.lock ] || touch ${V}/.undo.lock 2>/dev/null || true
|
|
@@ -889,9 +926,9 @@ undo_step() { # undo_step <line> <undo shell, base64>
|
|
|
889
926
|
_journal undo "$1" ""
|
|
890
927
|
fi
|
|
891
928
|
exec 9<&-
|
|
892
|
-
}`;async function
|
|
893
|
-
`),{sudo:c}),r.push(s),t=i}return n.length}function
|
|
894
|
-
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=
|
|
929
|
+
}`;async function ls(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} ${q(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await ss(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(wt)),await J(s,[K,cs,...o].join(`
|
|
930
|
+
`),{sudo:c}),r.push(s),t=i}return n.length}function us(e,t){let n=S.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${S.dim(`↩ `+e.what)}`:e.undone?`${n} ${S.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${S.cyan(`↺`)} ${e.what}`:`${n} ${S.dim(`· `+e.what+` (not reversible)`)}`}const ds=e=>e.trim().replace(/\\(.)/g,`$1`),fs=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function ps(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await Ao(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(`
|
|
931
|
+
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=wa(ds(e[1])),n=i.findIndex(e=>fs(e,t));n>=0&&i.splice(n,1)}else if(n){let e=wa(ds(n[3]));n[2]?i.splice(Number(n[2])-1,0,e):i=n[1]?[e,...i]:[...i,e]}else/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await Ho({...r,ufw:a},i,n)}const ms=e=>`${S.bold(`#${e.n}`.padEnd(5))} ${S.dim(me(e.time).padEnd(9))} ${S.bold(e.cmd||`?`)} ${S.dim(`${e.actor} · ${e.id}`)}`,hs=N({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:P,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);if(t.json)return console.log(JSON.stringify(await Lc(r,Number(t.n)||20),null,2));let i=(await as(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return T(`nothing in ${G} on ${n} yet`);for(let e of i)console.log(ms(e)),e.steps.forEach((e,t)=>console.log(us(e,t)));let a=i.find(e=>$o(e).length);a&&console.log(S.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),gs=N({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:P,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, or a later run changed the same site or file`}},async run([e],t){let{name:n,server:r}=z(t.server),i=await as(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?os(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>$o(e.run).length);if(!r)return w(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${me(r.run.time)})`;let s=is(i,r.run.n,a);s.length&&!t.force&&D(`later runs changed the same things: ${s.map(e=>`#${e.run.n} ${e.run.cmd} (${e.subject})`).join(`, `)}. Undo those first, newest first, or pass --force`);let c=i.filter(e=>e.n>r.run.n&&$o(e).length);c.length&&E(`later runs build on it and stay: ${c.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return w(`nothing left to undo in ${o}`);C(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${S.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${S.dim(`· ${e.what} stays (not reversible)`)}`);await ps(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)||D(`cancelled`),await ls(r,s),w(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&T(S.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),_s=/(^|\.)(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht|dev\.azure\.com|visualstudio\.com)$|^(git|gitlab|gitea|forgejo)\./i;function vs(e){if(_s.test(e))return!0;try{let t=Y({host:e});return t.user===`git`||_s.test(t.hostname)}catch{return!1}}const ys=/^(localhost|127(\.\d+){3}|::1|0:0:0:0:0:0:0:1)$/i;function bs(e){if(ys.test(e))return!0;try{return ys.test(Y({host:e}).hostname)}catch{return!1}}function xs(){let e=R(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=Gt().filter(t=>!e.servers[t]&&!vs(t)&&!bs(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}const Ss=/* @__PURE__ */ new Map;async function Cs(e,t=!1){let n={},r=[...e];return await Promise.all(Array.from({length:Math.min(8,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift()){let r=Ss.get(e);if(t||!r||Date.now()-r.at>3e4){let t=(async()=>{try{return await jt(z(e).server)}catch(e){return{state:`down`,detail:e.message,ms:0}}})();Ss.set(e,r={at:Date.now(),r:t})}n[e]=await r.r}})),n}function ws(e){try{let t=Y(z(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function Ts(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}const Es=N({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
|
|
895
932
|
|
|
896
933
|
Without --host, <name> is used as an ssh alias from ~/.ssh/config,
|
|
897
934
|
so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
@@ -899,25 +936,25 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
|
899
936
|
With --host lf logs in with --key, or installs its own key
|
|
900
937
|
(~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
|
|
901
938
|
to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
|
|
902
|
-
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||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&
|
|
939
|
+
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||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Fe(t.domain),r=R(),i=r.servers[e],a=Gt().includes(e)&&!tn(e);t.host&&a&&D(`${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 Yr(t[`password-stdin`]),c=await $r(e,o,{check:!s&&!de(),password:s}),l=c?ti(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=Pe(t.email)),n&&(l.domains=ze(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),De(r),w(`added ${S.bold(e)}${r.default===e?S.dim(` (default)`):``}`),c?(T(S.dim(`next: lf setup ${e} --check shows what setup would install`)),await Wi(e,l)):T(`then: ${S.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),Ds=N({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.
|
|
903
940
|
|
|
904
941
|
Git hosts (github.com, gitlab.*, User git …) are left out: they are not servers.
|
|
905
942
|
--check logs in to each with its key (5s timeout, 8 at a time):
|
|
906
943
|
ok key login works
|
|
907
944
|
login sshd answers but refuses the key (lf setup installs one)
|
|
908
|
-
down no ssh: host off, wrong port, firewall, or name not found`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},check:{type:`boolean`,short:`c`,desc:`try an ssh login to each and show which are reachable`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=
|
|
909
|
-
Any ~/.ssh/config alias works too (--all to list them).`))}}),
|
|
945
|
+
down no ssh: host off, wrong port, firewall, or name not found`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},check:{type:`boolean`,short:`c`,desc:`try an ssh login to each and show which are reachable`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=R(),r=xs().filter(e=>e.registered||t.all),i=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`,a=t.check?await Cs(r.map(e=>e.name)):{};if(t.json){console.log(JSON.stringify(r.map(e=>({...e,target:e.registered?i(n.servers[e.name]):e.name,...t.check&&{reach:a[e.name]}})),null,2));return}if(!r.length){console.log(`no servers yet — ${S.bold(`lf server add <ssh-alias>`)} or ${S.bold(`lf setup <name> --host <ip>`)}`);return}let o={ok:S.green,login:S.yellow,down:S.red},s=[[S.dim(`NAME`),S.dim(`TARGET`),S.dim(`SOURCE`),...t.check?[S.dim(`STATUS`)]:[]]];for(let e of r){let t=a[e.name];s.push([e.name+(e.isDefault?S.green(` *`):``),e.registered?i(n.servers[e.name]):e.name,e.registered?`landfall`:tn(e.name)?`ssh (landfall)`:`ssh`,...t?[`${o[t.state](t.state)} ${S.dim(t.state===`ok`?`${t.ms}ms`:t.detail)}`]:[]])}M(s),t.all||console.log(S.dim(`
|
|
946
|
+
Any ~/.ssh/config alias works too (--all to list them).`))}}),Os=N({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||D(`usage: lf server rm <name>`),!R().servers[e]&&!tn(e)&&D(`unknown server "${e}"`),await A(`Forget server ${e}? Nothing on the server changes.`),Oe(e),t[`ssh-config`]&&en(e)&&w(`removed Host ${e} from ~/.ssh/config`),w(`removed ${e}`)}}),ks=/^(setup|traefik setup|traefik use)( |$)/,As=N({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${V} and what setup changed.
|
|
910
947
|
|
|
911
948
|
Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
|
|
912
949
|
adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
|
|
913
950
|
alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
|
|
914
951
|
and deletes ${V}, its journal included. Then forgets the server locally.
|
|
915
|
-
Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+V},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||D(`usage: lf server uninstall <name>`);let{name:n,server:r}=
|
|
916
|
-
`)),l.length&&(
|
|
917
|
-
`)}:null}}}const
|
|
952
|
+
Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+V},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||D(`usage: lf server uninstall <name>`);let{name:n,server:r}=z(e),i=await ss(r,`root`),[a,s,c]=await Promise.all([Er(r),no(r),as(r)]);s.compose===`foreign`&&D(`${U}/docker-compose.yml was not written by landfall: move it out of ${V} first`);let l=t[`keep-setup`]?[]:c.filter(e=>ks.test(e.cmd)).flatMap($o).filter(e=>!e.undo.includes(V));C(`uninstall landfall from ${S.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${S.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${S.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${S.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${S.cyan(`↺`)} ${e.what}`);console.error(` ${S.red(`-`)} delete ${V}, its journal included`),console.error(` ${S.red(`-`)} forget ${n} locally`),console.error(` ${S.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await ps(r,l,t.force),await A(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[K];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${q(Z(e.name))}`]:Br(e,{volumes:t.volumes});u.push(`log ${q(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${q(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${U} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await J(r,u.join(`
|
|
953
|
+
`)),l.length&&(C(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ls(r,l)),await J(i.login,`rm -rf ${V}`,{sudo:i.sudo}),w(`landfall removed from ${n}`),Oe(n),en(n)&&w(`removed Host ${n} from ~/.ssh/config`),w(`forgot ${n}`),o.existsSync(Xr(n))&&T(S.dim(`${L(Xr(n))} stays: root on ${n} still accepts it`))}}),js=N({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||D(`usage: lf server use <name>`),ke(e),w(`default server: ${S.bold(e)}`)}}),Ms=N({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 Dt(r,n)}}),Ns=N({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);if(t.json)return void console.log(JSON.stringify(await Mc(n,r),null,2));let i=await sn(r),a=await un(r,i),o=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,s=i.mem.total-i.mem.available;console.log(S.bold(n)+S.dim(` (${r.host})`));let c=[[`os`,`${i.os}${i.pkg===`none`?``:S.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${xn(i.uptime)}${i.reboot?S.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${X(s)} / ${X(i.mem.total)}${o(s,i.mem.total)}`],[`disk`,`${X(i.disk.used)} / ${X(i.disk.total)}${o(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`login`,`${i.user}${a.can.admin?i.admin?S.dim(` · root or sudo`):S.dim(` · root's key for admin work`):S.yellow(` · no sudo`)}`],[`init`,i.init===`other`?S.dim(`no systemd or OpenRC`):i.init],[`docker`,i.docker?`${i.docker}${i.compose?S.dim(` · compose ${i.compose}`):S.yellow(` · no compose`)}`:a.why.docker],[`firewall`,i.ufw?`ufw`:a.can.firewall?S.dim(`none (lf setup installs ufw)`):S.dim(a.why.firewall)]];for(let[e,t]of c)console.log(`${S.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`eol`||i.support.level===`unsupported`?E:T)(i.support.note),i.docker&&(console.log(),await J(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Ps(e){let t=R(),n=t.servers[e],r=n??{host:e},i=Y(r),a=Qt(r.host),s=Qa(r,{});return{name:e,isDefault:t.default===e,configFile:L(Te),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:Se(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:$a(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:L(e),exists:o.existsSync(Ee(e))})),block:a?{file:L(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
|
|
954
|
+
`)}:null}}}const Fs=N({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
|
|
918
955
|
the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
|
|
919
|
-
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,server:r}=
|
|
920
|
-
`))console.log(` ${e}`)}else console.log(`\n${
|
|
956
|
+
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,server:r}=z(e);if(t.json)return void console.log(JSON.stringify(await jc(n,r),null,2));let i=Ps(n),a=(e,t)=>console.log(` ${S.dim(e.padEnd(10))} ${t}`);if(console.log(`${S.bold(n)}${i.isDefault?S.dim(` (default)`):``}`),console.log(`\n${S.bold(`landfall`)} ${S.dim(i.configFile)}`),!i.saved)a(`entry`,S.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(i.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&e!==`tunnelOnly`&&a(e,String(t));i.domains.length||a(`domain`,S.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${S.dim(`(primary: new sites get <site>.${e})`)}`)),a(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${i.tunnelOnlyOwn?``:S.dim(` (landfall default, lf config)`)}`),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${S.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:S.dim(`off`)),console.log(`\n${S.bold(`ssh`)} ${S.dim(`ssh -G ${i.saved?.host??n}`)}`),a(`login`,`${i.ssh.user}@${i.ssh.hostname}:${i.ssh.port}`);for(let e of i.ssh.keys)a(`key`,e.exists?e.file:`${e.file} ${S.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${S.bold(`ssh config`)} ${S.dim(`${i.ssh.block.file}:${i.ssh.block.line}${i.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of i.ssh.block.text.split(`
|
|
957
|
+
`))console.log(` ${e}`)}else console.log(`\n${S.bold(`ssh config`)} ${S.dim(`no Host block; ssh uses its defaults`)}`)}});async function Is(e,t){let n=await _n(e),[r,i]=await bn([t,`landfall-${ae(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function Ls(e){let{stdout:t}=await J(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 Rs=async e=>We(await Ls(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),zs=N({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
|
|
921
958
|
|
|
922
959
|
The first main domain is the primary: \`lf deploy\` serves a new site at
|
|
923
960
|
<site>.<primary>, \`--domain blog\` means blog.<primary>, and
|
|
@@ -933,10 +970,10 @@ the server's sites already use.
|
|
|
933
970
|
--tunnel-only makes new sites deploy with no domain unless -d gives one:
|
|
934
971
|
nothing is served on the internet, \`lf tunnel <site>\` reaches them.
|
|
935
972
|
--no-tunnel-only goes back to <site>.<primary>. Existing sites keep theirs.
|
|
936
|
-
Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,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`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=
|
|
973
|
+
Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,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`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=R(),r=e.length>0&&!(e=>!!n.servers[e]||Gt().includes(e))(e[0])&&je.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=z(i),c=t[`tunnel-only`],l=!!(a.length||t.add||t.rm||t.primary||t.clear);if(c!==void 0){let e=n.servers[o]??={host:o};if(c===(xe().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,De(n),s.tunnelOnly=e.tunnelOnly,w(c?`new sites on ${S.bold(o)} get no domain: reach them with lf tunnel, or deploy with -d`:`new sites on ${S.bold(o)} get a domain again`),!l)return}if(l){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Fe),i=t.clear?[]:a.length?a.map(Fe):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Fe)])].filter(e=>!r.includes(e)),i=ze(i,t.primary&&Fe(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&E(`${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,De(n),s.domains=e.domains,w(i.length?`main domains of ${S.bold(o)}: ${i.map((e,t)=>t?e:S.bold(e)).join(`, `)}${i.length>1?S.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let u=s.domains??[];if(!u.length){let e=await Rs(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:u,suggest:e}));T(`${o} has no main domain yet`);for(let t of e)T(`${t.hosts} routed hosts use ${S.bold(t.domain)} — ${S.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||T(`set one: ${S.bold(`lf server domain ${o} example.com`)}`);return}let d=await Promise.all(u.map(e=>Is(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:u.map((e,t)=>({domain:e,primary:t===0,...d[t]}))},null,2));let f=Math.max(...u.map(e=>e.length))+2,p=[];u.forEach((e,t)=>{let n=d[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?S.green(`dns ok`):e.ips.length?S.yellow(`dns → ${e.ips.join(`, `)}`):S.red(`no dns`);console.log(`${e.domain.padEnd(f)} ${r}${e===n.apex&&t===0&&u.length>1?S.dim(` primary`):``}`),e.ok||p.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),p.length&&console.log(S.dim(`
|
|
937
974
|
add these DNS records at your DNS provider:
|
|
938
975
|
`)+p.join(`
|
|
939
|
-
`));let m=Be(s);
|
|
976
|
+
`));let m=Be(s);T(S.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}}),Bs=String.raw`
|
|
940
977
|
set +e
|
|
941
978
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
|
942
979
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
@@ -972,8 +1009,8 @@ if [ -n "$D" ]; then
|
|
|
972
1009
|
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
|
|
973
1010
|
fi
|
|
974
1011
|
exit 0
|
|
975
|
-
`,
|
|
976
|
-
`)){let e=t.split(` `),f=e[0];if(/^T[12]$/.test(f))r[f[1]-1]=Number(e[1]);else if(/^CPU[12]$/.test(f))i[f[3]-1]=e[1].trim().split(/\s+/).slice(1).map(Number);else if(/^NET[12]$/.test(f))a[f[3]-1].set(e[1],[Number(e[2]),Number(e[3])]);else if(f===`PROC`)o.push({pid:Number(e[1]),ticks:Number(e[2]),rss:Number(e[3]),name:e.slice(4).join(` `)});else if(f===`DISK`)s.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024});else if(f===`STAT`)c.set(e[1],e);else if(f===`RST`)l.set(e[1].replace(/^\//,``),Number(e[2])||0);else if(f===`CTR`)u.push(e);else if(f===`SITE`)try{let e=JSON.parse(t.slice(5));d.set(
|
|
1012
|
+
`,Vs={b:1,kb:1e3,mb:1e6,gb:1e9,tb:0xe8d4a51000,kib:1024,mib:1024**2,gib:1024**3,tib:1024**4};function Hs(e){let t=/^([\d.]+)\s*([a-z]*)$/i.exec(e.trim());return t?Math.round(Number(t[1])*(Vs[(t[2]||`b`).toLowerCase()]??1)):0}const Us=(e=``)=>e.split(` / `).map(Hs),Ws=/^(lo|veth|docker|br-|virbr|cni|flannel|cali|vxlan)/;function Gs(e,t=/* @__PURE__ */ new Date){let n={},r=[],i=[],a=[/* @__PURE__ */ new Map,/* @__PURE__ */ new Map],o=[],s=[],c=/* @__PURE__ */ new Map,l=/* @__PURE__ */ new Map,u=[],d=/* @__PURE__ */ new Map;for(let t of e.split(`
|
|
1013
|
+
`)){let e=t.split(` `),f=e[0];if(/^T[12]$/.test(f))r[f[1]-1]=Number(e[1]);else if(/^CPU[12]$/.test(f))i[f[3]-1]=e[1].trim().split(/\s+/).slice(1).map(Number);else if(/^NET[12]$/.test(f))a[f[3]-1].set(e[1],[Number(e[2]),Number(e[3])]);else if(f===`PROC`)o.push({pid:Number(e[1]),ticks:Number(e[2]),rss:Number(e[3]),name:e.slice(4).join(` `)});else if(f===`DISK`)s.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024});else if(f===`STAT`)c.set(e[1],e);else if(f===`RST`)l.set(e[1].replace(/^\//,``),Number(e[2])||0);else if(f===`CTR`)u.push(e);else if(f===`SITE`)try{let e=JSON.parse(t.slice(5));d.set(Tr(e),e.name)}catch{}else t.includes(`=`)&&(n[t.slice(0,t.indexOf(`=`))]=t.slice(t.indexOf(`=`)+1).trim())}let f=Number(n.cpus)||1,p=(e=[])=>e.slice(0,8).reduce((e,t)=>e+(t||0),0),m=(e=[])=>(e[3]||0)+(e[4]||0),ee=p(i[1])-p(i[0]),te=m(i[1])-m(i[0]),ne=r[1]-r[0]||1,h=Number(n.page)||4096,[g=0,re=0,ie=0,_=0]=(n.mem??``).split(/\s+/).map(e=>Number(e)*1024||0),v=e=>Math.round(e*10)/10;return{at:t.toISOString(),uptime:Math.round(r[1]||0),cpus:f,load:(n.load??``).split(/\s+/).map(e=>Number(e)||0).concat([0,0,0]).slice(0,3),cpu:ee>0?v(100*(ee-te)/ee):0,mem:{total:g,available:re,swapTotal:ie,swapFree:_},disks:s,net:[...a[1]].filter(([e])=>!Ws.test(e)&&a[0].has(e)).map(([e,[t,n]])=>({iface:e,rx:Math.max(0,Math.round((t-a[0].get(e)[0])/ne)),tx:Math.max(0,Math.round((n-a[0].get(e)[1])/ne))})),procs:o.map(e=>({pid:e.pid,name:e.name,cpu:ee>0?v(100*e.ticks*f/ee):0,rss:e.rss*h})).sort((e,t)=>t.cpu-e.cpu||t.rss-e.rss),docker:n.docker===`1`,containers:u.map(([,e,t=``,n=``,r=``,i=``])=>{let a=c.get(e)??[],[o,s]=Us(a[3]),[u,f]=Us(a[4]),[p,m]=Us(a[5]);return{name:e,...d.has(n)&&{site:d.get(n)},...n&&{project:n,service:r},image:i,status:t,cpu:parseFloat(a[2]??``)||0,mem:o||0,memLimit:s||0,net:{rx:u||0,tx:f||0},block:{read:p||0,write:m||0},pids:Number(a[6])||0,restarts:l.get(e)??0}}).sort((e,t)=>(e.site??e.project??`~`).localeCompare(t.site??t.project??`~`)||e.name.localeCompare(t.name))}}async function Ks(e){let{stdout:t}=await J(e,Bs,{capture:!0});return Gs(t)}const qs=String.raw`
|
|
977
1014
|
set +e
|
|
978
1015
|
S=""; [ "$(id -u)" -eq 0 ] || { sudo -n true 2>/dev/null && S="sudo -n"; }
|
|
979
1016
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
@@ -996,8 +1033,8 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
|
996
1033
|
done
|
|
997
1034
|
fi
|
|
998
1035
|
exit 0
|
|
999
|
-
`;function
|
|
1000
|
-
`)){let e=r.split(` `);e[0]===`DISK`?n.disks.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024}):e[0]===`SITE`?n.sites.push({name:e[1],size:(+e[2]||0)*1024,releasesSize:(+e[3]||0)*1024,releases:+e[4]||0}):e[0]===`DF`?n.dockerDf.push({type:e[1],count:+e[2]||0,active:+e[3]||0,size:
|
|
1036
|
+
`;function Js(e){let t={},n={root:!1,disks:[],landfall:0,sites:[],journald:0,docker:!1,dockerDf:[],volumes:[],logs:[]};for(let r of e.split(`
|
|
1037
|
+
`)){let e=r.split(` `);e[0]===`DISK`?n.disks.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024}):e[0]===`SITE`?n.sites.push({name:e[1],size:(+e[2]||0)*1024,releasesSize:(+e[3]||0)*1024,releases:+e[4]||0}):e[0]===`DF`?n.dockerDf.push({type:e[1],count:+e[2]||0,active:+e[3]||0,size:Hs(e[4]??``),reclaimable:Hs((e[5]??``).split(` `)[0])}):e[0]===`VOL`?n.volumes.push({name:e[1],links:+e[2]||0,size:Hs(e[3]??``)}):e[0]===`LOG`?n.logs.push({container:e[1],...e[2]&&{size:e[2]*1024}}):r.includes(`=`)&&(t[r.slice(0,r.indexOf(`=`))]=r.slice(r.indexOf(`=`)+1).trim())}n.root=t.root===`1`,n.docker=t.docker===`1`,n.landfall=(+t.landfall||0)*1024;let r=/^([\d.]+)([BKMGT]?)/i.exec(t.journald??``);return n.journald=r?Math.round(Number(r[1])*1024**`BKMGT`.indexOf((r[2]||`B`).toUpperCase())):0,n.sites.sort((e,t)=>t.size-e.size),n.volumes.sort((e,t)=>t.size-e.size),n.logs.sort((e,t)=>(t.size??-1)-(e.size??-1)),n}async function Ys(e){let{stdout:t}=await J(e,qs,{capture:!0});return Js(t)}const Xs=String.raw`
|
|
1001
1038
|
set +e
|
|
1002
1039
|
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 || { echo "NODOCKER"; exit 0; }
|
|
1003
1040
|
docker image ls -a --no-trunc --format 'IMG\t{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}'
|
|
@@ -1010,22 +1047,22 @@ ids=$(docker ps -aq)
|
|
|
1010
1047
|
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
|
|
1011
1048
|
docker system df --format 'DF\t{{.Type}}\t{{.Reclaimable}}' 2>/dev/null
|
|
1012
1049
|
exit 0
|
|
1013
|
-
`,
|
|
1014
|
-
`)){let e=t.split(` `);if(e[0]===`NODOCKER`)n.docker=!1;else if(e[0]===`IMG`&&e[1]){let t=r.get(e[1])??{id:e[1],tags:[],size:
|
|
1050
|
+
`,Zs=e=>e.images.filter(e=>!e.keep),Qs=(e,t)=>e.images.filter(e=>e.keep===t);function $s(e,t=!1){let n={docker:!0,all:t,images:[],buildCache:0},r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Set,a=/* @__PURE__ */ new Set,o=/* @__PURE__ */ new Set;for(let t of e.split(`
|
|
1051
|
+
`)){let e=t.split(` `);if(e[0]===`NODOCKER`)n.docker=!1;else if(e[0]===`IMG`&&e[1]){let t=r.get(e[1])??{id:e[1],tags:[],size:Hs(e[4]??``),created:e[5]??``};e[2]&&e[2]!==`<none>`&&e[3]&&e[3]!==`<none>`&&t.tags.push(`${e[2]}:${e[3]}`),r.set(e[1],t)}else if(e[0]===`USED`&&e[1])i.add(e[1]);else if(e[0]===`KEEP`&&e[1])a.add(e[1]);else if(e[0]===`SITE`)try{let t=JSON.parse(e.slice(1).join(` `));for(let e of t.releases??[])for(let n of e.services??[])n.build&&o.add(Fn(t.name,n.name,e.id))}catch{}else e[0]===`DF`&&/build/i.test(e[1]??``)&&(n.buildCache=Hs((e[2]??``).split(` `)[0]))}for(let e of r.values())e.keep=i.has(e.id)?`used`:e.tags.some(e=>o.has(e))?`release`:a.has(e.id)?`undo`:e.tags.length&&!t?`tagged`:void 0,n.images.push(e);return n.images.sort((e,t)=>t.size-e.size),n}async function ec(e,t=!1){let{stdout:n}=await J(e,Xs,{capture:!0});return $s(n,t)}async function tc(e,t){let n=Zs(t),r=n.flatMap(e=>e.tags.length?e.tags:[e.id]),{stdout:i}=await J(e,[K,`set +e`,`failed=0`,...r.map(e=>`docker image rm ${q(e)} >/dev/null 2>&1 || { printf 'FAILED\\t%s\\n' ${q(e)}; failed=$((failed + 1)); }`),`docker builder prune -f${t.all?`a`:``} >/dev/null 2>&1 || true`,`record ${q(`pruned ${n.length} images and the build cache`)}`,`exit 0`].join(`
|
|
1015
1052
|
`),{capture:!0}),a=i.split(`
|
|
1016
|
-
`).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function
|
|
1017
|
-
`).map(e=>e.trim()).filter(Boolean)}const
|
|
1053
|
+
`).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function nc(e){let{stdout:t}=await J(e,`docker ps --format '{{.Names}}' 2>/dev/null || sudo -n docker ps --format '{{.Names}}' 2>/dev/null || true`,{capture:!0});return t.split(`
|
|
1054
|
+
`).map(e=>e.trim()).filter(Boolean)}const rc=String.raw`
|
|
1018
1055
|
log "rebooting"
|
|
1019
1056
|
record "rebooted"
|
|
1020
1057
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
1021
|
-
`,
|
|
1058
|
+
`,ic=e=>new Promise(t=>setTimeout(t,e));async function ac(e,t){let n=await nc(e).catch(()=>[]),r=Date.now();await J(e,K+rc),await wt(e),t.onWait?.(`down`);let i=r+t.timeout*1e3;for(;Date.now()<i&&Date.now()-r<6e4&&await At(e);)await ic(2e3);t.onWait?.(`ssh`);let a=!1;for(;Date.now()<i&&!(a=await At(e));)await ic(5e3);if(!a)return{back:!1,seconds:Math.round((Date.now()-r)/1e3),missing:n};let o=Math.round((Date.now()-r)/1e3);t.onWait?.(`containers`);let s=n,c=Math.min(i,Date.now()+6e4);for(;;){let t=new Set(await nc(e).catch(()=>[]));if(s=n.filter(e=>!t.has(e)),!s.length||Date.now()>=c)break;await ic(5e3)}return{back:!0,seconds:o,missing:s}}function oc(e){let t=/^(\d+)\s*([smhd])$/i.exec(e.trim());if(!t)return D(`not a time span: ${e} (use e.g. 30m, 24h or 7d)`);let n=Number(t[1])*{s:1,m:60,h:3600,d:86400}[t[2].toLowerCase()];return(n<60||n>2592e3)&&D(`--since must be between 1m and 30d, not ${e}`),n}const sc=e=>e%86400==0?`${e/86400}d`:e%3600==0?`${e/3600}h`:`${Math.round(e/60)}m`,cc=e=>e<=21600?16:13,lc=`for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done`,uc=e=>String.raw`
|
|
1022
1059
|
set +e
|
|
1023
1060
|
P=${et}
|
|
1024
1061
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
|
|
1025
1062
|
[ -f ${W} ] && echo OWN
|
|
1026
|
-
${
|
|
1063
|
+
${lc}
|
|
1027
1064
|
T=$(printf '\t')
|
|
1028
|
-
docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${
|
|
1065
|
+
docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${cc(e)} '
|
|
1029
1066
|
function str(k) { if (!match($0, "\"" k "\":\"[^\"]*")) return ""; v = substr($0, RSTART + length(k) + 4, RLENGTH - length(k) - 4); sub(/\\$/, "", v); return v }
|
|
1030
1067
|
function num(k) { return match($0, "\"" k "\":-?[0-9]+") ? substr($0, RSTART + length(k) + 3, RLENGTH - length(k) - 3) + 0 : 0 }
|
|
1031
1068
|
/^\{.*"RequestHost":/ {
|
|
@@ -1054,13 +1091,13 @@ END {
|
|
|
1054
1091
|
for (x in E) { split(x, a, SUBSEP); print "E\t" a[1] "\t" a[2] "\t" E[x] }
|
|
1055
1092
|
}' | LC_ALL=C sort -t"$T" -k1,1 -k2,2 -k4,4nr | awk -F'\t' '($1 != "P" && $1 != "I" && $1 != "E") || n[$1 FS $2]++ < 10'
|
|
1056
1093
|
exit 0
|
|
1057
|
-
`,
|
|
1058
|
-
`))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of
|
|
1059
|
-
`)){let e=t.split(` `);if(e[0]===`NOPROXY`)s.proxy=!1;else if(e[0]===`OWN`)s.own=!0;else if(e[0]===`N`)s.format=Number(e[1])?`json`:Number(e[2])?`common`:`none`,e[3]&&(s.from=e[3],s.to=e[4]);else if(e[0]===`C`){let t=o(e[1]);t.codes[e[2]]=(t.codes[e[2]]??0)+Number(e[3]),t.bytes+=Number(e[4])||0}else if(e[0]===`L`)o(e[1]).hist[e[2]]=Number(e[3]);else if(e[0]===`S`){let t=
|
|
1094
|
+
`,dc=[`2xx`,`3xx`,`4xx`,`5xx`],fc=()=>({"2xx":0,"3xx":0,"4xx":0,"5xx":0}),pc=e=>dc[Math.floor(e/100)-2]??null;function mc(e,t){let n=Object.keys(e).map(Number).sort((e,t)=>e-t),r=n.reduce((t,n)=>t+e[n],0),i=0;for(let a of n)if(i+=e[a],i>=t*r)return a?Math.round(2**(a/4)):1;return 0}function hc(e,t){let n=cc(e),r=n===16?6e4:36e5,i=Math.floor(t.getTime()/r)*r,a=[];for(let t=i-Math.ceil(e/(r/1e3))*r+r;t<=i;t+=r)a.push(new Date(t).toISOString().slice(0,n));return a}function gc(e,t){let n={},r={},i=fc(),a=/* @__PURE__ */ new Map,o=/* @__PURE__ */ new Map,s=/* @__PURE__ */ new Map,c=new Map(t.map(e=>[e,fc()])),l=0,u=0;for(let t of e){l+=t.requests,u+=t.bytes;for(let[e,r]of Object.entries(t.hist))n[e]=(n[e]??0)+r;for(let[e,n]of Object.entries(t.codes))r[e]=(r[e]??0)+n;for(let e of dc)i[e]+=t.classes[e];for(let e of t.paths){let t=a.get(e.path)??{count:0,errors:0};a.set(e.path,{count:t.count+e.count,errors:t.errors+e.errors})}for(let e of t.ips)o.set(e.ip,(o.get(e.ip)??0)+e.count);for(let e of t.errors)s.set(`${e.status} ${e.path}`,(s.get(`${e.status} ${e.path}`)??0)+e.count);for(let e of t.series){let t=c.get(e.at);if(t)for(let n of dc)t[n]+=e[n]}}let d=e=>e.sort((e,t)=>t.count-e.count).slice(0,10);return{requests:l,bytes:u,classes:i,codes:r,p50:mc(n,.5),p95:mc(n,.95),paths:d([...a].map(([e,t])=>({path:e,...t}))),ips:d([...o].map(([e,t])=>({ip:e,count:t}))),errors:d([...s].map(([e,t])=>({status:Number(e.slice(0,e.indexOf(` `))),path:e.slice(e.indexOf(` `)+1),count:t}))),series:[...c].map(([e,t])=>({at:e,...t}))}}function _c(e,t){return e.get(t)??e.get(`*.${t.slice(t.indexOf(`.`)+1)}`)}function vc(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(`
|
|
1095
|
+
`))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of Pn(e))t.set(n.toLowerCase(),e.name)}catch{}return t}function yc(e,t,n=/* @__PURE__ */ new Date){let r=hc(t,n),i=vc(e),a=/* @__PURE__ */ new Map,o=e=>{let t=a.get(e);return t||a.set(e,t={host:e,..._c(i,e)&&{site:_c(i,e)},codes:{},bytes:0,hist:{},paths:[],ips:[],errors:[],series:/* @__PURE__ */ new Map}),t},s={at:n.toISOString(),since:t,bucket:cc(t)===16?`minute`:`hour`,proxy:!0,own:!1,format:`none`,total:gc([],r),sites:[],hosts:[]};for(let t of e.split(`
|
|
1096
|
+
`)){let e=t.split(` `);if(e[0]===`NOPROXY`)s.proxy=!1;else if(e[0]===`OWN`)s.own=!0;else if(e[0]===`N`)s.format=Number(e[1])?`json`:Number(e[2])?`common`:`none`,e[3]&&(s.from=e[3],s.to=e[4]);else if(e[0]===`C`){let t=o(e[1]);t.codes[e[2]]=(t.codes[e[2]]??0)+Number(e[3]),t.bytes+=Number(e[4])||0}else if(e[0]===`L`)o(e[1]).hist[e[2]]=Number(e[3]);else if(e[0]===`S`){let t=pc(Number(e[3])*100);if(!t)continue;let n=o(e[1]).series,r=n.get(e[2])??fc();r[t]+=Number(e[4]),n.set(e[2],r)}else if(e[0]===`P`)o(e[1]).paths.push({path:e[2],count:Number(e[3]),errors:Number(e[4])||0});else if(e[0]===`I`)o(e[1]).ips.push({ip:e[2],count:Number(e[3])});else if(e[0]===`E`){let t=e[2].indexOf(` `);o(e[1]).errors.push({status:Number(e[2].slice(0,t)),path:e[2].slice(t+1),count:Number(e[3])})}}return s.hosts=[...a.values()].map(e=>{let t=fc();for(let[n,r]of Object.entries(e.codes)){let e=pc(Number(n));e&&(t[e]+=r)}let n=Object.values(e.codes).reduce((e,t)=>e+t,0);return{...e,requests:n,classes:t,p50:mc(e.hist,.5),p95:mc(e.hist,.95),series:r.map(t=>({at:t,...e.series.get(t)??fc()}))}}).sort((e,t)=>t.requests-e.requests),s.total=gc(s.hosts,r),s.sites=[...Map.groupBy(s.hosts.filter(e=>e.site),e=>e.site)].map(([e,t])=>{let{paths:n,ips:i,errors:a,series:o,...s}=gc(t,r);return{site:e,hosts:t.map(e=>e.host),...s}}).sort((e,t)=>t.requests-e.requests),s}async function bc(e,t){let{stdout:n}=await J(e,uc(t),{capture:!0});return yc(n,t)}function xc(e,t){let n=e.hosts.filter(e=>e.site===t||e.host===t);return n.length?gc(n,e.total.series.map(e=>e.at)):null}const Sc=e=>String.raw`
|
|
1060
1097
|
set +e
|
|
1061
1098
|
P=${et}
|
|
1062
1099
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
|
|
1063
|
-
${
|
|
1100
|
+
${lc}
|
|
1064
1101
|
readf() { cat "$1" 2>/dev/null || sudo -n cat "$1" 2>/dev/null; }
|
|
1065
1102
|
for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
|
|
1066
1103
|
[ -d "$d" ] || [ -f "$d" ] || continue
|
|
@@ -1082,18 +1119,18 @@ for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
|
|
|
1082
1119
|
done
|
|
1083
1120
|
docker logs --since ${e}s "$P" 2>&1 | grep -v '^{' | grep -i acme | grep -E ' ERR |level=error' | tail -n 100 | sed 's/^/LOG\t/'
|
|
1084
1121
|
exit 0
|
|
1085
|
-
`,
|
|
1086
|
-
`)){let e=r.split(` `);if(e[0]===`NOPROXY`)n.proxy=!1;else if(e[0]===`ACME`)n.acme.push(e[1]);else if(e[0]===`DENIED`)n.denied.push(e[1]);else if(e[0]===`CERT`){let r=
|
|
1122
|
+
`,Cc=/\x1b\[[0-9;]*m/g;function wc(e){let t=e.replace(Cc,``),n=/^(\d{4}-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(t)?.[1]??/time="([^"]+)"/.exec(t)?.[1],r=[],i=/domains=(\[[^\]]*\])/.exec(t)?.[1];if(i)try{r=JSON.parse(i)}catch{}r.length||(r=(/domains \[([^\]]*)\]/.exec(t)?.[1]??``).split(/[\s,]+/).filter(Boolean));let a=/error="((?:[^"\\]|\\.)*)"/.exec(t)?.[1]?.replace(/\\"/g,`"`)??/msg="((?:[^"\\]|\\.)*)"/.exec(t)?.[1]??t.replace(/^\S+\s+(ERR\s+)?/,``);return r.length||a?{...n&&{at:n},domains:r,error:a.trim()}:null}function Tc(e,t,n,r=/* @__PURE__ */ new Date){try{let i=new _(Buffer.from(e,`base64`)),a=(i.subjectAltName??``).split(/,\s*/).filter(e=>e.startsWith(`DNS:`)).map(e=>e.slice(4).toLowerCase()),o=/CN=([^\n,]+)/.exec(i.subject)?.[1],s=new Date(i.validTo),c=[/O=([^\n]+)/.exec(i.issuer)?.[1],/CN=([^\n]+)/.exec(i.issuer)?.[1]].filter(Boolean).join(` · `);return{domains:a.length?a:o?[o.toLowerCase()]:[],expires:s.toISOString(),days:Math.floor((s.getTime()-r.getTime())/864e5),issuer:c,staging:/staging|fake|\(STAGING\)/i.test(i.issuer),source:t,...n&&{path:n}}}catch{return null}}const Ec=(e,t)=>e===t||e.startsWith(`*.`)&&t.slice(t.indexOf(`.`)+1)===e.slice(2)&&t.indexOf(`.`)>0;function Dc(e,t=/* @__PURE__ */ new Date){let n={at:t.toISOString(),proxy:!0,acme:[],denied:[],certs:[],errors:[],domains:[]};for(let r of e.split(`
|
|
1123
|
+
`)){let e=r.split(` `);if(e[0]===`NOPROXY`)n.proxy=!1;else if(e[0]===`ACME`)n.acme.push(e[1]);else if(e[0]===`DENIED`)n.denied.push(e[1]);else if(e[0]===`CERT`){let r=Tc(e[2],e[1]===`acme`?`acme`:`file`,e[1]===`acme`?void 0:e[1],t);r&&n.certs.push(r)}else if(e[0]===`LOG`){let e=wc(r.slice(4));e&&n.errors.push(e)}}return n.certs.sort((e,t)=>(e.domains[0]??``).localeCompare(t.domains[0]??``)||t.days-e.days),n.domains=[...vc(e)].filter(([e])=>!e.startsWith(`*.`)).map(([e,t])=>{let r=n.certs.filter(t=>t.domains.some(t=>Ec(t,e))).sort((e,t)=>t.days-e.days)[0],i=n.errors.filter(t=>t.domains.includes(e)).at(-1);return{domain:e,site:t,...r&&{cert:r},...i&&{error:i}}}).sort((e,t)=>e.site.localeCompare(t.site)||e.domain.localeCompare(t.domain)),n}async function Oc(e,t=604800){let{stdout:n}=await J(e,Sc(t),{capture:!0});return Dc(n)}function kc(e,t={}){let[n,r]=t.resolve?/^[^:]*(:[^:]*)?$|^\[/.test(t.resolve)?/^\[?([^\]]+?)\]?(?::(\d+))?$/.exec(t.resolve)?.slice(1)??[]:[t.resolve]:[],i=performance.now();return new Promise(a=>{let o=se.request({host:n||e,port:Number(r)||443,servername:e,path:`/`,method:`GET`,headers:{host:e,"user-agent":`landfall-check`},rejectUnauthorized:!1,agent:!1,timeout:t.timeout??1e4},t=>{let n=Math.round(performance.now()-i),r=t.socket,s=r.getPeerCertificate(),c=s?.valid_to?{valid:r.authorized,...!r.authorized&&{error:String(r.authorizationError??`not trusted`)},expires:new Date(s.valid_to).toISOString(),days:Math.floor((new Date(s.valid_to).getTime()-Date.now())/864e5),issuer:[s.issuer?.O,s.issuer?.CN].filter(Boolean).join(` · `)}:void 0;t.resume(),o.destroy();let l=t.statusCode??0;a({domain:e,ok:l<500&&!!c?.valid&&c.days>=7,status:l,ms:n,...c&&{tls:c}})});o.on(`timeout`,()=>o.destroy(/* @__PURE__ */ Error(`timed out`))),o.on(`error`,t=>a({domain:e,ok:!1,error:t?.code??t?.message??String(t)})),o.end()})}async function Ac(e){let{login:t,probe:n,...r}=await Ao(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Y(t).user===`root`}}async function jc(e,t){let n=Ps(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>Is(t,e))),Rs(t)]);return{...n,dns:r,suggest:i}}async function Mc(e,t){let n=await sn(t),[r,i]=await Promise.all([_n(t,n),un(t,n)]),a=vn(r);return{name:e,...n,...i,ips:r,sslipIp:a??null,sslipSuffix:a?yn(``,a).slice(1):null}}const Nc=e=>Ks(e),Pc=e=>Ys(e),Fc=e=>ec(e);async function Ic(e,t){return Ui(e,t)}async function Lc(e,t){return(await as(e)).reverse().slice(0,t).map(e=>({...e,pending:$o(e).length}))}async function Rc(e,t=!1){let n=await Xi(e,t),r=await da(e);return{packages:n,images:await ha(e),engine:{...r,...fa(r)}}}async function zc(e,t){let n=await _n(e),[r,i]=await Promise.all([bn(t,n),Promise.all(t.map(e=>kc(e)))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}const Bc=(e,t)=>bc(e,t),Vc=e=>Oc(e);async function Hc(e,t,n,r,i,a){let o=await Er(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&E(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&Pn(e).some(e=>r.includes(e)));for(let e of s){let t=Pn(e).filter(e=>r.includes(e)),i=On(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Sn(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;D(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&D(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await A(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await Ir(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),fr(t,a.name,a.domains),T(S.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Uc=N({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
|
|
1087
1124
|
|
|
1088
1125
|
Without flags it lists and checks; --add/--rm/--set change the site (undoable)
|
|
1089
|
-
and update .landfall.json of remembered projects.`,options:{server:
|
|
1126
|
+
and update .landfall.json of remembered projects.`,options:{server:P,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}=mo(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await gr(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await zc(i,Pn(o));if(t.json)return void console.log(JSON.stringify({site:n,ips:e,domains:r},null,2));for(let e of r){let t=e.ok?S.green(`dns ok`):e.ips.length?S.yellow(`dns → ${e.ips.join(`, `)}`):S.red(`no dns`),{status:n,error:r,tls:i}=e.https,a=n?i&&!i.valid?S.red(`https ${n}, certificate: ${i.error}`):(n<400?S.green:n===401?S.cyan:S.yellow)(`https ${n}`):S.red(`https ${r}`);console.log(`${S.cyan(`https://${e.domain}`)} ${t} ${a}`)}r.some(e=>!e.ok)&&console.log(S.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}On(o)||D(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Ve(e.trim(),i)).filter(Boolean),l=t.set?c(t.set):[...o.domains];l=[.../* @__PURE__ */ new Set([...l,...c(t.add)])].filter(e=>!c(t.rm).includes(e));let u=l.filter(e=>!je.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&E(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return w(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await A(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await Hc(i,r,n,p,s))(),await Ir(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),fr(r,n,l);let m=B(process.cwd());m?.name===n&&qe(process.cwd(),{...m,domains:l}),l.length?w(`${n} serves ${l.map(e=>S.cyan(`https://${e}`)).join(`, `)}`):w(`${n} serves no domain now ${S.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await _n(i);for(let e of await bn(p,ee))e.ok||E(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Wc=N({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
|
|
1090
1127
|
|
|
1091
1128
|
lf auth my-site --user anna add or change a login (asks the password)
|
|
1092
1129
|
lf auth my-site --rm-user anna remove a login
|
|
1093
1130
|
lf auth my-site --off remove all logins: the site is public again
|
|
1094
|
-
lf auth my-site show who can log in`,options:{server:
|
|
1131
|
+
lf auth my-site show who can log in`,options:{server:P,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"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}=mo(e,t.server),{meta:i,proxy:a}=await gr(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(Ya),s=t[`password-stdin`]?(await b()).replace(/\r?\n$/,``):t.password,c=t.user??(s?`admin`:void 0);if(!c&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${S.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>S.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||E(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(Ya(e))),c&&l.push(await Za(c,s)),l.join(`
|
|
1095
1132
|
`)===(i.auth??[]).join(`
|
|
1096
|
-
`))return
|
|
1133
|
+
`))return w(`nothing to change`);let d=l.map(Ya),f=o.filter(e=>!d.includes(e));d.length?f.length&&await A(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await A(`Remove every login from ${n}? Anyone can open it then.`),await Ir(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),w(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),Gc=N({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:P},async run([e],t){let{site:n,server:r}=mo(e,t.server),{meta:i}=await gr(r,n),a=Pn(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(S.cyan(o)),ge(o)}}),Kc=[`tunnel-only`,`email`,`keep`,`default`];function qc(){return{local:[{path:L(Te),what:`servers and these defaults`},{path:L(m(I,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:L(m(I,`ui-token`)),what:`web UI login token`},{path:L(m(I,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:L(Ht),what:`Host <name> blocks between # >>> landfall:<name> markers`},{path:`~/.ssh/landfall_<name>`,what:`ssh key per server (lf keygen, lf setup)`},{path:`./.landfall.json`,what:`a project's deploy settings, saved by lf deploy`}],server:[{path:`${V}/README`,what:`how to run it all by hand, without landfall`},{path:`${H}/<site>`,what:`a site: .env, site.json, current -> releases/<id>`},{path:U,what:`landfall's Traefik: docker-compose.yml, letsencrypt/`},{path:Je,what:`Traefik file provider; files you add here are kept`},{path:W,what:`only when sites use a Traefik the server already had`},{path:G,what:`every change with its undo (lf log, lf undo)`},{path:Qe,what:`ssh hardening from lf setup`},{path:`user deploy`,what:`lf setup's deploy user (--deploy-user), in group docker`},{path:`packages`,what:`Docker + compose plugin, ufw, fail2ban, unattended-upgrades`},{path:`.<file>.landfall-*`,what:`backups next to each file landfall changed`}]}}function Jc(){let e=R(),t=e.defaults??{};return{configFile:L(Te),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:qc()}}const Yc=N({name:`config`,summary:`Show or change landfall's defaults for every server.
|
|
1097
1134
|
|
|
1098
1135
|
A server's own setting wins over these (lf server config shows which applies):
|
|
1099
1136
|
--tunnel-only new sites get no domain unless -d gives one; reach them with lf tunnel
|
|
@@ -1102,7 +1139,7 @@ A server's own setting wins over these (lf server config shows which applies):
|
|
|
1102
1139
|
--keep releases a deploy keeps for rollback (per deploy: lf deploy --keep)
|
|
1103
1140
|
--default the server commands use without --server (same as lf server use)
|
|
1104
1141
|
|
|
1105
|
-
--unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${
|
|
1142
|
+
--unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${Kc.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!Kc.includes(e));if(r.length&&D(`cannot unset ${r.join(`, `)}: one of ${Kc.join(`, `)}`),t[`tunnel-only`]!==void 0||t.email!==void 0||t.keep!==void 0||t.default!==void 0||n.length>0){t.default!==void 0&&ke(t.default);let e=R(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=Pe(t.email)),t.keep!==void 0&&(r.keep=Re(t.keep,`--keep`,1)),n.includes(`tunnel-only`)&&delete r.tunnelOnly,n.includes(`email`)&&delete r.email,n.includes(`keep`)&&delete r.keep,n.includes(`default`)&&delete e.default,Object.keys(r).length||delete e.defaults,De(e),w(`saved`)}let i=Jc();if(t.json)return void console.log(JSON.stringify(i,null,2));let a=e=>i.defaults[e]===void 0?S.dim(` (built in)`):``,o=(e,t)=>console.log(` ${S.dim(e.padEnd(12))} ${t}`);console.log(`${S.bold(`landfall defaults`)} ${S.dim(i.configFile)}`),o(`default`,i.default??S.dim(`none (lf config --default <server>)`)),o(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${a(`tunnelOnly`)}`),o(`e-mail`,i.email??S.dim(`none`)),o(`keep`,`${i.keep} releases${a(`keep`)}`);for(let[e,t]of[[`on this machine`,i.locations.local],[`on each server`,i.locations.server]]){console.log(`\n${S.bold(e)}`);let n=Math.max(...t.map(e=>e.path.length));for(let e of t)console.log(` ${e.path.padEnd(n)} ${S.dim(e.what)}`)}}}),Xc=N({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||D(`usage: lf keygen <name>`);let{path:t,created:n}=Zr(e);w(`${n?`created`:`key exists:`} ${L(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Zc(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?E(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await pe(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):E(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Qc(e,t){if(C(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await fe(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(T(S.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&&xe().email===void 0){let e=(await fe(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?Pe(e):void 0}let n=Be(e);t.dashboard??=await fe(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function $c(e,t){let n=Object.fromEntries(Object.entries(el.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return el.run([e],{...n,...t},[])}const el=N({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
|
|
1106
1143
|
|
|
1107
1144
|
Turns a fresh box into a deploy target (safe to re-run):
|
|
1108
1145
|
1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
|
|
@@ -1122,13 +1159,17 @@ missing. Steps that would break services already running there are skipped:
|
|
|
1122
1159
|
a Traefik already running there is used instead (lf traefik use)
|
|
1123
1160
|
Use --check to only see that report, --force to apply firewall/hardening anyway.
|
|
1124
1161
|
In a terminal, a first Traefik install asks for image, host network, e-mail and
|
|
1125
|
-
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: ${Ze})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},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||D(`usage: lf setup <name> [--host <ip>]`);let n=
|
|
1162
|
+
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: ${Ze})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},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||D(`usage: lf setup <name> [--host <ip>]`);let n=R(),r=n.servers[e],i=Gt().includes(e)&&!tn(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};if(!t.user&&t[`deploy-user`]&&Y(a).user===t[`deploy-user`]){let e;for(let t of[`root`,r?.admin])!e&&t&&await At({...a,user:t})&&(e=t);e?a.user=e:E(`${t[`deploy-user`]} can't change the system and root's key login does not work; pass -u <a sudo user> if setup fails`)}let s=Y(a);C(`setting up ${S.bold(e)} ${S.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>C(`[${++c}/5] ${e}`);l(`ssh key`);let u=await $r(e,a,{check:t.check,password:await Yr(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=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},[m,ee,te]=await Promise.all([ya(a,p),la(),ua()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=Na(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),re={email:t.email&&Pe(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:Xa},ie=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;ie&&(g.detail+=S.dim(` · ${$a(Qa(r??a,re))}`));let _=mn(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`,void 0,m.values.platform);T(`os: ${m.values.os??`unknown`}${m.values.pkg?S.dim(` · ${m.values.pkg}`):``}`),_.note&&(_.level===`eol`||_.level===`unsupported`?E:T)(_.note);let v=m.values.pkg===`none`&&`setup cannot change this server: ${fn[m.values.platform??``]?.name??`it has no apt, dnf, yum, apk or zypper`}${r?``:`. Register it with \`lf server add ${e}\``}`,ae=v?h.filter(e=>e.part===`docker`||e.part===`traefik`):h;if(La(ae),ue({checks:ae.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r})),...v&&{refuse:v}}),t.check)return void(v&&E(v));v&&D(v);let y=await Zc(h,t.force),oe=ie&&g.status===`todo`&&de(),se=t.domain?Fe(t.domain):r&&Be(r);if(se===void 0&&oe){let e=await fe(`main domain for sites, e.g. example.com (optional)`,``);se=e.trim()?Fe(e):void 0}oe&&(await Qc({...r??a,domains:ze(r?.domains,se)},re),T(`traefik: ${$a(Qa(r??a,re))}${re.dashboard?`, dashboard ${re.dashboard}`:``}`));let b=h.filter(e=>e.status!==`done`&&y.has(e.part)).length;if(b&&!await j(`apply the ${b} change${b===1?``:`s`} above?`))return;let ce=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>ce.includes(e.part)&&e.status!==`done`&&y.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await J(a,K+ut,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:y.has(`harden`),FIREWALL:y.has(`firewall`),HTTP3:(re.http3??r?.http3)!==!1}}),w(`server bootstrapped`)):(l(`bootstrap`),w(`already bootstrapped, nothing to change`));let x={...a};if(f){let e={...a,user:f};await At(e)?(w(`deploy user ${S.bold(f)} ready`),x=e):E(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let le=ti(e,x,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});se&&(le.domains=ze(le.domains,se)),s.user!==`root`&&Y(x).user!==s.user&&(le.admin=s.user),n.servers[e]=le,n.default??=e,De(n),l(`traefik`),g?.proxy?y.has(`traefik`)&&g.status===`todo`?await to(le,g.proxy):w(g.detail):y.has(`traefik`)?await eo(e,le,re):T(S.dim(`skipped`)),se?(console.log(`\n${S.green(`Done.`)} Point ${S.bold(se)} and ${S.bold(`*.${se}`)} (A records) at ${S.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${S.dim(`# https://blog.${se}`)}`),console.log(` lf server domain ${e} ${S.dim(`# check the DNS`)}\n`)):(console.log(`\n${S.green(`Done.`)} Point a domain's A record at ${S.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`)),await Wi(e,le,a)}}),tl={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function nl(e,t){if(!t)return e;let n=Ya(t);return[...(e??[]).filter(e=>Ya(e)!==n),t]}const rl=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function il(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(rl(new Date(n)));)n+=1e3;return rl(new Date(n))}const al=N({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, Node.js app, static folder, Dockerfile or image with HTTPS.
|
|
1126
1163
|
|
|
1127
1164
|
Kinds:
|
|
1128
1165
|
compose the folder has a compose.yaml: its services run on the server as one
|
|
1129
1166
|
project; x-landfall: { port: 3000 } on a service routes it through Traefik
|
|
1130
1167
|
dockerfile the folder has a Dockerfile: uploaded and built on the server
|
|
1131
|
-
|
|
1168
|
+
node a server-rendering framework (Next.js, Nuxt, TanStack Start, SvelteKit
|
|
1169
|
+
adapter-node, Astro Node, React Router, Remix, SolidStart, Express, …)
|
|
1170
|
+
or --start: landfall writes the Dockerfile, built on the server
|
|
1171
|
+
static a folder of files (auto-detects dist/, build/, out/, public/ and the
|
|
1172
|
+
output of Vite, Astro, Next export, Nuxt generate, SvelteKit, Hugo, …)
|
|
1132
1173
|
image --image: run a prebuilt image (e.g. ghcr.io/me/app:1.2)
|
|
1133
1174
|
|
|
1134
1175
|
Every site is a Compose project on the server (/opt/landfall/sites/<name>/current),
|
|
@@ -1136,15 +1177,21 @@ so \`docker compose\` works there without landfall. Settings are saved to
|
|
|
1136
1177
|
./.landfall.json, so later deploys are just \`lf deploy\`.
|
|
1137
1178
|
|
|
1138
1179
|
A server landfall has not set up yet is set up first (\`lf setup\`), asking
|
|
1139
|
-
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:
|
|
1140
|
-
`)
|
|
1180
|
+
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:P,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"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},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 or compose file 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 (- reads stdin)`},build:{type:`string`,short:`b`,desc:`build command: static sites run it locally first, Node.js apps in their image ('' for none)`},start:{type:`string`,desc:`run as a Node.js app with this start command (e.g. 'node server.js'); landfall writes its Dockerfile`},keep:{type:`string`,desc:`releases to keep for rollback (default: 5, lf config --keep)`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=B(n)??{};i.name&&T(S.dim(`using ${Ge}`));let{name:a,server:s}=z(t.server??i.server);t.host&&!t.server&&!i.server&&!R().default&&D(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await Mt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(de()&&await j(`${a} ${e}. Set it up now (lf setup)?`))&&D(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await $c(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=z(a),await Mt(s)!==`ready`&&D(`${a} is still not set up`)}let l=pr(t.name??i.name??or(f(n))),u=Re(t.keep??xe().keep??5,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&D(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Ve(e,s)).filter(e=>!je.test(e));g.length&&D(`not a domain: ${g.join(`, `)}`);let re=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),ie=re?await Za(re,t[`auth-password`],`--auth-user`):void 0;t.start&&t.static&&D(`--start and --static exclude each other`);let _=t.build??i.build,v=t.static??(!t.start&&i.static),ae=t.image??(v?void 0:i.image),y=ae&&Le(ae),oe=ne(n,e??i.dir??`.`),se=y||v?void 0:t.file??sr(oe,i),b=se&&ee(se);b?.includes(`/`)&&D(`${se} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,oe)||`.`,b))} -f ${f(b)}`),b&&!o.existsSync(m(oe,b))&&D(`no ${b} in ${te(n,oe)||`.`}`);let ce=!b&&!y&&!v&&o.existsSync(m(oe,`Dockerfile`));ce&&t.start&&E(`--start ignored: the Dockerfile says how the app starts`);let x=b||y||v||ce?void 0:tr(oe,t.start??i.start);if(_&&!x){C(`building locally: ${_}`);let e=r(_,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let le=er(n),O=b||y||ce||x?oe:ne(n,e??i.dir??ir(n,le)),k=b?`compose`:y?`image`:ce||x?`dockerfile`:`static`,pe=k===`static`&&!e&&!i.dir?le.tool:void 0;pe&&T(S.dim(`publishing ${te(n,O)}: where ${pe} builds the site (another folder: lf deploy <dir>)`)),k===`static`&&!o.existsSync(m(O,`index.html`))&&E(`no index.html in ${te(n,O)||`.`}${pe&&!_?`: build first, or let deploy do it (--build "${le.build}")`:``}`);let A=tl[k].port?Re(t.port??i.port??(ce?ar(m(O,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,M=x&&(_??x.build);x&&T(S.dim(`Node.js app${x.tool?` (${x.tool})`:``}: built on the server${M?` with ${M}`:``}, started with ${x.start} on port ${A}`));let me=k===`static`&&!!(t.spa??i.spa);C(`deploying ${S.bold(l)} (${x?`node`:k}) to ${S.bold(a)}`);let he=await hr(s,l),{env:ge,proxy:N,exists:P}=he,_e=he.meta&&Sn(he.meta)?he.meta:void 0,F=_e?void 0:he.meta;F?.adopted&&D(`${l} is adopted (${F.adopted}): deploy it there with docker compose, or pick another --name`),_e?E(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):F&&F.kind!==k&&T(S.dim(`site was ${F.kind}, now ${k}`));let ve;(t.env?.length||t[`env-file`])&&(ve=ge,t[`env-file`]&&(ve=Hr(ve,await Wr(t[`env-file`]))),t.env?.length&&(ve=Hr(ve,Gr(t.env))));let ye=il((he.meta?.releases??[]).map(e=>e.id)),be=`${Z(l)}/releases/${ye}`,Ce=k===`compose`?{}:{"compose.yaml":Kn(k,{port:A,image:y,node:!!x}),...k===`static`&&{"nginx.conf":qn(me)}};x&&(Ce.Dockerfile=rr(oe,{tool:x.tool,build:M,start:x.start,port:A}),o.existsSync(m(oe,`.dockerignore`))||(Ce[`.dockerignore`]=nr.join(`
|
|
1181
|
+
`)+`
|
|
1182
|
+
`));let we,I,Te;try{let e=tl[k].files;if(e!==void 0){C(`uploading ${te(n,O)||`.`}`);let t=F?.kind===k?F.current:void 0,r=await zt(s,O,be+e,t&&`${Z(l)}/current${e}`,x?nr:[]);r===`rsync`?T(S.dim(`sent only what changed since release ${t}`)):r===`tar:no-local-rsync`?T(S.dim(`sent every file: install rsync on this machine to send only what changed`)):r===`tar:no-server-rsync`&&T(S.dim(`sent every file: ${a} has no rsync, \`lf setup ${a}\` installs it to send only what changed`))}let t=await Dr(s,l,ye,{file:b,generated:Ce,env:ve}),r=Vn(t.config,t.dir,e=>Ve(e,s));for(let e of r.warnings)E(e);r.errors.length&&D(`${b??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
|
|
1183
|
+
`)}`),we={id:ye,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);I=[];let c=!1;if(Dn(we)){c=!h?.length&&!!i.domains?.length,I=d?[]:[h,i.domains,F?.domains,_e?.domains].find(e=>e?.length)??[];let e=d||!I.length&&(!!F&&!!On(F)&&!F.domains.length||i.domains?.length===0);if(!I.length&&!e&&Se(s)&&(e=!0),!I.length&&!e){let t=Be(s),n=t?void 0:vn(await _n(s)),r=await fe(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?yn(l,n):void 0);e=r===`none`,e||(I=r.split(/[\s,]+/).filter(Boolean))}I=[...new Set(I.map(e=>Ve(e,s)))];let t=I.filter(e=>!je.test(e)),n=I.filter(e=>o.includes(e));!I.length&&!e&&D(`at least one --domain is required`),e&&T(S.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&D(`not a domain: ${t.join(`, `)}`),n.length&&D(`${n.join(`, `)} is already routed by x-landfall.domains`)}else h?.length&&E(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);Te=await Hc(s,a,l,[...I,...o],N,c?`${Ge} lists it; deploy with -d to move it`:void 0)}catch(e){throw await kr(s,l,ye).catch(()=>{}),e}let Ee=(/* @__PURE__ */ new Date()).toISOString(),L={v:2,name:l,kind:k,domains:I,auth:nl(F?.auth??_e?.auth,ie),current:ye,releases:[...F?.releases??[],we],createdAt:F?.createdAt??_e?.createdAt??Ee,updatedAt:Ee};await Te(),await Lr(s,L,N,{env:ve,build:!0,pull:!0,keep:u,prev:F,takeover:_e,existed:!F&&!_e&&P,what:`deploy ${l} release ${ye}${F?``:` (new site, ${Pn(L).join(`, `)})`}`});let De=Pn(L);if(t.save){let e={name:l,server:a,domains:I};if(k===`image`)Object.assign(e,{image:y,port:A});else{let r=te(n,O)||`.`;k===`static`?r!==ir(n,le)&&(e.dir=r):(r!==`.`&&(e.dir=r),k===`compose`&&(t.file||i.compose)&&(e.compose=b)),k===`dockerfile`&&(e.port=A),x&&(t.start||i.start)&&(e.start=x.start),me&&(e.spa=!0),v&&k===`static`&&(e.static=!0)}(_||x&&_===``)&&(e.build=_),qe(n,e),dr({path:n,name:l,server:a,domains:De,kind:k})}w(`release ${S.bold(ye)} live`);for(let e of De)console.log(` ${S.cyan(`https://${e}`)}`);De[0]&&ue({url:`https://${De[0]}`}),De[0]&&await ol(De[0])}});async function ol(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?w(`https://${e} responded ${t.status}`):E(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);E(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const sl=N({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
|
|
1141
1184
|
|
|
1142
1185
|
compose.yaml what runs on the server; x-landfall routes a service through Traefik
|
|
1143
1186
|
compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
|
|
1144
1187
|
publishes the port on localhost
|
|
1188
|
+
Dockerfile a Node.js app (Next.js, Nuxt, TanStack Start, …) without one:
|
|
1189
|
+
the one lf deploy would generate, to change it here
|
|
1145
1190
|
|
|
1146
1191
|
\`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
|
|
1147
|
-
files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||D(`${e} is not a folder`);let r=
|
|
1192
|
+
files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||D(`${e} is not a folder`);let r=cr(n);r.kind===`compose`&&D(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&D(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:Re(t.port??r.port??3e3,`--port`,1,65535);r.start&&(i.Dockerfile=rr(n,{tool:r.tool,build:r.build,start:r.start,port:s}),o.existsSync(m(n,`.dockerignore`))||(i[`.dockerignore`]=nr.join(`
|
|
1193
|
+
`)+`
|
|
1194
|
+
`));let c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||T(S.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
|
|
1148
1195
|
image: nginx:alpine
|
|
1149
1196
|
restart: unless-stopped
|
|
1150
1197
|
volumes:
|
|
@@ -1153,7 +1200,8 @@ files are never overwritten.`,options:{port:{type:`string`,desc:`container port
|
|
|
1153
1200
|
port: 80
|
|
1154
1201
|
`}else u=` app:
|
|
1155
1202
|
${r.kind===`image`?`image: ${r.project.image}`:`build: .`}
|
|
1156
|
-
restart: unless-stopped
|
|
1203
|
+
restart: unless-stopped${r.start?`
|
|
1204
|
+
init: true`:``}
|
|
1157
1205
|
env_file:
|
|
1158
1206
|
- path: .env
|
|
1159
1207
|
required: false
|
|
@@ -1189,31 +1237,31 @@ services:
|
|
|
1189
1237
|
${d}:
|
|
1190
1238
|
ports:
|
|
1191
1239
|
- "127.0.0.1:${c}:${s}"
|
|
1192
|
-
`;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;
|
|
1193
|
-
no access to docker: containers not shown (run lf setup)`))}const
|
|
1194
|
-
With a site, only its containers.`,options:{server:
|
|
1195
|
-
no access to docker: images, volumes and logs not shown`)),t.sites.length&&(console.log(`\n${
|
|
1196
|
-
`+i.map(e=>
|
|
1197
|
-
`))}const
|
|
1198
|
-
With a site (or a domain), only its requests.`,options:{server:
|
|
1199
|
-
lf traffic <site> for its paths, errors and clients`))}});function
|
|
1200
|
-
Exits 1 when one fails: use it from cron or CI.`,options:{server:
|
|
1201
|
-
Keeps images any container uses (stopped ones too), images of site releases (for rollback) and the ones undo needs. Volumes and containers stay.`,options:{server:
|
|
1202
|
-
`)[0]:e.trim();return
|
|
1240
|
+
`;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;w(`wrote ${Object.keys(i).join(`, `)} in ${f}`),l&&!o.existsSync(m(n,`.env`))&&T(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),T(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),T(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),cl=(e,t)=>t?Math.round(e/t*100):0;function ll(e){let t=Math.max(0,Math.min(10,Math.round(e/10)));return(e>=90?S.red:e>=70?S.yellow:S.green)(`█`.repeat(t))+S.dim(`░`.repeat(10-t))}const ul=e=>`${X(e)}/s`,dl=e=>Mn(e)?/unhealthy|restarting/i.test(e)?S.yellow(e):S.green(e):Nn(e)?S.dim(e):S.red(e);function fl(e,t,n){let r=n?t.containers.filter(e=>e.site===n||e.project===n):t.containers;if(n&&!r.length&&D(`no containers of "${n}" on ${e}. See \`lf ls\``),!n){let n=t.mem.total-t.mem.available,r=t.mem.swapTotal-t.mem.swapFree,i=[[`cpu`,ll(t.cpu),`${t.cpu}%`,S.dim(`${t.cpus} cores · load ${t.load.join(` `)}`)],[`memory`,ll(cl(n,t.mem.total)),`${cl(n,t.mem.total)}%`,S.dim(`${X(n)} / ${X(t.mem.total)}`)],...t.mem.swapTotal?[[`swap`,ll(cl(r,t.mem.swapTotal)),`${cl(r,t.mem.swapTotal)}%`,S.dim(`${X(r)} / ${X(t.mem.swapTotal)}`)]]:[],...t.disks.map(e=>[`disk ${e.mount}`,ll(cl(e.used,e.total)),`${cl(e.used,e.total)}%`,S.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)]),...t.net.map(e=>[`net ${e.iface}`,``,``,`↓ ${ul(e.rx)} ↑ ${ul(e.tx)}`])];console.log(S.bold(e)+S.dim(` · up ${xn(t.uptime)}`)),M(i.map(([e,...t])=>[S.dim(e),...t])),t.procs.length&&(console.log(`\n${S.bold(`Processes`)} ${S.dim(`(cpu: 100% = one core)`)}`),M([[`pid`,`cpu`,`memory`,`name`].map(S.dim),...t.procs.slice(0,8).map(e=>[String(e.pid),`${e.cpu}%`,X(e.rss),e.name])]))}t.docker?r.length&&(console.log(`\n${S.bold(`Containers`)}`),M([[`site`,`container`,`cpu`,`memory`,`net in/out since start`,`restarts`,`status`].map(S.dim),...r.map(e=>[e.site??S.dim(e.project||`-`),e.name,Mn(e.status)?`${Math.round(e.cpu*10)/10}%`:``,Mn(e.status)?`${X(e.mem)}${e.memLimit&&e.memLimit<t.mem.total?S.dim(` (limit ${X(e.memLimit)})`):``}`:``,Mn(e.status)?`↓ ${X(e.net.rx)} ↑ ${X(e.net.tx)}`:``,e.restarts?S.yellow(String(e.restarts)):S.dim(`0`),dl(e.status)])])):console.log(S.dim(`
|
|
1241
|
+
no access to docker: containers not shown (run lf setup)`))}const pl=N({name:`stats`,args:`[site]`,summary:`Show live CPU, memory, disk, network, the busiest processes and each container's usage.
|
|
1242
|
+
With a site, only its containers.`,options:{server:P,watch:{type:`boolean`,short:`w`,desc:`refresh until Ctrl-C`},interval:{type:`string`,desc:`seconds between refreshes with --watch`,default:`5`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server);if(t.json){t.watch&&D(`--watch and --json do not go together: run lf stats --json in a loop`),console.log(JSON.stringify(await Ks(r),null,2));return}if(!t.watch)return fl(n,await Ks(r),e);let i=Re(t.interval,`--interval`,1,3600)*1e3;for(;;){let t=await Ks(r);process.stdout.isTTY&&process.stdout.write(`\x1B[H\x1B[2J`),fl(n,t,e),console.log(S.dim(`\n${(/* @__PURE__ */ new Date()).toLocaleTimeString()} · every ${i/1e3}s · Ctrl-C to stop`)),await new Promise(e=>setTimeout(e,i))}}});function ml(e,t){console.log(S.bold(e)),M(t.disks.map(e=>[S.dim(e.mount),ll(cl(e.used,e.total)),`${cl(e.used,e.total)}%`,S.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)])),t.docker&&t.dockerDf.length?(console.log(`\n${S.bold(`Docker`)}`),M([[``,`count`,`in use`,`size`,`reclaimable`].map(S.dim),...t.dockerDf.map(e=>[e.type,String(e.count),String(e.active),X(e.size),e.reclaimable?S.yellow(X(e.reclaimable)):S.dim(`0`)])])):t.docker||console.log(S.dim(`
|
|
1243
|
+
no access to docker: images, volumes and logs not shown`)),t.sites.length&&(console.log(`\n${S.bold(`Sites`)} ${S.dim(`(${V} ${X(t.landfall)})`)}`),M([[`site`,`files`,`releases`].map(S.dim),...t.sites.map(e=>[e.name,X(e.size),`${e.releases} · ${X(e.releasesSize)}`])]));let n=t.volumes.filter(e=>e.size>0).slice(0,10);n.length&&(console.log(`\n${S.bold(`Volumes`)} ${S.dim(`(largest)`)}`),M([[`volume`,`size`,`used by`].map(S.dim),...n.map(e=>[e.name,X(e.size),e.links?String(e.links):S.yellow(`nothing`)])]));let r=t.logs.filter(e=>(e.size??0)>0).slice(0,10);r.length&&(console.log(`\n${S.bold(`Container logs`)} ${S.dim(`(largest)`)}`),M(r.map(e=>[e.container,X(e.size)])));let i=[t.journald?`systemd journal: ${X(t.journald)}`:``,t.root?``:`sizes below root-owned folders and container logs need root or passwordless sudo: they may be missing`].filter(Boolean);i.length&&console.log(`
|
|
1244
|
+
`+i.map(e=>S.dim(e)).join(`
|
|
1245
|
+
`))}const hl=N({name:`disk`,summary:`Show where a server's disk space goes: filesystems, Docker images, volumes, build cache, sites and container logs.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=await Ys(r);if(t.json)return void console.log(JSON.stringify(i,null,2));ml(n,i)}}),gl=e=>e.toLocaleString(`en-US`),_l=e=>e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`;function vl(e){let t=e.map(e=>e[`2xx`]+e[`3xx`]+e[`4xx`]+e[`5xx`]),n=Math.max(1,...t),r=Math.ceil(e.length/100),i=``;for(let a=0;a<e.length;a+=r){let o=t.slice(a,a+r).reduce((e,t)=>e+t,0)/r,s=e.slice(a,a+r).some(e=>e[`5xx`]),c=o?`▁▂▃▄▅▆▇█`[Math.min(7,Math.ceil(o/n*8)-1)]:` `;i+=s?S.red(c):S.cyan(c)}return i}const yl=e=>[gl(e.requests),e.classes[`4xx`]?S.yellow(gl(e.classes[`4xx`])):S.dim(`0`),e.classes[`5xx`]?S.red(gl(e.classes[`5xx`])):S.dim(`0`),e.requests?_l(e.p50):``,e.requests?_l(e.p95):``,X(e.bytes)];function bl(e,t){M([[`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(S.dim),yl(e)]),console.log(`\n${S.dim(`requests per ${t}`)} ${vl(e.series)}`),e.paths.length&&(console.log(`\n${S.bold(`Paths`)}`),M(e.paths.map(e=>[gl(e.count),e.errors?S.yellow(`${gl(e.errors)} failed`):``,e.path]))),e.errors.length&&(console.log(`\n${S.bold(`Errors`)}`),M(e.errors.map(e=>[gl(e.count),e.status>=500?S.red(String(e.status)):S.yellow(String(e.status)),e.path]))),e.ips.length&&(console.log(`\n${S.bold(`Clients`)}`),M(e.ips.map(e=>[gl(e.count),e.ip])));let n=Object.entries(e.codes).sort((e,t)=>t[1]-e[1]);n.length&&console.log(`\n${S.dim(`status codes`)} ${n.map(([e,t])=>`${e} ${gl(t)}`).join(S.dim(` · `))}`)}const xl=N({name:`traffic`,args:`[site]`,summary:`Show requests, errors, response times, busiest paths and clients from Traefik's access log.
|
|
1246
|
+
With a site (or a domain), only its requests.`,options:{server:P,since:{type:`string`,desc:`how far back: 30m, 24h, 7d (up to what the container log still holds)`,default:`24h`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=oc(t.since),a=await bc(r,i);a.proxy||D(`no traefik on ${n} (lf traefik setup)`);let o=e?xc(a,e):null;if(t.json)return void console.log(JSON.stringify(e?{site:e,since:a.since,bucket:a.bucket,format:a.format,from:a.from,to:a.to,...o??{}}:a,null,2));if(a.format===`common`)return E(a.own?`${n}'s traefik writes its access log as plain text: add --accesslog.format=json to its command to see traffic here`:`traefik on ${n} writes its access log as plain text: \`lf traefik setup${t.server?` -s ${n}`:``}\` switches it to JSON (traefik restarts for a moment)`);let s=sc(i);if(a.format===`none`)return T(`no requests on ${n} in the last ${s}${a.own?S.dim(` (or its traefik has no access log: --accesslog=true --accesslog.format=json)`):``}`);let c=a.from&&new Date(a.from).getTime()-(Date.now()-i*1e3)>(a.bucket===`hour`?36e5:6e4)*2;if(console.log(`${S.bold(n)}${S.dim(` · last ${s}${c?` · the log reaches back to ${new Date(a.from).toLocaleString()}`:``}`)}`),e)return o||D(`no requests for "${e}" on ${n} in the last ${s}`),bl(o,a.bucket);let l=a.hosts.filter(e=>!e.site);M([[`site`,`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(S.dim),...a.sites.map(e=>[S.bold(e.site),...yl(e)]),...l.slice(0,8).map(e=>[S.dim(e.host),...yl(e)]),...l.length>8?[[S.dim(`${l.length-8} more hosts`),...yl(Sl(l.slice(8)))]]:[],[S.dim(`total`),...yl(a.total)]]),console.log(`\n${S.dim(`requests per ${a.bucket}`)} ${vl(a.total.series)}`),a.total.errors.length&&(console.log(`\n${S.bold(`Errors`)}`),M(a.total.errors.slice(0,5).map(e=>[gl(e.count),e.status>=500?S.red(String(e.status)):S.yellow(String(e.status)),e.path]))),console.log(S.dim(`
|
|
1247
|
+
lf traffic <site> for its paths, errors and clients`))}});function Sl(e){let t={requests:0,bytes:0,classes:{"2xx":0,"3xx":0,"4xx":0,"5xx":0},codes:{},p50:0,p95:0};for(let n of e){t.requests+=n.requests,t.bytes+=n.bytes;for(let e of Object.keys(t.classes))t.classes[e]+=n.classes[e]}return t}function Cl(e){if(!e)return S.red(`none`);let t=`${e.days} days`;return e.days<7?S.red(e.days<0?`expired`:t):e.staging?S.yellow(`${t}, staging (not trusted)`):e.days<30?S.yellow(t):S.green(t)}const wl=N({name:`certs`,summary:`Show each site domain's HTTPS certificate: when it expires, who issued it, and why getting one failed.`,options:{server:P,since:{type:`string`,desc:`how far back to look for certificate errors`,default:`7d`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=await Oc(r,oc(t.since));if(t.json)return void console.log(JSON.stringify(i,null,2));if(i.proxy||D(`no traefik on ${n} (lf traefik setup)`),i.domains.length){M([[`domain`,`site`,`certificate`,`expires`,`issuer`].map(S.dim),...i.domains.map(e=>[e.domain,S.dim(e.site),Cl(e.cert),e.cert?e.cert.expires.slice(0,10):``,S.dim(e.cert?.issuer??``)])]);for(let e of i.domains.filter(e=>e.error&&(!e.cert||e.cert.days<30)))E(`${e.domain}: ${e.error.error}${e.error.at?S.dim(` (${new Date(e.error.at).toLocaleString()})`):``}`)}else T(`no site domains on ${n}`);let a=new Set(i.domains.map(e=>e.cert)),o=i.certs.filter(e=>!a.has(e));o.length&&(console.log(`\n${S.bold(`Other certificates`)}`),M(o.map(e=>[e.domains.join(` `),Cl(e),S.dim(e.path??`acme.json`)])));let s=i.errors.filter(e=>!e.domains.some(e=>i.domains.some(t=>t.domain===e))).slice(-3);for(let e of s)E(`${e.domains.join(` `)||`acme`}: ${e.error}`);i.denied.length&&E(`${i.denied.join(`, `)} needs root to read: ${n}'s login has no passwordless sudo`)}}),Tl=e=>e.includes(`.`)&&je.test(e.toLowerCase()),El=N({name:`check`,args:`[site|domain…]`,summary:`Request each site over HTTPS from this machine: status, response time and days left on the certificate.
|
|
1248
|
+
Exits 1 when one fails: use it from cron or CI.`,options:{server:P,resolve:{type:`string`,desc:`connect to this ip[:port] instead of what DNS says (test before DNS points here)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=e.filter(Tl).map(e=>({domain:e,site:void 0})),r=e.filter(e=>!Tl(e));if(!e.length||r.length){let{name:e,server:i}=z(t.server??B(process.cwd())?.server),a=await Er(i);for(let t of r)a.sites.some(e=>e.meta.name===t)||D(`no site "${t}" on ${e}. See \`lf ls\``);let o=a.sites.filter(e=>!r.length||r.includes(e.meta.name));if(n=[...n,...o.flatMap(e=>e.domains.filter(e=>!e.startsWith(`*.`)).map(t=>({domain:t,site:e.meta.name})))],!n.length)return T(`no site on ${e} has a domain`)}let i=await Promise.all(n.map(async e=>({...await kc(e.domain,{resolve:t.resolve}),...e.site&&{site:e.site}})));return t.json?console.log(JSON.stringify(i,null,2)):M([[``,`domain`,`site`,`status`,`time`,`certificate`].map(S.dim),...i.map(e=>[e.ok?S.green(`✓`):S.red(`✗`),e.domain,S.dim(e.site??``),e.error?S.red(e.error):(e.status??0)>=500?S.red(String(e.status)):String(e.status),e.ms===void 0?``:e.ms>1e3?S.yellow(_l(e.ms)):_l(e.ms),e.tls?e.tls.valid?e.tls.days<7?S.red(`${e.tls.days} days`):e.tls.days<30?S.yellow(`${e.tls.days} days`):S.green(`${e.tls.days} days`):S.red(e.tls.error??`not trusted`):``])]),+!i.every(e=>e.ok)}});function Dl(e,t){let n=Zs(t),r=[[Qs(t,`release`).length,`for rollback`],[Qs(t,`undo`).length,`for undo`],[Qs(t,`tagged`).length,`tagged but unused (--all removes them)`]].filter(([e])=>e);n.length?(console.log(`${S.bold(e)}: ${n.length} unused images`),M([[`image`,`size`,`created`].map(S.dim),...n.slice(0,20).map(e=>[e.tags.join(`, `)||S.dim(e.id.replace(`sha256:`,``).slice(0,12)),X(e.size),S.dim(e.created.split(` `)[0])])]),n.length>20&&console.log(S.dim(`… ${n.length-20} more`))):console.log(`${S.bold(e)}: no unused images`),console.log(`build cache: ${t.buildCache?X(t.buildCache):S.dim(`0`)}${t.all?``:S.dim(` reclaimable (--all also removes cache still in use)`)}`),r.length&&console.log(S.dim(`kept: ${r.map(([e,t])=>`${e} ${t}`).join(`, `)}`))}const Ol=N({name:`prune`,summary:`Free disk space: remove unused Docker images and the build cache.
|
|
1249
|
+
Keeps images any container uses (stopped ones too), images of site releases (for rollback) and the ones undo needs. Volumes and containers stay.`,options:{server:P,all:{type:`boolean`,desc:`also remove unused tagged images (pulled again when needed) and all build cache`},"dry-run":{type:`boolean`,desc:`only show what would go`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=await ec(r,!!t.all);i.docker||D(`no access to docker on ${n} (run lf setup)`);let a=Zs(i);if(t[`dry-run`]||t.json&&!a.length&&!i.buildCache)return void(t.json?console.log(JSON.stringify(i,null,2)):Dl(n,i));if(t.json||Dl(n,i),!a.length&&!i.buildCache)return w(`nothing to clean up`);let o=a.reduce((e,t)=>e+t.size,0)+i.buildCache;if(!await j(`\nRemove ${a.length} images and the build cache (up to ${X(o)})?`,!0))return;t.json||C(`cleaning up`);let s=await tc(r,i);if(t.json)return void console.log(JSON.stringify({...i,removed:s.images-s.failed.length,failed:s.failed},null,2));for(let e of s.failed)E(`${e}: could not remove it (in use?)`);w(`removed ${s.images-s.failed.length} images and the build cache`)}}),kl=N({name:`reboot`,summary:`Reboot a server, wait until it is back and check its containers run again.`,options:{server:P,timeout:{type:`string`,desc:`seconds to wait for it to come back`,default:`300`}},async run(e,t){let{name:n,server:r}=z(t.server??B(process.cwd())?.server),i=Re(t.timeout,`--timeout`,30,3600),a=await Q(r,`rebooting`);if(!await j(`Reboot ${n}? Its sites are offline for a minute or two.`,!1))return;let o=await ac(a,{timeout:i,onWait:e=>C(e===`down`?`rebooting ${n}`:e===`ssh`?`waiting for ssh`:`waiting for containers`)});o.back||D(`${n} did not answer within ${i}s. Check its console at the provider`),w(`${n} is back after ${o.seconds}s`),o.missing.length?E(`not running again: ${o.missing.join(`, `)}. See lf stats -s ${n}`):T(`all containers that ran before run again`)}}),$=e=>z(e.server??B(process.cwd())?.server),Al={type:`boolean`,desc:`machine-readable output`},jl=e=>e.active===`failed`||e.active===`crashed`,Ml=e=>e.active===`active`||e.active===`started`,Nl=e=>ri.includes(ii(e.unit));function Pl(e){return jl(e)?S.red(e.sub||e.active):Ml(e)?S.green(e.sub||e.active):S.dim(e.sub||e.active)}function Fl(e){e.root||T(S.dim(`${e.user} has no passwordless sudo: other users' keys, bans and daemon.json may be missing`))}const Il=N({name:`system`,summary:`Show a server's basics: host name, time zone, swap, Docker log limits, failed services, SSH keys and bans.`,options:{server:P,json:Al},async run(e,t){let{name:n,server:r}=$(t),i=await ui(r);if(t.json)return void console.log(JSON.stringify(i,null,2));let a=i.swap.reduce((e,t)=>e+t.size,0),o=i.docker;M([[S.dim(`host name`),i.hostname],[S.dim(`time zone`),`${i.timezone}${i.ntp===!1?S.yellow(` · clock not synced`):i.ntp?S.dim(` · synced`):``}`],[S.dim(`swap`),a?`${X(a)}${S.dim(` (${i.swap.map(e=>e.file).join(`, `)})`)}`:S.yellow(`none`)],[S.dim(`docker logs`),o?o.maxSize?`${o.driver}, ${o.maxSize} × ${o.maxFile??1} per container`:o.driver===`json-file`?S.yellow(`${o.driver}, unlimited`):o.driver:S.dim(`no docker`)],[S.dim(`services`),i.init===`other`?S.dim(`no systemd or OpenRC`):`${i.services.filter(Ml).length} running${i.services.some(jl)?S.red(` · ${i.services.filter(jl).length} failed`):``}`],[S.dim(`ssh keys`),`${i.keys.length}`],[S.dim(`fail2ban`),i.fail2ban===`running`?`${i.jails.reduce((e,t)=>e+t.banned,0)} banned now`:S.dim(i.fail2ban===`missing`?`not installed`:`stopped`)]]),o?.daemonError&&E(o.daemonError);for(let e of i.services.filter(jl))E(`${ii(e.unit)} failed: lf services logs ${ii(e.unit)} -s ${n}`);!a&&i.mem&&i.mem<4*1024**3&&T(S.dim(`little memory and no swap: lf swap 2G adds some`)),o&&!o.maxSize&&o.driver===`json-file`&&T(S.dim(`container logs grow without limit: lf log-limit 10m caps them`)),Fl(i)}}),Ll=N({name:`services`,summary:`List services: the ones landfall relies on and failed ones; --all for every one.`,options:{server:P,all:{type:`boolean`,desc:`every service, inactive ones too`},json:Al},async run(e,t){let{name:n,server:r}=$(t),i=await li(r);if(t.json)return void console.log(JSON.stringify(i.services,null,2));i.init===`other`&&D(`no systemd or OpenRC on ${n}`);let a=t.all?i.services:i.services.filter(e=>jl(e)||Nl(e)||Ml(e)&&i.init===`openrc`),o=e=>jl(e)?0:Nl(e)?1:2;a.sort((e,t)=>o(e)-o(t)||e.unit.localeCompare(t.unit)),M([[`service`,`state`,...i.init===`systemd`?[`boot`]:[],`description`].map(S.dim),...a.map(e=>[ii(e.unit),Pl(e),...i.init===`systemd`?[S.dim(e.enabled??``)]:[],S.dim(e.description)])]),t.all||T(S.dim(`${i.services.length-a.length} more: lf services --all`))}});function Rl(e){return N({name:`services ${e}`,args:`<service>`,summary:`${e[0].toUpperCase()}${e.slice(1)} a service (systemd or OpenRC).${e===`restart`?``:` Undo ${e===`stop`?`starts`:`stops`} it again.`}`,options:{server:P,force:{type:`boolean`,desc:`even ssh`}},async run([t],n){t||D(`usage: lf services ${e} <service>`),di.test(t)||D(`not a service name: ${t}`);let{name:r,server:i}=$(n),a=ii(t);if(e===`stop`&&/^sshd?$/.test(a)&&!n.force&&!await pe(`Stopping ${a} can lock you out of ${r}. Do it anyway?`)&&D(`stopping ${a} can lock you out (--force to do it anyway)`),/^(docker|containerd)$/.test(a)&&e!==`start`&&!await j(`${e} ${a} on ${r}? Every site goes down${e===`stop`?` until it is started again`:` for a moment`}.`,!1))return;let o=await Q(i,`${e}ing a service`);C(`${e} ${a} on ${r}`),await fi(o,e,t),w(`${a}: ${e===`stop`?`stopped`:e===`start`?`started`:`restarted`}`)}})}const zl=Rl(`restart`),Bl=Rl(`start`),Vl=Rl(`stop`),Hl=N({name:`services logs`,args:`<service>`,summary:`Show a service's log (journald, else its log file).`,options:{server:P,follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},async run([e],t){e||D(`usage: lf services logs <service>`);let{server:n}=$(t);return Dt(n,[pi(e,{lines:Re(t.tail,`--tail`,1,1e5),follow:t.follow})])}}),Ul=e=>e.fingerprint.replace(/^SHA256:/,``).slice(0,12),Wl=N({name:`keys`,summary:`List the SSH keys that may log in, per user. ★ marks the key this machine uses.`,options:{server:P,json:Al},async run(e,t){let{server:n}=$(t),r=await ui(n);if(t.json)return void console.log(JSON.stringify(r.keys,null,2));r.keys.length||T(S.dim(`no authorized keys found`)),M([[``,`user`,`key`,`comment`].map(S.dim),...r.keys.map(e=>[e.mine?S.green(`★`):``,e.user,`${e.type.replace(/^ssh-/,``)} ${S.dim(Ul(e))}`,`${e.comment||S.dim(`(no comment)`)}${e.options?S.dim(` [${e.options}]`):``}`])]),Fl(r)}});function Gl(e){let t=Ee(e),n=!/^\S+ AAAA/.test(e)&&o.existsSync(t)?o.readFileSync(t,`utf8`).trim().split(`
|
|
1250
|
+
`)[0]:e.trim();return oi(n)||D(`not a public key: ${e} (a line like ssh-ed25519 AAAA… or a .pub file)`),n}const Kl=N({name:`keys add`,args:`[key|file.pub]`,summary:`Let another SSH key log in (to the login user, or --user).
|
|
1203
1251
|
|
|
1204
1252
|
With --generate lf makes a new pair at ~/.ssh/<server>_<user> (asking for a
|
|
1205
1253
|
passphrase in a terminal) and adds its public key: hand the private key to
|
|
1206
|
-
whoever logs in as that user, or keep it if that is you.`,options:{server:
|
|
1207
|
-
Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:
|
|
1254
|
+
whoever logs in as that user, or keep it if that is you.`,options:{server:P,user:{type:`string`,short:`u`,desc:`whose authorized_keys (default: the login user)`},generate:{type:`boolean`,desc:`make a new key pair here instead of passing one`}},async run([e],t){!e&&!t.generate&&D(`usage: lf keys add <key|file.pub>, or --generate`);let{name:n,server:r}=$(t),i=Y(r),a=t.user??i.user,o=t.generate?qi(n,a):void 0,s=o?o.pub:Gl(e);await hi(await Q(r,`adding a key`),a,s);let c=oi(s);w(`${c.comment||Ul(c)} may log in to ${n} as ${a}`),o&&(T(`${o.created?`private key`:`used the existing key`}: ${L(o.path)}`),T(`log in: ssh -i ${L(o.path)}${i.port===22?``:` -p ${i.port}`} ${a}@${i.hostname}`),o.created&&!o.passphrase&&T(S.dim(`it has no passphrase; add one with ssh-keygen -p -f ${L(o.path)}`)),ue({keyFile:o.path}))}}),ql=N({name:`keys rm`,args:`<fingerprint|comment>`,summary:"Remove an SSH key: by (part of) its fingerprint or its comment, as `lf keys` shows them.",options:{server:P,user:{type:`string`,short:`u`,desc:`only this user's keys`},force:{type:`boolean`,desc:`even the key this machine logs in with`}},async run([e],t){e||D(`usage: lf keys rm <fingerprint|comment>`);let{name:n,server:r}=$(t),i=await ui(r),a=e.replace(/^SHA256:/,``),o=i.keys.filter(n=>(!t.user||n.user===t.user)&&(n.comment===e||a.length>=6&&n.fingerprint.slice(7).startsWith(a)));o.length||D(`no key ${e}${t.user?` for ${t.user}`:``} on ${n}: see lf keys`),o.length>1&&D(`${e} matches ${o.length} keys (${o.map(e=>`${e.user} ${Ul(e)}`).join(`, `)}): give more of the fingerprint or --user`);let s=o[0];s.mine&&!t.force&&!await pe(`${s.comment||Ul(s)} is the key this machine logs in with: removing it can lock you out. Do it anyway?`)&&D(`that is the key this machine logs in with (--force to remove it anyway)`),await A(`Remove key ${s.comment||Ul(s)} of ${s.user} on ${n}?`),await gi(await Q(r,`removing a key`),s),w(`removed ${s.comment||Ul(s)} from ${s.user}`)}}),Jl=N({name:`bans`,summary:`Show what fail2ban blocks: banned IPs per jail.`,options:{server:P,json:Al},async run(e,t){let{name:n,server:r}=$(t),i=await ui(r);if(t.json)return void console.log(JSON.stringify({fail2ban:i.fail2ban,jails:i.jails},null,2));if(i.fail2ban!==`running`)return!i.root&&i.fail2ban===`stopped`&&D(`${i.user} cannot read fail2ban without passwordless sudo`),T(i.fail2ban===`missing`?`fail2ban is not installed on ${n}`:`fail2ban is not running on ${n}`);for(let e of i.jails){console.log(`${S.bold(e.name)}: ${e.banned} banned now${S.dim(` · ${e.total} in total · ${e.failed} failing`)}`);for(let t of e.ips)console.log(` ${t}`)}i.jails.some(e=>e.ips.length)&&T(S.dim(`lf unban <ip> lets one in again`))}}),Yl=N({name:`unban`,args:`<ip>`,summary:`Let a banned IP in again (all jails that ban it). Undo bans it again.`,options:{server:P},async run([e],t){e||D(`usage: lf unban <ip>`);let{name:n,server:r}=$(t),i=await Q(r,`unbanning`),a=(await li(i)).jails.filter(t=>t.ips.includes(e)).map(e=>e.name);a.length||D(`${e} is not banned on ${n}`),await vi(i,e,a),w(`unbanned ${e} (${a.join(`, `)})`)}}),Xl=N({name:`swap`,args:`[size|off]`,summary:`Show swap, add a swap file (e.g. 2G) or remove the one landfall made (off).`,options:{server:P,json:Al},async run([e],t){let{name:n,server:r}=$(t),i=await li(r);if(!e){if(t.json)return void console.log(JSON.stringify({swap:i.swap,swappiness:i.swappiness,mem:i.mem},null,2));if(!i.swap.length)return T(`no swap on ${n}${S.dim(` · ${X(i.mem)} memory · lf swap 2G adds a swap file`)}`);M([[`file`,`size`,`used`].map(S.dim),...i.swap.map(e=>[e.file,X(e.size),X(e.used)])]);return}i.managed&&D(i.managed);let a=await Q(r,`changing swap`);if(e===`off`){let e=i.swap.find(e=>e.file===`/swapfile`);return e?.used&&E(`${X(e.used)} of it is in use: it moves back to memory`),await A(`Remove the swap file /swapfile on ${n}?`),C(`turning swap off`),await wi(a),w(`removed /swapfile`)}let o=yi(e);o<64*1024**2&&D(`at least 64M`),i.diskFree&&o>i.diskFree-2*1024**3&&D(`only ${X(i.diskFree)} free on /: keep 2 GB besides the swap file`),C(`adding ${X(o)} swap at /swapfile`),await Si(a,o),w(`${X(o)} swap on ${n}`)}}),Zl=N({name:`hostname`,args:`[name]`,summary:`Show or change the host name.`,options:{server:P},async run([e],t){let{name:n,server:r}=$(t),i=await li(r);if(!e)return void console.log(i.hostname);if(e===i.hostname)return w(`already ${e}`);i.managed&&D(i.managed),await Di(await Q(r,`renaming the host`),i.hostname,e),w(`${n} is now called ${e}${S.dim(` (the shell prompt shows it on the next login)`)}`)}}),Ql=N({name:`timezone`,args:`[zone]`,summary:`Show or change the time zone (e.g. Europe/Berlin, UTC). Cron jobs and logs follow it.`,options:{server:P},async run([e],t){let{name:n,server:r}=$(t),i=await li(r);if(!e)return void console.log(`${i.timezone}${S.dim(` · ${i.time}`)}`);if(e===i.timezone)return w(`already ${e}`);i.managed&&D(i.managed),await Ai(await Q(r,`changing the time zone`),i.timezone,e),w(`${n}: time zone ${e}`)}}),$l=N({name:`log-limit`,args:`[size|off]`,summary:`Show or cap how much log Docker keeps per container (e.g. 10m, with --files 3 rotated files).
|
|
1255
|
+
Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:P,files:{type:`string`,desc:`rotated files per container`,default:`3`},restart:{type:`boolean`,desc:`restart docker now (sites go down for a moment); without, it applies on its next start`},json:Al},async run([e],t){let{name:n,server:r}=$(t),i=await li(r),a=i.docker;if(a||D(`no docker on ${n}`),!e){if(t.json)return void console.log(JSON.stringify({driver:a.driver,maxSize:a.maxSize,maxFile:a.maxFile},null,2));console.log(a.maxSize?`${a.driver}: ${a.maxSize} × ${a.maxFile??1} per container`:`${a.driver}: ${S.yellow(`no limit`)}`);return}i.managed&&D(i.managed),a.daemonError&&D(`${a.daemonError}: fix it first`);let o=e===`off`?void 0:e.toLowerCase();o&&!ji.test(o)&&D(`not a size: ${e} (e.g. 10m, 500k, 1g)`);let s=Re(t.files,`--files`,1,100);[`json-file`,`local`].includes(a.driver)||E(`docker logs to ${a.driver}: the limit only takes effect for json-file and local`);let c=!!t.restart&&await j(`Restart docker on ${n}? Every site goes down for a moment.`,!1);await Pi(await Q(r,`changing docker's log limit`),Mi(a.daemon,o,s),o?`docker log limit ${o} × ${s}`:`docker log limit off`,c),w(o?`docker keeps at most ${o} × ${s} log per container`:`docker log limit removed`),T(S.dim(c?`new containers get it: redeploy or lf restart sites to apply it to them`:`takes effect when docker restarts (--restart, or a reboot), for containers created after that`))}}),eu=N({name:`security`,summary:`Check a server's security (ssh passwords, firewall, fail2ban, automatic updates, pending reboot) and say how to fix what is open.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=$(t),{advice:i,...a}=await Ui(n,r);if(t.json)return void console.log(JSON.stringify({...a.security,fail2ban:a.fail2ban,advice:i},null,2));let o=a.security;M([[S.dim(`ssh login`),o.passwordLogin===void 0?S.dim(`unknown`):o.passwordLogin?S.red(`passwords and keys`):S.green(`keys only`)],[S.dim(`root login`),o.rootLogin===void 0?S.dim(`unknown`):o.rootLogin===`yes`?S.yellow(`yes`):o.rootLogin===`no`?`no`:`key only`],[S.dim(`firewall`),o.firewall===void 0?S.dim(`unknown`):o.firewall===`active`?S.green(`on`):S.yellow(o.firewall===`missing`?`no ufw`:`off`)],[S.dim(`fail2ban`),a.fail2ban===`running`?S.green(`running`):S.yellow(a.fail2ban===`missing`?`not installed`:`stopped`)],[S.dim(`auto updates`),o.autoUpdates===void 0?S.dim(`unknown`):o.autoUpdates?S.green(`on`):S.yellow(`off`)],[S.dim(`reboot`),o.reboot?S.yellow(`pending`):`not needed`]]),ue({advice:i}),console.log(),i.length?Hi(i):w(`nothing to fix`),Fl(a)}}),tu=N({name:`harden`,args:`[ssh|fail2ban|updates…]`,summary:`Close the usual holes; all three without arguments:
|
|
1208
1256
|
ssh keys only: no passwords, root by key only
|
|
1209
1257
|
fail2ban install and start it: bans addresses that keep failing to log in
|
|
1210
|
-
updates install security updates automatically (apt, dnf)
|
|
1211
|
-
Each change is journaled: lf undo takes it back.`,options:{server:
|
|
1212
|
-
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, else the next free one)`},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-Bbkw_Rxx.mjs`);return n({port:t.port===void 0?void 0:
|
|
1258
|
+
updates install security updates automatically (apt, apk, dnf)
|
|
1259
|
+
Each change is journaled: lf undo takes it back.`,options:{server:P,force:{type:`boolean`,desc:`turn ssh passwords off even if users without a key get locked out`}},async run(e,t){let n=e.filter(e=>!Fi.includes(e));n.length&&D(`unknown: ${n.join(`, `)} (ssh, fail2ban or updates)`);let r=e.length?e:[...Fi],{name:i,server:a}=$(t),o=await Q(a,`hardening`);if(r.includes(`ssh`)&&!t.force){let e=await Bi(o,Y(a).user);e.length&&D(`${Li(e)} Or --force.`)}(!r.includes(`ssh`)||await j(`Only keys log in to ${i} afterwards (lf undo turns passwords back on). Go on?`))&&(await Ki(o,r),w(`${i}: ${r.join(`, `)} hardened`))}}),nu=N({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
|
|
1260
|
+
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, else the next free one)`},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-Bbkw_Rxx.mjs`);return n({port:t.port===void 0?void 0:Re(t.port,`--port`,1,65535),open:t.open})}}),ru={current:S.green,update:S.yellow,unknown:S.dim,pinned:S.dim,rebuild:S.dim,foreign:S.dim,missing:S.red};function iu(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return T(`packages: no supported package manager`);let r=e.updates.length?`${S.bold(String(e.updates.length))} package updates${n?`, ${S.yellow(`${n} security`)}`:``}`:S.green(`packages up to date`);console.log(`${r}${e.reboot?S.yellow(` · reboot required`):``}${e.refreshed?``:S.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);M(i.map(e=>[` `+e.name,S.dim(e.from??``),`→ `+e.to,e.security?S.yellow(`security`):``])),i.length<e.updates.length&&console.log(S.dim(` … ${e.updates.length-i.length} more (--all)`))}function au(e){e.length&&(console.log(`\n${S.bold(`Images`)}`),M(e.map(e=>[` `+e.name,S.dim(e.ref||`-`),ru[e.state](e.state===`update`?`newer image`:e.state),S.dim(e.note??``)])))}function ou(e){let t=fa(e);console.log(`\n${S.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||S.red(`missing`)}`:S.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+S.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(S.dim(` newest releases`))}const su=N({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:P,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);if(t.json)return void console.log(JSON.stringify(await Rc(n,t.refresh),null,2));let r=await Xi(n,t.refresh),i=await ha(n),a=await da(n);iu(r,t.all),ou(a),au(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(S.dim(`\napply with: lf update${o}`)),fa(a).composeUpdate&&console.log(S.dim(`newest compose: lf update --compose${o}`))}}),cu=N({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
|
|
1213
1261
|
|
|
1214
1262
|
Without --packages/--images both are updated. Package upgrades cannot be undone;
|
|
1215
1263
|
image updates can (\`lf undo\` puts the old image back). --compose replaces the
|
|
1216
|
-
compose plugin setup installed with the newest release (undoable too).`,options:{server:
|
|
1217
|
-
`);e=n.pop(),e.length>
|
|
1218
|
-
`)[0]}`)}}t.push(``,
|
|
1219
|
-
`))}async function
|
|
1264
|
+
compose plugin setup installed with the newest release (undoable too).`,options:{server:P,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf/yum); implies --packages`},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)&&D(`--compose goes alone`);let i=await Q(r,`updating docker compose`),a=await da(i),o=fa(a);if(!o.composeUpdate)return void(o.composeNote?T(o.composeNote):w(`compose ${a.compose} is the newest`));await j(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(C(`downloading docker compose`),await pa(i),w(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images&&!t.security&&!t.reboot;e.length&&t.packages&&D(`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 Q(r,`upgrading packages`),i=await Xi(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?E(`no supported package manager, skipping packages`):a.length?(iu({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&D(`${i.mgr} has no security-only upgrades here; drop --security`),await j(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(C(`upgrading packages on ${n}`),await Qi(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),w(`packages upgraded`),t.reboot&&T(`rebooting if required; give it a minute`))):w(t.security?`no security updates`:`packages up to date`)}if(o){let t=await ha(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&D(`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`))T(S.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(au(a),await j(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(C(`updating images`),await ga(r,a),w(`images updated (lf undo puts the old ones back)`))):w(`images up to date`)}}}),lu=()=>[process.execPath,...process.execArgv,process.argv[1]];function uu(){let e=Number(process.env.LANDFALL_PARENT);delete process.env.LANDFALL_PARENT,e&&setInterval(()=>{if(process.ppid!==e)try{process.kill(-process.pid,`SIGTERM`)}catch{process.exit(1)}},1e3).unref()}const du=5e3,fu=1e4,pu=e=>e.length>fu?e.slice(0,fu)+`…`:e;function mu(e,t,r={}){let i={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,LANDFALL_PARENT:String(process.pid),...r.env};r.password&&Object.assign(i,Jr(r.password));let a=n(lu()[0],[...lu().slice(1),...e],{stdio:[r.stdin===void 0?`ignore`:`pipe`,`pipe`,`pipe`],env:i,cwd:r.cwd,detached:!0});a.stdin?.on(`error`,()=>{}).end(r.stdin);let o={argv:e,lines:[],dropped:0,report:{},stop(){if(o.code===void 0){o.stopped=!0;try{process.kill(-a.pid,`SIGTERM`)}catch{}}}},s,c=()=>s??=setTimeout(()=>(s=void 0,t()),50),l=()=>{let e=``;return{push(t){let n=(e+t).split(`
|
|
1265
|
+
`);e=n.pop(),e.length>fu&&(n.push(e),e=``);for(let e of n)if(e.startsWith(``))try{Object.assign(o.report,JSON.parse(e.slice(1)))}catch{}else o.lines.push(pu(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));o.lines.length>du&&(o.dropped+=o.lines.splice(0,o.lines.length-du).length),c()},flush:()=>e&&o.lines.push(pu(e))}},u=l(),d=l();return a.stdout.setEncoding(`utf8`).on(`data`,u.push),a.stderr.setEncoding(`utf8`).on(`data`,d.push),a.on(`error`,e=>{o.lines.push(`error: ${e.message}`),o.code=1,t()}),a.on(`exit`,()=>o.stopped&&(a.stdout.destroy(),a.stderr.destroy())),a.on(`close`,e=>{u.flush(),d.flush(),o.code=o.stopped?null:e??1,clearTimeout(s),t()}),o}var hu=`0.6.5`;const gu=[el,sl,al,go,_o,vo,Eo,Co,bo,xo,So,To,wo,Do,Uc,Wc,Gc,su,cu,Es,Ds,Ns,pl,hl,xl,wl,El,Ol,kl,Il,eu,tu,Ll,zl,Bl,Vl,Hl,Wl,Kl,ql,Jl,Yl,Xl,Zl,Ql,$l,Fs,zs,js,Os,As,Ms,oo,so,lo,uo,fo,co,po,Jo,Yo,Xo,Ko,qo,Qo,Zo,hs,gs,Xc,Yc,nu],_u={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`,uninstall:`server uninstall`,"traefik remove":`traefik 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`,"sites tunnel":`tunnel`,upgrade:`update`,"server stats":`stats`,"server disk":`disk`,"server traffic":`traffic`,"server certs":`certs`,"sites check":`check`,"server prune":`prune`,"server reboot":`reboot`,"server system":`system`,"services ls":`services`,"services list":`services`,"keys ls":`keys`,"keys list":`keys`,"keys remove":`keys rm`,"bans unban":`unban`,"server swap":`swap`,"server hostname":`hostname`,"server timezone":`timezone`},vu=new Map(gu.map(e=>[e.name,e]));function yu(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=vu.get(_u[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function bu(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`stats`,`disk`,`prune`,`reboot`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`System`,[`system`,`services`,`services restart`,`services start`,`services stop`,`services logs`,`keys`,`keys add`,`keys rm`,`bans`,`unban`,`swap`,`hostname`,`timezone`,`log-limit`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Monitoring`,[`traffic`,`certs`,`check`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`landfall`,[`config`,`ui`]]],t=[`${S.bold(`landfall`)} ${S.dim(hu)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${S.bold(`Usage:`)} lf <command> [options] ${S.dim(`lf <command> --help for details`)}`,` lf ui ${S.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,S.bold(n));for(let e of r){let n=vu.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
|
|
1266
|
+
`)[0]}`)}}t.push(``,S.bold(`Quick start`),` lf setup vps --check ${S.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${S.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${S.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${S.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${S.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${S.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${S.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${S.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${S.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${S.dim(`# what landfall changed, and revert the last of it`)}`,``,S.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
|
|
1267
|
+
`))}async function xu(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return bu(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(hu),0;let{cmd:t,rest:n}=yu(e);if(!t)return console.error(`${S.red(`error:`)} unknown command "${e.join(` `)}"\n`),bu(),2;let{values:r,positionals:i,rest:a}=ve(t,n);if(r.help)return console.log(ye(t)),0;k(!!r.yes),kt.cmd=be(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Su(e){try{return await xu(e)}catch(e){return e instanceof le?(console.error(`${S.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&ue({force:e.message})):console.error(e),1}}uu(),Su(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{H as A,F as B,ur as C,G as D,Ze as E,I as F,ge as H,Ee as I,R as L,Te as M,je as N,W as O,Me as P,B as R,cr as S,gt as T,S as V,Ts as _,jc as a,ws as b,Mc as c,Fc as d,Nc as f,oc as g,Rc as h,Vc as i,U as j,V as k,Ac as l,Bc as m,mu as n,Pc as o,Ic as p,Jc as r,zc as s,hu as t,Lc as u,xs as v,Ot as w,Er as x,Cs as y,z};
|