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