landfall 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,22 +1,22 @@
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 _}from"node:net";import{parse as re}from"yaml";import{X509Certificate as v,createHash as ie,randomBytes as ae}from"node:crypto";import oe from"bcryptjs";import y from"node:http";import se from"node:https";const b=async()=>{process.stdin.isTTY&&O(`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 D=class extends Error{};function le(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const O=e=>{throw new D(e)};let ue=!1;const de=e=>ue=e,fe=()=>!ue&&!!process.stdin.isTTY;async function pe(e,t){if(ue||!process.stdin.isTTY){if(t!==void 0)return t;O(`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 me(e){return ue||!process.stdin.isTTY?!1:A(e,!1)}async function k(e,t){ue||(process.stdin.isTTY||O(`${e} Pass -y to confirm (no terminal to ask).`),t?await pe(`${e} Type ${S.bold(t)} to confirm:`)!==t&&O(`cancelled`):await A(e,!1)||O(`cancelled`))}async function A(e,t=!0){return ue?!0:(process.stdin.isTTY||O(`${e} Pass -y to confirm (no terminal to ask).`),(await pe(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function j(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 he=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 ge(e){(ue||!process.stdin.isTTY)&&O(`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 D(`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 _e(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const M=e=>e,N={type:`string`,short:`s`,desc:`server name or ssh alias`},ve={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},ye=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function be(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({...ve,...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[ye(e)]&&(n[e]=process.env[ye(e)]);return{values:n,positionals:r,rest:a}}catch(e){return O(`${e.message}\n\n${xe(t)}`)}}function xe(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:ve.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 P(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 Se=()=>L().defaults??{},Ce=e=>e.tunnelOnly??Se().tunnelOnly??!1,we=e=>process.env[`LANDFALL_${e}`],Te=process.env.XDG_CONFIG_HOME??m(c(),`.config`),Ee=we(`CONFIG_DIR`)??m(Te,`landfall`),F=m(Ee,`config.json`),De=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,I=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function L(){try{let e=JSON.parse(o.readFileSync(F,`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:{}}:O(`cannot read ${F}: ${e.message}`)}}function Oe(e){o.mkdirSync(p(F),{recursive:!0,mode:448}),o.writeFileSync(F,JSON.stringify(e,null,2)+`
6
- `,{mode:384})}function ke(e){let t=L();delete t.servers[e],t.default===e&&delete t.default,Oe(t)}function Ae(e){let t=L();t.servers[e]??={host:e},t.default=e,Oe(t)}function R(e){let t=L();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?O("no server given. Use --server <ssh-alias> or `lf server add`"):O(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}(!/^[A-Za-z0-9._@:\[\]-]+$/.test(e)||e.startsWith(`-`))&&O(`not a server name: ${e}`);let n=t.servers[e]??{host:e};return{name:e,server:n}}const je=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||O(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),Me=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Ne=/^[A-Za-z0-9._@-]{1,64}$/,Pe=(e,t=`user`)=>(Ne.test(e)||O(`${t} may only have letters, digits and . _ @ -`),e),Fe=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||O(`not an e-mail address: ${e}`),e),Ie=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return Me.test(t)||O(`not a domain: ${e}`),t},Le=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Re=e=>Le.test(e)?e:O(`not an image: ${e}`);function z(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)&&O(`${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??O("`@` 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(!Me.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
- `)}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
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 ae,randomBytes as oe}from"node:crypto";import se from"bcryptjs";import v from"node:http";import ce from"node:https";const y=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},le=process.stdout.isTTY&&!process.env.NO_COLOR,b=e=>t=>le?`\x1b[${e}m${t}\x1b[0m`:t,x={bold:b(`1`),dim:b(`2`),red:b(`31`),green:b(`32`),yellow:b(`33`),blue:b(`34`),cyan:b(`36`)},S=e=>console.error(`${x.cyan(`==>`)} ${x.bold(e)}`),C=e=>console.error(`${x.green(`✓`)} ${e}`),w=e=>console.error(` ${e}`),T=e=>console.error(`${x.yellow(`!`)} ${e}`);var E=class extends Error{};function ue(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const D=e=>{throw new E(e)};let de=!1;const fe=e=>de=e,pe=()=>!de&&!!process.stdin.isTTY;async function me(e,t){if(de||!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(`${x.blue(`?`)} ${e}${t?x.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function he(e){return de||!process.stdin.isTTY?!1:k(e,!1)}async function O(e,t){de||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await me(`${e} Type ${x.bold(t)} to confirm:`)!==t&&D(`cancelled`):await k(e,!1)||D(`cancelled`))}async function k(e,t=!0){return de?!0:(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),(await me(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function A(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 ge=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 _e(e){(de||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${x.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 E(`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 ve(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const j=e=>e,M={type:`string`,short:`s`,desc:`server name or ssh alias`},ye={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},be=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function xe(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({...ye,...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[be(e)]&&(n[e]=process.env[be(e)]);return{values:n,positionals:r,rest:a}}catch(e){return D(`${e.message}\n\n${Se(t)}`)}}function Se(e){let t=[`${x.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,x.bold(`Options:`));let n=Object.entries({...e.options,yes:ye.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?x.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?x.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 N(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 Ce=()=>I().defaults??{},we=e=>e.tunnelOnly??Ce().tunnelOnly??!1,Te=e=>process.env[`LANDFALL_${e}`],Ee=process.env.XDG_CONFIG_HOME??m(c(),`.config`),De=Te(`CONFIG_DIR`)??m(Ee,`landfall`),P=m(De,`config.json`),Oe=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,F=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function I(){try{let e=JSON.parse(o.readFileSync(P,`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 ${P}: ${e.message}`)}}function ke(e){o.mkdirSync(p(P),{recursive:!0,mode:448}),o.writeFileSync(P,JSON.stringify(e,null,2)+`
6
+ `,{mode:384})}function Ae(e){let t=I();delete t.servers[e],t.default===e&&delete t.default,ke(t)}function je(e){let t=I();t.servers[e]??={host:e},t.default=e,ke(t)}function L(e){let t=I();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 Me=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||D(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),Ne=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Pe=/^[A-Za-z0-9._@-]{1,64}$/,Fe=(e,t=`user`)=>(Pe.test(e)||D(`${t} may only have letters, digits and . _ @ -`),e),Ie=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||D(`not an e-mail address: ${e}`),e),Le=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return Ne.test(t)||D(`not a domain: ${e}`),t},Re=/^[a-z0-9][a-z0-9._\-/:@]*$/i,ze=e=>Re.test(e)?e:D(`not an image: ${e}`);function R(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 Be=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Ve=e=>e.domains?.[0];function He(e,t){let n=Ve(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 Ue=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),We=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 Ge(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!Ne.test(n))continue;let e=We(n);Ue.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 Ke=`.landfall.json`,qe=e=>m(e,Ke);function z(e){try{return JSON.parse(o.readFileSync(qe(e),`utf8`))}catch{return}}function Je(e,t){o.writeFileSync(m(e,Ke),JSON.stringify(t,null,2)+`
7
+ `)}const B=`/opt/landfall`,V=`${B}/sites`,H=`${B}/traefik`,Ye=`${H}/dynamic`,Xe=8080,Ze=`dashboard-tunnel`,Qe=`traefik:v3`,U=`${B}/proxy.env`,W=`${B}/journal.log`,$e=`/etc/ssh/sshd_config.d/00-landfall.conf`,et={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},tt=`"$(sed -n 's/^container=//p' ${U} 2>/dev/null | grep . || echo traefik)"`;function nt(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 rt=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`,it=`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
10
10
  productversion=; . /etc/VERSION 2>/dev/null
11
11
  ID=dsm; VERSION_ID=$productversion; PRETTY_NAME="Synology DSM $productversion"; kv platform synology
12
12
  elif [ "$ID" = steamos ]; then PRETTY_NAME="SteamOS $VERSION_ID"; kv platform steamos
13
- fi`,K=String.raw`
13
+ fi`,at=String.raw`pkg_mgr() { for m in apt-get dnf yum apk zypper; do command -v $m >/dev/null 2>&1 && { echo $m; return 0; }; done; echo none; }`,G=String.raw`
14
14
  log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
15
15
  SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
16
16
  # Files a container wrote may be root's.
17
17
  rmf() { rm -rf "$@" 2>/dev/null || $SUDO rm -rf "$@"; }
18
18
  # The distro's package manager, and installing with it.
19
- pkg_mgr() { for m in apt-get dnf yum apk zypper; do command -v $m >/dev/null 2>&1 && { echo $m; return 0; }; done; echo none; }
19
+ ${at}
20
20
  pkg_has() {
21
21
  case "$(pkg_mgr)" in
22
22
  apt-get) dpkg -s "$1" ;;
@@ -35,13 +35,13 @@ pkg_install() {
35
35
  *) log "no apt, dnf, yum, apk or zypper to install $*"; return 1 ;;
36
36
  esac
37
37
  }
38
- # What setup installs. RHEL-likes and SUSE have no ufw/fail2ban in their base repos.
38
+ # What setup installs (rsync: redeploys send only what changed). RHEL-likes and SUSE have no ufw/fail2ban in their base repos.
39
39
  base_pkgs() {
40
40
  case "$(pkg_mgr)" in
41
- apt-get) echo "ca-certificates curl ufw fail2ban unattended-upgrades" ;;
42
- apk) echo "ca-certificates curl ufw" ;;
41
+ apt-get) echo "ca-certificates curl rsync ufw fail2ban unattended-upgrades" ;;
42
+ apk) echo "ca-certificates curl rsync ufw" ;;
43
43
  none) ;;
44
- *) echo "ca-certificates curl" ;;
44
+ *) echo "ca-certificates curl rsync" ;;
45
45
  esac
46
46
  }
47
47
  # Resolved on first use: bootstrap sources this before docker is installed.
@@ -71,19 +71,19 @@ _clean() { printf %s "$1" | tr '\t\n' ' '; }
71
71
  _journal() { # _journal <do|undo> <what> <undo shell>
72
72
  line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$LANDFALL_RUN" "$(_clean "$LANDFALL_ACTOR")" "$(id -un)" "$(_clean "$LANDFALL_CMD")" "$1" "$(_clean "$2")")
73
73
  line="$line$(printf %s "$3" | base64 | tr -d '\n')"
74
- if [ -w ${G} ] || { [ ! -e ${G} ] && [ -w ${V} ]; }; then
75
- printf '%s\n' "$line" >> ${G}
74
+ if [ -w ${W} ] || { [ ! -e ${W} ] && [ -w ${B} ]; }; then
75
+ printf '%s\n' "$line" >> ${W}
76
76
  elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
77
77
  else
78
- { $SUDO install -d ${V} && printf '%s\n' "$line" | $SUDO tee -a ${G} >/dev/null; } 2>/dev/null
79
- fi || { log "warning: could not write ${G}"; return 0; }
78
+ { $SUDO install -d ${B} && printf '%s\n' "$line" | $SUDO tee -a ${W} >/dev/null; } 2>/dev/null
79
+ fi || { log "warning: could not write ${W}"; return 0; }
80
80
  # A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
81
- # append; it belongs to whoever owns ${V}.
82
- [ -O ${G} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${V})" ${G} 2>/dev/null || true
81
+ # append; it belongs to whoever owns ${B}.
82
+ [ -O ${W} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${B})" ${W} 2>/dev/null || true
83
83
  }
84
84
  # record <what> [undo shell]: journal a change; no undo shell = not reversible.
85
85
  record() { _journal do "$1" "$(shift; printf %s "$*")"; }
86
- # Run as $SUDO only when <dir> is not writable: deploy users own ${V} and need no sudo.
86
+ # Run as $SUDO only when <dir> is not writable: deploy users own ${B} and need no sudo.
87
87
  _as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
88
88
  # saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
89
89
  saved() {
@@ -123,7 +123,35 @@ ufw_enable() {
123
123
  *inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
124
124
  esac
125
125
  }
126
- `,it=String.raw`
126
+ `,ot=String.raw`
127
+ log "hardening sshd (keys only)"
128
+ # Never loosen an admin's stricter root login setting.
129
+ root_login=prohibit-password
130
+ if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
131
+ # 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
132
+ want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
133
+ undo=""
134
+ if [ "$($SUDO cat ${$e} 2>/dev/null || true)" != "$want" ]; then
135
+ $SUDO install -d /etc/ssh/sshd_config.d
136
+ undo=$(saved ${$e})
137
+ printf '%s\n' "$want" | $SUDO tee ${$e} >/dev/null
138
+ fi
139
+ if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
140
+ if [ -n "$undo" ]; then undo="$undo; "; fi
141
+ undo="$undo$(saved /etc/ssh/sshd_config)"
142
+ printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
143
+ fi
144
+ if [ -z "$undo" ]; then :
145
+ elif $SUDO sshd -t; then
146
+ sshd_reload
147
+ record "hardened sshd (keys only, root login $root_login)" "$undo; sshd_reload"
148
+ else
149
+ log "sshd config test failed, putting the old config back"
150
+ eval "$undo"
151
+ fi
152
+ pw=$($SUDO sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n')
153
+ [ -z "$pw" ] || [ "$pw" = no ] || log "warning: sshd still accepts passwords; a Match block or another file in /etc/ssh overrides ${$e} (check: sudo sshd -T)"
154
+ `,st=String.raw`
127
155
  compose_install() {
128
156
  arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
129
157
  f=${`/usr/local/lib/docker/cli-plugins/docker-compose`}
@@ -134,27 +162,27 @@ compose_install() {
134
162
  $SUDO mv $f.new $f
135
163
  record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
136
164
  }
137
- `,at=`This server is run by landfall (https://www.npmjs.com/package/landfall).
165
+ `,ct=`This server is run by landfall (https://www.npmjs.com/package/landfall).
138
166
  Everything here works without it: it is plain Docker Compose.
139
167
 
140
- ${U}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
141
- ${H}/<site>/ one Compose project per site
168
+ ${H}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
169
+ ${V}/<site>/ one Compose project per site
142
170
  .env the site's environment (lf env), mode 600
143
171
  site.json landfall's notes: domains, releases
144
172
  current -> releases/<id> the live release
145
173
  releases/<id>/compose.yaml the site's compose file as deployed
146
174
  releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
147
- ${G} every change landfall made, with the shell that undoes it
175
+ ${W} every change landfall made, with the shell that undoes it
148
176
 
149
177
  Run a site by hand:
150
- cd ${H}/<site>/current && docker compose ps (logs, restart, up -d, down …)
178
+ cd ${V}/<site>/current && docker compose ps (logs, restart, up -d, down …)
151
179
  Roll back by hand:
152
- cd ${H}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
180
+ cd ${V}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
153
181
 
154
182
  Named volumes (databases, uploads) belong to the project, not the release: they survive
155
183
  deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
156
- `,ot=String.raw`
157
- ${it}
184
+ `,lt=String.raw`
185
+ ${st}
158
186
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
159
187
  echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
160
188
  exit 1
@@ -216,16 +244,16 @@ else
216
244
  OWNER="$(id -un)"
217
245
  fi
218
246
 
219
- log "preparing ${V}"
220
- new_sites=$([ -d ${H} ] || echo 1)
221
- new_traefik=$([ -d ${U} ] || echo 1)
222
- $SUDO install -d -o "$OWNER" -g "$OWNER" ${V} ${H} ${U}
223
- $SUDO chown "$OWNER:$OWNER" ${G} 2>/dev/null || true
224
- $SUDO tee ${V}/README >/dev/null <<'LANDFALL_README'
225
- ${at}LANDFALL_README
226
- # ${V} itself stays: it holds the journal. Each dir is only removed if setup created it.
227
- [ -z "$new_sites" ] || record "created ${H}" "_as ${V} rmdir ${H}"
228
- [ -z "$new_traefik" ] || record "created ${U}" "_as ${V} rm -rf ${U}"
247
+ log "preparing ${B}"
248
+ new_sites=$([ -d ${V} ] || echo 1)
249
+ new_traefik=$([ -d ${H} ] || echo 1)
250
+ $SUDO install -d -o "$OWNER" -g "$OWNER" ${B} ${V} ${H}
251
+ $SUDO chown "$OWNER:$OWNER" ${W} 2>/dev/null || true
252
+ $SUDO tee ${B}/README >/dev/null <<'LANDFALL_README'
253
+ ${ct}LANDFALL_README
254
+ # ${B} itself stays: it holds the journal. Each dir is only removed if setup created it.
255
+ [ -z "$new_sites" ] || record "created ${V}" "_as ${B} rmdir ${V}"
256
+ [ -z "$new_traefik" ] || record "created ${H}" "_as ${B} rm -rf ${H}"
229
257
 
230
258
  DOCKER="$SUDO docker" ensure_network ${`web`}
231
259
 
@@ -239,42 +267,16 @@ if [ "$FIREWALL" = 1 ] && command -v ufw >/dev/null 2>&1; then
239
267
  fi
240
268
 
241
269
  if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
242
- log "hardening sshd (keys only)"
243
- # Never loosen an admin's stricter root login setting.
244
- root_login=prohibit-password
245
- if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
246
- # 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
247
- want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
248
- undo=""
249
- if [ "$($SUDO cat ${Qe} 2>/dev/null || true)" != "$want" ]; then
250
- $SUDO install -d /etc/ssh/sshd_config.d
251
- undo=$(saved ${Qe})
252
- printf '%s\n' "$want" | $SUDO tee ${Qe} >/dev/null
253
- fi
254
- if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
255
- if [ -n "$undo" ]; then undo="$undo; "; fi
256
- undo="$undo$(saved /etc/ssh/sshd_config)"
257
- printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
258
- fi
259
- if [ -z "$undo" ]; then :
260
- elif $SUDO sshd -t; then
261
- sshd_reload
262
- record "hardened sshd (keys only, root login $root_login)" "$undo; sshd_reload"
263
- else
264
- log "sshd config test failed, putting the old config back"
265
- eval "$undo"
266
- fi
267
- pw=$($SUDO sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n')
268
- [ -z "$pw" ] || [ "$pw" = no ] || log "warning: sshd still accepts passwords; a Match block or another file in /etc/ssh overrides ${Qe} (check: sudo sshd -T)"
270
+ ${ot}
269
271
  fi
270
272
  log "bootstrap done"
271
- `,st=String.raw`
273
+ `,ut=String.raw`
272
274
  set +e
273
275
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
274
276
  kv() { printf '%s=%s
275
277
  ' "$1" "$2"; }
276
278
  yn() { if "$@" >/dev/null 2>&1; then echo 1; else echo 0; fi; }
277
- ${rt}
279
+ ${it}
278
280
  kv os "$PRETTY_NAME"
279
281
  kv os_id "$ID"
280
282
  kv os_version "$VERSION_ID"
@@ -297,18 +299,18 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
297
299
  else
298
300
  kv user 0
299
301
  fi
300
- kv root_dir "$(yn test -d ${H})"
301
- kv sites "$(ls ${H} 2>/dev/null | wc -l | tr -d ' ')"
302
+ kv root_dir "$(yn test -d ${V})"
303
+ kv sites "$(ls ${V} 2>/dev/null | wc -l | tr -d ' ')"
302
304
  kv network "$(yn S docker network inspect ${`web`})"
303
- kv proxy "$(grep -v '^#' ${W} 2>/dev/null | tr '\n' ' ')"
305
+ kv proxy "$(grep -v '^#' ${U} 2>/dev/null | tr '\n' ' ')"
304
306
  if command -v ufw >/dev/null 2>&1; then kv ufw "$(S ufw status 2>/dev/null | awk 'NR == 1 {print $2}')"; else kv ufw missing; fi
305
307
  # Hardened = what sshd really uses (a Match block or an earlier file can override ours). Without
306
308
  # root, sshd -T prints nothing: then our file is all there is to go by.
307
309
  pw=$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n')
308
- if [ -f ${Qe} ] && { [ -z "$pw" ] || [ "$pw" = no ]; }; then kv hardened 1; else kv hardened 0; fi
310
+ if [ -f ${$e} ] && { [ -z "$pw" ] || [ "$pw" = no ]; }; then kv hardened 1; else kv hardened 0; fi
309
311
  kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
310
312
  kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
311
- f=${U}/docker-compose.yml
313
+ f=${H}/docker-compose.yml
312
314
  if [ ! -f "$f" ]; then kv traefik_compose none
313
315
  elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
314
316
  else kv traefik_compose foreign; fi
@@ -335,14 +337,14 @@ else
335
337
  S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
336
338
  fi
337
339
  exit 0
338
- `,ct=e=>e.replace(/\$/g,`$$$$`),lt=e=>JSON.stringify(e);function ut(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`,`--accesslog.format=json`,`--log.nocolor=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.http3&&t.push(`--entrypoints.websecure.http3=true`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return t.push(`--api.dashboard=true`,`--entrypoints.tunnel.address=${e.hostNetwork?`127.0.0.1`:``}:${Ye}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Xe}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Xe}.entrypoints=tunnel`,`traefik.http.routers.${Xe}.service=api@internal`,`traefik.http.routers.${Xe}.middlewares=tunnel-only`,"traefik.http.middlewares.tunnel-only.ipallowlist.sourcerange=127.0.0.1/32,${LANDFALL_GATEWAY:-127.0.0.1}/32"),e.dashboard&&n.push(`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${ct(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
340
+ `,dt=e=>e.replace(/\$/g,`$$$$`),ft=e=>JSON.stringify(e);function pt(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`,`--accesslog.format=json`,`--log.nocolor=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.http3&&t.push(`--entrypoints.websecure.http3=true`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return t.push(`--api.dashboard=true`,`--entrypoints.tunnel.address=${e.hostNetwork?`127.0.0.1`:``}:${Xe}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Ze}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Ze}.entrypoints=tunnel`,`traefik.http.routers.${Ze}.service=api@internal`,`traefik.http.routers.${Ze}.middlewares=tunnel-only`,"traefik.http.middlewares.tunnel-only.ipallowlist.sourcerange=127.0.0.1/32,${LANDFALL_GATEWAY:-127.0.0.1}/32"),e.dashboard&&n.push(`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${dt(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
339
341
  services:
340
342
  traefik:
341
343
  image: ${e.image}
342
344
  container_name: traefik
343
345
  restart: unless-stopped
344
346
  command:
345
- ${t.map(e=>` - ${lt(e)}`).join(`
347
+ ${t.map(e=>` - ${ft(e)}`).join(`
346
348
  `)}
347
349
  ${e.hostNetwork?` network_mode: host
348
350
  `:` ports:
@@ -355,24 +357,24 @@ ${e.http3?` - "443:443/udp"
355
357
  - ./dynamic:/etc/traefik/dynamic:ro
356
358
  ${e.hostNetwork?``:` networks:
357
359
  - web
358
- `}${n.length?` labels:\n${n.map(e=>` - ${lt(e)}`).join(`
360
+ `}${n.length?` labels:\n${n.map(e=>` - ${ft(e)}`).join(`
359
361
  `)}\n`:``}${e.hostNetwork?``:`networks:
360
362
  web:
361
363
  external: true
362
- `}`}let dt=!1;const ft=e=>dt=e,q=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,pt=(e=we(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],mt=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&O(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function ht(e){let t=[...pt(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,De(e.key),`-o`,`IdentitiesOnly=yes`),t}let gt;function _t(){return gt||(gt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(gt,{recursive:!0,mode:448})),gt}function vt(e,t={}){let n=[...ht(e),`-o`,`ServerAliveInterval=15`];return(t.batch||dt)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${_t()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,mt(e)),n}function yt(e){return new Promise(t=>{let r=[...ht(e),`-o`,`ControlPath=${_t()}/%C`,`-O`,`exit`,mt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function bt(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`,dt||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 xt=`PATH="$PATH:/usr/local/sbin:/usr/local/bin"`;async function St(e,t=[]){let{code:n}=await bt(`ssh`,[...vt(e,{tty:process.stdin.isTTY}),...t.length?[`${xt};`,...t]:[]],{});return n}const Ct=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,wt={run:process.env.LANDFALL_RUN||Ct(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function J(e,t,n={}){let r={LANDFALL_RUN:wt.run,LANDFALL_ACTOR:wt.actor,LANDFALL_CMD:wt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${q(typeof t==`boolean`?+!!t:t)}`).join(`
363
- `),a=await bt(`ssh`,[...vt(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${xt}\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
364
- `).at(-1);O(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function Tt(e){let{code:t}=await bt(`ssh`,[...vt(e,{batch:!0,noMux:!0}),`true`],{capture:!0,silent:!0});return t===0}function Et(e,t=5){let r=Date.now(),i=[...ht(e),`-o`,`ConnectTimeout=${t}`,`-o`,`BatchMode=yes`,`-o`,`ControlPath=none`,`-T`,mt(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(`
365
- `).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 Dt(e){let{code:t}=await bt(`ssh`,[...vt(e,{batch:!0}),`test -d ${V}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const Ot=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let kt;async function At(){if(!kt){let{stdout:e}=await bt(`tar`,[`--version`],{capture:!0});kt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return kt}async function jt(e,t,r,i=Ot){o.existsSync(t)||O(`directory not found: ${t}`);let a=[...await At(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`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`}}),o=n(`ssh`,[...vt(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Mt=(e,t,n=`644`)=>`printf %s ${q(Buffer.from(t).toString(`base64`))} | base64 -d > ${q(e)} && chmod ${n} ${q(e)}`,Nt=we(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Pt=/* @__PURE__ */ new Map;function Y(e){let n=[...ht(e),`-G`,mt(e)],r=Pt.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return O(`ssh cannot resolve ${mt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>De(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Pt.set(n.join(`\0`),s),s}function Ft(e){return(e.key?[De(e.key)]:Y(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function It(e=Nt,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(`
366
- `)){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=De(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of Lt(i))n.push(...It(e,t))}}return[...new Set(n)]}function Lt(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 Rt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],zt=(e,t)=>RegExp(`# >>> landfall:${qt(e)}\\n[\\s\\S]*?# <<< landfall:${qt(e)}${t}`),Bt=(e,t)=>e===`# >>> landfall:${t}`;function Vt(e){let[t,n]=Rt(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(`
367
- `)}function Ht(e,t=Nt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);je(e.alias);let i=Vt(e),a=zt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
368
- `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Pt.clear(),{created:n,block:i}}function Ut(e,t=Nt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
369
- `);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:Bt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=De(t);for(let t of Lt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Ut(e,t,n);if(r)return r}}}}function Wt(e,t){let n=Ut(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
370
- `),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&&(De(n)===t||!/[%$]/.test(n)&&!o.existsSync(De(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(`
371
- `)),Pt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Gt(e,t=Nt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=zt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const Kt=(e,t=Nt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
372
- `).some(t=>Bt(t,e)),qt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Jt=String.raw`
364
+ `}`}let mt=!1;const ht=e=>mt=e,K=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,gt=(e=Te(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],_t=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 vt(e){let t=[...gt(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,Oe(e.key),`-o`,`IdentitiesOnly=yes`),t}let yt;function bt(){return yt||(yt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(yt,{recursive:!0,mode:448})),yt}function xt(e,t={}){let n=[...vt(e),`-o`,`ServerAliveInterval=15`];return(t.batch||mt)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${bt()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`),n}const St=(e,t={})=>[...xt(e,t),_t(e)];function Ct(e){return new Promise(t=>{let r=[...vt(e),`-o`,`ControlPath=${bt()}/%C`,`-O`,`exit`,_t(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function wt(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`,mt||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 Tt=`PATH="$PATH:/usr/local/sbin:/usr/local/bin"`;async function Et(e,t=[]){let{code:n}=await wt(`ssh`,[...St(e,{tty:process.stdin.isTTY}),...t.length?[`${Tt};`,...t]:[]],{});return n}const Dt=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,Ot={run:process.env.LANDFALL_RUN||Dt(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function q(e,t,n={}){let r={LANDFALL_RUN:Ot.run,LANDFALL_ACTOR:Ot.actor,LANDFALL_CMD:Ot.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${K(typeof t==`boolean`?+!!t:t)}`).join(`
365
+ `),a=await wt(`ssh`,[...St(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${Tt}\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
366
+ `).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function J(e){let{code:t}=await wt(`ssh`,[...St(e,{batch:!0,noMux:!0}),`true`],{capture:!0,silent:!0});return t===0}function kt(e,t=5){let r=Date.now(),i=[...vt(e),`-o`,`ConnectTimeout=${t}`,`-o`,`BatchMode=yes`,`-o`,`ControlPath=none`,`-T`,_t(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(`
367
+ `).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 At(e){let{code:t}=await wt(`ssh`,[...St(e,{batch:!0}),`test -d ${B}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const jt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`].flatMap(e=>[`--exclude`,e]);let Mt;async function Nt(){if(!Mt){let{stdout:e}=await wt(`tar`,[`--version`],{capture:!0});Mt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Mt}let Pt;const Ft=()=>Pt??=wt(`rsync`,[`--version`],{capture:!0,silent:!0}).then(e=>e.code===0,()=>!1);async function It(e,t,n,r){o.existsSync(t)||D(`directory not found: ${t}`);let i=async r=>(await Lt(e,t,n),r);if(!r)return i(`tar`);if(!await Ft())return i(`tar:no-local-rsync`);let{stdout:a}=await q(e,`if ! command -v rsync >/dev/null; then echo no-rsync; elif [ -d ${K(r)} ]; then mkdir -p ${K(n)} && echo ok; fi`,{capture:!0}),s=a.trim();if(s===`no-rsync`)return i(`tar:no-server-rsync`);if(s!==`ok`)return i(`tar`);let c=[`ssh`,...xt(e)].map(K).join(` `),{code:l}=await wt(`rsync`,[`-rlptz`,`--copy-dest=${r}`,...jt,`-e`,c,`${t.replace(/\/*$/,``)}/`,`${_t(e)}:${n}/`],{});if(l!==0)throw Error(`rsync failed (exit ${l})`);return`rsync`}async function Lt(e,t,r){let i=[...await Nt(),`-czf`,`-`,...jt,`-C`,t,`.`],a=`mkdir -p ${K(r)} && tar -xzof - -C ${K(r)}`;await new Promise((t,r)=>{let o=n(`tar`,i,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),s=n(`ssh`,[...St(e),a],{stdio:[`pipe`,`inherit`,`inherit`]});o.stdout.pipe(s.stdin),o.on(`error`,r),s.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(o),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 Rt=(e,t,n=`644`)=>`printf %s ${K(Buffer.from(t).toString(`base64`))} | base64 -d > ${K(e)} && chmod ${n} ${K(e)}`,zt=Te(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Bt=/* @__PURE__ */ new Map;function Y(e){let n=[...vt(e),`-G`,_t(e)],r=Bt.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 ${_t(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>Oe(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Bt.set(n.join(`\0`),s),s}function Vt(e){return(e.key?[Oe(e.key)]:Y(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Ht(e=zt,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(`
368
+ `)){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=Oe(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of Ut(i))n.push(...Ht(e,t))}}return[...new Set(n)]}function Ut(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 Wt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],Gt=(e,t)=>RegExp(`# >>> landfall:${$t(e)}\\n[\\s\\S]*?# <<< landfall:${$t(e)}${t}`),Kt=(e,t)=>e===`# >>> landfall:${t}`;function qt(e){let[t,n]=Wt(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(`
369
+ `)}function Jt(e,t=zt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);Me(e.alias);let i=qt(e),a=Gt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
370
+ `):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Bt.clear(),{created:n,block:i}}function Yt(e,t=zt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
371
+ `);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:Kt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=Oe(t);for(let t of Ut(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Yt(e,t,n);if(r)return r}}}}function Xt(e,t){let n=Yt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
372
+ `),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&&(Oe(n)===t||!/[%$]/.test(n)&&!o.existsSync(Oe(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(`
373
+ `)),Bt.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function Zt(e,t=zt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=Gt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const Qt=(e,t=zt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
374
+ `).some(t=>Kt(t,e)),$t=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),en=String.raw`
373
375
  set +e
374
376
  kv() { printf '%s=%s\n' "$1" "$2"; }
375
- ${rt}
377
+ ${it}
376
378
  kv os "$PRETTY_NAME"
377
379
  kv id "$ID"
378
380
  kv version "$VERSION_ID"
@@ -397,10 +399,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
397
399
  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)"
398
400
  kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
399
401
  exit 0
400
- `,Yt=[`setup`,`packages`,`docker`,`compose`,`firewall`,`services`,`admin`];function Xt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
401
- `)){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:an(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function Zt(e){let{stdout:t}=await J(e,K+Jt,{capture:!0});return Xt(t)}function Qt(e,t=!1){let n=nn[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:rn,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`,!$t(e.platform,e.pkg,e.ufw)&&r,$t(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(Yt.map(e=>[e,!i[e]])),why:i}}function $t(e,t,n){if(nn[e]?.firewall)return nn[e].firewall;if(!n&&t===`none`)return`no ufw, and ${rn} to install it`}async function en(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await Tt({...e,user:i})))break;return Qt(t,r)}const tn={"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`},nn={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`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},rn=`no apt, dnf, yum, apk or zypper`;function an(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=nn[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${rn}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=tn[`${e} ${t}`]??tn[`${e} ${o}`]??tn[`${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 on=/^(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])\.)/,sn=e=>_(e)===4&&!on.test(e);async function cn(e,t){let n=Y(e).hostname,r=_(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const ln=e=>e.find(sn)??e[0],un=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function dn(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]}`},fn=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`},pn=e=>e.v!==2,mn=e=>e.project??e.name,hn=e=>pn(e)?void 0:e.releases.find(t=>t.id===e.current),gn=e=>!!(e.port||e.own),_n=e=>!!e.port&&!e.domains&&!e.own,vn=e=>e?.services.find(_n),yn=e=>e.adopted?void 0:vn(hn(e)),bn=e=>`landfall-${e}`,xn=e=>e.adopted?`adopted`:pn(e)?`old`:`managed`,Sn=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[xn(e)],Cn=e=>/^up/i.test(e),wn=e=>/^exited \(0\)/i.test(e);function Tn(e){let t=hn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...vn(t)?e.domains:[],...n])]}const En=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Dn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],On=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],kn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),An=/^traefik\.http\.routers\.[^.]+\.rule$/,jn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Mn(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])=>An.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])=>kn(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&&(gn(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(gn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(_n);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 Nn=lt,Pn=`landfall-proxy`;function Fn(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 In(e,t,n=$e){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${Nn(mn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${Nn(a.name)}:`),a.build&&r.push(` image: ${Nn(En(e.name,a.name,t.id))}`);let o={};gn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${Nn(e)}: {}`),` ${Pn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Fn(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])=>` ${Nn(e)}: ${Nn(ct(t))}`))}return i&&r.push(`networks:`,` ${Pn}:`,` name: ${Nn(n.network)}`,` external: true`),r.join(`
402
+ `,tn=[`setup`,`packages`,`docker`,`compose`,`firewall`,`services`,`admin`];function nn(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
403
+ `)){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:dn(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function rn(e){let{stdout:t}=await q(e,G+en,{capture:!0});return nn(t)}function an(e,t=!1){let n=ln[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:un,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`,!on(e.platform,e.pkg,e.ufw)&&r,on(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(tn.map(e=>[e,!i[e]])),why:i}}function on(e,t,n){if(ln[e]?.firewall)return ln[e].firewall;if(!n&&t===`none`)return`no ufw, and ${un} to install it`}async function sn(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await J({...e,user:i})))break;return an(t,r)}const cn={"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`},ln={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`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},un=`no apt, dnf, yum, apk or zypper`;function dn(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=ln[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${un}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=cn[`${e} ${t}`]??cn[`${e} ${o}`]??cn[`${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 fn=/^(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])\.)/,pn=e=>re(e)===4&&!fn.test(e);async function mn(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 hn=e=>e.find(pn)??e[0],gn=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function _n(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]}`},vn=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`},yn=e=>e.v!==2,bn=e=>e.project??e.name,xn=e=>yn(e)?void 0:e.releases.find(t=>t.id===e.current),Sn=e=>!!(e.port||e.own),Cn=e=>!!e.port&&!e.domains&&!e.own,wn=e=>e?.services.find(Cn),Tn=e=>e.adopted?void 0:wn(xn(e)),En=e=>`landfall-${e}`,Dn=e=>e.adopted?`adopted`:yn(e)?`old`:`managed`,On=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Dn(e)],kn=e=>/^up/i.test(e),An=e=>/^exited \(0\)/i.test(e);function jn(e){let t=xn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...wn(t)?e.domains:[],...n])]}const Mn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Nn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],Pn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Fn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),In=/^traefik\.http\.routers\.[^.]+\.rule$/,Ln=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Rn(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])=>In.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])=>Fn(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=>!Ne.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&&(Sn(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(Sn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Cn);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 zn=ft,Bn=`landfall-proxy`;function Vn(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 Hn(e,t,n=et){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${zn(bn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${zn(a.name)}:`),a.build&&r.push(` image: ${zn(Mn(e.name,a.name,t.id))}`);let o={};Sn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${zn(e)}: {}`),` ${Bn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Vn(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])=>` ${zn(e)}: ${zn(dt(t))}`))}return i&&r.push(`networks:`,` ${Bn}:`,` name: ${zn(n.network)}`,` external: true`),r.join(`
402
404
  `)+`
403
- `}function Ln(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
405
+ `}function Un(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
404
406
  image: nginx:alpine
405
407
  restart: unless-stopped
406
408
  volumes:
@@ -409,14 +411,14 @@ exit 0
409
411
  x-landfall:
410
412
  port: 80
411
413
  `:`${n} app:
412
- ${e===`image`?`image: ${Nn(t.image)}`:`build: .`}
414
+ ${e===`image`?`image: ${zn(t.image)}`:`build: .`}
413
415
  restart: unless-stopped
414
416
  env_file:
415
417
  - path: .env
416
418
  required: false
417
419
  x-landfall:
418
420
  port: ${t.port??3e3}
419
- `}function Rn(e){return`# generated by landfall
421
+ `}function Wn(e){return`# generated by landfall
420
422
  server {
421
423
  listen 80;
422
424
  server_name _;
@@ -440,34 +442,34 @@ server {
440
442
  error_page 404 /404.html;
441
443
  location = /404.html { internal; }
442
444
  }
443
- `}const zn=[`dist`,`build`,`out`,`public`,`_site`,`site`],Bn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function Vn(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=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,Bn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,Bn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,Bn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,Bn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,Bn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,Bn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}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:Bn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function Hn(e,t=Vn(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:zn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function Un(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Wn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Gn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return Dn.find(t=>o.existsSync(m(e,t)))}function Kn(e){let t=B(e),n=t?.name??Wn(f(e)),r=Gn(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??Un(m(e,`Dockerfile`))??3e3,project:t};let a=Vn(e),s=t?.dir??Hn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const qn=m(Ee,`projects.json`);function Jn(){try{return JSON.parse(o.readFileSync(qn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function Yn(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...Jn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(Ee,{recursive:!0,mode:448}),o.writeFileSync(qn,JSON.stringify(n,null,2)+`
444
- `,{mode:384})}function Xn(e,t,n){let r=Jn();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(qn,JSON.stringify(r,null,2)+`
445
- `,{mode:384})}const Z=e=>`${H}/${e}`;function Zn(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||O(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const Qn=`
445
+ `}const Gn=[`dist`,`build`,`out`,`public`,`_site`,`site`],Kn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function qn(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=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,Kn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,Kn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,Kn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,Kn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,Kn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,Kn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}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:Kn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function Jn(e,t=qn(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:Gn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function Yn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Xn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function Zn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return Nn.find(t=>o.existsSync(m(e,t)))}function Qn(e){let t=z(e),n=t?.name??Xn(f(e)),r=Zn(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??Yn(m(e,`Dockerfile`))??3e3,project:t};let a=qn(e),s=t?.dir??Jn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const $n=m(De,`projects.json`);function er(){try{return JSON.parse(o.readFileSync($n,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function tr(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...er().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(De,{recursive:!0,mode:448}),o.writeFileSync($n,JSON.stringify(n,null,2)+`
446
+ `,{mode:384})}function nr(e,t,n){let r=er();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=z(i.path);e?.name===t&&Je(i.path,{...e,domains:n})}r.length&&o.writeFileSync($n,JSON.stringify(r,null,2)+`
447
+ `,{mode:384})}const Z=e=>`${V}/${e}`;function rr(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const ir=`
446
448
  --8<--landfall--8<--
447
- `;async function $n(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(Qn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(Qn),c;if(i.trim())try{c=JSON.parse(i)}catch{O(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:tt(o)??$e,exists:s.trim()===`1`}}async function er(e,t,n={}){let r=await $n(e,t);r.meta||O(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return pn(i)&&O(`${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&&O(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const tr=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),nr=(e,t,n)=>St(e,[`docker logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),rr=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function ir(e){return ar(e).flatMap(e=>e.hosts)}function ar(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:kn(e[`traefik.http.routers.${t}.rule`]??``)}))}const or=e=>e[`traefik.http.routers.${Xe}.service`]===`api@internal`;function sr(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])=>An.test(e)).flatMap(([,e])=>kn(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&&Cn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function cr(e,t){if(!t.length)return`not running`;let n=hn(e),r=new Set(n?.services.filter(gn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!Cn(e.status))??t.find(e=>!r.has(e.service)&&!Cn(e.status)&&!wn(e.status))??i[0]??t[0]).status}const lr=e=>pn(e)?bn(e.name):mn(e);async function ur(e){let{stdout:t}=await J(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
448
- P=${et}
449
- 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)"
450
- for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
449
+ `;async function ar(e,t){let n=Z(t),{stdout:r}=await q(e,[...[`${n}/site.json`,`${n}/.env`,U].map(e=>`cat ${K(e)} 2>/dev/null || true`),`[ ! -d ${K(n)} ] || echo 1`].join(`; printf %s ${K(ir)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(ir),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:nt(o)??et,exists:s.trim()===`1`}}async function or(e,t,n={}){let r=await ar(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return yn(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 sr=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),cr=(e,t,n)=>Et(e,[`docker logs --tail ${K(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),lr=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function ur(e){return dr(e).flatMap(e=>e.hosts)}function dr(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:Fn(e[`traefik.http.routers.${t}.rule`]??``)}))}const fr=e=>e[`traefik.http.routers.${Ze}.service`]===`api@internal`;function pr(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])=>In.test(e)).flatMap(([,e])=>Fn(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&&kn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function mr(e,t){if(!t.length)return`not running`;let n=xn(e),r=new Set(n?.services.filter(Sn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!kn(e.status))??t.find(e=>!r.has(e.service)&&!kn(e.status)&&!An(e.status))??i[0]??t[0]).status}const hr=e=>yn(e)?En(e.name):bn(e);async function gr(e){let{stdout:t}=await q(e,`printf 'READY\\t%s\\n' "$([ -d ${V} ] && command -v docker >/dev/null 2>&1 && echo 1)"
450
+ P=${tt}
451
+ printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${U} ] && echo 1)"
452
+ for f in ${V}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
451
453
  docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.compose.service"}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null || true
452
454
  ids=$(docker ps -aq 2>/dev/null || true)
453
455
  # inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
454
456
  [ -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(`
455
- `)){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:rr(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=sr(a,n,o.proxy,new Set(i.map(lr))),o.dashboard={hosts:[...new Set(a.flatMap(e=>ir(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(or(e.labels)||ir(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(lr));return o.unrouted=s.filter(e=>Cn(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(lr(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:cr(e,t),services:t,domains:Tn(e),main:yn(e)?.name,state:xn(e),kind:Sn(e)}}),o}async function dr(e,t,n,r={}){let i=`${Z(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&O(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...Dn,...On].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])=>Mt(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${q(a)} compose.yaml`]:[],`rm -f ${o.map(q).join(` `)}`,...r.env===void 0?[]:[Mt(`.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(Qn)}; printf '%s\\n' "$cfg"`].join(`
456
- `),{capture:!0}),[c,l=``]=s.split(Qn),u;try{u=re(l)}catch(e){O(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function fr(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`,Mt(`site.json`,JSON.stringify(t,null,2)+`
457
- `),`record ${q(`adopted ${t.project} as site ${t.name}`)} ${q(`rm -rf ${n}`)}`].join(`
458
- `))}async function pr(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 mr=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; }`],hr=e=>`docker image rm ${q(e)} >/dev/null 2>&1 || true`;function gr(e,t,n={}){let r=Z(e.name),i=hn(e),a=q(`releases/`+e.current),o=mn(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; }`:Mt(`.env`,n.env,`600`),`(cd -P ${a} && ${Mt(`compose.override.yaml`,In(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(Mt(`site.json`,JSON.stringify(e,null,2)+`
459
- `)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...mr(e.name)),s}const _r=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>En(e.name,n.name,t.id)),vr=(e,t)=>[`rmf ${q(`${Z(e.name)}/releases/${t.id}`)}`,..._r(e,t).map(hr)];function yr(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(bn(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${q(bn(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(hr)]}async function br(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await xr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function xr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=tr(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)`)}`,...yr(r.takeover,t.current),...gr(t,n,r)),await J(e,l.join(`
460
- `));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${wr(t,{keepDir:r.existed}).join(`
457
+ `)){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{T(`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:lr(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=pr(a,n,o.proxy,new Set(i.map(hr))),o.dashboard={hosts:[...new Set(a.flatMap(e=>ur(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(fr(e.labels)||ur(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(hr));return o.unrouted=s.filter(e=>kn(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(hr(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:mr(e,t),services:t,domains:jn(e),main:Tn(e)?.name,state:Dn(e),kind:On(e)}}),o}async function _r(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=[...Nn,...Pn].filter(e=>e!==`compose.yaml`),{stdout:s}=await q(e,[G,`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 ${K(i)} && cd -P ${K(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Rt(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${K(a)} compose.yaml`]:[],`rm -f ${o.map(K).join(` `)}`,...r.env===void 0?[]:[Rt(`.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 ${K(ir)}; printf '%s\\n' "$cfg"`].join(`
458
+ `),{capture:!0}),[c,l=``]=s.split(ir),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 vr(e,t){let n=Z(t.name);await q(e,[G,`[ -r ${K(t.adopted)} ] && [ -x ${K(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${K(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${K(n)} && cd ${K(n)}`,`ln -s ${K(t.adopted)} current`,Rt(`site.json`,JSON.stringify(t,null,2)+`
459
+ `),`record ${K(`adopted ${t.project} as site ${t.name}`)} ${K(`rm -rf ${n}`)}`].join(`
460
+ `))}async function yr(e,t,n){let r=Z(t);await q(e,`cd / && rm -rf ${K(`${r}/releases/${n}`)}; [ -f ${K(r+`/site.json`)} ] || { rmdir ${K(r+`/releases`)} 2>/dev/null; { [ -s ${K(r+`/.env`)} ] || rm -f ${K(r+`/.env`)}; }; rmdir ${K(r)} 2>/dev/null; } || true`)}const br=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; }`],xr=e=>`docker image rm ${K(e)} >/dev/null 2>&1 || true`;function Sr(e,t,n={}){let r=Z(e.name),i=xn(e),a=K(`releases/`+e.current),o=bn(e),s=[`mkdir -p ${K(r)} && cd ${K(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${K(`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 ${K(`a Compose project named ${o} already runs from`)} "$other:" ${K(`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; }`:Rt(`.env`,n.env,`600`),`(cd -P ${a} && ${Rt(`compose.override.yaml`,Hn(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(Rt(`site.json`,JSON.stringify(e,null,2)+`
461
+ `)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...br(e.name)),s}const Cr=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>Mn(e.name,n.name,t.id)),wr=(e,t)=>[`rmf ${K(`${Z(e.name)}/releases/${t.id}`)}`,...Cr(e,t).map(xr)];function Tr(e,t){let n=Z(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${K(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${K(En(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${K(En(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 ${K(`releases/`+e.id)}`),...r.map(xr)]}async function Er(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await Dr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function Dr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=sr(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=[G];if(r.takeover){l.push(`record ${K(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Tr(r.takeover,t.current),...Sr(t,n,r)),await q(e,l.join(`
462
+ `));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${K(c+`/.env`)})`),!r.prev)u=`[ ! -d ${K(c)} ] || {\n${Ar(t,{keepDir:r.existed}).join(`
461
463
  `)}\n`,d=`
462
- }`;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=`
463
- `+[...gr(i,n),...a.flatMap(e=>vr(t,e))].join(`
464
- `)}l.push(`record ${q(r.what)} ${q(u)}${f?`"$u"`:``}${q(d)}`),l.push(...gr(t,n,r));for(let e of s)l.push(...vr(t,e));await J(e,l.join(`
465
- `))}async function Sr(e,t,n,r=``){await J(e,`${K}\ncd ${q(Z(t))}\n${r}\ncd -P current && dc ${n}`)}const Cr=(e,t,n)=>St(e,[`cd -P ${q(Z(t)+`/current`)} && docker compose logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?q(n.service):``}`]);function wr(e,t={}){let n=Z(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>_r(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(lr(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(hr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${q(n)}`]]}async function Tr(e,t,n={}){let r=`removed site ${t.name} (${Tn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await J(e,[K,...wr(t,n),`record ${q(r)}`].join(`
466
- `))}function Er(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
464
+ }`;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 ${K(c)} ] || { log ${K(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
465
+ `+[...Sr(i,n),...a.flatMap(e=>wr(t,e))].join(`
466
+ `)}l.push(`record ${K(r.what)} ${K(u)}${f?`"$u"`:``}${K(d)}`),l.push(...Sr(t,n,r));for(let e of s)l.push(...wr(t,e));await q(e,l.join(`
467
+ `))}async function Or(e,t,n,r=``){await q(e,`${G}\ncd ${K(Z(t))}\n${r}\ncd -P current && dc ${n}`)}const kr=(e,t,n)=>Et(e,[`cd -P ${K(Z(t)+`/current`)} && docker compose logs --tail ${K(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?K(n.service):``}`]);function Ar(e,t={}){let n=Z(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Cr(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(K).join(` `);return[`cd ${K(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${K(hr(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(xr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${K(n)}`]]}async function jr(e,t,n={}){let r=`removed site ${t.name} (${jn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await q(e,[G,...Ar(t,n),`record ${K(r)}`].join(`
468
+ `))}function Mr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
467
469
  `)){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(`
468
470
  `)+`
469
- `:``}const Dr=e=>e.split(`
470
- `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),Or=async e=>kr(Dr(e===`-`?await b():o.readFileSync(e,`utf8`)));function kr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&O(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}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 Tt({...e,user:t}))return{...e,user:t};return O(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const Ar=String.raw`
471
+ `:``}const Nr=e=>e.split(`
472
+ `).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),Pr=async e=>Fr(Nr(e===`-`?await y():o.readFileSync(e,`utf8`)));function Fr(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}async function Q(e,t){let{stdout:n}=await q(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 J({...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 Ir=String.raw`
471
473
  set +e
472
474
  S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
473
475
  m=$(pkg_mgr); echo "mgr=$m"
@@ -491,8 +493,8 @@ case "$m" in
491
493
  esac
492
494
  [ -f /var/run/reboot-required ] && echo reboot=1
493
495
  exit 0
494
- `;function jr(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
495
- `)){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 Mr(e,t=!0){let{stdout:n}=await J(e,K+Ar,{capture:!0,env:{REFRESH:t}});return jr(n)}const Nr=String.raw`
496
+ `;function Lr(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
497
+ `)){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 Rr(e,t=!0){let{stdout:n}=await q(e,G+Ir,{capture:!0,env:{REFRESH:t}});return Lr(n)}const zr=String.raw`
496
498
  if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
497
499
  keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
498
500
  case "$(pkg_mgr)" in
@@ -512,8 +514,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
512
514
  # Detached, so this ssh session can end cleanly first.
513
515
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
514
516
  fi
515
- `;async function Pr(e,t){await J(e,K+Nr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Fr(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 Ir(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 Lr(e){let t=Fr(e);return t.digest?t.digest:(await Ir(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Rr(e){let t=Fr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Ir(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 zr(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 Br=/^(v?)(\d+(?:\.\d+){0,2})$/;function Vr(e,t){let n=Br.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=Br.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&zr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const Hr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,Ur=/* @__PURE__ */ new Map;function Wr(e){return Ur.has(e)||Ur.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)),Ur.get(e)}const Gr=()=>Wr(`moby/moby`),Kr=()=>Wr(`docker/compose`);async function qr(e){let t=String.raw`
516
- ${K}
517
+ `;async function Br(e,t){await q(e,G+zr,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Vr(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 Hr(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 Ur(e){let t=Vr(e);return t.digest?t.digest:(await Hr(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Wr(e){let t=Vr(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Hr(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 Gr(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 Kr=/^(v?)(\d+(?:\.\d+){0,2})$/;function qr(e,t){let n=Kr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=Kr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Gr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const Jr=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,Yr=/* @__PURE__ */ new Map;function Xr(e){return Yr.has(e)||Yr.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)),Yr.get(e)}const Zr=()=>Xr(`moby/moby`),Qr=()=>Xr(`docker/compose`);async function $r(e){let t=String.raw`
518
+ ${G}
517
519
  set +e
518
520
  printf 'docker=%s
519
521
  ' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
@@ -525,11 +527,11 @@ printf 'ce=%s
525
527
  ' "$(pkg_has docker-ce && echo 1)"
526
528
  printf 'syno=%s
527
529
  ' "$([ -f /etc/synoinfo.conf ] && echo 1)"
528
- exit 0`,[{stdout:n},r,i]=await Promise.all([J(e,t,{capture:!0}),Gr(),Kr()]),a=Object.fromEntries(n.split(`
529
- `).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 Jr(e){let t=(e,t)=>!!e&&!!t&&zr(e,t)<0,n=e.synology?` (comes with Container Manager updates in Package Center)`:void 0,r=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${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;return{dockerNote:r,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${i?``:n??(e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`)}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:i}}async function Yr(e){await J(e,K+it+`compose_install || { log "could not download docker compose"; exit 1; }
530
- `)}const Xr=String.raw`
530
+ exit 0`,[{stdout:n},r,i]=await Promise.all([q(e,t,{capture:!0}),Zr(),Qr()]),a=Object.fromEntries(n.split(`
531
+ `).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 ei(e){let t=(e,t)=>!!e&&!!t&&Gr(e,t)<0,n=e.synology?` (comes with Container Manager updates in Package Center)`:void 0,r=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${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;return{dockerNote:r,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${i?``:n??(e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`)}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:i}}async function ti(e){await q(e,G+st+`compose_install || { log "could not download docker compose"; exit 1; }
532
+ `)}const ni=String.raw`
531
533
  set +e
532
- P=${et}
534
+ P=${tt}
533
535
  img() { # img <kind> <name> <container> <dir> [service]
534
536
  svc=; [ $# -lt 5 ] || svc=$5
535
537
  x=$(docker inspect -f '{{.Config.Image}} {{.Image}}' "$3" 2>/dev/null) || { printf 'IMG\t%s\t%s\t%s\t%s\t%s\t\t\n' "$1" "$2" "$3" "$4" "$svc"; return 0; }
@@ -538,11 +540,11 @@ $x
538
540
  EOF
539
541
  printf 'IMG\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$svc" "$ref" "$(docker image inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$id" 2>/dev/null)"
540
542
  }
541
- if [ "$P" = traefik ] && grep -q "managed by landfall" ${U}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${U}
543
+ if [ "$P" = traefik ] && grep -q "managed by landfall" ${H}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${H}
542
544
  else img proxy "$P" "$P" ""; fi
543
545
  # All containers at once: <name> <service> <project>, the last two empty outside Compose.
544
546
  PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
545
- for f in ${H}/*/site.json; do
547
+ for f in ${V}/*/site.json; do
546
548
  [ -f "$f" ] || continue
547
549
  d=$(dirname "$f"); n=$(basename "$d")
548
550
  # Sites of landfall before Compose sites: one container, its compose file next to site.json.
@@ -551,15 +553,15 @@ for f in ${H}/*/site.json; do
551
553
  printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
552
554
  done
553
555
  exit 0
554
- `;async function Zr(e){let{stdout:t}=await J(e,Xr,{capture:!0}),n=t.split(`
555
- `).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(!Br.test(Fr(e).tag))return;i.has(t)||i.set(t,Rr(e));let n=Vr(Fr(e).tag,await i.get(t));return n&&Hr(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(En(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,Lr(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Fr(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 Qr(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(`
556
- `))}function $r(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 ei(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
557
- `)){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($r(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function ti(e,t={}){let{stdout:n}=await J(e,K+st,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return ei(n)}const ni=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),ri=e=>e.includes(`:`),ii=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,ai=[68,546,5353];function oi(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=pi(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 si(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 ci=(e,t)=>{let n=si(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},li=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[ii(e),t(e)])).values()];function ui(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=di(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 di(e,t){let n=e.values,r=e.rules.map(oi),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&ai.includes(e.port)||ci(r,e),a=e=>!ni(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[ri(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...li(o,e=>`${ii(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:li(l,e=>`${ii(e)} (docker${ri(e.addr)?`, IPv6`:``})`)],kept:c?[]:li(s.filter(e=>!l.includes(e)),ii)}}function fi(e){let t=e.values,n=e.rules.map(oi),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[ri(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(ni(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=si(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`&&ai.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=`${ii(e)} ${t?`docker`:e.proc}`,r={port:ii(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 pi=(e=``)=>e.split(/\s+/).filter(Boolean);function mi(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&&zr(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 hi(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(mi(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(ui(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=pi(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(vi(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function gi(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 _i=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function vi(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 ${_i(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=pi(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=gi(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=pi(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=gi(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${_i(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 yi(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 bi(e,t){let{stdout:n}=await J(e,`id=$(docker ps -q ${t.map(e=>`--filter ${q(e)}`).join(` `)} | head -n1)
558
- [ -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 xi(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||O(`${e.name} has no IP address to tunnel to`),n}function Si(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 Ci={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`},wi=e=>Ci[e]??`http`;async function Ti(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 Ei=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 Di(e,t,n,r){let i=n.tls?se:y,a=y.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 Oi=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function ki(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:z(i.local,`--local`,1,65535),s=await Ti(o??(r>=1024?r:8080),a);o&&s!==o&&O(`local port ${o} is taken; pick another with --local`);let c=i.as?await Ti():s,l=[...ht(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}`,mt(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 Ei(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await Di(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?Oi():[];le({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&&O(`the tunnel closed (ssh exit ${m})`)}async function Ai(e,t){return`${e}:${(await oe.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const ji=e=>e.slice(0,e.indexOf(`:`)),Mi=`admin`;async function Ni(e,t,n=`user`){Pe(e,n);let r=t??await ge(`Password for ${e}:`);return r.length<8&&O(`use a password of at least 8 characters`),Ai(e,r)}function Pi(e,t){return{email:t.email??e.email??Se().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 Fi=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 Ii(e,t,n){let r=Pi(t,n);Re(r.image),r.email&&Fe(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){Me.test(c)||O(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Pe(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??ae(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Ai(e,s)}}let l=ut({...r,dashboard:o});r.hostNetwork&&await zi(t,r.http3),await J(t,`${K}
556
+ `;async function ri(e){let{stdout:t}=await q(e,ni,{capture:!0}),n=t.split(`
557
+ `).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(!Kr.test(Vr(e).tag))return;i.has(t)||i.set(t,Wr(e));let n=qr(Vr(e).tag,await i.get(t));return n&&Jr(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(Mn(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,Ur(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Vr(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 ii(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[G];for(let[e,t]of n){r.push(`log ${K(`pulling ${e}`)}`,`docker pull -q ${K(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${K(e)})`);for(let n of t){let t=n.service?` ${K(n.service)}`:``,i=`docker tag $old ${K(e)} && cd -P ${K(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${K(n.container)})`,`if [ "$old" = "$new" ]; then log ${K(`${n.name} already runs the newest ${e}`)}; else`,` record ${K(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${K(`recreating ${n.container}`)}`,` (cd -P ${K(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${K(n.container)} || exit 1`,`fi`)}}await q(e,r.join(`
558
+ `))}function ai(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 oi(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
559
+ `)){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(ai(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function si(e,t={}){let{stdout:n}=await q(e,G+ut,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return oi(n)}const ci=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),li=e=>e.includes(`:`),ui=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,di=[68,546,5353];function fi(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=yi(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 pi(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 mi=(e,t)=>{let n=pi(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},hi=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[ui(e),t(e)])).values()];function gi(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=_i(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 _i(e,t){let n=e.values,r=e.rules.map(fi),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&di.includes(e.port)||mi(r,e),a=e=>!ci(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[li(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...hi(o,e=>`${ui(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:hi(l,e=>`${ui(e)} (docker${li(e.addr)?`, IPv6`:``})`)],kept:c?[]:hi(s.filter(e=>!l.includes(e)),ui)}}function vi(e){let t=e.values,n=e.rules.map(fi),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[li(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(ci(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=pi(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`&&di.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=`${ui(e)} ${t?`docker`:e.proc}`,r={port:ui(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 yi=(e=``)=>e.split(/\s+/).filter(Boolean);function bi(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&&Gr(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 xi(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(bi(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,B,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(gi(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=yi(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(wi(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Si(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 Ci=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function wi(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=nt(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Ci(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${H}/docker-compose.yml was not written by landfall`);let[i,a]=yi(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Si(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=yi(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Si(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Ci(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 Ti(e){let t={done:x.green(`✓`),todo:x.cyan(`→`),conflict:x.yellow(`!`),off:x.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?x.dim(`disabled`):r.status===`conflict`?x.yellow(r.detail):x.dim(r.detail);w(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Ei(e,t){let{stdout:n}=await q(e,`id=$(docker ps -q ${t.map(e=>`--filter ${K(e)}`).join(` `)} | head -n1)
560
+ [ -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 Di(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 Oi(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 ki={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`},Ai=e=>ki[e]??`http`;async function ji(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 Mi=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 Ni(e,t,n,r){let i=n.tls?ce:v,a=v.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 Pi=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function Fi(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:R(i.local,`--local`,1,65535),s=await ji(o??(r>=1024?r:8080),a);o&&s!==o&&D(`local port ${o} is taken; pick another with --local`);let c=i.as?await ji():s,l=[...vt(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}`,_t(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 Mi(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await Ni(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;C(`${i.what}: ${x.cyan(t(`localhost`))}`);let n=i.lan?Pi():[];ue({url:t(`localhost`),lan:n.map(t)}),i.lan&&(w(n.length?`on your network: ${n.map(e=>x.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),T(`anyone on that network reaches it too: close it when you are done`)),i.note&&w(x.dim(i.note)),w(x.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 Ii(e,t){return`${e}:${(await se.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const Li=e=>e.slice(0,e.indexOf(`:`)),Ri=`admin`;async function zi(e,t,n=`user`){Fe(e,n);let r=t??await _e(`Password for ${e}:`);return r.length<8&&D(`use a password of at least 8 characters`),Ii(e,r)}function Bi(e,t){return{email:t.email??e.email??Ce().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 Vi=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 Hi(e,t,n){let r=Bi(t,n);ze(r.image),r.email&&Ie(r.email);let{email:i,staging:a}=r;S(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?He(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){Ne.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Fe(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??oe(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Ii(e,s)}}let l=pt({...r,dashboard:o});r.hostNetwork&&await Gi(t,r.http3),await q(t,`${G}
559
561
  ensure_network web
560
- [ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
561
- mkdir -p ${U}/letsencrypt ${Je} && cd ${U}
562
- ${Mt(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
562
+ [ -w ${H} ] || { $SUDO install -d -o "$(id -un)" ${H}; }
563
+ mkdir -p ${H}/letsencrypt ${Ye} && cd ${H}
564
+ ${Rt(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
563
565
  tls:
564
566
  options:
565
567
  default:
@@ -570,11 +572,11 @@ tls:
570
572
  gw=$(docker network inspect web -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\\n' | grep -m1 '[.]' || true)
571
573
  printf 'LANDFALL_GATEWAY=%s\\n' "\${gw:-127.0.0.1}" > .env
572
574
  if [ -e docker-compose.yml ]; then
573
- ${Mt(`.docker-compose.yml.new`,l)}
575
+ ${Rt(`.docker-compose.yml.new`,l)}
574
576
  # .landfall.sha256: what landfall wrote last. A different file was edited by hand.
575
577
  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
576
578
  diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
577
- echo "${U}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
579
+ echo "${H}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
578
580
  exit 1
579
581
  fi
580
582
  # Same config again: nothing to journal.
@@ -582,48 +584,48 @@ if [ -e docker-compose.yml ]; then
582
584
  else
583
585
  u=$(saved docker-compose.yml)
584
586
  mv .docker-compose.yml.new docker-compose.yml
585
- record ${q(`reconfigured traefik (${Fi(r)})`)} "cd ${U} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
587
+ record ${K(`reconfigured traefik (${Vi(r)})`)} "cd ${H} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
586
588
  fi
587
589
  else
588
- ${Mt(`docker-compose.yml`,l)}
590
+ ${Rt(`docker-compose.yml`,l)}
589
591
  # Certificates in letsencrypt/ stay, a later install reuses them.
590
- record ${q(`installed traefik (${Fi(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
592
+ record ${K(`installed traefik (${Vi(r)})`)} "cd ${H} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
591
593
  fi
592
594
  # Written now: a start that fails below must not make landfall's own file look edited by hand.
593
595
  sha256sum docker-compose.yml > .landfall.sha256
594
596
  log "starting traefik"
595
597
  dc up -d --remove-orphans --quiet-pull
596
598
  sleep 2 # traefik exits shortly after start on a bad config
597
- ensure_running traefik`);let u=L();u.servers[e]&&(Object.assign(u.servers[e],{email:i===Se().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}),Oe(u)),w(`traefik running (${Fi(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 Li(e,t){C(`using traefik ${S.bold(t.container)} for lf sites`),await J(e,`${K}
598
- docker network inspect ${q(t.network)} >/dev/null 2>&1 || { echo ${q(`docker network ${t.network} does not exist`)} >&2; exit 1; }
599
- [ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
600
- u=$(saved ${W})
601
- ${Mt(W,nt(t))}
602
- 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 Ri(e){let{stdout:t}=await J(e,`[ ! -f ${W} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${W})"
603
- f=${U}/docker-compose.yml
599
+ ensure_running traefik`);let u=I();u.servers[e]&&(Object.assign(u.servers[e],{email:i===Ce().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}),ke(u)),C(`traefik running (${Vi(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&w(x.dim(`host network: ufw now decides who reaches 80/443`)),o&&(C(`dashboard: https://${o.host}`),w(s?`user ${x.bold(o.user)} password ${x.bold(s)}`:x.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Ui(e,t){S(`using traefik ${x.bold(t.container)} for lf sites`),await q(e,`${G}
600
+ docker network inspect ${K(t.network)} >/dev/null 2>&1 || { echo ${K(`docker network ${t.network} does not exist`)} >&2; exit 1; }
601
+ [ -w ${B} ] || $SUDO install -d -o "$(id -un)" ${B}
602
+ u=$(saved ${U})
603
+ ${Rt(U,rt(t))}
604
+ record ${K(`sites use traefik ${t.container}`)} "$u"`),C(`sites join network ${x.bold(t.network)} with entrypoint ${x.bold(t.entrypoint)} and resolver ${x.bold(t.resolver)}`),w(x.dim(`redeploy existing sites to switch them over; delete ${U} to let landfall run its own traefik again`))}async function Wi(e){let{stdout:t}=await q(e,`[ ! -f ${U} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${U})"
605
+ f=${H}/docker-compose.yml
604
606
  [ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
605
- 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(`
606
- `)){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 zi(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}
607
+ for d in ${V}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
608
+ `)){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 Gi(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await Q(e,`checking ufw`)}catch{return T(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await q(r,`${G}
607
609
  command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" || exit 0
608
610
  ${n.map(e=>`ufw_add allow ${e}`).join(`
609
- `)}`)}async function Bi(e,t={}){C(`removing traefik`),await J(e,`${K}
610
- cd ${U}
611
+ `)}`)}async function Ki(e,t={}){S(`removing traefik`),await q(e,`${G}
612
+ cd ${H}
611
613
  log "stopping traefik"
612
614
  dc down --remove-orphans
613
615
  # One assignment each: an assignment's status is only its last $(...), so a failed backup would go unnoticed.
614
- u1=$(saved ${U}/docker-compose.yml)
615
- u2=$(saved ${U}/.landfall.sha256)
616
- u3=$(saved ${Je}/landfall.yml)
616
+ u1=$(saved ${H}/docker-compose.yml)
617
+ u2=$(saved ${H}/.landfall.sha256)
618
+ u3=$(saved ${Ye}/landfall.yml)
617
619
  u="$u1; $u2; $u3"
618
- rm -f docker-compose.yml .landfall.sha256 ${Je}/landfall.yml
620
+ rm -f docker-compose.yml .landfall.sha256 ${Ye}/landfall.yml
619
621
  ${t.certs?`rmf letsencrypt`:``}
620
622
  # Certificates removed with --certs come back from Let's Encrypt on the next start.
621
- record ${q(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${U} && dc up -d --remove-orphans"`)}const Vi={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Hi=M({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:N,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:Mi},"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}=R(t.server),i=await ti(r),a=vi(i);if(a.status===`conflict`&&O(`not touching ${n}: ${a.detail}`),a.proxy&&O(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),Pi(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&O(`port ${Ye} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await Ii(n,r,{email:t.email&&Fe(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})}}),Ui=M({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
623
+ record ${K(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${H} && dc up -d --remove-orphans"`)}const qi={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Ji=j({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:M,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: ${Qe})`},"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:Ri},"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}=L(t.server),i=await si(r),a=wi(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)`),Bi(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&D(`port ${Xe} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await Hi(n,r,{email:t.email&&Ie(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})}}),Yi=j({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
622
624
 
623
625
  landfall reads network, entrypoint and cert resolver from the container's flags;
624
- pass them when it is configured by a file. That Traefik is never changed.`,options:{server:N,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}=R(t.server),i=gi((await ti(r)).values.foreign_proxy),a=e??i?.name;a||O(`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&&O(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Li(r,s)}}),Wi=M({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:N,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await Ri(r);if(i.proxy)return await A(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||O(`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`&&O(`${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 k(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Bi(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`))}}),Gi=M({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:N},async run(e,t){let{server:n}=R(t.server);await J(n,`P=${et}
626
+ pass them when it is configured by a file. That Traefik is never changed.`,options:{server:M,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}=L(t.server),i=Si((await si(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 Ui(r,s)}}),Xi=j({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:M,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=L(t.server),i=await Wi(r);if(i.proxy)return await k(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||D(`cancelled`),await q(r,`${G}\nu=$(saved ${U})\n_as ${B} rm -f ${U}\nrecord ${K(`sites stop using traefik ${i.proxy}`)} "$u"`),C(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),w(x.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&D(`${H}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return C(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await O(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Ki(r,{certs:t.certs}),C(`traefik removed${t.certs?``:x.dim(` (certificates stay for a later install; --certs deletes them)`)}`),w(x.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),Zi=j({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:M},async run(e,t){let{server:n}=L(t.server);await q(n,`P=${tt}
625
627
  if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
626
- [ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
628
+ [ -f ${U} ] && echo "your own traefik, landfall only routes sites through it (${U})"
627
629
  docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
628
630
  echo
629
631
  echo "routes:"
@@ -647,39 +649,219 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
647
649
  printf " %s %s (%s)\\n", n, x, s
648
650
  }
649
651
  }' || true
650
- done | sort`)}}),Ki=M({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).
651
- Requests are left out unless --access: lf traffic sums them up.`,options:{server:N,...Vi,access:{type:`boolean`,desc:`include the access log (a JSON line per request)`}},async run(e,t){let{server:n}=R(t.server);if(t.access)return nr(n,et,t);let r=z(t.tail,`--tail`,1),i=`awk '!/^\\{.*"DownstreamStatus":/ { print; fflush() }'`;return St(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}`])}}),qi=M({name:`traefik restart`,summary:`Restart traefik.`,options:{server:N},async run(e,t){let{server:n}=R(t.server);await J(n,`${K}\ndocker restart ${et} >/dev/null\nrecord "restarted traefik"`),w(`traefik restarted`)}}),Ji=M({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
652
+ done | sort`)}}),Qi=j({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).
653
+ Requests are left out unless --access: lf traffic sums them up.`,options:{server:M,...qi,access:{type:`boolean`,desc:`include the access log (a JSON line per request)`}},async run(e,t){let{server:n}=L(t.server);if(t.access)return cr(n,tt,t);let r=R(t.tail,`--tail`,1),i=`awk '!/^\\{.*"DownstreamStatus":/ { print; fflush() }'`;return Et(n,[t.follow?`docker logs --tail ${r} -f ${tt} 2>&1 | ${i}`:`docker logs --tail ${Math.max(r*50,5e3)} ${tt} 2>&1 | ${i} | tail -n ${r}`])}}),$i=j({name:`traefik restart`,summary:`Restart traefik.`,options:{server:M},async run(e,t){let{server:n}=L(t.server);await q(n,`${G}\ndocker restart ${tt} >/dev/null\nrecord "restarted traefik"`),C(`traefik restarted`)}}),ea=j({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
652
654
 
653
655
  No domain or password needed: landfall's Traefik serves the dashboard to the server
654
656
  itself, and only there. To also serve it on a domain behind a password:
655
657
  lf traefik setup --dashboard traefik (traefik.<main domain>).
656
658
 
657
659
  A Traefik of your own works too when it routes the dashboard to a domain: the tunnel
658
- goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:N,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}=R(t.server),i=await Ri(r),a=await bi(r,[`name=^${i.proxy??`traefik`}$`]);a||O(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=ir(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?z(t.port,`--port`,1,65535):!i.proxy&&or(a.labels)?Ye:void 0,l=`traefik dashboard of ${n}`;if(c)return ki(r,xi(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=ar(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:Si(a,e.router)}]:[]).find(e=>e.at);u||O(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 ki(r,xi(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 Yi(e,t){let n=B(process.cwd()),r=e??n?.name;return r||O(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...R(t??n?.server)}}const Xi=e=>Cn(e)?S.green(e):wn(e)?S.dim(e):S.red(e),Zi=M({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:N,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}=R(t.server??B(process.cwd())?.server),i=await ur(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):")),j([[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(`, `)),Xi(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${S.bold(`lf deploy --domain example.com`)}`);j([[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(`-`),he(e.updatedAt),Xi(t)])])}}),Qi=M({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:N},async run([e],t){let{site:n,server:r}=Yi(e,t.server),i=(await ur(r)).sites.find(e=>e.meta.name===n);i||O(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${S.bold(a.name)} ${i.kind} ${Xi(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`)}`),j(i.services.map(e=>[` ${e.service}`,S.dim(e.container),Xi(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${S.bold(`Releases`)}`);for(let e of tr(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(he(e.at))} ${S.dim(e.services.map(e=>e.name).join(`, `))}${n.length?S.dim(` → ${n.join(`, `)}`):``}`)}}}}),$i=M({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:N,...Vi},async run([e,t],n){let{site:r,server:i}=Yi(e,n.server);return Cr(i,r,{...n,service:t})}}),ea=(e,t,n,r,i)=>M({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:N},async run([a,o],s){let{site:c,server:l}=Yi(a,s.server);await er(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 Sr(l,c,t+u,p),w(`${o?`${c} ${o}`:c} ${n}`)}}),ta=ea(`restart`,`restart`,`restarted`),na=ea(`stop`,`stop`,`stopped`,`start`,!0),ra=ea(`start`,`up -d --wait`,`started`,`stop`,!1),ia=M({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:N,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=Yi(e,t.server),{meta:i,proxy:a}=await er(r,n,{deployed:`roll back`}),o=tr(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&O(`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||O(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await br(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)}`)}}),aa=M({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:N,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||O("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=R(t.server),i=await ur(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>mn(t.meta)===e);O(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||O(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=Zn(t.name??Wn(e));i.sites.some(e=>e.meta.name===s)&&O(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...Dn,...On].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&O(`${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 fr(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`))}}),oa=M({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
660
+ goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:M,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}=L(t.server),i=await Wi(r),a=await Ei(r,[`name=^${i.proxy??`traefik`}$`]);a||D(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=ur(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?R(t.port,`--port`,1,65535):!i.proxy&&fr(a.labels)?Xe:void 0,l=`traefik dashboard of ${n}`;if(c)return Fi(r,Di(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=dr(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:Oi(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 Fi(r,Di(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 ta(e,t){let n=z(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...L(t??n?.server)}}const na=e=>kn(e)?x.green(e):An(e)?x.dim(e):x.red(e),ra=j({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:M,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}=L(t.server??z(process.cwd())?.server),i=await gr(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(x.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),A([[x.dim(`NAME`),x.dim(`DOMAINS`),x.dim(`FOLDER`),x.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??x.dim(e.containers.join(`, `)),na(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${x.bold(`lf deploy --domain example.com`)}`);A([[x.dim(`NAME`),x.dim(`KIND`),x.dim(`DOMAINS`),x.dim(`SERVICES`),x.dim(`RELEASE`),x.dim(`UPDATED`),x.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[x.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||x.dim(`-`),e.current||x.dim(`-`),ge(e.updatedAt),na(t)])])}}),ia=j({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:M},async run([e],t){let{site:n,server:r}=ta(e,t.server),i=(await gr(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(`${x.bold(a.name)} ${i.kind} ${na(i.status)}`);for(let e of i.domains)console.log(` ${x.cyan(`https://`+e)}`);if(console.log(x.dim(` ${a.adopted??Z(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${x.bold(`Services`)}`),A(i.services.map(e=>[` ${e.service}`,x.dim(e.container),na(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${x.bold(`Releases`)}`);for(let e of sr(a)){let t=e.id===a.current?x.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${x.dim(ge(e.at))} ${x.dim(e.services.map(e=>e.name).join(`, `))}${n.length?x.dim(` → ${n.join(`, `)}`):``}`)}}}}),aa=j({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:M,...qi},async run([e,t],n){let{site:r,server:i}=ta(e,n.server);return kr(i,r,{...n,service:t})}}),oa=(e,t,n,r,i)=>j({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:M},async run([a,o],s){let{site:c,server:l}=ta(a,s.server);await or(l,c);let u=o?` ${K(o)}`:``,d=`record ${K(`${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} ${K(`cd -P ${Z(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Or(l,c,t+u,p),C(`${o?`${c} ${o}`:c} ${n}`)}}),sa=oa(`restart`,`restart`,`restarted`),ca=oa(`stop`,`stop`,`stopped`,`start`,!0),la=oa(`start`,`up -d --wait`,`started`,`stop`,!1),ua=j({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:M,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=ta(e,t.server),{meta:i,proxy:a}=await or(r,n,{deployed:`roll back`}),o=sr(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 Er(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),C(`${n} rolled back to ${x.bold(c.id)}`)}}),da=j({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:M,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}=L(t.server),i=await gr(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>bn(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=rr(t.name??Xn(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([...Nn,...Pn].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 vr(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),C(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(x.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),fa=j({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
659
661
 
660
662
  Named volumes (databases, uploads) stay, so deploying the site again finds its data;
661
- --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:N,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||O(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=Yi(e,t.server),{meta:a}=await $n(r,n);a||O(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Tn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await k(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 Tr(r,o,{volumes:t.volumes}),w(`removed ${n}${t.volumes?` and its volumes`:S.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),sa=M({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:N,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 Or(t[`env-file`]):{},...kr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=Yi(n,t.server),{env:o}=await er(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||S.dim(`(empty)
662
- `));let s=Er(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await Sr(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
663
- record ${q(`env ${i} ${c.join(` `)}`)} "cd ${Z(i)} && $u && cd -P current && dc up -d"
664
- ${Mt(`.env`,s,`600`)}`),w(`updated env of ${i}`)}}),ca=M({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
663
+ --volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:M,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}=ta(e,t.server),{meta:a}=await ar(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=jn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await O(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 jr(r,o,{volumes:t.volumes}),C(`removed ${n}${t.volumes?` and its volumes`:x.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),pa=j({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:M,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 Pr(t[`env-file`]):{},...Fr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=ta(n,t.server),{env:o}=await or(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||x.dim(`(empty)
664
+ `));let s=Mr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await Or(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
665
+ record ${K(`env ${i} ${c.join(` `)}`)} "cd ${Z(i)} && $u && cd -P current && dc up -d"
666
+ ${Rt(`.env`,s,`600`)}`),C(`updated env of ${i}`)}}),ma=j({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
665
667
 
666
668
  Nothing changes on the server: no port is published and its domains stay as they are,
667
669
  so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
668
670
  Works for any Compose project or container on the server too, not only landfall's sites.
669
- The tunnel stays open until Ctrl-C.`,options:{server:N,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}=Yi(e,n.server),{meta:a,proxy:o}=await $n(i,r);if(!a)return la(i,r,t,o.network,n);pn(a)&&O(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=hn(a),c=t?s?.services.find(e=>e.name===t):yn(a)??s?.services.find(gn);t&&s&&!c&&O(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||O(`which service of ${r}? lf tunnel ${r} <service>`);let u=await bi(i,[`label=com.docker.compose.project=${mn(a)}`,`label=com.docker.compose.service=${l}`]);u||O(`${l} of ${r} is not running (lf start ${r})`),await ua(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function la(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await bi(e,[...a,`label=traefik.enable=true`])??await bi(e,a)??(n?void 0:await bi(e,[`name=^/?${t}$`]));o||O(`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 ua(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function ua(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>jn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&O(`which port of ${t.name}? pass --port`);let s=z(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=wi(s);await ki(e,xi(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 da(e){let t=e,n=await ti(t);n.values.sudo!==`1`&&(t=await Q(e,`the firewall`),n=await ti(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(oi),ports:fi(n),blocked:di(n,[i]).blocked,probe:n}}const fa=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,pa=/^[0-9a-fA-F.:]+(\/\d+)?$/;function ma(e){fa.test(e.port)||O(`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)&&O(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&O(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!pa.test(e.from)&&O(`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 ha=e=>`ufw_add ${e.map(q).join(` `)}`,ga=e=>e[0]===`deny`?`${ha([`prepend`,...e])} 2>/dev/null || ${ha(e)}`:ha(e),_a=(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`,va=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
670
- record ${q(`ufw delete ${e.spec}`)} ${q(_a(e,t))}`,ya=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,ba=e=>oi(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `));function xa(e){let t=$t(e.probe.values.platform??``,e.probe.values.pkg??``,e.ufw!==`missing`);t&&O(t)}const Sa=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Ca(e,t,n){if(e.ufw!==`active`)return;let r=si(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 me(`${i}. Do it anyway?`)||O(`${i} (--force to do it anyway)`)}function wa(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||O(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const Ta={server:N,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 Ea(e){return M({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:{...Ta,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||O(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=ma({action:e,port:t,from:n.from,comment:n.comment}),i=await da(R(n.server).server);xa(i),e===`deny`&&await Ca(i,[ba(r),...i.rules],n.force),await J(i.login,`${K}\n${Sa}\n${ga(r)}`),w(`${r.join(` `)}${i.ufw===`active`?``:S.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Da=Ea(`allow`),Oa=Ea(`deny`),ka=M({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:N,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server);if(t.json)return void console.log(JSON.stringify(await tc(r),null,2));let i=await da(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`)),j(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};j(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)]));let s=$t(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)`)))}}),Aa=M({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:N,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await da(r);if(xa(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 me(`${e}. Turn it on anyway?`)||O(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Ca({...i,ufw:`active`},[...i.rules,ba([`allow`,`${i.sshPort}/tcp`])],t.force),await J(i.login,`${K}
671
+ The tunnel stays open until Ctrl-C.`,options:{server:M,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}=ta(e,n.server),{meta:a,proxy:o}=await ar(i,r);if(!a)return ha(i,r,t,o.network,n);yn(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=xn(a),c=t?s?.services.find(e=>e.name===t):Tn(a)??s?.services.find(Sn);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 Ei(i,[`label=com.docker.compose.project=${bn(a)}`,`label=com.docker.compose.service=${l}`]);u||D(`${l} of ${r} is not running (lf start ${r})`),await ga(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function ha(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await Ei(e,[...a,`label=traefik.enable=true`])??await Ei(e,a)??(n?void 0:await Ei(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 ga(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function ga(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>Ln.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&D(`which port of ${t.name}? pass --port`);let s=R(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&w(x.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=Ai(s);await Fi(e,Di(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 _a(e){let t=e,n=await si(t);n.values.sudo!==`1`&&(t=await Q(e,`the firewall`),n=await si(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(fi),ports:vi(n),blocked:_i(n,[i]).blocked,probe:n}}const va=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,ya=/^[0-9a-fA-F.:]+(\/\d+)?$/;function ba(e){va.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&&!ya.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 xa=e=>`ufw_add ${e.map(K).join(` `)}`,Sa=e=>e[0]===`deny`?`${xa([`prepend`,...e])} 2>/dev/null || ${xa(e)}`:xa(e),Ca=(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`,wa=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
672
+ record ${K(`ufw delete ${e.spec}`)} ${K(Ca(e,t))}`,Ta=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,Ea=e=>fi(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `));function Da(e){let t=on(e.probe.values.platform??``,e.probe.values.pkg??``,e.ufw!==`missing`);t&&D(t)}const Oa=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function ka(e,t,n){if(e.ufw!==`active`)return;let r=pi(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 he(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function Aa(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 ja={server:M,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 Ma(e){return j({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:{...ja,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=ba({action:e,port:t,from:n.from,comment:n.comment}),i=await _a(L(n.server).server);Da(i),e===`deny`&&await ka(i,[Ea(r),...i.rules],n.force),await q(i.login,`${G}\n${Oa}\n${Sa(r)}`),C(`${r.join(` `)}${i.ufw===`active`?``:x.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Na=Ma(`allow`),Pa=Ma(`deny`),Fa=j({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=L(t.server);if(t.json)return void console.log(JSON.stringify(await yc(r),null,2));let i=await _a(r),a=i.ufw===`active`?x.green(`on`):i.ufw===`missing`?x.yellow(`not installed`):x.yellow(`off`);S(`firewall on ${n}: ${a}${i.ufw===`active`?x.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${x.bold(`Rules`)}`),i.rules.length||w(x.dim(`none`)),A(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?x.green(e.action):x.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${x.bold(`Listening ports`)}`);let o={open:x.green,limited:x.yellow,blocked:x.red,local:x.dim};A(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)]));let s=on(i.probe.values.platform??``,i.probe.values.pkg??``,i.ufw!==`missing`);s?(console.log(),w(x.dim(s))):i.ufw!==`active`&&(console.log(),i.blocked.length?T(`enabling would block ${i.blocked.join(`, `)}: allow them first`):w(x.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Ia=j({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:M,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=L(t.server),i=await _a(r);if(Da(i),i.ufw===`active`)return C(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await he(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await ka({...i,ufw:`active`},[...i.rules,Ea([`allow`,`${i.sshPort}/tcp`])],t.force),await q(i.login,`${G}
671
673
  command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
672
- ${ha([`allow`,`${i.sshPort}/tcp`])}
673
- ufw_enable`),w(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),ja=M({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:N},async run(e,t){let{name:n,server:r}=R(t.server),i=await da(r);if(i.ufw!==`active`)return w(`firewall on ${n} is already off`);await A(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||O(`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)`)}}),Ma=M({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:N,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||O(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=R(t.server),i=await da(r),{rule:a,n:o}=wa(i,e);await Ca(i,i.rules.filter(e=>e!==a),t.force),await k(`Remove rule ${o} (${a.spec}) on ${n}?`),await J(i.login,`${K}\n${Sa}\n${va(a,ya(i,o))}`),w(`removed rule ${o}: ${a.spec}`)}}),Na=M({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:{...Ta,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||O(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await da(R(t.server).server),{rule:r,n:i}=wa(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||O(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||O(`rule ${i} (${r.spec}) names no port; pass --port`);let s=ma({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=ya(n,i),l=n.rules.filter(e=>e!==r);await Ca(n,c?n.rules.map(e=>e===r?ba(s):e):a===`deny`?[ba(s),...l]:[...l,ba(s)],t.force);let u=c&&c<n.rules.length?`${ha([`insert`,String(c),...s])} || ${ga(s)}`:ga(s);await J(n.login,`${K}
674
- ${Sa}
675
- ${va(r,c)}
676
- ${u} || { ${_a(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),w(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),Pa=e=>e.steps.filter(e=>e.undo&&!e.undone);function Fa(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
677
- `).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 Ia=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),La=RegExp(`${Ia(H)}/[A-Za-z0-9._-]+|${Ia(U)}`,`g`);function Ra(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(La))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function za(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ra(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=Pa(n).flatMap(e=>[...Ra(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Ba(e){let{stdout:t}=await J(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return Fa(t)}function Va(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||O(`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||O(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Ha(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 Tt(i)?{login:i}:O(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Ua=String.raw`
674
+ ${xa([`allow`,`${i.sshPort}/tcp`])}
675
+ ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),La=j({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:M},async run(e,t){let{name:n,server:r}=L(t.server),i=await _a(r);if(i.ufw!==`active`)return C(`firewall on ${n} is already off`);await k(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await q(i.login,`${G}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),C(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Ra=j({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:M,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}=L(t.server),i=await _a(r),{rule:a,n:o}=Aa(i,e);await ka(i,i.rules.filter(e=>e!==a),t.force),await O(`Remove rule ${o} (${a.spec}) on ${n}?`),await q(i.login,`${G}\n${Oa}\n${wa(a,Ta(i,o))}`),C(`removed rule ${o}: ${a.spec}`)}}),za=j({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:{...ja,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 _a(L(t.server).server),{rule:r,n:i}=Aa(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=ba({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=Ta(n,i),l=n.rules.filter(e=>e!==r);await ka(n,c?n.rules.map(e=>e===r?Ea(s):e):a===`deny`?[Ea(s),...l]:[...l,Ea(s)],t.force);let u=c&&c<n.rules.length?`${xa([`insert`,String(c),...s])} || ${Sa(s)}`:Sa(s);await q(n.login,`${G}
676
+ ${Oa}
677
+ ${wa(r,c)}
678
+ ${u} || { ${Ca(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),C(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});let Ba;function Va(){if(Ba)return Ba;let e=o.mkdtempSync(m(u(),`landfall-`));return Ba=m(e,`askpass`),o.writeFileSync(Ba,`#!/bin/sh
679
+ printf '%s\\n' "$LANDFALL_PASSWORD"
680
+ `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Ba}const Ha=e=>({SSH_ASKPASS:Va(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Ua(e){if(e){let e=(await y()).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return Te(`PASSWORD`)||void 0}const Wa=e=>m(c(),`.ssh`,`landfall_${Me(e)}`);function Ga(e){let t=Wa(e);return{path:t,created:Ka(t,`landfall-${e}`)}}function Ka(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 qa(e,t,n){let i=Y(t),a=t.key?Oe(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 J(t);if(!s&&!t.key&&o.existsSync(Wa(e))&&await J({...t,key:Wa(e)})&&(t.key=a=Wa(e),s=!0),s)C(`key login works`),a??=Vt(t);else if(n.check){T("no key login yet — `lf setup` would install a key first"),ue({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Ga(e);a=t.path,c=t.created,c&&C(`generated key ${F(a)}`)}let l=t.key&&Oe(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await J(t))){S(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ha(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...vt(t),_t(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await J(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const Ja=e=>{for(let t of e)console.log(x.dim(` `)+t)};function Ya(e,t,n,r){let i=Y(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Jt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${F(zt)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),Ja(r.block.split(`
681
+ `)),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!==Vt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Xt(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${F(s.file)} ${x.dim(`(backup: ${F(s.backup)})`)}`),Ja([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=F(n)),r.ownAlias&&w(x.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Xa=String.raw`
682
+ set +e
683
+ kv() { printf '%s=%s\n' "$1" "$2"; }
684
+ S=""; R=1
685
+ if [ "$(id -u)" -ne 0 ]; then if sudo -n true 2>/dev/null; then S="sudo -n"; else R=0; fi; fi
686
+ kv root "$R"
687
+ kv user "$(id -un)"
688
+ [ -f /etc/synoinfo.conf ] && kv platform synology
689
+ kv hostname "$(hostname 2>/dev/null || cat /etc/hostname)"
690
+ tz=$(timedatectl show -p Timezone --value 2>/dev/null)
691
+ [ -n "$tz" ] || tz=$(cat /etc/timezone 2>/dev/null)
692
+ [ -n "$tz" ] || tz=$(readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')
693
+ kv timezone "$tz"
694
+ kv ntp "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
695
+ kv time "$(date '+%Y-%m-%d %H:%M %Z')"
696
+ if [ -d /run/systemd/system ]; then init=systemd; elif command -v rc-service >/dev/null 2>&1; then init=openrc; else init=other; fi
697
+ kv init "$init"
698
+ awk 'NR > 1 { print "SWAP\t" $1 "\t" $2 "\t" $3 * 1024 "\t" $4 * 1024 }' /proc/swaps 2>/dev/null
699
+ kv swappiness "$(cat /proc/sys/vm/swappiness 2>/dev/null)"
700
+ kv disk_free "$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 * 1024 }')"
701
+ kv mem "$(awk '/^MemTotal:/ { print $2 * 1024 }' /proc/meminfo)"
702
+ if command -v docker >/dev/null 2>&1; then
703
+ kv log_driver "$(docker info -f '{{.LoggingDriver}}' 2>/dev/null || $S docker info -f '{{.LoggingDriver}}' 2>/dev/null)"
704
+ printf 'DAEMON\t'; { cat /etc/docker/daemon.json 2>/dev/null || $S cat /etc/docker/daemon.json 2>/dev/null; } | tr -d '\n'; echo
705
+ fi
706
+ case "$init" in
707
+ systemd)
708
+ systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null \
709
+ | awk '{ d = ""; for (i = 5; i <= NF; i++) d = d (i > 5 ? " " : "") $i; print "SVC\t" $1 "\t" $2 "\t" $3 "\t" $4 "\t" d }'
710
+ systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{ print "UF\t" $1 "\t" $2 }' ;;
711
+ openrc)
712
+ rc-status -a 2>/dev/null | awk '/\[/ { s = $0; sub(/.*\[[ \t]*/, "", s); sub(/[ \t]*\].*/, "", s); print "SVC\t" $1 "\t-\t" s "\t" s "\t" }' | sort -u ;;
713
+ esac
714
+ if command -v fail2ban-client >/dev/null 2>&1; then
715
+ if st=$($S fail2ban-client status 2>/dev/null); then
716
+ kv fail2ban running
717
+ for j in $(printf '%s\n' "$st" | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do
718
+ $S fail2ban-client status "$j" 2>/dev/null | awk -v j="$j" '
719
+ /Currently banned:/ { c = $NF } /Total banned:/ { t = $NF } /Currently failed:/ { f = $NF }
720
+ /Banned IP list:/ { sub(/.*Banned IP list:[ \t]*/, ""); ips = $0 }
721
+ END { print "JAIL\t" j "\t" c + 0 "\t" t + 0 "\t" f + 0 "\t" ips }'
722
+ done
723
+ else kv fail2ban stopped; fi
724
+ else kv fail2ban missing; fi
725
+ # Security: what sshd really uses (sshd -T needs root), who has a password, firewall, updates.
726
+ $S sshd -T 2>/dev/null | awk '$1 ~ /^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)$/ && !seen[$1]++ { print "sshd_" $1 "=" $2 }'
727
+ [ "$R" = 1 ] && kv password_users "$($S awk -F: '$2 ~ /^\$/ { printf "%s ", $1 }' /etc/shadow 2>/dev/null)"
728
+ ${at}
729
+ kv pkg "$(pkg_mgr)"
730
+ if command -v ufw >/dev/null 2>&1; then kv ufw "$($S ufw status 2>/dev/null | awk 'NR == 1 { print $2 }')"; else kv ufw missing; fi
731
+ kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
732
+ if command -v apt-get >/dev/null 2>&1; then
733
+ if dpkg -s unattended-upgrades >/dev/null 2>&1 && apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; then kv auto_updates on; else kv auto_updates off; fi
734
+ elif command -v dnf >/dev/null 2>&1 && [ "$init" = systemd ]; then
735
+ # dnf-automatic.timer only downloads unless automatic.conf says apply_updates.
736
+ if systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null \
737
+ || { { systemctl is-enabled -q dnf-automatic.timer 2>/dev/null || systemctl is-enabled -q dnf5-automatic.timer 2>/dev/null; } \
738
+ && 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
739
+ fi
740
+ # Keys of every user with a home: /root and /home/* (deploy users live there).
741
+ for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
742
+ u=$(printf %s "$f" | sed 's#^/root/.*#root#; s#^/home/\([^/]*\)/.*#\1#')
743
+ { cat "$f" 2>/dev/null || $S cat "$f" 2>/dev/null; } | while IFS= read -r l; do
744
+ case "$l" in ''|'#'*) ;; *) printf 'KEY\t%s\t%s\n' "$u" "$l" ;; esac
745
+ done
746
+ done
747
+ exit 0
748
+ `,Za=[`docker`,`containerd`,`ssh`,`sshd`,`fail2ban`,`ufw`,`unattended-upgrades`,`cron`,`crond`,`systemd-timesyncd`,`chronyd`,`chrony`],Qa=e=>e.replace(/\.service$/,``),$a=/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-\S+@openssh\.com)$/;function eo(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=>$a.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:${ae(`sha256`).update(a).digest(`base64`).replace(/=+$/,``)}`,comment:t.slice(i+2).join(` `),options:t.slice(0,i).join(` `),line:e.trim()}}function to(e){let t=/* @__PURE__ */ new Set;for(let n of Y(e).identityFiles)try{let e=eo(o.readFileSync(`${n}.pub`,`utf8`));e&&t.add(e.fingerprint)}catch{}return t}function no(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(`
749
+ `)){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=eo(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`},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 ro(e){let{stdout:t}=await q(e,Xa,{capture:!0});return no(t,to(e))}async function io(e){let t=await ro(e);if(t.root)return t;let n=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==n&&await J({...e,user:t}))return ro({...e,user:t});return t}const ao=String.raw`
750
+ svc() { # svc <verb> <name>
751
+ if [ -d /run/systemd/system ]; then $SUDO systemctl "$1" "$2"
752
+ elif command -v rc-service >/dev/null 2>&1; then $SUDO rc-service "$2" "$1"
753
+ else log "no systemd or OpenRC: cannot $1 $2"; return 1; fi
754
+ }
755
+ `,oo=/^[A-Za-z0-9@_.:-]+$/;async function so(e,t,n){oo.test(n)||D(`not a service name: ${n}`);let r=t===`start`?`stop`:t===`stop`?`start`:``,i=`${t} service ${n}`;await q(e,[G,ao,`svc ${t} ${K(n)}`,`record ${K(i)}${r?` ${K(`${ao}\nsvc ${r} ${K(n)}`)}`:``}`].join(`
756
+ `))}function co(e,t){oo.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 ${K(e)} -n ${r} -f --no-pager 2>/dev/null || ${n} tail -n ${r} -f /var/log/${K(Qa(e))}.log /var/log/messages`:`${n} journalctl -u ${K(e)} -n ${r} --no-pager 2>/dev/null || ${n} tail -n ${r} /var/log/${K(Qa(e))}.log /var/log/messages 2>/dev/null`}const lo=`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)
757
+ [ -n "$home" ] || { log "no user $KEY_USER"; exit 1; }
758
+ f="$home/.ssh/authorized_keys"`;async function uo(e,t,n){eo(n)||D(`not a public key: expected a line like ssh-ed25519 AAAA… comment`);let r=String.raw`
759
+ ${lo}
760
+ if $SUDO grep -qxF -- "$KEY" "$f" 2>/dev/null; then log "already there"; exit 0; fi
761
+ $SUDO mkdir -p "$home/.ssh"; $SUDO chmod 700 "$home/.ssh"
762
+ printf '%s\n' "$KEY" | $SUDO tee -a "$f" >/dev/null
763
+ g=$(id -gn "$KEY_USER" 2>/dev/null || echo "$KEY_USER")
764
+ $SUDO chown "$KEY_USER:$g" "$home/.ssh" "$f"; $SUDO chmod 600 "$f"
765
+ record "authorized key $WHAT for $KEY_USER" "drop_line $(printf %q "$f") $(printf %q "$KEY")"
766
+ `,i=eo(n);await q(e,G+r,{env:{KEY_USER:t,KEY:i.line,WHAT:i.comment||i.fingerprint}})}async function fo(e,t){let n=String.raw`
767
+ ${lo}
768
+ $SUDO grep -qxF -- "$KEY" "$f" || { log "key not found in $f"; exit 1; }
769
+ drop_line "$f" "$KEY"
770
+ record "removed key $WHAT of $KEY_USER" "printf '%s\n' $(printf %q "$KEY") | \$SUDO tee -a $(printf %q "$f") >/dev/null"
771
+ `;await q(e,G+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const po=/^[0-9a-fA-F.:]+$/;async function mo(e,t,n){po.test(t)||D(`not an IP address: ${t}`);let r=n.map(e=>`\\$SUDO fail2ban-client set ${K(e)} banip ${K(t)} >/dev/null`).join(`; `);await q(e,[G,...n.map(e=>`$SUDO fail2ban-client set ${K(e)} unbanip ${K(t)} >/dev/null`),`record ${K(`unbanned ${t}`)} "${r}"`].join(`
772
+ `))}function ho(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 go=`/swapfile none swap sw 0 0`,_o=e=>String.raw`
773
+ if [ -e /swapfile ]; then log "/swapfile exists already: lf swap off first"; exit 1; fi
774
+ mk() { # fallocate is instant but makes files swapon refuses on some filesystems (btrfs, older xfs): dd then
775
+ { $SUDO fallocate -l ${e}M /swapfile 2>/dev/null || $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none; } &&
776
+ $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile
777
+ }
778
+ if ! mk; then
779
+ $SUDO rm -f /swapfile
780
+ $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none && $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile \
781
+ || { $SUDO rm -f /swapfile; log "could not turn on swap (a container, or a filesystem without swap files?)"; exit 1; }
782
+ fi
783
+ record "created ${e} MB swap at /swapfile" '$SUDO swapoff /swapfile 2>/dev/null; $SUDO rm -f /swapfile'
784
+ if ! grep -qxF '${go}' /etc/fstab 2>/dev/null; then
785
+ printf '%s\n' '${go}' | $SUDO tee -a /etc/fstab >/dev/null
786
+ record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${go}'"
787
+ fi
788
+ `,vo=(e,t)=>q(e,G+_o(Math.round(t/1024/1024))),yo=String.raw`
789
+ [ -e /swapfile ] || { log "no /swapfile: landfall only removes the swap file it made"; exit 1; }
790
+ $SUDO swapoff /swapfile
791
+ $SUDO rm -f /swapfile
792
+ drop_line /etc/fstab '${go}'
793
+ record "removed the swap file /swapfile"
794
+ `,bo=e=>q(e,G+yo),xo=/^(?=.{1,63}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,So=e=>`$SUDO hostnamectl set-hostname ${K(e)} 2>/dev/null || { printf '%s\\n' ${K(e)} | $SUDO tee /etc/hostname >/dev/null && $SUDO hostname ${K(e)}; }`;async function Co(e,t,n){xo.test(n)||D(`not a host name: ${n} (letters, digits and -, up to 63)`),await q(e,[G,So(n),`record ${K(`hostname ${t} → ${n}`)} ${K(So(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'${K(n)}'/' /etc/hosts`,` record "named ${n} in /etc/hosts" "$undo"`,`fi`].join(`
795
+ `))}const wo=/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/,To=e=>`$SUDO timedatectl set-timezone ${K(e)} 2>/dev/null || { $SUDO ln -sf /usr/share/zoneinfo/${K(e)} /etc/localtime && printf '%s\\n' ${K(e)} | $SUDO tee /etc/timezone >/dev/null; }`;async function Eo(e,t,n){wo.test(n)||D(`not a time zone: ${n} (e.g. Europe/Berlin, UTC)`),await q(e,[G,`[ -e /usr/share/zoneinfo/${K(n)} ] || { log ${K(`unknown time zone ${n} (or tzdata is not installed)`)}; exit 1; }`,To(n),`record ${K(`time zone ${t} → ${n}`)} ${K(To(t))}`].join(`
796
+ `))}const Do=/^\d+[kmg]$/;function Oo(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 ko=`$SUDO systemctl restart docker 2>/dev/null || $SUDO rc-service docker restart 2>/dev/null || $SUDO service docker restart`;async function Ao(e,t,n,r){let i=JSON.stringify(t,null,2)+`
797
+ `,a=String.raw`
798
+ $SUDO install -d /etc/docker
799
+ undo=$(saved /etc/docker/daemon.json) || exit 1
800
+ printf %s ${K(Buffer.from(i).toString(`base64`))} | base64 -d | $SUDO tee /etc/docker/daemon.json >/dev/null
801
+ ${r?`log "restarting docker"\n${ko}\nrecord ${K(n)} "$undo; ${ko.replace(/\$/g,`\\$`)}"`:`record ${K(n)} "$undo"`}
802
+ `;await q(e,G+a)}const jo=[`ssh`,`fail2ban`,`updates`],Mo=e=>e.length>1?`${e.slice(0,-1).join(`, `)} and ${e.at(-1)}`:e[0],No=e=>`${Mo(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.`;function Po(e,t){let n=new Set(e.keys.map(e=>e.user));return(e.security.passwordUsers??[]).filter(e=>e!==`root`&&e!==t&&!n.has(e))}function Fo(e,t){let n=e.security,r=[`-s`,t.name],i=[],a=n.pkg===`apt-get`||n.pkg===`apk`;if(n.passwordLogin){let a=Po(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?` ${No(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:`No ufw`,why:`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]}}),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 o={risk:0,warn:1,info:2};return i.sort((e,t)=>o[e.level]-o[t.level])}function Io(e){for(let t of e){let e=t.level===`risk`?x.red(`!`):t.level===`warn`?x.yellow(`!`):x.dim(`·`);console.log(`${e} ${x.bold(t.title)}\n ${x.dim(t.why)}${t.fix?`\n ${x.cyan(`lf ${t.fix.argv.join(` `)}`)}`:``}`)}}async function Lo(e,t,n){let r=n?await ro(n):await io(t);return{...r,advice:Fo(r,{name:e,loginUser:Y(t).user})}}async function Ro(e,t,n){try{let r=(await Lo(e,t,n)).advice;if(ue({advice:r}),!r.length)return;console.log(`\n${x.bold(`Security`)}`),Io(r)}catch{}}const zo=String.raw`
803
+ if [ "$H_SSH" = 1 ]; then
804
+ if [ -d /etc/ssh ]; then
805
+ ${ot}
806
+ else log "no /etc/ssh, ssh skipped"; fi
807
+ fi
808
+ if [ "$H_F2B" = 1 ]; then
809
+ if ! command -v fail2ban-client >/dev/null 2>&1; then
810
+ log "installing fail2ban"
811
+ pkg_install fail2ban || { echo "could not install fail2ban (RHEL-likes have it in EPEL)" >&2; exit 1; }
812
+ record "installed fail2ban"
813
+ fi
814
+ # Debian 12 logs to the journal only: the sshd jail's /var/log/auth.log never appears and fail2ban will not start.
815
+ jail=/etc/fail2ban/jail.d/00-landfall.local
816
+ if [ ! -e /var/log/auth.log ] && [ ! -e /var/log/secure ] && [ -d /run/systemd/system ] && [ ! -e $jail ]; then
817
+ printf '[sshd]\nbackend = systemd\n' | $SUDO tee $jail >/dev/null
818
+ record "fail2ban reads sshd's log from the journal" "\$SUDO rm -f $jail"
819
+ fi
820
+ if ! $SUDO fail2ban-client ping >/dev/null 2>&1; then
821
+ log "starting fail2ban"
822
+ if [ -d /run/systemd/system ]; then
823
+ $SUDO systemctl enable fail2ban >/dev/null 2>&1 || true; $SUDO systemctl restart fail2ban || true
824
+ undo='$SUDO systemctl disable --now fail2ban'
825
+ else
826
+ $SUDO rc-update add fail2ban default >/dev/null 2>&1 || true; $SUDO rc-service fail2ban start >/dev/null || true
827
+ undo='$SUDO rc-service fail2ban stop; $SUDO rc-update del fail2ban default'
828
+ fi
829
+ up=0; for _ in 1 2 3 4 5 6; do $SUDO fail2ban-client ping >/dev/null 2>&1 && { up=1; break; }; sleep 1; done
830
+ if [ $up = 1 ]; then record "started fail2ban" "$undo"
831
+ else echo "fail2ban did not start; see: lf services logs fail2ban" >&2; exit 1; fi
832
+ fi
833
+ fi
834
+ if [ "$H_UPDATES" = 1 ]; then
835
+ case "$(pkg_mgr)" in
836
+ apt-get)
837
+ pkg_has unattended-upgrades || { log "installing unattended-upgrades"; pkg_install unattended-upgrades; record "installed unattended-upgrades"; }
838
+ if ! apt-config dump | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; then
839
+ log "turning on automatic security updates"
840
+ f=/etc/apt/apt.conf.d/20auto-upgrades
841
+ undo=$(saved $f)
842
+ printf '%s\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Unattended-Upgrade "1";' | $SUDO tee $f >/dev/null
843
+ record "turned on automatic security updates" "$undo"
844
+ fi ;;
845
+ dnf)
846
+ pkg_has dnf-automatic || { log "installing dnf-automatic"; pkg_install dnf-automatic && record "installed dnf-automatic"; }
847
+ if systemctl cat dnf-automatic-install.timer >/dev/null 2>&1; then
848
+ if ! systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null; then
849
+ $SUDO systemctl enable --now dnf-automatic-install.timer >/dev/null
850
+ record "turned on automatic updates" '$SUDO systemctl disable --now dnf-automatic-install.timer'
851
+ fi
852
+ else
853
+ echo "set apply_updates = yes in /etc/dnf/automatic.conf, then: systemctl enable --now dnf5-automatic.timer" >&2; exit 1
854
+ fi ;;
855
+ *) echo "automatic updates: landfall knows apt and dnf; on $(pkg_mgr) set them up by hand" >&2; exit 1 ;;
856
+ esac
857
+ fi
858
+ `;async function Bo(e,t){await q(e,G+zo,{env:{H_SSH:t.includes(`ssh`),H_F2B:t.includes(`fail2ban`),H_UPDATES:t.includes(`updates`)}})}function Vo(e,t){/^[a-z_][a-z0-9_.-]*$/i.test(t)||D(`not a user name: ${t}`);let n=m(c(),`.ssh`,`${Me(e)}_${t}`),r=pe(),i=Ka(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()}}const Ho=e=>e.steps.filter(e=>e.undo&&!e.undone);function Uo(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
859
+ `).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 Wo=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Go=RegExp(`${Wo(V)}/[A-Za-z0-9._-]+|${Wo(H)}`,`g`);function Ko(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(Go))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function qo(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ko(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=Ho(n).flatMap(e=>[...Ko(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Jo(e){let{stdout:t}=await q(e,`cat ${W} 2>/dev/null || sudo -n cat ${W} 2>/dev/null || true`,{capture:!0});return Uo(t)}function Yo(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 Xo(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 q(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 J(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Zo=String.raw`
678
860
  undo_step() { # undo_step <line> <undo shell, base64>
679
861
  # Two undos at once must not both revert a step: the second waits here, then finds it done.
680
- [ -e ${V}/.undo.lock ] || touch ${V}/.undo.lock 2>/dev/null || true
681
- if [ -r ${V}/.undo.lock ] && command -v flock >/dev/null 2>&1; then exec 9<${V}/.undo.lock; flock 9; fi
682
- if awk -F'\t' -v l="$1" '$6 == "undo" && $7 == l {f = 1} END {exit !f}' ${G} 2>/dev/null; then
862
+ [ -e ${B}/.undo.lock ] || touch ${B}/.undo.lock 2>/dev/null || true
863
+ if [ -r ${B}/.undo.lock ] && command -v flock >/dev/null 2>&1; then exec 9<${B}/.undo.lock; flock 9; fi
864
+ if awk -F'\t' -v l="$1" '$6 == "undo" && $7 == l {f = 1} END {exit !f}' ${W} 2>/dev/null; then
683
865
  echo "journal line $1 was already undone" >&2
684
866
  else
685
867
  set +e
@@ -690,12 +872,9 @@ undo_step() { # undo_step <line> <undo shell, base64>
690
872
  _journal undo "$1" ""
691
873
  fi
692
874
  exec 9<&-
693
- }`;async function Wa(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 Ha(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(yt)),await J(s,[K,Ua,...o].join(`
694
- `),{sudo:c}),r.push(s),t=i}return n.length}function Ga(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 Ka=e=>e.trim().replace(/\\(.)/g,`$1`),qa=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function Ja(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await da(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(`
695
- `)){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=oi(Ka(e[1])),n=i.findIndex(e=>qa(e,t));n>=0&&i.splice(n,1)}else if(n){let e=oi(Ka(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 Ca({...r,ufw:a},i,n)}const Ya=e=>`${S.bold(`#${e.n}`.padEnd(5))} ${S.dim(he(e.time).padEnd(9))} ${S.bold(e.cmd||`?`)} ${S.dim(`${e.actor} · ${e.id}`)}`,Xa=M({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:N,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}=R(t.server);if(t.json)return console.log(JSON.stringify(await cc(r,Number(t.n)||20),null,2));let i=(await Ba(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(Ya(e)),e.steps.forEach((e,t)=>console.log(Ga(e,t)));let a=i.find(e=>Pa(e).length);a&&console.log(S.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),Za=M({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:N,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}=R(t.server),i=await Ba(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?Va(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Pa(e.run).length);if(!r)return w(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${he(r.run.time)})`;let s=za(i,r.run.n,a);s.length&&!t.force&&O(`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&&Pa(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 Ja(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await k(l,n):await A(l,!1)||O(`cancelled`),await Wa(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`))}}),Qa=/(^|\.)(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht|dev\.azure\.com|visualstudio\.com)$|^(git|gitlab|gitea|forgejo)\./i;function $a(e){if(Qa.test(e))return!0;try{let t=Y({host:e});return t.user===`git`||Qa.test(t.hostname)}catch{return!1}}const eo=/^(localhost|127(\.\d+){3}|::1|0:0:0:0:0:0:0:1)$/i;function to(e){if(eo.test(e))return!0;try{return eo.test(Y({host:e}).hostname)}catch{return!1}}function no(){let e=L(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=It().filter(t=>!e.servers[t]&&!$a(t)&&!to(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}const ro=/* @__PURE__ */ new Map;async function io(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=ro.get(e);if(t||!r||Date.now()-r.at>3e4){let t=(async()=>{try{return await Et(R(e).server)}catch(e){return{state:`down`,detail:e.message,ms:0}}})();ro.set(e,r={at:Date.now(),r:t})}n[e]=await r.r}})),n}function ao(e){try{let t=Y(R(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function oo(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)}let so;function co(){if(so)return so;let e=o.mkdtempSync(m(u(),`landfall-`));return so=m(e,`askpass`),o.writeFileSync(so,`#!/bin/sh
696
- printf '%s\\n' "$LANDFALL_PASSWORD"
697
- `,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),so}const lo=e=>({SSH_ASKPASS:co(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function uo(e){if(e){let e=(await b()).replace(/\r?\n$/,``);return e||O(`--password-stdin: nothing on stdin`),e}return we(`PASSWORD`)||void 0}const fo=e=>m(c(),`.ssh`,`landfall_${je(e)}`);function po(e){let t=fo(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&O(`ssh-keygen failed`),{path:t,created:!0})}async function mo(e,t,n){let i=Y(t),a=t.key?De(t.key):void 0;if(a&&!o.existsSync(a)&&O(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&O(`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 Tt(t);if(!s&&!t.key&&o.existsSync(fo(e))&&await Tt({...t,key:fo(e)})&&(t.key=a=fo(e),s=!0),s)w(`key login works`),a??=Ft(t);else if(n.check){E("no key login yet — `lf setup` would install a key first"),le({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=po(e);a=t.path,c=t.created,c&&w(`generated key ${I(a)}`)}let l=t.key&&De(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await Tt(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,...lo(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...ht(t),mt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await Tt(t)||O(`could not log in with the key — check host, user and password`),w(`key installed`)}return a}const ho=e=>{for(let t of e)console.log(S.dim(` `)+t)};function go(e,t,n,r){let i=Y(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Ht({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});w(`${r.created?`created`:`updated`} ${I(Nt)} with ${S.bold(`Host ${e}`)} — try \`ssh ${e}\``),ho(r.block.split(`
698
- `)),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!==Ft({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Wt(e,n):void 0;s?(w(`added the key to your ${S.bold(`Host ${e}`)} in ${I(s.file)} ${S.dim(`(backup: ${I(s.backup)})`)}`),ho([...s.removed.map(e=>S.red(`- ${e}`)),S.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=I(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 _o=M({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
875
+ }`;async function Qo(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} ${K(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Xo(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(Ct)),await q(s,[G,Zo,...o].join(`
876
+ `),{sudo:c}),r.push(s),t=i}return n.length}function $o(e,t){let n=x.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${x.dim(`↩ `+e.what)}`:e.undone?`${n} ${x.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${x.cyan(`↺`)} ${e.what}`:`${n} ${x.dim(`· `+e.what+` (not reversible)`)}`}const es=e=>e.trim().replace(/\\(.)/g,`$1`),ts=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function ns(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await _a(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(`
877
+ `)){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=fi(es(e[1])),n=i.findIndex(e=>ts(e,t));n>=0&&i.splice(n,1)}else if(n){let e=fi(es(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 ka({...r,ufw:a},i,n)}const rs=e=>`${x.bold(`#${e.n}`.padEnd(5))} ${x.dim(ge(e.time).padEnd(9))} ${x.bold(e.cmd||`?`)} ${x.dim(`${e.actor} · ${e.id}`)}`,is=j({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:M,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}=L(t.server);if(t.json)return console.log(JSON.stringify(await Ec(r,Number(t.n)||20),null,2));let i=(await Jo(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return w(`nothing in ${W} on ${n} yet`);for(let e of i)console.log(rs(e)),e.steps.forEach((e,t)=>console.log($o(e,t)));let a=i.find(e=>Ho(e).length);a&&console.log(x.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),as=j({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:M,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}=L(t.server),i=await Jo(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?Yo(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Ho(e.run).length);if(!r)return C(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${ge(r.run.time)})`;let s=qo(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&&Ho(e).length);c.length&&T(`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 C(`nothing left to undo in ${o}`);S(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${x.dim(`· ${e.what} stays (not reversible)`)}`);await ns(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await O(l,n):await k(l,!1)||D(`cancelled`),await Qo(r,s),C(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&w(x.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),os=/(^|\.)(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht|dev\.azure\.com|visualstudio\.com)$|^(git|gitlab|gitea|forgejo)\./i;function ss(e){if(os.test(e))return!0;try{let t=Y({host:e});return t.user===`git`||os.test(t.hostname)}catch{return!1}}const cs=/^(localhost|127(\.\d+){3}|::1|0:0:0:0:0:0:0:1)$/i;function ls(e){if(cs.test(e))return!0;try{return cs.test(Y({host:e}).hostname)}catch{return!1}}function us(){let e=I(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=Ht().filter(t=>!e.servers[t]&&!ss(t)&&!ls(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}const ds=/* @__PURE__ */ new Map;async function fs(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=ds.get(e);if(t||!r||Date.now()-r.at>3e4){let t=(async()=>{try{return await kt(L(e).server)}catch(e){return{state:`down`,detail:e.message,ms:0}}})();ds.set(e,r={at:Date.now(),r:t})}n[e]=await r.r}})),n}function ps(e){try{let t=Y(L(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function ms(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 hs=j({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
699
878
 
700
879
  Without --host, <name> is used as an ssh alias from ~/.ssh/config,
701
880
  so \`lf server add vps\` works whenever \`ssh vps\` works.
@@ -703,25 +882,25 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
703
882
  With --host lf logs in with --key, or installs its own key
704
883
  (~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
705
884
  to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
706
- 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||O(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Ie(t.domain),r=L(),i=r.servers[e],a=It().includes(e)&&!Kt(e);t.host&&a&&O(`${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 uo(t[`password-stdin`]),c=await mo(e,o,{check:!s&&!fe(),password:s}),l=c?go(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=Fe(t.email)),n&&(l.domains=ze(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),Oe(r),w(`added ${S.bold(e)}${r.default===e?S.dim(` (default)`):``}`),T(c?S.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${S.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),vo=M({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.
885
+ 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&&Le(t.domain),r=I(),i=r.servers[e],a=Ht().includes(e)&&!Qt(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 Ua(t[`password-stdin`]),c=await qa(e,o,{check:!s&&!pe(),password:s}),l=c?Ya(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=Ie(t.email)),n&&(l.domains=Be(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),ke(r),C(`added ${x.bold(e)}${r.default===e?x.dim(` (default)`):``}`),c?(w(x.dim(`next: lf setup ${e} --check shows what setup would install`)),await Ro(e,l)):w(`then: ${x.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),gs=j({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.
707
886
 
708
887
  Git hosts (github.com, gitlab.*, User git …) are left out: they are not servers.
709
888
  --check logs in to each with its key (5s timeout, 8 at a time):
710
889
  ok key login works
711
890
  login sshd answers but refuses the key (lf setup installs one)
712
- 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=L(),r=no().filter(e=>e.registered||t.all),i=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`,a=t.check?await io(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`:Kt(e.name)?`ssh (landfall)`:`ssh`,...t?[`${o[t.state](t.state)} ${S.dim(t.state===`ok`?`${t.ms}ms`:t.detail)}`]:[]])}j(s),t.all||console.log(S.dim(`
713
- Any ~/.ssh/config alias works too (--all to list them).`))}}),yo=M({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||O(`usage: lf server rm <name>`),!L().servers[e]&&!Kt(e)&&O(`unknown server "${e}"`),await k(`Forget server ${e}? Nothing on the server changes.`),ke(e),t[`ssh-config`]&&Gt(e)&&w(`removed Host ${e} from ~/.ssh/config`),w(`removed ${e}`)}}),bo=/^(setup|traefik setup|traefik use)( |$)/,xo=M({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${V} and what setup changed.
891
+ 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=I(),r=us().filter(e=>e.registered||t.all),i=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`,a=t.check?await fs(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 — ${x.bold(`lf server add <ssh-alias>`)} or ${x.bold(`lf setup <name> --host <ip>`)}`);return}let o={ok:x.green,login:x.yellow,down:x.red},s=[[x.dim(`NAME`),x.dim(`TARGET`),x.dim(`SOURCE`),...t.check?[x.dim(`STATUS`)]:[]]];for(let e of r){let t=a[e.name];s.push([e.name+(e.isDefault?x.green(` *`):``),e.registered?i(n.servers[e.name]):e.name,e.registered?`landfall`:Qt(e.name)?`ssh (landfall)`:`ssh`,...t?[`${o[t.state](t.state)} ${x.dim(t.state===`ok`?`${t.ms}ms`:t.detail)}`]:[]])}A(s),t.all||console.log(x.dim(`
892
+ Any ~/.ssh/config alias works too (--all to list them).`))}}),_s=j({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>`),!I().servers[e]&&!Qt(e)&&D(`unknown server "${e}"`),await O(`Forget server ${e}? Nothing on the server changes.`),Ae(e),t[`ssh-config`]&&Zt(e)&&C(`removed Host ${e} from ~/.ssh/config`),C(`removed ${e}`)}}),vs=/^(setup|traefik setup|traefik use)( |$)/,ys=j({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${B} and what setup changed.
714
893
 
715
894
  Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
716
895
  adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
717
896
  alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
718
- and deletes ${V}, its journal included. Then forgets the server locally.
719
- 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||O(`usage: lf server uninstall <name>`);let{name:n,server:r}=R(e),i=await Ha(r,`root`),[a,s,c]=await Promise.all([ur(r),Ri(r),Ba(r)]);s.compose===`foreign`&&O(`${U}/docker-compose.yml was not written by landfall: move it out of ${V} first`);let l=t[`keep-setup`]?[]:c.filter(e=>bo.test(e.cmd)).flatMap(Pa).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 Ja(r,l,t.force),await k(`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))}`]:wr(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(`
720
- `)),l.length&&(C(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await Wa(r,l)),await J(i.login,`rm -rf ${V}`,{sudo:i.sudo}),w(`landfall removed from ${n}`),ke(n),Gt(n)&&w(`removed Host ${n} from ~/.ssh/config`),w(`forgot ${n}`),o.existsSync(fo(n))&&T(S.dim(`${I(fo(n))} stays: root on ${n} still accepts it`))}}),So=M({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||O(`usage: lf server use <name>`),Ae(e),w(`default server: ${S.bold(e)}`)}}),Co=M({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}=R(e);return St(r,n)}}),wo=M({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}=R(e);if(t.json)return void console.log(JSON.stringify(await rc(n,r),null,2));let i=await Zt(r),a=await en(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`,`${fn(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 To(e){let t=L(),n=t.servers[e],r=n??{host:e},i=Y(r),a=Ut(r.host),s=Pi(r,{});return{name:e,isDefault:t.default===e,configFile:I(F),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:Ce(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:Fi(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:I(e),exists:o.existsSync(De(e))})),block:a?{file:I(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
721
- `)}:null}}}const Eo=M({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
897
+ and deletes ${B}, its journal included. Then forgets the server locally.
898
+ 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 `+B},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}=L(e),i=await Xo(r,`root`),[a,s,c]=await Promise.all([gr(r),Wi(r),Jo(r)]);s.compose===`foreign`&&D(`${H}/docker-compose.yml was not written by landfall: move it out of ${B} first`);let l=t[`keep-setup`]?[]:c.filter(e=>vs.test(e.cmd)).flatMap(Ho).filter(e=>!e.undo.includes(B));S(`uninstall landfall from ${x.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(` ${x.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${x.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${x.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);console.error(` ${x.red(`-`)} delete ${B}, its journal included`),console.error(` ${x.red(`-`)} forget ${n} locally`),console.error(` ${x.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await ns(r,l,t.force),await O(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[G];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${K(Z(e.name))}`]:Ar(e,{volumes:t.volumes});u.push(`log ${K(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${K(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${H} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await q(r,u.join(`
899
+ `)),l.length&&(S(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await Qo(r,l)),await q(i.login,`rm -rf ${B}`,{sudo:i.sudo}),C(`landfall removed from ${n}`),Ae(n),Zt(n)&&C(`removed Host ${n} from ~/.ssh/config`),C(`forgot ${n}`),o.existsSync(Wa(n))&&w(x.dim(`${F(Wa(n))} stays: root on ${n} still accepts it`))}}),bs=j({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>`),je(e),C(`default server: ${x.bold(e)}`)}}),xs=j({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}=L(e);return Et(r,n)}}),Ss=j({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}=L(e);if(t.json)return void console.log(JSON.stringify(await xc(n,r),null,2));let i=await rn(r),a=await sn(r,i),o=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,s=i.mem.total-i.mem.available;console.log(x.bold(n)+x.dim(` (${r.host})`));let c=[[`os`,`${i.os}${i.pkg===`none`?``:x.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${vn(i.uptime)}${i.reboot?x.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?x.dim(` · root or sudo`):x.dim(` · root's key for admin work`):x.yellow(` · no sudo`)}`],[`init`,i.init===`other`?x.dim(`no systemd or OpenRC`):i.init],[`docker`,i.docker?`${i.docker}${i.compose?x.dim(` · compose ${i.compose}`):x.yellow(` · no compose`)}`:a.why.docker],[`firewall`,i.ufw?`ufw`:a.can.firewall?x.dim(`none (lf setup installs ufw)`):x.dim(a.why.firewall)]];for(let[e,t]of c)console.log(`${x.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`eol`||i.support.level===`unsupported`?T:w)(i.support.note),i.docker&&(console.log(),await q(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Cs(e){let t=I(),n=t.servers[e],r=n??{host:e},i=Y(r),a=Yt(r.host),s=Bi(r,{});return{name:e,isDefault:t.default===e,configFile:F(P),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:we(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:Vi(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:F(e),exists:o.existsSync(Oe(e))})),block:a?{file:F(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
900
+ `)}:null}}}const ws=j({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
722
901
  the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
723
- 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}=R(e);if(t.json)return void console.log(JSON.stringify(await nc(n,r),null,2));let i=To(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(`
724
- `))console.log(` ${e}`)}else console.log(`\n${S.bold(`ssh config`)} ${S.dim(`no Host block; ssh uses its defaults`)}`)}});async function Do(e,t){let n=await cn(e),[r,i]=await dn([t,`landfall-${ae(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function Oo(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 ko=async e=>We(await Oo(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Ao=M({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
902
+ 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}=L(e);if(t.json)return void console.log(JSON.stringify(await bc(n,r),null,2));let i=Cs(n),a=(e,t)=>console.log(` ${x.dim(e.padEnd(10))} ${t}`);if(console.log(`${x.bold(n)}${i.isDefault?x.dim(` (default)`):``}`),console.log(`\n${x.bold(`landfall`)} ${x.dim(i.configFile)}`),!i.saved)a(`entry`,x.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`,x.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${x.dim(`(primary: new sites get <site>.${e})`)}`)),a(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${i.tunnelOnlyOwn?``:x.dim(` (landfall default, lf config)`)}`),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${x.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:x.dim(`off`)),console.log(`\n${x.bold(`ssh`)} ${x.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} ${x.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${x.bold(`ssh config`)} ${x.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(`
903
+ `))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function Ts(e,t){let n=await mn(e),[r,i]=await _n([t,`landfall-${oe(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function Es(e){let{stdout:t}=await q(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 Ds=async e=>Ge(await Es(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Os=j({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
725
904
 
726
905
  The first main domain is the primary: \`lf deploy\` serves a new site at
727
906
  <site>.<primary>, \`--domain blog\` means blog.<primary>, and
@@ -737,10 +916,10 @@ the server's sites already use.
737
916
  --tunnel-only makes new sites deploy with no domain unless -d gives one:
738
917
  nothing is served on the internet, \`lf tunnel <site>\` reaches them.
739
918
  --no-tunnel-only goes back to <site>.<primary>. Existing sites keep theirs.
740
- 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=L(),r=e.length>0&&!(e=>!!n.servers[e]||It().includes(e))(e[0])&&Me.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=R(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===(Se().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,Oe(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(Ie),i=t.clear?[]:a.length?a.map(Ie):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Ie)])].filter(e=>!r.includes(e)),i=ze(i,t.primary&&Ie(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 k(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,Oe(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 ko(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=>Do(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(`
919
+ 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=I(),r=e.length>0&&!(e=>!!n.servers[e]||Ht().includes(e))(e[0])&&Ne.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=L(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===(Ce().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,ke(n),s.tunnelOnly=e.tunnelOnly,C(c?`new sites on ${x.bold(o)} get no domain: reach them with lf tunnel, or deploy with -d`:`new sites on ${x.bold(o)} get a domain again`),!l)return}if(l){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Le),i=t.clear?[]:a.length?a.map(Le):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Le)])].filter(e=>!r.includes(e)),i=Be(i,t.primary&&Le(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&T(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await O(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,ke(n),s.domains=e.domains,C(i.length?`main domains of ${x.bold(o)}: ${i.map((e,t)=>t?e:x.bold(e)).join(`, `)}${i.length>1?x.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let u=s.domains??[];if(!u.length){let e=await Ds(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:u,suggest:e}));w(`${o} has no main domain yet`);for(let t of e)w(`${t.hosts} routed hosts use ${x.bold(t.domain)} — ${x.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||w(`set one: ${x.bold(`lf server domain ${o} example.com`)}`);return}let d=await Promise.all(u.map(e=>Ts(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?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`);console.log(`${e.domain.padEnd(f)} ${r}${e===n.apex&&t===0&&u.length>1?x.dim(` primary`):``}`),e.ok||p.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),p.length&&console.log(x.dim(`
741
920
  add these DNS records at your DNS provider:
742
921
  `)+p.join(`
743
- `));let m=Be(s);T(S.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}}),jo=String.raw`
922
+ `));let m=Ve(s);w(x.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}}),ks=String.raw`
744
923
  set +e
745
924
  T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
746
925
  kv() { printf '%s=%s\n' "$1" "$2"; }
@@ -773,18 +952,18 @@ if [ -n "$D" ]; then
773
952
  docker ps -a --format 'CTR\t{{.Names}}\t{{.Status}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.Image}}'
774
953
  ids=$(docker ps -aq)
775
954
  [ -z "$ids" ] || docker inspect -f 'RST{{"\t"}}{{.Name}}{{"\t"}}{{.RestartCount}}' $ids 2>/dev/null
776
- for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
955
+ for f in ${V}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
777
956
  fi
778
957
  exit 0
779
- `,Mo={b:1,kb:1e3,mb:1e6,gb:1e9,tb:0xe8d4a51000,kib:1024,mib:1024**2,gib:1024**3,tib:1024**4};function No(e){let t=/^([\d.]+)\s*([a-z]*)$/i.exec(e.trim());return t?Math.round(Number(t[1])*(Mo[(t[2]||`b`).toLowerCase()]??1)):0}const Po=(e=``)=>e.split(` / `).map(No),Fo=/^(lo|veth|docker|br-|virbr|cni|flannel|cali|vxlan)/;function Io(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(`
780
- `)){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(lr(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,_=0,re=0,v=0]=(n.mem??``).split(/\s+/).map(e=>Number(e)*1024||0),ie=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?ie(100*(ee-te)/ee):0,mem:{total:g,available:_,swapTotal:re,swapFree:v},disks:s,net:[...a[1]].filter(([e])=>!Fo.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?ie(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]=Po(a[3]),[u,f]=Po(a[4]),[p,m]=Po(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 Lo(e){let{stdout:t}=await J(e,jo,{capture:!0});return Io(t)}const Ro=String.raw`
958
+ `,As={b:1,kb:1e3,mb:1e6,gb:1e9,tb:0xe8d4a51000,kib:1024,mib:1024**2,gib:1024**3,tib:1024**4};function js(e){let t=/^([\d.]+)\s*([a-z]*)$/i.exec(e.trim());return t?Math.round(Number(t[1])*(As[(t[2]||`b`).toLowerCase()]??1)):0}const Ms=(e=``)=>e.split(` / `).map(js),Ns=/^(lo|veth|docker|br-|virbr|cni|flannel|cali|vxlan)/;function Ps(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(`
959
+ `)){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(hr(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),ae=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?ae(100*(ee-te)/ee):0,mem:{total:g,available:re,swapTotal:ie,swapFree:_},disks:s,net:[...a[1]].filter(([e])=>!Ns.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?ae(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]=Ms(a[3]),[u,f]=Ms(a[4]),[p,m]=Ms(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 Fs(e){let{stdout:t}=await q(e,ks,{capture:!0});return Ps(t)}const Is=String.raw`
781
960
  set +e
782
961
  S=""; [ "$(id -u)" -eq 0 ] || { sudo -n true 2>/dev/null && S="sudo -n"; }
783
962
  kv() { printf '%s=%s\n' "$1" "$2"; }
784
963
  kv root "$([ "$(id -u)" -eq 0 ] || [ -n "$S" ] && echo 1)"
785
964
  df -Pk 2>/dev/null | awk 'NR > 1 && $1 ~ /^\// && $6 !~ /^\/(snap|boot)(\/|$)/ && !seen[$1]++ { print "DISK\t" $6 "\t" $2 "\t" $3 "\t" $4 }'
786
- kv landfall "$($S du -sk ${V} 2>/dev/null | cut -f1)"
787
- for d in ${H}/*/; do
965
+ kv landfall "$($S du -sk ${B} 2>/dev/null | cut -f1)"
966
+ for d in ${V}/*/; do
788
967
  [ -d "$d" ] || continue
789
968
  n=$(basename "$d")
790
969
  printf 'SITE\t%s\t%s\t%s\t%s\n' "$n" "$($S du -sk "$d" 2>/dev/null | cut -f1)" "$($S du -sk "$d/releases" 2>/dev/null | cut -f1)" "$(ls -1 "$d/releases" 2>/dev/null | wc -l)"
@@ -800,142 +979,36 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
800
979
  done
801
980
  fi
802
981
  exit 0
803
- `;function zo(e){let t={},n={root:!1,disks:[],landfall:0,sites:[],journald:0,docker:!1,dockerDf:[],volumes:[],logs:[]};for(let r of e.split(`
804
- `)){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:No(e[4]??``),reclaimable:No((e[5]??``).split(` `)[0])}):e[0]===`VOL`?n.volumes.push({name:e[1],links:+e[2]||0,size:No(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 Bo(e){let{stdout:t}=await J(e,Ro,{capture:!0});return zo(t)}const Vo=String.raw`
982
+ `;function Ls(e){let t={},n={root:!1,disks:[],landfall:0,sites:[],journald:0,docker:!1,dockerDf:[],volumes:[],logs:[]};for(let r of e.split(`
983
+ `)){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:js(e[4]??``),reclaimable:js((e[5]??``).split(` `)[0])}):e[0]===`VOL`?n.volumes.push({name:e[1],links:+e[2]||0,size:js(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 Rs(e){let{stdout:t}=await q(e,Is,{capture:!0});return Ls(t)}const zs=String.raw`
805
984
  set +e
806
985
  command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 || { echo "NODOCKER"; exit 0; }
807
986
  docker image ls -a --no-trunc --format 'IMG\t{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}'
808
987
  ids=$(docker ps -aq)
809
988
  [ -z "$ids" ] || docker inspect -f 'USED{{"\t"}}{{.Image}}' $ids 2>/dev/null
810
989
  # The undo shell is the last field, base64: decode it to find the image IDs it puts back.
811
- { cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null; } | awk -F'\t' '{ print $NF }' \
990
+ { cat ${W} 2>/dev/null || sudo -n cat ${W} 2>/dev/null; } | awk -F'\t' '{ print $NF }' \
812
991
  | while IFS= read -r b; do [ -n "$b" ] && printf %s "$b" | base64 -d 2>/dev/null; echo; done \
813
992
  | grep -o 'sha256:[0-9a-f]\{64\}' | sort -u | sed 's/^/KEEP\t/'
814
- for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
993
+ for f in ${V}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
815
994
  docker system df --format 'DF\t{{.Type}}\t{{.Reclaimable}}' 2>/dev/null
816
995
  exit 0
817
- `,Ho=e=>e.images.filter(e=>!e.keep),Uo=(e,t)=>e.images.filter(e=>e.keep===t);function Wo(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(`
818
- `)){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:No(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(En(t.name,n.name,e.id))}catch{}else e[0]===`DF`&&/build/i.test(e[1]??``)&&(n.buildCache=No((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 Go(e,t=!1){let{stdout:n}=await J(e,Vo,{capture:!0});return Wo(n,t)}async function Ko(e,t){let n=Ho(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(`
996
+ `,Bs=e=>e.images.filter(e=>!e.keep),Vs=(e,t)=>e.images.filter(e=>e.keep===t);function Hs(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(`
997
+ `)){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:js(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(Mn(t.name,n.name,e.id))}catch{}else e[0]===`DF`&&/build/i.test(e[1]??``)&&(n.buildCache=js((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 Us(e,t=!1){let{stdout:n}=await q(e,zs,{capture:!0});return Hs(n,t)}async function Ws(e,t){let n=Bs(t),r=n.flatMap(e=>e.tags.length?e.tags:[e.id]),{stdout:i}=await q(e,[G,`set +e`,`failed=0`,...r.map(e=>`docker image rm ${K(e)} >/dev/null 2>&1 || { printf 'FAILED\\t%s\\n' ${K(e)}; failed=$((failed + 1)); }`),`docker builder prune -f${t.all?`a`:``} >/dev/null 2>&1 || true`,`record ${K(`pruned ${n.length} images and the build cache`)}`,`exit 0`].join(`
819
998
  `),{capture:!0}),a=i.split(`
820
- `).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function qo(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(`
821
- `).map(e=>e.trim()).filter(Boolean)}const Jo=String.raw`
999
+ `).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function Gs(e){let{stdout:t}=await q(e,`docker ps --format '{{.Names}}' 2>/dev/null || sudo -n docker ps --format '{{.Names}}' 2>/dev/null || true`,{capture:!0});return t.split(`
1000
+ `).map(e=>e.trim()).filter(Boolean)}const Ks=String.raw`
822
1001
  log "rebooting"
823
1002
  record "rebooted"
824
1003
  (sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
825
- `,Yo=e=>new Promise(t=>setTimeout(t,e));async function Xo(e,t){let n=await qo(e).catch(()=>[]),r=Date.now();await J(e,K+Jo),await yt(e),t.onWait?.(`down`);let i=r+t.timeout*1e3;for(;Date.now()<i&&Date.now()-r<6e4&&await Tt(e);)await Yo(2e3);t.onWait?.(`ssh`);let a=!1;for(;Date.now()<i&&!(a=await Tt(e));)await Yo(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 qo(e).catch(()=>[]));if(s=n.filter(e=>!t.has(e)),!s.length||Date.now()>=c)break;await Yo(5e3)}return{back:!0,seconds:o,missing:s}}const Zo=String.raw`
1004
+ `,qs=e=>new Promise(t=>setTimeout(t,e));async function Js(e,t){let n=await Gs(e).catch(()=>[]),r=Date.now();await q(e,G+Ks),await Ct(e),t.onWait?.(`down`);let i=r+t.timeout*1e3;for(;Date.now()<i&&Date.now()-r<6e4&&await J(e);)await qs(2e3);t.onWait?.(`ssh`);let a=!1;for(;Date.now()<i&&!(a=await J(e));)await qs(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 Gs(e).catch(()=>[]));if(s=n.filter(e=>!t.has(e)),!s.length||Date.now()>=c)break;await qs(5e3)}return{back:!0,seconds:o,missing:s}}function Ys(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 Xs=e=>e%86400==0?`${e/86400}d`:e%3600==0?`${e/3600}h`:`${Math.round(e/60)}m`,Zs=e=>e<=21600?16:13,Qs=`for f in ${V}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done`,$s=e=>String.raw`
826
1005
  set +e
827
- kv() { printf '%s=%s\n' "$1" "$2"; }
828
- S=""; R=1
829
- if [ "$(id -u)" -ne 0 ]; then if sudo -n true 2>/dev/null; then S="sudo -n"; else R=0; fi; fi
830
- kv root "$R"
831
- kv user "$(id -un)"
832
- [ -f /etc/synoinfo.conf ] && kv platform synology
833
- kv hostname "$(hostname 2>/dev/null || cat /etc/hostname)"
834
- tz=$(timedatectl show -p Timezone --value 2>/dev/null)
835
- [ -n "$tz" ] || tz=$(cat /etc/timezone 2>/dev/null)
836
- [ -n "$tz" ] || tz=$(readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')
837
- kv timezone "$tz"
838
- kv ntp "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
839
- kv time "$(date '+%Y-%m-%d %H:%M %Z')"
840
- if [ -d /run/systemd/system ]; then init=systemd; elif command -v rc-service >/dev/null 2>&1; then init=openrc; else init=other; fi
841
- kv init "$init"
842
- awk 'NR > 1 { print "SWAP\t" $1 "\t" $2 "\t" $3 * 1024 "\t" $4 * 1024 }' /proc/swaps 2>/dev/null
843
- kv swappiness "$(cat /proc/sys/vm/swappiness 2>/dev/null)"
844
- kv disk_free "$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 * 1024 }')"
845
- kv mem "$(awk '/^MemTotal:/ { print $2 * 1024 }' /proc/meminfo)"
846
- if command -v docker >/dev/null 2>&1; then
847
- kv log_driver "$(docker info -f '{{.LoggingDriver}}' 2>/dev/null || $S docker info -f '{{.LoggingDriver}}' 2>/dev/null)"
848
- printf 'DAEMON\t'; { cat /etc/docker/daemon.json 2>/dev/null || $S cat /etc/docker/daemon.json 2>/dev/null; } | tr -d '\n'; echo
849
- fi
850
- case "$init" in
851
- systemd)
852
- systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null \
853
- | awk '{ d = ""; for (i = 5; i <= NF; i++) d = d (i > 5 ? " " : "") $i; print "SVC\t" $1 "\t" $2 "\t" $3 "\t" $4 "\t" d }'
854
- systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{ print "UF\t" $1 "\t" $2 }' ;;
855
- openrc)
856
- rc-status -a 2>/dev/null | awk '/\[/ { s = $0; sub(/.*\[[ \t]*/, "", s); sub(/[ \t]*\].*/, "", s); print "SVC\t" $1 "\t-\t" s "\t" s "\t" }' | sort -u ;;
857
- esac
858
- if command -v fail2ban-client >/dev/null 2>&1; then
859
- if st=$($S fail2ban-client status 2>/dev/null); then
860
- kv fail2ban running
861
- for j in $(printf '%s\n' "$st" | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do
862
- $S fail2ban-client status "$j" 2>/dev/null | awk -v j="$j" '
863
- /Currently banned:/ { c = $NF } /Total banned:/ { t = $NF } /Currently failed:/ { f = $NF }
864
- /Banned IP list:/ { sub(/.*Banned IP list:[ \t]*/, ""); ips = $0 }
865
- END { print "JAIL\t" j "\t" c + 0 "\t" t + 0 "\t" f + 0 "\t" ips }'
866
- done
867
- else kv fail2ban stopped; fi
868
- else kv fail2ban missing; fi
869
- # Keys of every user with a home: /root and /home/* (deploy users live there).
870
- for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
871
- u=$(printf %s "$f" | sed 's#^/root/.*#root#; s#^/home/\([^/]*\)/.*#\1#')
872
- { cat "$f" 2>/dev/null || $S cat "$f" 2>/dev/null; } | while IFS= read -r l; do
873
- case "$l" in ''|'#'*) ;; *) printf 'KEY\t%s\t%s\n' "$u" "$l" ;; esac
874
- done
875
- done
876
- exit 0
877
- `,Qo=[`docker`,`containerd`,`ssh`,`sshd`,`fail2ban`,`ufw`,`unattended-upgrades`,`cron`,`crond`,`systemd-timesyncd`,`chronyd`,`chrony`],$o=e=>e.replace(/\.service$/,``),es=/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-\S+@openssh\.com)$/;function ts(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=>es.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:${ie(`sha256`).update(a).digest(`base64`).replace(/=+$/,``)}`,comment:t.slice(i+2).join(` `),options:t.slice(0,i).join(` `),line:e.trim()}}function ns(e){let t=/* @__PURE__ */ new Set;for(let n of Y(e).identityFiles)try{let e=ts(o.readFileSync(`${n}.pub`,`utf8`));e&&t.add(e.fingerprint)}catch{}return t}function rs(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:[]},i=/* @__PURE__ */ new Map,a;for(let o of e.split(`
878
- `)){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=ts(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));if(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`}),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 is(e){let{stdout:t}=await J(e,Zo,{capture:!0});return rs(t,ns(e))}async function as(e){let t=await is(e);if(t.root)return t;let n=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==n&&await Tt({...e,user:t}))return is({...e,user:t});return t}const os=String.raw`
879
- svc() { # svc <verb> <name>
880
- if [ -d /run/systemd/system ]; then $SUDO systemctl "$1" "$2"
881
- elif command -v rc-service >/dev/null 2>&1; then $SUDO rc-service "$2" "$1"
882
- else log "no systemd or OpenRC: cannot $1 $2"; return 1; fi
883
- }
884
- `,ss=/^[A-Za-z0-9@_.:-]+$/;async function cs(e,t,n){ss.test(n)||O(`not a service name: ${n}`);let r=t===`start`?`stop`:t===`stop`?`start`:``,i=`${t} service ${n}`;await J(e,[K,os,`svc ${t} ${q(n)}`,`record ${q(i)}${r?` ${q(`${os}\nsvc ${r} ${q(n)}`)}`:``}`].join(`
885
- `))}function ls(e,t){ss.test(e)||O(`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($o(e))}.log /var/log/messages`:`${n} journalctl -u ${q(e)} -n ${r} --no-pager 2>/dev/null || ${n} tail -n ${r} /var/log/${q($o(e))}.log /var/log/messages 2>/dev/null`}const us=`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)
886
- [ -n "$home" ] || { log "no user $KEY_USER"; exit 1; }
887
- f="$home/.ssh/authorized_keys"`;async function ds(e,t,n){ts(n)||O(`not a public key: expected a line like ssh-ed25519 AAAA… comment`);let r=String.raw`
888
- ${us}
889
- if $SUDO grep -qxF -- "$KEY" "$f" 2>/dev/null; then log "already there"; exit 0; fi
890
- $SUDO mkdir -p "$home/.ssh"; $SUDO chmod 700 "$home/.ssh"
891
- printf '%s\n' "$KEY" | $SUDO tee -a "$f" >/dev/null
892
- g=$(id -gn "$KEY_USER" 2>/dev/null || echo "$KEY_USER")
893
- $SUDO chown "$KEY_USER:$g" "$home/.ssh" "$f"; $SUDO chmod 600 "$f"
894
- record "authorized key $WHAT for $KEY_USER" "drop_line $(printf %q "$f") $(printf %q "$KEY")"
895
- `,i=ts(n);await J(e,K+r,{env:{KEY_USER:t,KEY:i.line,WHAT:i.comment||i.fingerprint}})}async function fs(e,t){let n=String.raw`
896
- ${us}
897
- $SUDO grep -qxF -- "$KEY" "$f" || { log "key not found in $f"; exit 1; }
898
- drop_line "$f" "$KEY"
899
- record "removed key $WHAT of $KEY_USER" "printf '%s\n' $(printf %q "$KEY") | \$SUDO tee -a $(printf %q "$f") >/dev/null"
900
- `;await J(e,K+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const ps=/^[0-9a-fA-F.:]+$/;async function ms(e,t,n){ps.test(t)||O(`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(`
901
- `))}function hs(e){let t=/^(\d+(?:\.\d+)?)\s*([mg])i?b?$/i.exec(e.trim());return t||O(`not a size: ${e} (e.g. 512M, 2G)`),Math.round(Number(t[1])*(t[2].toLowerCase()===`g`?1024:1))*1024*1024}const gs=`/swapfile none swap sw 0 0`,_s=e=>String.raw`
902
- if [ -e /swapfile ]; then log "/swapfile exists already: lf swap off first"; exit 1; fi
903
- mk() { # fallocate is instant but makes files swapon refuses on some filesystems (btrfs, older xfs): dd then
904
- { $SUDO fallocate -l ${e}M /swapfile 2>/dev/null || $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none; } &&
905
- $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile
906
- }
907
- if ! mk; then
908
- $SUDO rm -f /swapfile
909
- $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none && $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile \
910
- || { $SUDO rm -f /swapfile; log "could not turn on swap (a container, or a filesystem without swap files?)"; exit 1; }
911
- fi
912
- record "created ${e} MB swap at /swapfile" '$SUDO swapoff /swapfile 2>/dev/null; $SUDO rm -f /swapfile'
913
- if ! grep -qxF '${gs}' /etc/fstab 2>/dev/null; then
914
- printf '%s\n' '${gs}' | $SUDO tee -a /etc/fstab >/dev/null
915
- record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${gs}'"
916
- fi
917
- `,vs=(e,t)=>J(e,K+_s(Math.round(t/1024/1024))),ys=String.raw`
918
- [ -e /swapfile ] || { log "no /swapfile: landfall only removes the swap file it made"; exit 1; }
919
- $SUDO swapoff /swapfile
920
- $SUDO rm -f /swapfile
921
- drop_line /etc/fstab '${gs}'
922
- record "removed the swap file /swapfile"
923
- `,bs=e=>J(e,K+ys),xs=/^(?=.{1,63}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,Ss=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 Cs(e,t,n){xs.test(n)||O(`not a host name: ${n} (letters, digits and -, up to 63)`),await J(e,[K,Ss(n),`record ${q(`hostname ${t} → ${n}`)} ${q(Ss(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(`
924
- `))}const ws=/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/,Ts=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 Es(e,t,n){ws.test(n)||O(`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; }`,Ts(n),`record ${q(`time zone ${t} → ${n}`)} ${q(Ts(t))}`].join(`
925
- `))}const Ds=/^\d+[kmg]$/;function Os(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 ks=`$SUDO systemctl restart docker 2>/dev/null || $SUDO rc-service docker restart 2>/dev/null || $SUDO service docker restart`;async function As(e,t,n,r){let i=JSON.stringify(t,null,2)+`
926
- `,a=String.raw`
927
- $SUDO install -d /etc/docker
928
- undo=$(saved /etc/docker/daemon.json) || exit 1
929
- printf %s ${q(Buffer.from(i).toString(`base64`))} | base64 -d | $SUDO tee /etc/docker/daemon.json >/dev/null
930
- ${r?`log "restarting docker"\n${ks}\nrecord ${q(n)} "$undo; ${ks.replace(/\$/g,`\\$`)}"`:`record ${q(n)} "$undo"`}
931
- `;await J(e,K+a)}function js(e){let t=/^(\d+)\s*([smhd])$/i.exec(e.trim());if(!t)return O(`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)&&O(`--since must be between 1m and 30d, not ${e}`),n}const Ms=e=>e%86400==0?`${e/86400}d`:e%3600==0?`${e/3600}h`:`${Math.round(e/60)}m`,Ns=e=>e<=21600?16:13,Ps=`for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done`,Fs=e=>String.raw`
932
- set +e
933
- P=${et}
1006
+ P=${tt}
934
1007
  if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
935
- [ -f ${W} ] && echo OWN
936
- ${Ps}
1008
+ [ -f ${U} ] && echo OWN
1009
+ ${Qs}
937
1010
  T=$(printf '\t')
938
- docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${Ns(e)} '
1011
+ docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${Zs(e)} '
939
1012
  function str(k) { if (!match($0, "\"" k "\":\"[^\"]*")) return ""; v = substr($0, RSTART + length(k) + 4, RLENGTH - length(k) - 4); sub(/\\$/, "", v); return v }
940
1013
  function num(k) { return match($0, "\"" k "\":-?[0-9]+") ? substr($0, RSTART + length(k) + 3, RLENGTH - length(k) - 3) + 0 : 0 }
941
1014
  /^\{.*"RequestHost":/ {
@@ -964,13 +1037,13 @@ END {
964
1037
  for (x in E) { split(x, a, SUBSEP); print "E\t" a[1] "\t" a[2] "\t" E[x] }
965
1038
  }' | 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'
966
1039
  exit 0
967
- `,Is=[`2xx`,`3xx`,`4xx`,`5xx`],Ls=()=>({"2xx":0,"3xx":0,"4xx":0,"5xx":0}),Rs=e=>Is[Math.floor(e/100)-2]??null;function zs(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 Bs(e,t){let n=Ns(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 Vs(e,t){let n={},r={},i=Ls(),a=/* @__PURE__ */ new Map,o=/* @__PURE__ */ new Map,s=/* @__PURE__ */ new Map,c=new Map(t.map(e=>[e,Ls()])),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 Is)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 Is)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:zs(n,.5),p95:zs(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 Hs(e,t){return e.get(t)??e.get(`*.${t.slice(t.indexOf(`.`)+1)}`)}function Us(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(`
968
- `))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of Tn(e))t.set(n.toLowerCase(),e.name)}catch{}return t}function Ws(e,t,n=/* @__PURE__ */ new Date){let r=Bs(t,n),i=Us(e),a=/* @__PURE__ */ new Map,o=e=>{let t=a.get(e);return t||a.set(e,t={host:e,...Hs(i,e)&&{site:Hs(i,e)},codes:{},bytes:0,hist:{},paths:[],ips:[],errors:[],series:/* @__PURE__ */ new Map}),t},s={at:n.toISOString(),since:t,bucket:Ns(t)===16?`minute`:`hour`,proxy:!0,own:!1,format:`none`,total:Vs([],r),sites:[],hosts:[]};for(let t of e.split(`
969
- `)){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=Rs(Number(e[3])*100);if(!t)continue;let n=o(e[1]).series,r=n.get(e[2])??Ls();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=Ls();for(let[n,r]of Object.entries(e.codes)){let e=Rs(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:zs(e.hist,.5),p95:zs(e.hist,.95),series:r.map(t=>({at:t,...e.series.get(t)??Ls()}))}}).sort((e,t)=>t.requests-e.requests),s.total=Vs(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}=Vs(t,r);return{site:e,hosts:t.map(e=>e.host),...s}}).sort((e,t)=>t.requests-e.requests),s}async function Gs(e,t){let{stdout:n}=await J(e,Fs(t),{capture:!0});return Ws(n,t)}function Ks(e,t){let n=e.hosts.filter(e=>e.site===t||e.host===t);return n.length?Vs(n,e.total.series.map(e=>e.at)):null}const qs=e=>String.raw`
1040
+ `,ec=[`2xx`,`3xx`,`4xx`,`5xx`],tc=()=>({"2xx":0,"3xx":0,"4xx":0,"5xx":0}),nc=e=>ec[Math.floor(e/100)-2]??null;function rc(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 ic(e,t){let n=Zs(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 ac(e,t){let n={},r={},i=tc(),a=/* @__PURE__ */ new Map,o=/* @__PURE__ */ new Map,s=/* @__PURE__ */ new Map,c=new Map(t.map(e=>[e,tc()])),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 ec)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 ec)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:rc(n,.5),p95:rc(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 oc(e,t){return e.get(t)??e.get(`*.${t.slice(t.indexOf(`.`)+1)}`)}function sc(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(`
1041
+ `))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of jn(e))t.set(n.toLowerCase(),e.name)}catch{}return t}function cc(e,t,n=/* @__PURE__ */ new Date){let r=ic(t,n),i=sc(e),a=/* @__PURE__ */ new Map,o=e=>{let t=a.get(e);return t||a.set(e,t={host:e,...oc(i,e)&&{site:oc(i,e)},codes:{},bytes:0,hist:{},paths:[],ips:[],errors:[],series:/* @__PURE__ */ new Map}),t},s={at:n.toISOString(),since:t,bucket:Zs(t)===16?`minute`:`hour`,proxy:!0,own:!1,format:`none`,total:ac([],r),sites:[],hosts:[]};for(let t of e.split(`
1042
+ `)){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=nc(Number(e[3])*100);if(!t)continue;let n=o(e[1]).series,r=n.get(e[2])??tc();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=tc();for(let[n,r]of Object.entries(e.codes)){let e=nc(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:rc(e.hist,.5),p95:rc(e.hist,.95),series:r.map(t=>({at:t,...e.series.get(t)??tc()}))}}).sort((e,t)=>t.requests-e.requests),s.total=ac(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}=ac(t,r);return{site:e,hosts:t.map(e=>e.host),...s}}).sort((e,t)=>t.requests-e.requests),s}async function lc(e,t){let{stdout:n}=await q(e,$s(t),{capture:!0});return cc(n,t)}function uc(e,t){let n=e.hosts.filter(e=>e.site===t||e.host===t);return n.length?ac(n,e.total.series.map(e=>e.at)):null}const dc=e=>String.raw`
970
1043
  set +e
971
- P=${et}
1044
+ P=${tt}
972
1045
  if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
973
- ${Ps}
1046
+ ${Qs}
974
1047
  readf() { cat "$1" 2>/dev/null || sudo -n cat "$1" 2>/dev/null; }
975
1048
  for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
976
1049
  [ -d "$d" ] || [ -f "$d" ] || continue
@@ -992,18 +1065,18 @@ for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
992
1065
  done
993
1066
  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/'
994
1067
  exit 0
995
- `,Js=/\x1b\[[0-9;]*m/g;function Ys(e){let t=e.replace(Js,``),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 Xs(e,t,n,r=/* @__PURE__ */ new Date){try{let i=new v(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 Zs=(e,t)=>e===t||e.startsWith(`*.`)&&t.slice(t.indexOf(`.`)+1)===e.slice(2)&&t.indexOf(`.`)>0;function Qs(e,t=/* @__PURE__ */ new Date){let n={at:t.toISOString(),proxy:!0,acme:[],denied:[],certs:[],errors:[],domains:[]};for(let r of e.split(`
996
- `)){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=Xs(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=Ys(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=[...Us(e)].filter(([e])=>!e.startsWith(`*.`)).map(([e,t])=>{let r=n.certs.filter(t=>t.domains.some(t=>Zs(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 $s(e,t=604800){let{stdout:n}=await J(e,qs(t),{capture:!0});return Qs(n)}function ec(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 tc(e){let{login:t,probe:n,...r}=await da(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Y(t).user===`root`}}async function nc(e,t){let n=To(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>Do(t,e))),ko(t)]);return{...n,dns:r,suggest:i}}async function rc(e,t){let n=await Zt(t),[r,i]=await Promise.all([cn(t,n),en(t,n)]),a=ln(r);return{name:e,...n,...i,ips:r,sslipIp:a??null,sslipSuffix:a?un(``,a).slice(1):null}}const ic=e=>Lo(e),ac=e=>Bo(e),oc=e=>Go(e),sc=e=>as(e);async function cc(e,t){return(await Ba(e)).reverse().slice(0,t).map(e=>({...e,pending:Pa(e).length}))}async function lc(e,t=!1){let n=await Mr(e,t),r=await qr(e);return{packages:n,images:await Zr(e),engine:{...r,...Jr(r)}}}async function uc(e,t){let n=await cn(e),[r,i]=await Promise.all([dn(t,n),Promise.all(t.map(e=>ec(e)))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}const dc=(e,t)=>Gs(e,t),fc=e=>$s(e);async function pc(e,t,n,r,i,a){let o=await ur(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&&Tn(e).some(e=>r.includes(e)));for(let e of s){let t=Tn(e).filter(e=>r.includes(e)),i=yn(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=pn(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)`;O(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&O(`${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 k(`${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 br(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)))),Xn(t,a.name,a.domains),T(S.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const mc=M({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
1068
+ `,fc=/\x1b\[[0-9;]*m/g;function pc(e){let t=e.replace(fc,``),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 mc(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 hc=(e,t)=>e===t||e.startsWith(`*.`)&&t.slice(t.indexOf(`.`)+1)===e.slice(2)&&t.indexOf(`.`)>0;function gc(e,t=/* @__PURE__ */ new Date){let n={at:t.toISOString(),proxy:!0,acme:[],denied:[],certs:[],errors:[],domains:[]};for(let r of e.split(`
1069
+ `)){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=mc(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=pc(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=[...sc(e)].filter(([e])=>!e.startsWith(`*.`)).map(([e,t])=>{let r=n.certs.filter(t=>t.domains.some(t=>hc(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 _c(e,t=604800){let{stdout:n}=await q(e,dc(t),{capture:!0});return gc(n)}function vc(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=ce.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 yc(e){let{login:t,probe:n,...r}=await _a(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Y(t).user===`root`}}async function bc(e,t){let n=Cs(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>Ts(t,e))),Ds(t)]);return{...n,dns:r,suggest:i}}async function xc(e,t){let n=await rn(t),[r,i]=await Promise.all([mn(t,n),sn(t,n)]),a=hn(r);return{name:e,...n,...i,ips:r,sslipIp:a??null,sslipSuffix:a?gn(``,a).slice(1):null}}const Sc=e=>Fs(e),Cc=e=>Rs(e),wc=e=>Us(e);async function Tc(e,t){return Lo(e,t)}async function Ec(e,t){return(await Jo(e)).reverse().slice(0,t).map(e=>({...e,pending:Ho(e).length}))}async function Dc(e,t=!1){let n=await Rr(e,t),r=await $r(e);return{packages:n,images:await ri(e),engine:{...r,...ei(r)}}}async function Oc(e,t){let n=await mn(e),[r,i]=await Promise.all([_n(t,n),Promise.all(t.map(e=>vc(e)))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}const kc=(e,t)=>lc(e,t),Ac=e=>_c(e);async function jc(e,t,n,r,i,a){let o=await gr(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&T(`${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&&jn(e).some(e=>r.includes(e)));for(let e of s){let t=jn(e).filter(e=>r.includes(e)),i=Tn(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=yn(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 O(`${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 Er(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)))),nr(t,a.name,a.domains),w(x.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Mc=j({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
997
1070
 
998
1071
  Without flags it lists and checks; --add/--rm/--set change the site (undoable)
999
- and update .landfall.json of remembered projects.`,options:{server:N,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}=Yi(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await er(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await uc(i,Tn(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}yn(o)||O(`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=>!Me.test(e));u.length&&O(`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 k(`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 pc(i,r,n,p,s))(),await br(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),Xn(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 cn(i);for(let e of await dn(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`)}}),hc=M({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
1072
+ and update .landfall.json of remembered projects.`,options:{server:M,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}=ta(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await or(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await Oc(i,jn(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?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`),{status:n,error:r,tls:i}=e.https,a=n?i&&!i.valid?x.red(`https ${n}, certificate: ${i.error}`):(n<400?x.green:n===401?x.cyan:x.yellow)(`https ${n}`):x.red(`https ${r}`);console.log(`${x.cyan(`https://${e.domain}`)} ${t} ${a}`)}r.some(e=>!e.ok)&&console.log(x.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}Tn(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=>He(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=>!Ne.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&T(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return C(`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 O(`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 jc(i,r,n,p,s))(),await Er(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),nr(r,n,l);let m=z(process.cwd());m?.name===n&&Je(process.cwd(),{...m,domains:l}),l.length?C(`${n} serves ${l.map(e=>x.cyan(`https://${e}`)).join(`, `)}`):C(`${n} serves no domain now ${x.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await mn(i);for(let e of await _n(p,ee))e.ok||T(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Nc=j({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
1000
1073
 
1001
1074
  lf auth my-site --user anna add or change a login (asks the password)
1002
1075
  lf auth my-site --rm-user anna remove a login
1003
1076
  lf auth my-site --off remove all logins: the site is public again
1004
- lf auth my-site show who can log in`,options:{server:N,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}=Yi(e,t.server),{meta:i,proxy:a}=await er(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(ji),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(ji(e))),c&&l.push(await Ni(c,s)),l.join(`
1077
+ lf auth my-site show who can log in`,options:{server:M,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}=ta(e,t.server),{meta:i,proxy:a}=await or(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(Li),s=t[`password-stdin`]?(await y()).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 ${x.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>x.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||T(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(Li(e))),c&&l.push(await zi(c,s)),l.join(`
1005
1078
  `)===(i.auth??[]).join(`
1006
- `))return w(`nothing to change`);let d=l.map(ji),f=o.filter(e=>!d.includes(e));d.length?f.length&&await k(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await k(`Remove every login from ${n}? Anyone can open it then.`),await br(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=M({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:N},async run([e],t){let{site:n,server:r}=Yi(e,t.server),{meta:i}=await er(r,n),a=Tn(i)[0];a||O(`${n} serves no domain`);let o=`https://${a}`;console.log(S.cyan(o)),_e(o)}}),_c=[`tunnel-only`,`email`,`keep`,`default`];function vc(){return{local:[{path:I(F),what:`servers and these defaults`},{path:I(m(Ee,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:I(m(Ee,`ui-token`)),what:`web UI login token`},{path:I(m(Ee,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:I(Nt),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 yc(){let e=L(),t=e.defaults??{};return{configFile:I(F),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:vc()}}const bc=M({name:`config`,summary:`Show or change landfall's defaults for every server.
1079
+ `))return C(`nothing to change`);let d=l.map(Li),f=o.filter(e=>!d.includes(e));d.length?f.length&&await O(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await O(`Remove every login from ${n}? Anyone can open it then.`),await Er(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),C(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),Pc=j({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:M},async run([e],t){let{site:n,server:r}=ta(e,t.server),{meta:i}=await or(r,n),a=jn(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(x.cyan(o)),ve(o)}}),Fc=[`tunnel-only`,`email`,`keep`,`default`];function Ic(){return{local:[{path:F(P),what:`servers and these defaults`},{path:F(m(De,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:F(m(De,`ui-token`)),what:`web UI login token`},{path:F(m(De,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:F(zt),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:`${B}/README`,what:`how to run it all by hand, without landfall`},{path:`${V}/<site>`,what:`a site: .env, site.json, current -> releases/<id>`},{path:H,what:`landfall's Traefik: docker-compose.yml, letsencrypt/`},{path:Ye,what:`Traefik file provider; files you add here are kept`},{path:U,what:`only when sites use a Traefik the server already had`},{path:W,what:`every change with its undo (lf log, lf undo)`},{path:$e,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 Lc(){let e=I(),t=e.defaults??{};return{configFile:F(P),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:Ic()}}const Rc=j({name:`config`,summary:`Show or change landfall's defaults for every server.
1007
1080
 
1008
1081
  A server's own setting wins over these (lf server config shows which applies):
1009
1082
  --tunnel-only new sites get no domain unless -d gives one; reach them with lf tunnel
@@ -1012,11 +1085,11 @@ A server's own setting wins over these (lf server config shows which applies):
1012
1085
  --keep releases a deploy keeps for rollback (per deploy: lf deploy --keep)
1013
1086
  --default the server commands use without --server (same as lf server use)
1014
1087
 
1015
- --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: ${_c.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!_c.includes(e));if(r.length&&O(`cannot unset ${r.join(`, `)}: one of ${_c.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&&Ae(t.default);let e=L(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=Fe(t.email)),t.keep!==void 0&&(r.keep=z(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,Oe(e),w(`saved`)}let i=yc();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=M({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||O(`usage: lf keygen <name>`);let{path:t,created:n}=po(e);w(`${n?`created`:`key exists:`} ${I(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Sc(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 me(`${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 Cc(e,t){if(C(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await pe(`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 A(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&Se().email===void 0){let e=(await pe(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?Fe(e):void 0}let n=Be(e);t.dashboard??=await pe(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function wc(e,t){let n=Object.fromEntries(Object.entries(Tc.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Tc.run([e],{...n,...t},[])}const Tc=M({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
1088
+ --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: ${Fc.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!Fc.includes(e));if(r.length&&D(`cannot unset ${r.join(`, `)}: one of ${Fc.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&&je(t.default);let e=I(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=Ie(t.email)),t.keep!==void 0&&(r.keep=R(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,ke(e),C(`saved`)}let i=Lc();if(t.json)return void console.log(JSON.stringify(i,null,2));let a=e=>i.defaults[e]===void 0?x.dim(` (built in)`):``,o=(e,t)=>console.log(` ${x.dim(e.padEnd(12))} ${t}`);console.log(`${x.bold(`landfall defaults`)} ${x.dim(i.configFile)}`),o(`default`,i.default??x.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??x.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${x.bold(e)}`);let n=Math.max(...t.map(e=>e.path.length));for(let e of t)console.log(` ${e.path.padEnd(n)} ${x.dim(e.what)}`)}}}),zc=j({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}=Ga(e);C(`${n?`created`:`key exists:`} ${F(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Bc(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?T(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await he(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):T(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Vc(e,t){if(S(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await me(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(w(x.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await k(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&Ce().email===void 0){let e=(await me(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?Ie(e):void 0}let n=Ve(e);t.dashboard??=await me(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function Hc(e,t){let n=Object.fromEntries(Object.entries(Uc.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Uc.run([e],{...n,...t},[])}const Uc=j({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
1016
1089
 
1017
1090
  Turns a fresh box into a deploy target (safe to re-run):
1018
1091
  1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
1019
- 2. install Docker, firewall (ufw: ssh/80/443), fail2ban, unattended-upgrades
1092
+ 2. install Docker, rsync, firewall (ufw: ssh/80/443), fail2ban, unattended-upgrades
1020
1093
  3. create a deploy user with docker access, disable SSH password login
1021
1094
  4. write \`Host <name>\` to ~/.ssh/config so \`ssh <name>\` just works
1022
1095
  5. install Traefik with automatic Let's Encrypt certificates
@@ -1032,7 +1105,7 @@ missing. Steps that would break services already running there are skipped:
1032
1105
  a Traefik already running there is used instead (lf traefik use)
1033
1106
  Use --check to only see that report, --force to apply firewall/hardening anyway.
1034
1107
  In a terminal, a first Traefik install asks for image, host network, e-mail and
1035
- 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||O(`usage: lf setup <name> [--host <ip>]`);let n=L(),r=n.servers[e],i=It().includes(e)&&!Kt(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 Tt({...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 mo(e,a,{check:t.check,password:await uo(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([ti(a,p),Gr(),Kr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=hi(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),_={email:t.email&&Fe(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:Mi},re=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;re&&(g.detail+=S.dim(` · ${Fi(Pi(r??a,_))}`));let v=an(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}`):``}`),v.note&&(v.level===`eol`||v.level===`unsupported`?E:T)(v.note),yi(h),le({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))});let ie=m.values.pkg===`none`&&`setup does not run here (${v.note}). Use \`lf server add ${e}\` to deploy to it as it is`;if(t.check)return void(ie&&E(ie));ie&&O(ie);let ae=await Sc(h,t.force),oe=re&&g.status===`todo`&&fe(),y=t.domain?Ie(t.domain):r&&Be(r);if(y===void 0&&oe){let e=await pe(`main domain for sites, e.g. example.com (optional)`,``);y=e.trim()?Ie(e):void 0}oe&&(await Cc({...r??a,domains:ze(r?.domains,y)},_),T(`traefik: ${Fi(Pi(r??a,_))}${_.dashboard?`, dashboard ${_.dashboard}`:``}`));let se=h.filter(e=>e.status!==`done`&&ae.has(e.part)).length;if(se&&!await A(`apply the ${se} change${se===1?``:`s`} above?`))return;let b=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>b.includes(e.part)&&e.status!==`done`&&ae.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await J(a,K+ot,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:ae.has(`harden`),FIREWALL:ae.has(`firewall`),HTTP3:(_.http3??r?.http3)!==!1}}),w(`server bootstrapped`)):(l(`bootstrap`),w(`already bootstrapped, nothing to change`));let ce={...a};if(f){let e={...a,user:f};await Tt(e)?(w(`deploy user ${S.bold(f)} ready`),ce=e):E(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let x=go(e,ce,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});y&&(x.domains=ze(x.domains,y)),s.user!==`root`&&Y(ce).user!==s.user&&(x.admin=s.user),n.servers[e]=x,n.default??=e,Oe(n),l(`traefik`),g?.proxy?ae.has(`traefik`)&&g.status===`todo`?await Li(x,g.proxy):w(g.detail):ae.has(`traefik`)?await Ii(e,x,_):T(S.dim(`skipped`)),y?(console.log(`\n${S.green(`Done.`)} Point ${S.bold(y)} and ${S.bold(`*.${y}`)} (A records) at ${S.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${S.dim(`# https://blog.${y}`)}`),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`))}}),Ec={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Dc(e,t){if(!t)return e;let n=ji(t);return[...(e??[]).filter(e=>ji(e)!==n),t]}const Oc=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function kc(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Oc(new Date(n)));)n+=1e3;return Oc(new Date(n))}const Ac=M({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
1108
+ 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: ${Qe})`},"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=I(),r=n.servers[e],i=Ht().includes(e)&&!Qt(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 J({...a,user:t})&&(e=t);e?a.user=e:T(`${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);S(`setting up ${x.bold(e)} ${x.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>S(`[${++c}/5] ${e}`);l(`ssh key`);let u=await qa(e,a,{check:t.check,password:await Ua(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([si(a,p),Zr(),Qr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=xi(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&&Ie(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:Ri},ie=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;ie&&(g.detail+=x.dim(` · ${Vi(Bi(r??a,re))}`));let _=dn(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`,void 0,m.values.platform);w(`os: ${m.values.os??`unknown`}${m.values.pkg?x.dim(` · ${m.values.pkg}`):``}`),_.note&&(_.level===`eol`||_.level===`unsupported`?T:w)(_.note),Ti(h),ue({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))});let ae=m.values.pkg===`none`&&`setup does not run here (${_.note}). Use \`lf server add ${e}\` to deploy to it as it is`;if(t.check)return void(ae&&T(ae));ae&&D(ae);let oe=await Bc(h,t.force),se=ie&&g.status===`todo`&&pe(),v=t.domain?Le(t.domain):r&&Ve(r);if(v===void 0&&se){let e=await me(`main domain for sites, e.g. example.com (optional)`,``);v=e.trim()?Le(e):void 0}se&&(await Vc({...r??a,domains:Be(r?.domains,v)},re),w(`traefik: ${Vi(Bi(r??a,re))}${re.dashboard?`, dashboard ${re.dashboard}`:``}`));let ce=h.filter(e=>e.status!==`done`&&oe.has(e.part)).length;if(ce&&!await k(`apply the ${ce} change${ce===1?``:`s`} above?`))return;let y=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>y.includes(e.part)&&e.status!==`done`&&oe.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await q(a,G+lt,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:oe.has(`harden`),FIREWALL:oe.has(`firewall`),HTTP3:(re.http3??r?.http3)!==!1}}),C(`server bootstrapped`)):(l(`bootstrap`),C(`already bootstrapped, nothing to change`));let le={...a};if(f){let e={...a,user:f};await J(e)?(C(`deploy user ${x.bold(f)} ready`),le=e):T(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let b=Ya(e,le,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});v&&(b.domains=Be(b.domains,v)),s.user!==`root`&&Y(le).user!==s.user&&(b.admin=s.user),n.servers[e]=b,n.default??=e,ke(n),l(`traefik`),g?.proxy?oe.has(`traefik`)&&g.status===`todo`?await Ui(b,g.proxy):C(g.detail):oe.has(`traefik`)?await Hi(e,b,re):w(x.dim(`skipped`)),v?(console.log(`\n${x.green(`Done.`)} Point ${x.bold(v)} and ${x.bold(`*.${v}`)} (A records) at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${x.dim(`# https://blog.${v}`)}`),console.log(` lf server domain ${e} ${x.dim(`# check the DNS`)}\n`)):(console.log(`\n${x.green(`Done.`)} Point a domain's A record at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`)),await Ro(e,b,a)}}),Wc={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Gc(e,t){if(!t)return e;let n=Li(t);return[...(e??[]).filter(e=>Li(e)!==n),t]}const Kc=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function qc(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Kc(new Date(n)));)n+=1e3;return Kc(new Date(n))}const Jc=j({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
1036
1109
 
1037
1110
  Kinds:
1038
1111
  compose the folder has a compose.yaml: its services run on the server as one
@@ -1046,15 +1119,15 @@ so \`docker compose\` works there without landfall. Settings are saved to
1046
1119
  ./.landfall.json, so later deploys are just \`lf deploy\`.
1047
1120
 
1048
1121
  A server landfall has not set up yet is set up first (\`lf setup\`), asking
1049
- before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:N,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:`run this command locally first (e.g. 'npm run build')`},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}=R(t.server??i.server);t.host&&!t.server&&!i.server&&!L().default&&O(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await Dt(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&&!(fe()&&await A(`${a} ${e}. Set it up now (lf setup)?`))&&O(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await wc(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=R(a),await Dt(s)!==`ready`&&O(`${a} is still not set up`)}let l=Zn(t.name??i.name??Wn(f(n))),u=z(t.keep??Se().keep??5,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&O(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Ve(e,s)).filter(e=>!Me.test(e));g.length&&O(`not a domain: ${g.join(`, `)}`);let _=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),re=_?await Ni(_,t[`auth-password`],`--auth-user`):void 0,v=t.build??i.build;if(v){C(`building locally: ${v}`);let e=r(v,{shell:!0,stdio:`inherit`});e.status!==0&&O(`build failed (exit ${e.status})`)}let ie=t.static??i.static,ae=t.image??(ie?void 0:i.image),oe=ae&&Re(ae),y=ne(n,e??i.dir??`.`),se=oe||ie?void 0:t.file??Gn(y,i),b=se&&ee(se);b?.includes(`/`)&&O(`${se} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,y)||`.`,b))} -f ${f(b)}`),b&&!o.existsSync(m(y,b))&&O(`no ${b} in ${te(n,y)||`.`}`);let ce=Vn(n),x=b||oe?y:ne(n,e??i.dir??Hn(n,ce)),D=b?`compose`:oe?`image`:!ie&&o.existsSync(m(x,`Dockerfile`))?`dockerfile`:`static`,ue=D===`static`&&!e&&!i.dir?ce.tool:void 0;ue&&T(S.dim(`publishing ${te(n,x)}: where ${ue} builds the site (another folder: lf deploy <dir>)`)),D===`static`&&!o.existsSync(m(x,`index.html`))&&E(`no index.html in ${te(n,x)||`.`}${ue&&!v?`: build first, or let deploy do it (--build "${ce.build}")`:``}`);let de=Ec[D].port?z(t.port??i.port??(D===`dockerfile`?Un(m(x,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,me=D===`static`&&!!(t.spa??i.spa);C(`deploying ${S.bold(l)} (${D}) to ${S.bold(a)}`);let k=await $n(s,l),{env:j,proxy:he,exists:ge}=k,_e=k.meta&&pn(k.meta)?k.meta:void 0,M=_e?void 0:k.meta;M?.adopted&&O(`${l} is adopted (${M.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)`):M&&M.kind!==D&&T(S.dim(`site was ${M.kind}, now ${D}`));let N;(t.env?.length||t[`env-file`])&&(N=j,t[`env-file`]&&(N=Er(N,await Or(t[`env-file`]))),t.env?.length&&(N=Er(N,kr(t.env))));let ve=kc((k.meta?.releases??[]).map(e=>e.id)),ye=`${Z(l)}/releases/${ve}`,be=D===`compose`?{}:{"compose.yaml":Ln(D,{port:de,image:oe}),...D===`static`&&{"nginx.conf":Rn(me)}},xe,P,we;try{let e=Ec[D].files;e!==void 0&&(C(`uploading ${te(n,x)||`.`}`),await jt(s,x,ye+e));let t=await dr(s,l,ve,{file:b,generated:be,env:N}),r=Mn(t.config,t.dir,e=>Ve(e,s));for(let e of r.warnings)E(e);r.errors.length&&O(`${b??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
1050
- `)}`),xe={id:ve,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);P=[];let c=!1;if(vn(xe)){c=!h?.length&&!!i.domains?.length,P=d?[]:[h,i.domains,M?.domains,_e?.domains].find(e=>e?.length)??[];let e=d||!P.length&&(!!M&&!!yn(M)&&!M.domains.length||i.domains?.length===0);if(!P.length&&!e&&Ce(s)&&(e=!0),!P.length&&!e){let t=Be(s),n=t?void 0:ln(await cn(s)),r=await pe(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?un(l,n):void 0);e=r===`none`,e||(P=r.split(/[\s,]+/).filter(Boolean))}P=[...new Set(P.map(e=>Ve(e,s)))];let t=P.filter(e=>!Me.test(e)),n=P.filter(e=>o.includes(e));!P.length&&!e&&O(`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&&O(`not a domain: ${t.join(`, `)}`),n.length&&O(`${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`);we=await pc(s,a,l,[...P,...o],he,c?`${Ge} lists it; deploy with -d to move it`:void 0)}catch(e){throw await pr(s,l,ve).catch(()=>{}),e}let Te=(/* @__PURE__ */ new Date()).toISOString(),Ee={v:2,name:l,kind:D,domains:P,auth:Dc(M?.auth??_e?.auth,re),current:ve,releases:[...M?.releases??[],xe],createdAt:M?.createdAt??_e?.createdAt??Te,updatedAt:Te};await we(),await xr(s,Ee,he,{env:N,build:!0,pull:!0,keep:u,prev:M,takeover:_e,existed:!M&&!_e&&ge,what:`deploy ${l} release ${ve}${M?``:` (new site, ${Tn(Ee).join(`, `)})`}`});let F=Tn(Ee);if(t.save){let e={name:l,server:a,domains:P};if(D===`image`)Object.assign(e,{image:oe,port:de});else{let r=te(n,x)||`.`;D===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=b)):r!==Hn(n,ce)&&(e.dir=r),D===`dockerfile`&&(e.port=de),me&&(e.spa=!0),ie&&D===`static`&&(e.static=!0)}v&&(e.build=v),qe(n,e),Yn({path:n,name:l,server:a,domains:F,kind:D})}w(`release ${S.bold(ve)} live`);for(let e of F)console.log(` ${S.cyan(`https://${e}`)}`);F[0]&&le({url:`https://${F[0]}`}),F[0]&&await jc(F[0])}});async function jc(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 Mc=M({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
1122
+ before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:M,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:`run this command locally first (e.g. 'npm run build')`},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=z(n)??{};i.name&&w(x.dim(`using ${Ke}`));let{name:a,server:s}=L(t.server??i.server);t.host&&!t.server&&!i.server&&!I().default&&D(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await At(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&&!(pe()&&await k(`${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 Hc(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=L(a),await At(s)!==`ready`&&D(`${a} is still not set up`)}let l=rr(t.name??i.name??Xn(f(n))),u=R(t.keep??Ce().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=>He(e,s)).filter(e=>!Ne.test(e));g.length&&D(`not a domain: ${g.join(`, `)}`);let re=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),ie=re?await zi(re,t[`auth-password`],`--auth-user`):void 0,_=t.build??i.build;if(_){S(`building locally: ${_}`);let e=r(_,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let ae=t.static??i.static,oe=t.image??(ae?void 0:i.image),se=oe&&ze(oe),v=ne(n,e??i.dir??`.`),ce=se||ae?void 0:t.file??Zn(v,i),y=ce&&ee(ce);y?.includes(`/`)&&D(`${ce} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,v)||`.`,y))} -f ${f(y)}`),y&&!o.existsSync(m(v,y))&&D(`no ${y} in ${te(n,v)||`.`}`);let le=qn(n),b=y||se?v:ne(n,e??i.dir??Jn(n,le)),E=y?`compose`:se?`image`:!ae&&o.existsSync(m(b,`Dockerfile`))?`dockerfile`:`static`,de=E===`static`&&!e&&!i.dir?le.tool:void 0;de&&w(x.dim(`publishing ${te(n,b)}: where ${de} builds the site (another folder: lf deploy <dir>)`)),E===`static`&&!o.existsSync(m(b,`index.html`))&&T(`no index.html in ${te(n,b)||`.`}${de&&!_?`: build first, or let deploy do it (--build "${le.build}")`:``}`);let fe=Wc[E].port?R(t.port??i.port??(E===`dockerfile`?Yn(m(b,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,he=E===`static`&&!!(t.spa??i.spa);S(`deploying ${x.bold(l)} (${E}) to ${x.bold(a)}`);let O=await ar(s,l),{env:A,proxy:ge,exists:_e}=O,ve=O.meta&&yn(O.meta)?O.meta:void 0,j=ve?void 0:O.meta;j?.adopted&&D(`${l} is adopted (${j.adopted}): deploy it there with docker compose, or pick another --name`),ve?T(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):j&&j.kind!==E&&w(x.dim(`site was ${j.kind}, now ${E}`));let M;(t.env?.length||t[`env-file`])&&(M=A,t[`env-file`]&&(M=Mr(M,await Pr(t[`env-file`]))),t.env?.length&&(M=Mr(M,Fr(t.env))));let ye=qc((O.meta?.releases??[]).map(e=>e.id)),be=`${Z(l)}/releases/${ye}`,xe=E===`compose`?{}:{"compose.yaml":Un(E,{port:fe,image:se}),...E===`static`&&{"nginx.conf":Wn(he)}},Se,N,Te;try{let e=Wc[E].files;if(e!==void 0){S(`uploading ${te(n,b)||`.`}`);let t=j?.kind===E?j.current:void 0,r=await It(s,b,be+e,t&&`${Z(l)}/current${e}`);r===`rsync`?w(x.dim(`sent only what changed since release ${t}`)):r===`tar:no-local-rsync`?w(x.dim(`sent every file: install rsync on this machine to send only what changed`)):r===`tar:no-server-rsync`&&w(x.dim(`sent every file: ${a} has no rsync, \`lf setup ${a}\` installs it to send only what changed`))}let t=await _r(s,l,ye,{file:y,generated:xe,env:M}),r=Rn(t.config,t.dir,e=>He(e,s));for(let e of r.warnings)T(e);r.errors.length&&D(`${y??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
1123
+ `)}`),Se={id:ye,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);N=[];let c=!1;if(wn(Se)){c=!h?.length&&!!i.domains?.length,N=d?[]:[h,i.domains,j?.domains,ve?.domains].find(e=>e?.length)??[];let e=d||!N.length&&(!!j&&!!Tn(j)&&!j.domains.length||i.domains?.length===0);if(!N.length&&!e&&we(s)&&(e=!0),!N.length&&!e){let t=Ve(s),n=t?void 0:hn(await mn(s)),r=await me(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?gn(l,n):void 0);e=r===`none`,e||(N=r.split(/[\s,]+/).filter(Boolean))}N=[...new Set(N.map(e=>He(e,s)))];let t=N.filter(e=>!Ne.test(e)),n=N.filter(e=>o.includes(e));!N.length&&!e&&D(`at least one --domain is required`),e&&w(x.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&&T(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);Te=await jc(s,a,l,[...N,...o],ge,c?`${Ke} lists it; deploy with -d to move it`:void 0)}catch(e){throw await yr(s,l,ye).catch(()=>{}),e}let Ee=(/* @__PURE__ */ new Date()).toISOString(),De={v:2,name:l,kind:E,domains:N,auth:Gc(j?.auth??ve?.auth,ie),current:ye,releases:[...j?.releases??[],Se],createdAt:j?.createdAt??ve?.createdAt??Ee,updatedAt:Ee};await Te(),await Dr(s,De,ge,{env:M,build:!0,pull:!0,keep:u,prev:j,takeover:ve,existed:!j&&!ve&&_e,what:`deploy ${l} release ${ye}${j?``:` (new site, ${jn(De).join(`, `)})`}`});let P=jn(De);if(t.save){let e={name:l,server:a,domains:N};if(E===`image`)Object.assign(e,{image:se,port:fe});else{let r=te(n,b)||`.`;E===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=y)):r!==Jn(n,le)&&(e.dir=r),E===`dockerfile`&&(e.port=fe),he&&(e.spa=!0),ae&&E===`static`&&(e.static=!0)}_&&(e.build=_),Je(n,e),tr({path:n,name:l,server:a,domains:P,kind:E})}C(`release ${x.bold(ye)} live`);for(let e of P)console.log(` ${x.cyan(`https://${e}`)}`);P[0]&&ue({url:`https://${P[0]}`}),P[0]&&await Yc(P[0])}});async function Yc(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?C(`https://${e} responded ${t.status}`):T(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);T(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const Xc=j({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
1051
1124
 
1052
1125
  compose.yaml what runs on the server; x-landfall routes a service through Traefik
1053
1126
  compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
1054
1127
  publishes the port on localhost
1055
1128
 
1056
1129
  \`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
1057
- 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()||O(`${e} is not a folder`);let r=Kn(n);r.kind===`compose`&&O(`${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&&O(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:z(t.port??r.port??3e3,`--port`,1,65535),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:
1130
+ 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=Qn(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:R(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||w(x.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
1058
1131
  image: nginx:alpine
1059
1132
  restart: unless-stopped
1060
1133
  volumes:
@@ -1099,23 +1172,31 @@ services:
1099
1172
  ${d}:
1100
1173
  ports:
1101
1174
  - "127.0.0.1:${c}:${s}"
1102
- `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;w(`wrote compose.yaml and compose.override.yaml 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)`:``}`)}}),Nc=(e,t)=>t?Math.round(e/t*100):0;function Pc(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 Fc=e=>`${X(e)}/s`,Ic=e=>Cn(e)?/unhealthy|restarting/i.test(e)?S.yellow(e):S.green(e):wn(e)?S.dim(e):S.red(e);function Lc(e,t,n){let r=n?t.containers.filter(e=>e.site===n||e.project===n):t.containers;if(n&&!r.length&&O(`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`,Pc(t.cpu),`${t.cpu}%`,S.dim(`${t.cpus} cores · load ${t.load.join(` `)}`)],[`memory`,Pc(Nc(n,t.mem.total)),`${Nc(n,t.mem.total)}%`,S.dim(`${X(n)} / ${X(t.mem.total)}`)],...t.mem.swapTotal?[[`swap`,Pc(Nc(r,t.mem.swapTotal)),`${Nc(r,t.mem.swapTotal)}%`,S.dim(`${X(r)} / ${X(t.mem.swapTotal)}`)]]:[],...t.disks.map(e=>[`disk ${e.mount}`,Pc(Nc(e.used,e.total)),`${Nc(e.used,e.total)}%`,S.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)]),...t.net.map(e=>[`net ${e.iface}`,``,``,`↓ ${Fc(e.rx)} ↑ ${Fc(e.tx)}`])];console.log(S.bold(e)+S.dim(` · up ${fn(t.uptime)}`)),j(i.map(([e,...t])=>[S.dim(e),...t])),t.procs.length&&(console.log(`\n${S.bold(`Processes`)} ${S.dim(`(cpu: 100% = one core)`)}`),j([[`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`)}`),j([[`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,Cn(e.status)?`${Math.round(e.cpu*10)/10}%`:``,Cn(e.status)?`${X(e.mem)}${e.memLimit&&e.memLimit<t.mem.total?S.dim(` (limit ${X(e.memLimit)})`):``}`:``,Cn(e.status)?`↓ ${X(e.net.rx)} ↑ ${X(e.net.tx)}`:``,e.restarts?S.yellow(String(e.restarts)):S.dim(`0`),Ic(e.status)])])):console.log(S.dim(`
1103
- no access to docker: containers not shown (run lf setup)`))}const Rc=M({name:`stats`,args:`[site]`,summary:`Show live CPU, memory, disk, network, the busiest processes and each container's usage.
1104
- With a site, only its containers.`,options:{server:N,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}=R(t.server??B(process.cwd())?.server);if(t.json){t.watch&&O(`--watch and --json do not go together: run lf stats --json in a loop`),console.log(JSON.stringify(await Lo(r),null,2));return}if(!t.watch)return Lc(n,await Lo(r),e);let i=z(t.interval,`--interval`,1,3600)*1e3;for(;;){let t=await Lo(r);process.stdout.isTTY&&process.stdout.write(`\x1B[H\x1B[2J`),Lc(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 zc(e,t){console.log(S.bold(e)),j(t.disks.map(e=>[S.dim(e.mount),Pc(Nc(e.used,e.total)),`${Nc(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`)}`),j([[``,`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(`
1105
- 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)})`)}`),j([[`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)`)}`),j([[`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)`)}`),j(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(`
1106
- `+i.map(e=>S.dim(e)).join(`
1107
- `))}const Bc=M({name:`disk`,summary:`Show where a server's disk space goes: filesystems, Docker images, volumes, build cache, sites and container logs.`,options:{server:N,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=await Bo(r);if(t.json)return void console.log(JSON.stringify(i,null,2));zc(n,i)}}),Vc=e=>e.toLocaleString(`en-US`),Hc=e=>e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`;function Uc(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 Wc=e=>[Vc(e.requests),e.classes[`4xx`]?S.yellow(Vc(e.classes[`4xx`])):S.dim(`0`),e.classes[`5xx`]?S.red(Vc(e.classes[`5xx`])):S.dim(`0`),e.requests?Hc(e.p50):``,e.requests?Hc(e.p95):``,X(e.bytes)];function Gc(e,t){j([[`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(S.dim),Wc(e)]),console.log(`\n${S.dim(`requests per ${t}`)} ${Uc(e.series)}`),e.paths.length&&(console.log(`\n${S.bold(`Paths`)}`),j(e.paths.map(e=>[Vc(e.count),e.errors?S.yellow(`${Vc(e.errors)} failed`):``,e.path]))),e.errors.length&&(console.log(`\n${S.bold(`Errors`)}`),j(e.errors.map(e=>[Vc(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`)}`),j(e.ips.map(e=>[Vc(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} ${Vc(t)}`).join(S.dim(` · `))}`)}const Kc=M({name:`traffic`,args:`[site]`,summary:`Show requests, errors, response times, busiest paths and clients from Traefik's access log.
1108
- With a site (or a domain), only its requests.`,options:{server:N,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}=R(t.server??B(process.cwd())?.server),i=js(t.since),a=await Gs(r,i);a.proxy||O(`no traefik on ${n} (lf traefik setup)`);let o=e?Ks(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=Ms(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||O(`no requests for "${e}" on ${n} in the last ${s}`),Gc(o,a.bucket);let l=a.hosts.filter(e=>!e.site);j([[`site`,`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(S.dim),...a.sites.map(e=>[S.bold(e.site),...Wc(e)]),...l.slice(0,8).map(e=>[S.dim(e.host),...Wc(e)]),...l.length>8?[[S.dim(`${l.length-8} more hosts`),...Wc(qc(l.slice(8)))]]:[],[S.dim(`total`),...Wc(a.total)]]),console.log(`\n${S.dim(`requests per ${a.bucket}`)} ${Uc(a.total.series)}`),a.total.errors.length&&(console.log(`\n${S.bold(`Errors`)}`),j(a.total.errors.slice(0,5).map(e=>[Vc(e.count),e.status>=500?S.red(String(e.status)):S.yellow(String(e.status)),e.path]))),console.log(S.dim(`
1109
- lf traffic <site> for its paths, errors and clients`))}});function qc(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 Jc(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 Yc=M({name:`certs`,summary:`Show each site domain's HTTPS certificate: when it expires, who issued it, and why getting one failed.`,options:{server:N,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}=R(t.server??B(process.cwd())?.server),i=await $s(r,js(t.since));if(t.json)return void console.log(JSON.stringify(i,null,2));if(i.proxy||O(`no traefik on ${n} (lf traefik setup)`),i.domains.length){j([[`domain`,`site`,`certificate`,`expires`,`issuer`].map(S.dim),...i.domains.map(e=>[e.domain,S.dim(e.site),Jc(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`)}`),j(o.map(e=>[e.domains.join(` `),Jc(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`)}}),Xc=e=>e.includes(`.`)&&Me.test(e.toLowerCase()),Zc=M({name:`check`,args:`[site|domain…]`,summary:`Request each site over HTTPS from this machine: status, response time and days left on the certificate.
1110
- Exits 1 when one fails: use it from cron or CI.`,options:{server:N,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(Xc).map(e=>({domain:e,site:void 0})),r=e.filter(e=>!Xc(e));if(!e.length||r.length){let{name:e,server:i}=R(t.server??B(process.cwd())?.server),a=await ur(i);for(let t of r)a.sites.some(e=>e.meta.name===t)||O(`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 ec(e.domain,{resolve:t.resolve}),...e.site&&{site:e.site}})));return t.json?console.log(JSON.stringify(i,null,2)):j([[``,`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(Hc(e.ms)):Hc(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 Qc(e,t){let n=Ho(t),r=[[Uo(t,`release`).length,`for rollback`],[Uo(t,`undo`).length,`for undo`],[Uo(t,`tagged`).length,`tagged but unused (--all removes them)`]].filter(([e])=>e);n.length?(console.log(`${S.bold(e)}: ${n.length} unused images`),j([[`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 $c=M({name:`prune`,summary:`Free disk space: remove unused Docker images and the build cache.
1111
- 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:N,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}=R(t.server??B(process.cwd())?.server),i=await Go(r,!!t.all);i.docker||O(`no access to docker on ${n} (run lf setup)`);let a=Ho(i);if(t[`dry-run`]||t.json&&!a.length&&!i.buildCache)return void(t.json?console.log(JSON.stringify(i,null,2)):Qc(n,i));if(t.json||Qc(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 A(`\nRemove ${a.length} images and the build cache (up to ${X(o)})?`,!0))return;t.json||C(`cleaning up`);let s=await Ko(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`)}}),el=M({name:`reboot`,summary:`Reboot a server, wait until it is back and check its containers run again.`,options:{server:N,timeout:{type:`string`,desc:`seconds to wait for it to come back`,default:`300`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=z(t.timeout,`--timeout`,30,3600),a=await Q(r,`rebooting`);if(!await A(`Reboot ${n}? Its sites are offline for a minute or two.`,!1))return;let o=await Xo(a,{timeout:i,onWait:e=>C(e===`down`?`rebooting ${n}`:e===`ssh`?`waiting for ssh`:`waiting for containers`)});o.back||O(`${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=>R(e.server??B(process.cwd())?.server),tl={type:`boolean`,desc:`machine-readable output`},nl=e=>e.active===`failed`||e.active===`crashed`,rl=e=>e.active===`active`||e.active===`started`,il=e=>Qo.includes($o(e.unit));function al(e){return nl(e)?S.red(e.sub||e.active):rl(e)?S.green(e.sub||e.active):S.dim(e.sub||e.active)}function ol(e){e.root||T(S.dim(`${e.user} has no passwordless sudo: other users' keys, bans and daemon.json may be missing`))}const sl=M({name:`system`,summary:`Show a server's basics: host name, time zone, swap, Docker log limits, failed services, SSH keys and bans.`,options:{server:N,json:tl},async run(e,t){let{name:n,server:r}=$(t),i=await as(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;j([[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(rl).length} running${i.services.some(nl)?S.red(` · ${i.services.filter(nl).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(nl))E(`${$o(e.unit)} failed: lf services logs ${$o(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`)),ol(i)}}),cl=M({name:`services`,summary:`List services: the ones landfall relies on and failed ones; --all for every one.`,options:{server:N,all:{type:`boolean`,desc:`every service, inactive ones too`},json:tl},async run(e,t){let{name:n,server:r}=$(t),i=await is(r);if(t.json)return void console.log(JSON.stringify(i.services,null,2));i.init===`other`&&O(`no systemd or OpenRC on ${n}`);let a=t.all?i.services:i.services.filter(e=>nl(e)||il(e)||rl(e)&&i.init===`openrc`),o=e=>nl(e)?0:il(e)?1:2;a.sort((e,t)=>o(e)-o(t)||e.unit.localeCompare(t.unit)),j([[`service`,`state`,...i.init===`systemd`?[`boot`]:[],`description`].map(S.dim),...a.map(e=>[$o(e.unit),al(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 ll(e){return M({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:N,force:{type:`boolean`,desc:`even ssh`}},async run([t],n){t||O(`usage: lf services ${e} <service>`),ss.test(t)||O(`not a service name: ${t}`);let{name:r,server:i}=$(n),a=$o(t);if(e===`stop`&&/^sshd?$/.test(a)&&!n.force&&!await me(`Stopping ${a} can lock you out of ${r}. Do it anyway?`)&&O(`stopping ${a} can lock you out (--force to do it anyway)`),/^(docker|containerd)$/.test(a)&&e!==`start`&&!await A(`${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 cs(o,e,t),w(`${a}: ${e===`stop`?`stopped`:e===`start`?`started`:`restarted`}`)}})}const ul=ll(`restart`),dl=ll(`start`),fl=ll(`stop`),pl=M({name:`services logs`,args:`<service>`,summary:`Show a service's log (journald, else its log file).`,options:{server:N,follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},async run([e],t){e||O(`usage: lf services logs <service>`);let{server:n}=$(t);return St(n,[ls(e,{lines:z(t.tail,`--tail`,1,1e5),follow:t.follow})])}}),ml=e=>e.fingerprint.replace(/^SHA256:/,``).slice(0,12),hl=M({name:`keys`,summary:`List the SSH keys that may log in, per user. ★ marks the key this machine uses.`,options:{server:N,json:tl},async run(e,t){let{server:n}=$(t),r=await as(n);if(t.json)return void console.log(JSON.stringify(r.keys,null,2));r.keys.length||T(S.dim(`no authorized keys found`)),j([[``,`user`,`key`,`comment`].map(S.dim),...r.keys.map(e=>[e.mine?S.green(`★`):``,e.user,`${e.type.replace(/^ssh-/,``)} ${S.dim(ml(e))}`,`${e.comment||S.dim(`(no comment)`)}${e.options?S.dim(` [${e.options}]`):``}`])]),ol(r)}});function gl(e){let t=De(e),n=!/^\S+ AAAA/.test(e)&&o.existsSync(t)?o.readFileSync(t,`utf8`).trim().split(`
1112
- `)[0]:e.trim();return ts(n)||O(`not a public key: ${e} (a line like ssh-ed25519 AAAA… or a .pub file)`),n}const _l=M({name:`keys add`,args:`<key|file.pub>`,summary:`Let another SSH key log in (to the login user, or --user).`,options:{server:N,user:{type:`string`,short:`u`,desc:`whose authorized_keys (default: the login user)`}},async run([e],t){e||O(`usage: lf keys add <key|file.pub>`);let n=gl(e),{name:r,server:i}=$(t),a=t.user??Y(i).user;await ds(await Q(i,`adding a key`),a,n);let o=ts(n);w(`${o.comment||ml(o)} may log in to ${r} as ${a}`)}}),vl=M({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:N,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||O(`usage: lf keys rm <fingerprint|comment>`);let{name:n,server:r}=$(t),i=await as(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||O(`no key ${e}${t.user?` for ${t.user}`:``} on ${n}: see lf keys`),o.length>1&&O(`${e} matches ${o.length} keys (${o.map(e=>`${e.user} ${ml(e)}`).join(`, `)}): give more of the fingerprint or --user`);let s=o[0];s.mine&&!t.force&&!await me(`${s.comment||ml(s)} is the key this machine logs in with: removing it can lock you out. Do it anyway?`)&&O(`that is the key this machine logs in with (--force to remove it anyway)`),await k(`Remove key ${s.comment||ml(s)} of ${s.user} on ${n}?`),await fs(await Q(r,`removing a key`),s),w(`removed ${s.comment||ml(s)} from ${s.user}`)}}),yl=M({name:`bans`,summary:`Show what fail2ban blocks: banned IPs per jail.`,options:{server:N,json:tl},async run(e,t){let{name:n,server:r}=$(t),i=await as(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`&&O(`${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`))}}),bl=M({name:`unban`,args:`<ip>`,summary:`Let a banned IP in again (all jails that ban it). Undo bans it again.`,options:{server:N},async run([e],t){e||O(`usage: lf unban <ip>`);let{name:n,server:r}=$(t),i=await Q(r,`unbanning`),a=(await is(i)).jails.filter(t=>t.ips.includes(e)).map(e=>e.name);a.length||O(`${e} is not banned on ${n}`),await ms(i,e,a),w(`unbanned ${e} (${a.join(`, `)})`)}}),xl=M({name:`swap`,args:`[size|off]`,summary:`Show swap, add a swap file (e.g. 2G) or remove the one landfall made (off).`,options:{server:N,json:tl},async run([e],t){let{name:n,server:r}=$(t),i=await is(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`)}`);j([[`file`,`size`,`used`].map(S.dim),...i.swap.map(e=>[e.file,X(e.size),X(e.used)])]);return}i.managed&&O(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 k(`Remove the swap file /swapfile on ${n}?`),C(`turning swap off`),await bs(a),w(`removed /swapfile`)}let o=hs(e);o<64*1024**2&&O(`at least 64M`),i.diskFree&&o>i.diskFree-2*1024**3&&O(`only ${X(i.diskFree)} free on /: keep 2 GB besides the swap file`),C(`adding ${X(o)} swap at /swapfile`),await vs(a,o),w(`${X(o)} swap on ${n}`)}}),Sl=M({name:`hostname`,args:`[name]`,summary:`Show or change the host name.`,options:{server:N},async run([e],t){let{name:n,server:r}=$(t),i=await is(r);if(!e)return void console.log(i.hostname);if(e===i.hostname)return w(`already ${e}`);i.managed&&O(i.managed),await Cs(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)`)}`)}}),Cl=M({name:`timezone`,args:`[zone]`,summary:`Show or change the time zone (e.g. Europe/Berlin, UTC). Cron jobs and logs follow it.`,options:{server:N},async run([e],t){let{name:n,server:r}=$(t),i=await is(r);if(!e)return void console.log(`${i.timezone}${S.dim(` · ${i.time}`)}`);if(e===i.timezone)return w(`already ${e}`);i.managed&&O(i.managed),await Es(await Q(r,`changing the time zone`),i.timezone,e),w(`${n}: time zone ${e}`)}}),wl=M({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).
1113
- Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:N,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:tl},async run([e],t){let{name:n,server:r}=$(t),i=await is(r),a=i.docker;if(a||O(`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&&O(i.managed),a.daemonError&&O(`${a.daemonError}: fix it first`);let o=e===`off`?void 0:e.toLowerCase();o&&!Ds.test(o)&&O(`not a size: ${e} (e.g. 10m, 500k, 1g)`);let s=z(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 A(`Restart docker on ${n}? Every site goes down for a moment.`,!1);await As(await Q(r,`changing docker's log limit`),Os(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`))}}),Tl=M({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
1114
- 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-D2_Msm-i.mjs`);return n({port:t.port===void 0?void 0:z(t.port,`--port`,1,65535),open:t.open})}}),El={current:S.green,update:S.yellow,unknown:S.dim,pinned:S.dim,rebuild:S.dim,foreign:S.dim,missing:S.red};function Dl(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);j(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 Ol(e){e.length&&(console.log(`\n${S.bold(`Images`)}`),j(e.map(e=>[` `+e.name,S.dim(e.ref||`-`),El[e.state](e.state===`update`?`newer image`:e.state),S.dim(e.note??``)])))}function kl(e){let t=Jr(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 Al=M({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:N,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}=R(t.server);if(t.json)return void console.log(JSON.stringify(await lc(n,t.refresh),null,2));let r=await Mr(n,t.refresh),i=await Zr(n),a=await qr(n);Dl(r,t.all),kl(a),Ol(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}`)),Jr(a).composeUpdate&&console.log(S.dim(`newest compose: lf update --compose${o}`))}}),jl=M({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
1175
+ `;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Zc=(e,t)=>t?Math.round(e/t*100):0;function Qc(e){let t=Math.max(0,Math.min(10,Math.round(e/10)));return(e>=90?x.red:e>=70?x.yellow:x.green)(`█`.repeat(t))+x.dim(`░`.repeat(10-t))}const $c=e=>`${X(e)}/s`,el=e=>kn(e)?/unhealthy|restarting/i.test(e)?x.yellow(e):x.green(e):An(e)?x.dim(e):x.red(e);function tl(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`,Qc(t.cpu),`${t.cpu}%`,x.dim(`${t.cpus} cores · load ${t.load.join(` `)}`)],[`memory`,Qc(Zc(n,t.mem.total)),`${Zc(n,t.mem.total)}%`,x.dim(`${X(n)} / ${X(t.mem.total)}`)],...t.mem.swapTotal?[[`swap`,Qc(Zc(r,t.mem.swapTotal)),`${Zc(r,t.mem.swapTotal)}%`,x.dim(`${X(r)} / ${X(t.mem.swapTotal)}`)]]:[],...t.disks.map(e=>[`disk ${e.mount}`,Qc(Zc(e.used,e.total)),`${Zc(e.used,e.total)}%`,x.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)]),...t.net.map(e=>[`net ${e.iface}`,``,``,`↓ ${$c(e.rx)} ↑ ${$c(e.tx)}`])];console.log(x.bold(e)+x.dim(` · up ${vn(t.uptime)}`)),A(i.map(([e,...t])=>[x.dim(e),...t])),t.procs.length&&(console.log(`\n${x.bold(`Processes`)} ${x.dim(`(cpu: 100% = one core)`)}`),A([[`pid`,`cpu`,`memory`,`name`].map(x.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${x.bold(`Containers`)}`),A([[`site`,`container`,`cpu`,`memory`,`net in/out since start`,`restarts`,`status`].map(x.dim),...r.map(e=>[e.site??x.dim(e.project||`-`),e.name,kn(e.status)?`${Math.round(e.cpu*10)/10}%`:``,kn(e.status)?`${X(e.mem)}${e.memLimit&&e.memLimit<t.mem.total?x.dim(` (limit ${X(e.memLimit)})`):``}`:``,kn(e.status)?`↓ ${X(e.net.rx)} ↑ ${X(e.net.tx)}`:``,e.restarts?x.yellow(String(e.restarts)):x.dim(`0`),el(e.status)])])):console.log(x.dim(`
1176
+ no access to docker: containers not shown (run lf setup)`))}const nl=j({name:`stats`,args:`[site]`,summary:`Show live CPU, memory, disk, network, the busiest processes and each container's usage.
1177
+ With a site, only its containers.`,options:{server:M,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}=L(t.server??z(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 Fs(r),null,2));return}if(!t.watch)return tl(n,await Fs(r),e);let i=R(t.interval,`--interval`,1,3600)*1e3;for(;;){let t=await Fs(r);process.stdout.isTTY&&process.stdout.write(`\x1B[H\x1B[2J`),tl(n,t,e),console.log(x.dim(`\n${(/* @__PURE__ */ new Date()).toLocaleTimeString()} · every ${i/1e3}s · Ctrl-C to stop`)),await new Promise(e=>setTimeout(e,i))}}});function rl(e,t){console.log(x.bold(e)),A(t.disks.map(e=>[x.dim(e.mount),Qc(Zc(e.used,e.total)),`${Zc(e.used,e.total)}%`,x.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)])),t.docker&&t.dockerDf.length?(console.log(`\n${x.bold(`Docker`)}`),A([[``,`count`,`in use`,`size`,`reclaimable`].map(x.dim),...t.dockerDf.map(e=>[e.type,String(e.count),String(e.active),X(e.size),e.reclaimable?x.yellow(X(e.reclaimable)):x.dim(`0`)])])):t.docker||console.log(x.dim(`
1178
+ no access to docker: images, volumes and logs not shown`)),t.sites.length&&(console.log(`\n${x.bold(`Sites`)} ${x.dim(`(${B} ${X(t.landfall)})`)}`),A([[`site`,`files`,`releases`].map(x.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${x.bold(`Volumes`)} ${x.dim(`(largest)`)}`),A([[`volume`,`size`,`used by`].map(x.dim),...n.map(e=>[e.name,X(e.size),e.links?String(e.links):x.yellow(`nothing`)])]));let r=t.logs.filter(e=>(e.size??0)>0).slice(0,10);r.length&&(console.log(`\n${x.bold(`Container logs`)} ${x.dim(`(largest)`)}`),A(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(`
1179
+ `+i.map(e=>x.dim(e)).join(`
1180
+ `))}const il=j({name:`disk`,summary:`Show where a server's disk space goes: filesystems, Docker images, volumes, build cache, sites and container logs.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=L(t.server??z(process.cwd())?.server),i=await Rs(r);if(t.json)return void console.log(JSON.stringify(i,null,2));rl(n,i)}}),al=e=>e.toLocaleString(`en-US`),ol=e=>e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`;function sl(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?x.red(c):x.cyan(c)}return i}const cl=e=>[al(e.requests),e.classes[`4xx`]?x.yellow(al(e.classes[`4xx`])):x.dim(`0`),e.classes[`5xx`]?x.red(al(e.classes[`5xx`])):x.dim(`0`),e.requests?ol(e.p50):``,e.requests?ol(e.p95):``,X(e.bytes)];function ll(e,t){A([[`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(x.dim),cl(e)]),console.log(`\n${x.dim(`requests per ${t}`)} ${sl(e.series)}`),e.paths.length&&(console.log(`\n${x.bold(`Paths`)}`),A(e.paths.map(e=>[al(e.count),e.errors?x.yellow(`${al(e.errors)} failed`):``,e.path]))),e.errors.length&&(console.log(`\n${x.bold(`Errors`)}`),A(e.errors.map(e=>[al(e.count),e.status>=500?x.red(String(e.status)):x.yellow(String(e.status)),e.path]))),e.ips.length&&(console.log(`\n${x.bold(`Clients`)}`),A(e.ips.map(e=>[al(e.count),e.ip])));let n=Object.entries(e.codes).sort((e,t)=>t[1]-e[1]);n.length&&console.log(`\n${x.dim(`status codes`)} ${n.map(([e,t])=>`${e} ${al(t)}`).join(x.dim(` · `))}`)}const ul=j({name:`traffic`,args:`[site]`,summary:`Show requests, errors, response times, busiest paths and clients from Traefik's access log.
1181
+ With a site (or a domain), only its requests.`,options:{server:M,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}=L(t.server??z(process.cwd())?.server),i=Ys(t.since),a=await lc(r,i);a.proxy||D(`no traefik on ${n} (lf traefik setup)`);let o=e?uc(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 T(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=Xs(i);if(a.format===`none`)return w(`no requests on ${n} in the last ${s}${a.own?x.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(`${x.bold(n)}${x.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}`),ll(o,a.bucket);let l=a.hosts.filter(e=>!e.site);A([[`site`,`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(x.dim),...a.sites.map(e=>[x.bold(e.site),...cl(e)]),...l.slice(0,8).map(e=>[x.dim(e.host),...cl(e)]),...l.length>8?[[x.dim(`${l.length-8} more hosts`),...cl(dl(l.slice(8)))]]:[],[x.dim(`total`),...cl(a.total)]]),console.log(`\n${x.dim(`requests per ${a.bucket}`)} ${sl(a.total.series)}`),a.total.errors.length&&(console.log(`\n${x.bold(`Errors`)}`),A(a.total.errors.slice(0,5).map(e=>[al(e.count),e.status>=500?x.red(String(e.status)):x.yellow(String(e.status)),e.path]))),console.log(x.dim(`
1182
+ lf traffic <site> for its paths, errors and clients`))}});function dl(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 fl(e){if(!e)return x.red(`none`);let t=`${e.days} days`;return e.days<7?x.red(e.days<0?`expired`:t):e.staging?x.yellow(`${t}, staging (not trusted)`):e.days<30?x.yellow(t):x.green(t)}const pl=j({name:`certs`,summary:`Show each site domain's HTTPS certificate: when it expires, who issued it, and why getting one failed.`,options:{server:M,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}=L(t.server??z(process.cwd())?.server),i=await _c(r,Ys(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){A([[`domain`,`site`,`certificate`,`expires`,`issuer`].map(x.dim),...i.domains.map(e=>[e.domain,x.dim(e.site),fl(e.cert),e.cert?e.cert.expires.slice(0,10):``,x.dim(e.cert?.issuer??``)])]);for(let e of i.domains.filter(e=>e.error&&(!e.cert||e.cert.days<30)))T(`${e.domain}: ${e.error.error}${e.error.at?x.dim(` (${new Date(e.error.at).toLocaleString()})`):``}`)}else w(`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${x.bold(`Other certificates`)}`),A(o.map(e=>[e.domains.join(` `),fl(e),x.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)T(`${e.domains.join(` `)||`acme`}: ${e.error}`);i.denied.length&&T(`${i.denied.join(`, `)} needs root to read: ${n}'s login has no passwordless sudo`)}}),ml=e=>e.includes(`.`)&&Ne.test(e.toLowerCase()),hl=j({name:`check`,args:`[site|domain…]`,summary:`Request each site over HTTPS from this machine: status, response time and days left on the certificate.
1183
+ Exits 1 when one fails: use it from cron or CI.`,options:{server:M,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(ml).map(e=>({domain:e,site:void 0})),r=e.filter(e=>!ml(e));if(!e.length||r.length){let{name:e,server:i}=L(t.server??z(process.cwd())?.server),a=await gr(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 w(`no site on ${e} has a domain`)}let i=await Promise.all(n.map(async e=>({...await vc(e.domain,{resolve:t.resolve}),...e.site&&{site:e.site}})));return t.json?console.log(JSON.stringify(i,null,2)):A([[``,`domain`,`site`,`status`,`time`,`certificate`].map(x.dim),...i.map(e=>[e.ok?x.green(`✓`):x.red(`✗`),e.domain,x.dim(e.site??``),e.error?x.red(e.error):(e.status??0)>=500?x.red(String(e.status)):String(e.status),e.ms===void 0?``:e.ms>1e3?x.yellow(ol(e.ms)):ol(e.ms),e.tls?e.tls.valid?e.tls.days<7?x.red(`${e.tls.days} days`):e.tls.days<30?x.yellow(`${e.tls.days} days`):x.green(`${e.tls.days} days`):x.red(e.tls.error??`not trusted`):``])]),+!i.every(e=>e.ok)}});function gl(e,t){let n=Bs(t),r=[[Vs(t,`release`).length,`for rollback`],[Vs(t,`undo`).length,`for undo`],[Vs(t,`tagged`).length,`tagged but unused (--all removes them)`]].filter(([e])=>e);n.length?(console.log(`${x.bold(e)}: ${n.length} unused images`),A([[`image`,`size`,`created`].map(x.dim),...n.slice(0,20).map(e=>[e.tags.join(`, `)||x.dim(e.id.replace(`sha256:`,``).slice(0,12)),X(e.size),x.dim(e.created.split(` `)[0])])]),n.length>20&&console.log(x.dim(`… ${n.length-20} more`))):console.log(`${x.bold(e)}: no unused images`),console.log(`build cache: ${t.buildCache?X(t.buildCache):x.dim(`0`)}${t.all?``:x.dim(` reclaimable (--all also removes cache still in use)`)}`),r.length&&console.log(x.dim(`kept: ${r.map(([e,t])=>`${e} ${t}`).join(`, `)}`))}const _l=j({name:`prune`,summary:`Free disk space: remove unused Docker images and the build cache.
1184
+ 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:M,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}=L(t.server??z(process.cwd())?.server),i=await Us(r,!!t.all);i.docker||D(`no access to docker on ${n} (run lf setup)`);let a=Bs(i);if(t[`dry-run`]||t.json&&!a.length&&!i.buildCache)return void(t.json?console.log(JSON.stringify(i,null,2)):gl(n,i));if(t.json||gl(n,i),!a.length&&!i.buildCache)return C(`nothing to clean up`);let o=a.reduce((e,t)=>e+t.size,0)+i.buildCache;if(!await k(`\nRemove ${a.length} images and the build cache (up to ${X(o)})?`,!0))return;t.json||S(`cleaning up`);let s=await Ws(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)T(`${e}: could not remove it (in use?)`);C(`removed ${s.images-s.failed.length} images and the build cache`)}}),vl=j({name:`reboot`,summary:`Reboot a server, wait until it is back and check its containers run again.`,options:{server:M,timeout:{type:`string`,desc:`seconds to wait for it to come back`,default:`300`}},async run(e,t){let{name:n,server:r}=L(t.server??z(process.cwd())?.server),i=R(t.timeout,`--timeout`,30,3600),a=await Q(r,`rebooting`);if(!await k(`Reboot ${n}? Its sites are offline for a minute or two.`,!1))return;let o=await Js(a,{timeout:i,onWait:e=>S(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`),C(`${n} is back after ${o.seconds}s`),o.missing.length?T(`not running again: ${o.missing.join(`, `)}. See lf stats -s ${n}`):w(`all containers that ran before run again`)}}),$=e=>L(e.server??z(process.cwd())?.server),yl={type:`boolean`,desc:`machine-readable output`},bl=e=>e.active===`failed`||e.active===`crashed`,xl=e=>e.active===`active`||e.active===`started`,Sl=e=>Za.includes(Qa(e.unit));function Cl(e){return bl(e)?x.red(e.sub||e.active):xl(e)?x.green(e.sub||e.active):x.dim(e.sub||e.active)}function wl(e){e.root||w(x.dim(`${e.user} has no passwordless sudo: other users' keys, bans and daemon.json may be missing`))}const Tl=j({name:`system`,summary:`Show a server's basics: host name, time zone, swap, Docker log limits, failed services, SSH keys and bans.`,options:{server:M,json:yl},async run(e,t){let{name:n,server:r}=$(t),i=await io(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;A([[x.dim(`host name`),i.hostname],[x.dim(`time zone`),`${i.timezone}${i.ntp===!1?x.yellow(` · clock not synced`):i.ntp?x.dim(` · synced`):``}`],[x.dim(`swap`),a?`${X(a)}${x.dim(` (${i.swap.map(e=>e.file).join(`, `)})`)}`:x.yellow(`none`)],[x.dim(`docker logs`),o?o.maxSize?`${o.driver}, ${o.maxSize} × ${o.maxFile??1} per container`:o.driver===`json-file`?x.yellow(`${o.driver}, unlimited`):o.driver:x.dim(`no docker`)],[x.dim(`services`),i.init===`other`?x.dim(`no systemd or OpenRC`):`${i.services.filter(xl).length} running${i.services.some(bl)?x.red(` · ${i.services.filter(bl).length} failed`):``}`],[x.dim(`ssh keys`),`${i.keys.length}`],[x.dim(`fail2ban`),i.fail2ban===`running`?`${i.jails.reduce((e,t)=>e+t.banned,0)} banned now`:x.dim(i.fail2ban===`missing`?`not installed`:`stopped`)]]),o?.daemonError&&T(o.daemonError);for(let e of i.services.filter(bl))T(`${Qa(e.unit)} failed: lf services logs ${Qa(e.unit)} -s ${n}`);!a&&i.mem&&i.mem<4*1024**3&&w(x.dim(`little memory and no swap: lf swap 2G adds some`)),o&&!o.maxSize&&o.driver===`json-file`&&w(x.dim(`container logs grow without limit: lf log-limit 10m caps them`)),wl(i)}}),El=j({name:`services`,summary:`List services: the ones landfall relies on and failed ones; --all for every one.`,options:{server:M,all:{type:`boolean`,desc:`every service, inactive ones too`},json:yl},async run(e,t){let{name:n,server:r}=$(t),i=await ro(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=>bl(e)||Sl(e)||xl(e)&&i.init===`openrc`),o=e=>bl(e)?0:Sl(e)?1:2;a.sort((e,t)=>o(e)-o(t)||e.unit.localeCompare(t.unit)),A([[`service`,`state`,...i.init===`systemd`?[`boot`]:[],`description`].map(x.dim),...a.map(e=>[Qa(e.unit),Cl(e),...i.init===`systemd`?[x.dim(e.enabled??``)]:[],x.dim(e.description)])]),t.all||w(x.dim(`${i.services.length-a.length} more: lf services --all`))}});function Dl(e){return j({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:M,force:{type:`boolean`,desc:`even ssh`}},async run([t],n){t||D(`usage: lf services ${e} <service>`),oo.test(t)||D(`not a service name: ${t}`);let{name:r,server:i}=$(n),a=Qa(t);if(e===`stop`&&/^sshd?$/.test(a)&&!n.force&&!await he(`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 k(`${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`);S(`${e} ${a} on ${r}`),await so(o,e,t),C(`${a}: ${e===`stop`?`stopped`:e===`start`?`started`:`restarted`}`)}})}const Ol=Dl(`restart`),kl=Dl(`start`),Al=Dl(`stop`),jl=j({name:`services logs`,args:`<service>`,summary:`Show a service's log (journald, else its log file).`,options:{server:M,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 Et(n,[co(e,{lines:R(t.tail,`--tail`,1,1e5),follow:t.follow})])}}),Ml=e=>e.fingerprint.replace(/^SHA256:/,``).slice(0,12),Nl=j({name:`keys`,summary:`List the SSH keys that may log in, per user. ★ marks the key this machine uses.`,options:{server:M,json:yl},async run(e,t){let{server:n}=$(t),r=await io(n);if(t.json)return void console.log(JSON.stringify(r.keys,null,2));r.keys.length||w(x.dim(`no authorized keys found`)),A([[``,`user`,`key`,`comment`].map(x.dim),...r.keys.map(e=>[e.mine?x.green(`★`):``,e.user,`${e.type.replace(/^ssh-/,``)} ${x.dim(Ml(e))}`,`${e.comment||x.dim(`(no comment)`)}${e.options?x.dim(` [${e.options}]`):``}`])]),wl(r)}});function Pl(e){let t=Oe(e),n=!/^\S+ AAAA/.test(e)&&o.existsSync(t)?o.readFileSync(t,`utf8`).trim().split(`
1185
+ `)[0]:e.trim();return eo(n)||D(`not a public key: ${e} (a line like ssh-ed25519 AAAA… or a .pub file)`),n}const Fl=j({name:`keys add`,args:`[key|file.pub]`,summary:`Let another SSH key log in (to the login user, or --user).
1186
+
1187
+ With --generate lf makes a new pair at ~/.ssh/<server>_<user> (asking for a
1188
+ passphrase in a terminal) and adds its public key: hand the private key to
1189
+ whoever logs in as that user, or keep it if that is you.`,options:{server:M,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?Vo(n,a):void 0,s=o?o.pub:Pl(e);await uo(await Q(r,`adding a key`),a,s);let c=eo(s);C(`${c.comment||Ml(c)} may log in to ${n} as ${a}`),o&&(w(`${o.created?`private key`:`used the existing key`}: ${F(o.path)}`),w(`log in: ssh -i ${F(o.path)}${i.port===22?``:` -p ${i.port}`} ${a}@${i.hostname}`),o.created&&!o.passphrase&&w(x.dim(`it has no passphrase; add one with ssh-keygen -p -f ${F(o.path)}`)),ue({keyFile:o.path}))}}),Il=j({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:M,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 io(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} ${Ml(e)}`).join(`, `)}): give more of the fingerprint or --user`);let s=o[0];s.mine&&!t.force&&!await he(`${s.comment||Ml(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 O(`Remove key ${s.comment||Ml(s)} of ${s.user} on ${n}?`),await fo(await Q(r,`removing a key`),s),C(`removed ${s.comment||Ml(s)} from ${s.user}`)}}),Ll=j({name:`bans`,summary:`Show what fail2ban blocks: banned IPs per jail.`,options:{server:M,json:yl},async run(e,t){let{name:n,server:r}=$(t),i=await io(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`),w(i.fail2ban===`missing`?`fail2ban is not installed on ${n}`:`fail2ban is not running on ${n}`);for(let e of i.jails){console.log(`${x.bold(e.name)}: ${e.banned} banned now${x.dim(` · ${e.total} in total · ${e.failed} failing`)}`);for(let t of e.ips)console.log(` ${t}`)}i.jails.some(e=>e.ips.length)&&w(x.dim(`lf unban <ip> lets one in again`))}}),Rl=j({name:`unban`,args:`<ip>`,summary:`Let a banned IP in again (all jails that ban it). Undo bans it again.`,options:{server:M},async run([e],t){e||D(`usage: lf unban <ip>`);let{name:n,server:r}=$(t),i=await Q(r,`unbanning`),a=(await ro(i)).jails.filter(t=>t.ips.includes(e)).map(e=>e.name);a.length||D(`${e} is not banned on ${n}`),await mo(i,e,a),C(`unbanned ${e} (${a.join(`, `)})`)}}),zl=j({name:`swap`,args:`[size|off]`,summary:`Show swap, add a swap file (e.g. 2G) or remove the one landfall made (off).`,options:{server:M,json:yl},async run([e],t){let{name:n,server:r}=$(t),i=await ro(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 w(`no swap on ${n}${x.dim(` · ${X(i.mem)} memory · lf swap 2G adds a swap file`)}`);A([[`file`,`size`,`used`].map(x.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&&T(`${X(e.used)} of it is in use: it moves back to memory`),await O(`Remove the swap file /swapfile on ${n}?`),S(`turning swap off`),await bo(a),C(`removed /swapfile`)}let o=ho(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`),S(`adding ${X(o)} swap at /swapfile`),await vo(a,o),C(`${X(o)} swap on ${n}`)}}),Bl=j({name:`hostname`,args:`[name]`,summary:`Show or change the host name.`,options:{server:M},async run([e],t){let{name:n,server:r}=$(t),i=await ro(r);if(!e)return void console.log(i.hostname);if(e===i.hostname)return C(`already ${e}`);i.managed&&D(i.managed),await Co(await Q(r,`renaming the host`),i.hostname,e),C(`${n} is now called ${e}${x.dim(` (the shell prompt shows it on the next login)`)}`)}}),Vl=j({name:`timezone`,args:`[zone]`,summary:`Show or change the time zone (e.g. Europe/Berlin, UTC). Cron jobs and logs follow it.`,options:{server:M},async run([e],t){let{name:n,server:r}=$(t),i=await ro(r);if(!e)return void console.log(`${i.timezone}${x.dim(` · ${i.time}`)}`);if(e===i.timezone)return C(`already ${e}`);i.managed&&D(i.managed),await Eo(await Q(r,`changing the time zone`),i.timezone,e),C(`${n}: time zone ${e}`)}}),Hl=j({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).
1190
+ Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:M,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:yl},async run([e],t){let{name:n,server:r}=$(t),i=await ro(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}: ${x.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&&!Do.test(o)&&D(`not a size: ${e} (e.g. 10m, 500k, 1g)`);let s=R(t.files,`--files`,1,100);[`json-file`,`local`].includes(a.driver)||T(`docker logs to ${a.driver}: the limit only takes effect for json-file and local`);let c=!!t.restart&&await k(`Restart docker on ${n}? Every site goes down for a moment.`,!1);await Ao(await Q(r,`changing docker's log limit`),Oo(a.daemon,o,s),o?`docker log limit ${o} × ${s}`:`docker log limit off`,c),C(o?`docker keeps at most ${o} × ${s} log per container`:`docker log limit removed`),w(x.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`))}}),Ul=j({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:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=$(t),{advice:i,...a}=await Lo(n,r);if(t.json)return void console.log(JSON.stringify({...a.security,fail2ban:a.fail2ban,advice:i},null,2));let o=a.security;A([[x.dim(`ssh login`),o.passwordLogin===void 0?x.dim(`unknown`):o.passwordLogin?x.red(`passwords and keys`):x.green(`keys only`)],[x.dim(`root login`),o.rootLogin===void 0?x.dim(`unknown`):o.rootLogin===`yes`?x.yellow(`yes`):o.rootLogin===`no`?`no`:`key only`],[x.dim(`firewall`),o.firewall===void 0?x.dim(`unknown`):o.firewall===`active`?x.green(`on`):x.yellow(o.firewall===`missing`?`no ufw`:`off`)],[x.dim(`fail2ban`),a.fail2ban===`running`?x.green(`running`):x.yellow(a.fail2ban===`missing`?`not installed`:`stopped`)],[x.dim(`auto updates`),o.autoUpdates===void 0?x.dim(`unknown`):o.autoUpdates?x.green(`on`):x.yellow(`off`)],[x.dim(`reboot`),o.reboot?x.yellow(`pending`):`not needed`]]),ue({advice:i}),console.log(),i.length?Io(i):C(`nothing to fix`),wl(a)}}),Wl=j({name:`harden`,args:`[ssh|fail2ban|updates…]`,summary:`Close the usual holes; all three without arguments:
1191
+ ssh keys only: no passwords, root by key only
1192
+ fail2ban install and start it: bans addresses that keep failing to log in
1193
+ updates install security updates automatically (apt, dnf)
1194
+ Each change is journaled: lf undo takes it back.`,options:{server:M,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=>!jo.includes(e));n.length&&D(`unknown: ${n.join(`, `)} (ssh, fail2ban or updates)`);let r=e.length?e:[...jo],{name:i,server:a}=$(t),o=await Q(a,`hardening`);if(r.includes(`ssh`)&&!t.force){let e=Po(await ro(o),Y(a).user);e.length&&D(`${No(e)} Or --force.`)}(!r.includes(`ssh`)||await k(`Only keys log in to ${i} afterwards (lf undo turns passwords back on). Go on?`))&&(await Bo(o,r),C(`${i}: ${r.join(`, `)} hardened`))}}),Gl=j({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
1195
+ 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:R(t.port,`--port`,1,65535),open:t.open})}}),Kl={current:x.green,update:x.yellow,unknown:x.dim,pinned:x.dim,rebuild:x.dim,foreign:x.dim,missing:x.red};function ql(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return w(`packages: no supported package manager`);let r=e.updates.length?`${x.bold(String(e.updates.length))} package updates${n?`, ${x.yellow(`${n} security`)}`:``}`:x.green(`packages up to date`);console.log(`${r}${e.reboot?x.yellow(` · reboot required`):``}${e.refreshed?``:x.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);A(i.map(e=>[` `+e.name,x.dim(e.from??``),`→ `+e.to,e.security?x.yellow(`security`):``])),i.length<e.updates.length&&console.log(x.dim(` … ${e.updates.length-i.length} more (--all)`))}function Jl(e){e.length&&(console.log(`\n${x.bold(`Images`)}`),A(e.map(e=>[` `+e.name,x.dim(e.ref||`-`),Kl[e.state](e.state===`update`?`newer image`:e.state),x.dim(e.note??``)])))}function Yl(e){let t=ei(e);console.log(`\n${x.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||x.red(`missing`)}`:x.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+x.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(x.dim(` newest releases`))}const Xl=j({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:M,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}=L(t.server);if(t.json)return void console.log(JSON.stringify(await Dc(n,t.refresh),null,2));let r=await Rr(n,t.refresh),i=await ri(n),a=await $r(n);ql(r,t.all),Yl(a),Jl(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(x.dim(`\napply with: lf update${o}`)),ei(a).composeUpdate&&console.log(x.dim(`newest compose: lf update --compose${o}`))}}),Zl=j({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
1115
1196
 
1116
1197
  Without --packages/--images both are updated. Package upgrades cannot be undone;
1117
1198
  image updates can (\`lf undo\` puts the old image back). --compose replaces the
1118
- compose plugin setup installed with the newest release (undoable too).`,options:{server:N,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}=R(t.server);if(t.compose){(e.length||t.packages||t.images)&&O(`--compose goes alone`);let i=await Q(r,`updating docker compose`),a=await qr(i),o=Jr(a);if(!o.composeUpdate)return void(o.composeNote?T(o.composeNote):w(`compose ${a.compose} is the newest`));await A(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(C(`downloading docker compose`),await Yr(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&&O(`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 Mr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?E(`no supported package manager, skipping packages`):a.length?(Dl({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&O(`${i.mgr} has no security-only upgrades here; drop --security`),await A(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(C(`upgrading packages on ${n}`),await Pr(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 Zr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&O(`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?(Ol(a),await A(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(C(`updating images`),await Qr(r,a),w(`images updated (lf undo puts the old ones back)`))):w(`images up to date`)}}}),Ml=()=>[process.execPath,...process.execArgv,process.argv[1]];function Nl(){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 Pl=5e3,Fl=1e4,Il=e=>e.length>Fl?e.slice(0,Fl)+`…`:e;function Ll(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,lo(r.password));let a=n(Ml()[0],[...Ml().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(`
1119
- `);e=n.pop(),e.length>Fl&&(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(Il(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));o.lines.length>Pl&&(o.dropped+=o.lines.splice(0,o.lines.length-Pl).length),c()},flush:()=>e&&o.lines.push(Il(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(`close`,e=>{u.flush(),d.flush(),o.code=o.stopped?null:e??1,clearTimeout(s),t()}),o}var Rl=`0.6.0`;const zl=[Tc,Mc,Ac,Zi,Qi,$i,sa,ia,ta,na,ra,oa,aa,ca,mc,hc,gc,Al,jl,_o,vo,wo,Rc,Bc,Kc,Yc,Zc,$c,el,sl,cl,ul,dl,fl,pl,hl,_l,vl,yl,bl,xl,Sl,Cl,wl,Eo,Ao,So,yo,xo,Co,Hi,Ui,Gi,Ki,qi,Wi,Ji,ka,Aa,ja,Da,Oa,Na,Ma,Xa,Za,xc,bc,Tl],Bl={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`},Vl=new Map(zl.map(e=>[e.name,e]));function Hl(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Vl.get(Bl[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Ul(){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(Rl)} — 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=Vl.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
1120
- `)[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(`
1121
- `))}async function Wl(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Ul(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(Rl),0;let{cmd:t,rest:n}=Hl(e);if(!t)return console.error(`${S.red(`error:`)} unknown command "${e.join(` `)}"\n`),Ul(),2;let{values:r,positionals:i,rest:a}=be(t,n);if(r.help)return console.log(xe(t)),0;de(!!r.yes),wt.cmd=P(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Gl(e){try{return await Wl(e)}catch(e){return e instanceof D?(console.error(`${S.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&le({force:e.message})):console.error(e),1}}Nl(),Gl(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{H as A,ye as B,Jn as C,G as D,Ze as E,Ee as F,_e as H,De as I,L,F as M,Me as N,W as O,Ne as P,B as R,Kn as S,ft as T,S as V,oo as _,nc as a,ao as b,rc as c,oc as d,ic as f,js as g,lc as h,fc as i,U as j,V as k,tc as l,dc as m,Ll as n,ac as o,sc as p,yc as r,uc as s,Rl as t,cc as u,no as v,Ct as w,ur as x,io as y,R as z};
1199
+ compose plugin setup installed with the newest release (undoable too).`,options:{server:M,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}=L(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 $r(i),o=ei(a);if(!o.composeUpdate)return void(o.composeNote?w(o.composeNote):C(`compose ${a.compose} is the newest`));await k(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(S(`downloading docker compose`),await ti(i),C(`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 Rr(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?T(`no supported package manager, skipping packages`):a.length?(ql({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&D(`${i.mgr} has no security-only upgrades here; drop --security`),await k(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(S(`upgrading packages on ${n}`),await Br(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),C(`packages upgraded`),t.reboot&&w(`rebooting if required; give it a minute`))):C(t.security?`no security updates`:`packages up to date`)}if(o){let t=await ri(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`))w(x.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(Jl(a),await k(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(S(`updating images`),await ii(r,a),C(`images updated (lf undo puts the old ones back)`))):C(`images up to date`)}}}),Ql=()=>[process.execPath,...process.execArgv,process.argv[1]];function $l(){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 eu=5e3,tu=1e4,nu=e=>e.length>tu?e.slice(0,tu)+`…`:e;function ru(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,Ha(r.password));let a=n(Ql()[0],[...Ql().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(`
1200
+ `);e=n.pop(),e.length>tu&&(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(nu(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));o.lines.length>eu&&(o.dropped+=o.lines.splice(0,o.lines.length-eu).length),c()},flush:()=>e&&o.lines.push(nu(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(`close`,e=>{u.flush(),d.flush(),o.code=o.stopped?null:e??1,clearTimeout(s),t()}),o}var iu=`0.6.2`;const au=[Uc,Xc,Jc,ra,ia,aa,pa,ua,sa,ca,la,fa,da,ma,Mc,Nc,Pc,Xl,Zl,hs,gs,Ss,nl,il,ul,pl,hl,_l,vl,Tl,Ul,Wl,El,Ol,kl,Al,jl,Nl,Fl,Il,Ll,Rl,zl,Bl,Vl,Hl,ws,Os,bs,_s,ys,xs,Ji,Yi,Zi,Qi,$i,Xi,ea,Fa,Ia,La,Na,Pa,za,Ra,is,as,zc,Rc,Gl],ou={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`},su=new Map(au.map(e=>[e.name,e]));function cu(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=su.get(ou[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function lu(){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=[`${x.bold(`landfall`)} ${x.dim(iu)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${x.bold(`Usage:`)} lf <command> [options] ${x.dim(`lf <command> --help for details`)}`,` lf ui ${x.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,x.bold(n));for(let e of r){let n=su.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
1201
+ `)[0]}`)}}t.push(``,x.bold(`Quick start`),` lf setup vps --check ${x.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${x.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${x.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${x.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${x.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${x.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${x.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${x.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${x.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${x.dim(`# what landfall changed, and revert the last of it`)}`,``,x.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
1202
+ `))}async function uu(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return lu(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(iu),0;let{cmd:t,rest:n}=cu(e);if(!t)return console.error(`${x.red(`error:`)} unknown command "${e.join(` `)}"\n`),lu(),2;let{values:r,positionals:i,rest:a}=xe(t,n);if(r.help)return console.log(Se(t)),0;fe(!!r.yes),Ot.cmd=N(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function du(e){try{return await uu(e)}catch(e){return e instanceof E?(console.error(`${x.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&ue({force:e.message})):console.error(e),1}}$l(),du(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{V as A,be as B,er as C,W as D,Qe as E,De as F,ve as H,Oe as I,I as L,P as M,Ne as N,U as O,Pe as P,z as R,Qn as S,ht as T,x as V,ms as _,bc as a,ps as b,xc as c,wc as d,Sc as f,Ys as g,Dc as h,Ac as i,H as j,B as k,yc as l,kc as m,ru as n,Cc as o,Tc as p,Lc as r,Oc as s,iu as t,Ec as u,us as v,Dt as w,gr as x,fs as y,L as z};