landfall 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +417 -400
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -2,21 +2,42 @@
|
|
|
2
2
|
import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import s,{homedir as c,hostname as l,tmpdir as u,userInfo as d}from"node:os";import{basename as f,dirname as p,join as m,normalize as ee,relative as te,resolve as ne}from"node:path";import{promises as h}from"node:dns";import g,{isIP as re}from"node:net";import{parse as ie}from"yaml";import{X509Certificate as _,createHash as ae,randomBytes as oe}from"node:crypto";import se from"bcryptjs";import v from"node:http";import ce from"node:https";const y=async()=>{process.stdin.isTTY&&D(`expected the value on stdin, e.g. printf %s "$SECRET" | lf …`);let e=``;for await(let t of process.stdin)e+=t;return e},le=process.stdout.isTTY&&!process.env.NO_COLOR,b=e=>t=>le?`\x1b[${e}m${t}\x1b[0m`:t,x={bold:b(`1`),dim:b(`2`),red:b(`31`),green:b(`32`),yellow:b(`33`),blue:b(`34`),cyan:b(`36`)},S=e=>console.error(`${x.cyan(`==>`)} ${x.bold(e)}`),C=e=>console.error(`${x.green(`✓`)} ${e}`),w=e=>console.error(` ${e}`),T=e=>console.error(`${x.yellow(`!`)} ${e}`);var E=class extends Error{};function ue(e){process.env.LANDFALL_REPORT&&process.stderr.write(`${JSON.stringify(e)}\n`)}const D=e=>{throw new E(e)};let de=!1;const fe=e=>de=e,pe=()=>!de&&!!process.stdin.isTTY;async function me(e,t){if(de||!process.stdin.isTTY){if(t!==void 0)return t;D(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${x.blue(`?`)} ${e}${t?x.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function he(e){return de||!process.stdin.isTTY?!1:k(e,!1)}async function O(e,t){de||(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),t?await me(`${e} Type ${x.bold(t)} to confirm:`)!==t&&D(`cancelled`):await k(e,!1)||D(`cancelled`))}async function k(e,t=!0){return de?!0:(process.stdin.isTTY||D(`${e} Pass -y to confirm (no terminal to ask).`),(await me(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`))}function A(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const ge=e=>{let t=(Date.now()-Date.parse(e))/1e3;return t<90?`${Math.round(t)}s ago`:t<5400?`${Math.round(t/60)}m ago`:t<129600?`${Math.round(t/3600)}h ago`:`${Math.round(t/86400)}d ago`};async function _e(e){(de||!process.stdin.isTTY)&&D(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${x.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
|
|
3
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
4
|
`)}}function ve(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const j=e=>e,M={type:`string`,short:`s`,desc:`server name or ssh alias`},ye={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}},be=e=>`LANDFALL_SECRET_${e.toUpperCase().replace(/-/g,`_`)}`;function xe(t,n){let r=n.indexOf(`--`),i=t.passthrough&&r>=0?n.slice(0,r):n,a=t.passthrough&&r>=0?n.slice(r+1):[],o={};for(let[e,{desc:n,...r}]of Object.entries({...ye,...t.options}))o[e]=r;try{let{values:n,positionals:r}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});for(let e of Object.keys(t.options??{}))/(^|-)password$/.test(e)&&n[e]===void 0&&process.env[be(e)]&&(n[e]=process.env[be(e)]);return{values:n,positionals:r,rest:a}}catch(e){return D(`${e.message}\n\n${Se(t)}`)}}function Se(e){let t=[`${x.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,x.bold(`Options:`));let n=Object.entries({...e.options,yes:ye.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?x.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?x.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
|
|
5
|
-
`)}function N(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const Ce=()=>
|
|
6
|
-
`,{mode:384})}function
|
|
7
|
-
`)}const
|
|
5
|
+
`)}function N(e,t,n){let r=e=>e.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.*/s,`$1=…`),i=Object.entries(n).flatMap(([t,n])=>t===`server`||t===`yes`||t===`help`||n===void 0||n===e.options?.[t]?.default?[]:n===!1?[`--no-${t}`]:n===!0?[`--${t}`]:(Array.isArray(n)?n:[n]).map(e=>/pass|secret|token/i.test(t)?`--${t} …`:`--${t} ${r(String(e))}`));return[e.name,...t.map(r),...i].join(` `)}const Ce=()=>L().defaults??{},we=e=>e.tunnelOnly??Ce().tunnelOnly??!1,Te=e=>process.env[`LANDFALL_${e}`],Ee=process.env.XDG_CONFIG_HOME??m(c(),`.config`),De=Te(`CONFIG_DIR`)??m(Ee,`landfall`),P=m(De,`config.json`),F=e=>e.startsWith(`~/`)?m(c(),e.slice(2)):e,I=e=>{let t=c();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function L(){try{let e=JSON.parse(o.readFileSync(P,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:D(`cannot read ${P}: ${e.message}`)}}function Oe(e){o.mkdirSync(p(P),{recursive:!0,mode:448}),o.writeFileSync(P,JSON.stringify(e,null,2)+`
|
|
6
|
+
`,{mode:384})}function ke(e){let t=L();delete t.servers[e],t.default===e&&delete t.default,Oe(t)}function Ae(e){let t=L();t.servers[e]??={host:e},t.default=e,Oe(t)}function R(e){let t=L();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?D("no server given. Use --server <ssh-alias> or `lf server add`"):D(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}(!/^[A-Za-z0-9._@:\[\]-]+$/.test(e)||e.startsWith(`-`))&&D(`not a server name: ${e}`);let n=t.servers[e]??{host:e};return{name:e,server:n}}const je=e=>(/^[A-Za-z0-9_@:\[\]][A-Za-z0-9._@:\[\]-]*$/.test(e)||D(`not a server name: ${e} (letters, digits and . _ - @ : only)`),e),Me=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,Ne=/^[A-Za-z0-9._@-]{1,64}$/,Pe=(e,t=`user`)=>(Ne.test(e)||D(`${t} may only have letters, digits and . _ @ -`),e),Fe=e=>(/^[^\s@"'`$\\]+@[^\s@"'`$\\]+\.[^\s@"'`$\\]+$/.test(e)||D(`not an e-mail address: ${e}`),e),Ie=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return Me.test(t)||D(`not a domain: ${e}`),t},Le=/^[a-z0-9][a-z0-9._\-/:@]*$/i,Re=e=>Le.test(e)?e:D(`not an image: ${e}`);function z(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&D(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const ze=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Be=e=>e.domains?.[0];function Ve(e,t){let n=Be(t);return e===`@`?n??D("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const He=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Ue=e=>{let t=e.toLowerCase().split(`.`),n=t.length>=3&&t.at(-1).length===2&&t.at(-2).length<=3?3:2;return t.slice(-n).join(`.`)};function We(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!Me.test(n))continue;let e=Ue(n);He.has(e)||t.set(e,(t.get(e)??0)+1)}return[...t].filter(([,e])=>e>=2).sort((e,t)=>t[1]-e[1]).map(([e,t])=>({domain:e,hosts:t}))}const Ge=`.landfall.json`,Ke=e=>m(e,Ge);function B(e){try{return JSON.parse(o.readFileSync(Ke(e),`utf8`))}catch{return}}function qe(e,t){o.writeFileSync(m(e,Ge),JSON.stringify(t,null,2)+`
|
|
7
|
+
`)}const V=`/opt/landfall`,H=`${V}/sites`,U=`${V}/traefik`,Je=`${U}/dynamic`,Ye=8080,Xe=`dashboard-tunnel`,Ze=`traefik:v3`,W=`${V}/proxy.env`,G=`${V}/journal.log`,Qe=`/etc/ssh/sshd_config.d/00-landfall.conf`,$e={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},et=`"$(sed -n 's/^container=//p' ${W} 2>/dev/null | grep . || echo traefik)"`;function tt(e){let{container:t,network:n,entrypoint:r,resolver:i}=Object.fromEntries(e.split(/\s+/).filter(e=>e.includes(`=`)&&!e.startsWith(`#`)).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)]));return t&&n&&r&&i?{container:t,network:n,entrypoint:r,resolver:i}:void 0}const nt=e=>`# landfall routes its sites through this existing traefik; delete the file to let landfall run its own\ncontainer=${e.container}\nnetwork=${e.network}\nentrypoint=${e.entrypoint}\nresolver=${e.resolver}\n`,rt=`PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
|
|
8
8
|
# Synology DSM has no os-release, only /etc/VERSION.
|
|
9
9
|
if [ -f /etc/synoinfo.conf ]; then
|
|
10
10
|
productversion=; . /etc/VERSION 2>/dev/null
|
|
11
11
|
ID=dsm; VERSION_ID=$productversion; PRETTY_NAME="Synology DSM $productversion"; kv platform synology
|
|
12
12
|
elif [ "$ID" = steamos ]; then PRETTY_NAME="SteamOS $VERSION_ID"; kv platform steamos
|
|
13
|
-
fi`,at=String.raw`pkg_mgr() { for m in apt-get dnf yum apk zypper; do command -v $m >/dev/null 2>&1 && { echo $m; return 0; }; done; echo none; }`,
|
|
13
|
+
fi`,it=String.raw`reboot_needed() { [ -f /var/run/reboot-required ] && return 0; command -v needs-restarting >/dev/null 2>&1 || return 1; needs-restarting -r >/dev/null 2>&1; [ $? = 1 ]; }`,at=String.raw`pkg_mgr() { for m in apt-get dnf yum apk zypper; do command -v $m >/dev/null 2>&1 && { echo $m; return 0; }; done; echo none; }`,ot=String.raw`
|
|
14
|
+
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
15
|
+
# ufw.conf, not ufw status: that starts Python (slow) and needs root. ufw enable/disable write ENABLED.
|
|
16
|
+
ufw_state() {
|
|
17
|
+
if ! command -v ufw >/dev/null 2>&1; then echo missing
|
|
18
|
+
elif grep -qx 'ENABLED=yes' /etc/ufw/ufw.conf 2>/dev/null; then echo active
|
|
19
|
+
else echo inactive; fi
|
|
20
|
+
}
|
|
21
|
+
password_users() { S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null; }
|
|
22
|
+
# What sshd really uses (a Match block or an earlier file can override ours): "no", "" without root, else passwords work.
|
|
23
|
+
sshd_passwords() { S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" || $1 == "kbdinteractiveauthentication" {print $2}' | sort -u | tr -d '\n'; }
|
|
24
|
+
key_file_user() { printf %s "$1" | sed 's#^/root/.*#root#; s#^/home/\([^/]*\)/.*#\1#'; }
|
|
25
|
+
# Users with a key: keys-only ssh does not lock them out.
|
|
26
|
+
key_users() {
|
|
27
|
+
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
|
28
|
+
S grep -q '^[^#[:space:]]' "$f" 2>/dev/null && printf '%s ' "$(key_file_user "$f")"
|
|
29
|
+
done; return 0
|
|
30
|
+
}
|
|
31
|
+
apt_auto_updates() { dpkg -s unattended-upgrades >/dev/null 2>&1 && apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; }
|
|
32
|
+
`,K=String.raw`
|
|
14
33
|
log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
|
|
15
34
|
SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
|
35
|
+
${ot}
|
|
16
36
|
# Files a container wrote may be root's.
|
|
17
37
|
rmf() { rm -rf "$@" 2>/dev/null || $SUDO rm -rf "$@"; }
|
|
18
38
|
# The distro's package manager, and installing with it.
|
|
19
39
|
${at}
|
|
40
|
+
${it}
|
|
20
41
|
pkg_has() {
|
|
21
42
|
case "$(pkg_mgr)" in
|
|
22
43
|
apt-get) dpkg -s "$1" ;;
|
|
@@ -64,6 +85,13 @@ ensure_running() {
|
|
|
64
85
|
[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = true ] && return 0
|
|
65
86
|
log "$1 failed to start, last logs:"; docker logs --tail 30 "$1" >&2 || true; return 1
|
|
66
87
|
}
|
|
88
|
+
# svc <start|stop|restart|enable|disable> <name>; enable/disable: start at boot or not.
|
|
89
|
+
svc() {
|
|
90
|
+
if [ -d /run/systemd/system ]; then $SUDO systemctl "$1" "$2"
|
|
91
|
+
elif command -v rc-service >/dev/null 2>&1; then
|
|
92
|
+
case "$1" in enable) $SUDO rc-update add "$2" default ;; disable) $SUDO rc-update del "$2" default ;; *) $SUDO rc-service "$2" "$1" ;; esac
|
|
93
|
+
else log "no systemd or OpenRC: cannot $1 $2"; return 1; fi
|
|
94
|
+
}
|
|
67
95
|
sshd_reload() { $SUDO systemctl reload ssh 2>/dev/null || $SUDO systemctl reload sshd 2>/dev/null || $SUDO service ssh reload 2>/dev/null || true; }
|
|
68
96
|
|
|
69
97
|
# Journal. LANDFALL_RUN/LANDFALL_ACTOR/LANDFALL_CMD come from runScript. Writing it never fails a script.
|
|
@@ -71,19 +99,19 @@ _clean() { printf %s "$1" | tr '\t\n' ' '; }
|
|
|
71
99
|
_journal() { # _journal <do|undo> <what> <undo shell>
|
|
72
100
|
line=$(printf '%s\t' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$LANDFALL_RUN" "$(_clean "$LANDFALL_ACTOR")" "$(id -un)" "$(_clean "$LANDFALL_CMD")" "$1" "$(_clean "$2")")
|
|
73
101
|
line="$line$(printf %s "$3" | base64 | tr -d '\n')"
|
|
74
|
-
if [ -w ${
|
|
75
|
-
printf '%s\n' "$line" >> ${
|
|
102
|
+
if [ -w ${G} ] || { [ ! -e ${G} ] && [ -w ${V} ]; }; then
|
|
103
|
+
printf '%s\n' "$line" >> ${G}
|
|
76
104
|
elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
|
|
77
105
|
else
|
|
78
|
-
{ $SUDO install -d ${
|
|
79
|
-
fi || { log "warning: could not write ${
|
|
106
|
+
{ $SUDO install -d ${V} && printf '%s\n' "$line" | $SUDO tee -a ${G} >/dev/null; } 2>/dev/null
|
|
107
|
+
fi || { log "warning: could not write ${G}"; return 0; }
|
|
80
108
|
# A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
|
|
81
|
-
# append; it belongs to whoever owns ${
|
|
82
|
-
[ -O ${
|
|
109
|
+
# append; it belongs to whoever owns ${V}.
|
|
110
|
+
[ -O ${G} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${V})" ${G} 2>/dev/null || true
|
|
83
111
|
}
|
|
84
112
|
# record <what> [undo shell]: journal a change; no undo shell = not reversible.
|
|
85
113
|
record() { _journal do "$1" "$(shift; printf %s "$*")"; }
|
|
86
|
-
# Run as $SUDO only when <dir> is not writable: deploy users own ${
|
|
114
|
+
# Run as $SUDO only when <dir> is not writable: deploy users own ${V} and need no sudo.
|
|
87
115
|
_as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
|
|
88
116
|
# saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
|
|
89
117
|
saved() {
|
|
@@ -123,7 +151,7 @@ ufw_enable() {
|
|
|
123
151
|
*inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
|
|
124
152
|
esac
|
|
125
153
|
}
|
|
126
|
-
`,
|
|
154
|
+
`,st=String.raw`
|
|
127
155
|
log "hardening sshd (keys only)"
|
|
128
156
|
# Never loosen an admin's stricter root login setting.
|
|
129
157
|
root_login=prohibit-password
|
|
@@ -131,10 +159,10 @@ if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" =
|
|
|
131
159
|
# 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
|
|
132
160
|
want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
|
|
133
161
|
undo=""
|
|
134
|
-
if [ "$($SUDO cat ${
|
|
162
|
+
if [ "$($SUDO cat ${Qe} 2>/dev/null || true)" != "$want" ]; then
|
|
135
163
|
$SUDO install -d /etc/ssh/sshd_config.d
|
|
136
|
-
undo=$(saved ${
|
|
137
|
-
printf '%s\n' "$want" | $SUDO tee ${
|
|
164
|
+
undo=$(saved ${Qe})
|
|
165
|
+
printf '%s\n' "$want" | $SUDO tee ${Qe} >/dev/null
|
|
138
166
|
fi
|
|
139
167
|
if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
|
|
140
168
|
if [ -n "$undo" ]; then undo="$undo; "; fi
|
|
@@ -149,9 +177,9 @@ else
|
|
|
149
177
|
log "sshd config test failed, putting the old config back"
|
|
150
178
|
eval "$undo"
|
|
151
179
|
fi
|
|
152
|
-
pw=$(
|
|
153
|
-
[ -z "$pw" ] || [ "$pw" = no ] || log "warning: sshd still accepts passwords; a Match block or another file in /etc/ssh overrides ${
|
|
154
|
-
`,
|
|
180
|
+
pw=$(sshd_passwords)
|
|
181
|
+
[ -z "$pw" ] || [ "$pw" = no ] || log "warning: sshd still accepts passwords; a Match block or another file in /etc/ssh overrides ${Qe} (check: sudo sshd -T)"
|
|
182
|
+
`,ct=String.raw`
|
|
155
183
|
compose_install() {
|
|
156
184
|
arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
|
|
157
185
|
f=${`/usr/local/lib/docker/cli-plugins/docker-compose`}
|
|
@@ -162,27 +190,27 @@ compose_install() {
|
|
|
162
190
|
$SUDO mv $f.new $f
|
|
163
191
|
record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
|
|
164
192
|
}
|
|
165
|
-
`,
|
|
193
|
+
`,lt=`This server is run by landfall (https://www.npmjs.com/package/landfall).
|
|
166
194
|
Everything here works without it: it is plain Docker Compose.
|
|
167
195
|
|
|
168
|
-
${
|
|
169
|
-
${
|
|
196
|
+
${U}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
|
|
197
|
+
${H}/<site>/ one Compose project per site
|
|
170
198
|
.env the site's environment (lf env), mode 600
|
|
171
199
|
site.json landfall's notes: domains, releases
|
|
172
200
|
current -> releases/<id> the live release
|
|
173
201
|
releases/<id>/compose.yaml the site's compose file as deployed
|
|
174
202
|
releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
|
|
175
|
-
${
|
|
203
|
+
${G} every change landfall made, with the shell that undoes it
|
|
176
204
|
|
|
177
205
|
Run a site by hand:
|
|
178
|
-
cd ${
|
|
206
|
+
cd ${H}/<site>/current && docker compose ps (logs, restart, up -d, down …)
|
|
179
207
|
Roll back by hand:
|
|
180
|
-
cd ${
|
|
208
|
+
cd ${H}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
|
|
181
209
|
|
|
182
210
|
Named volumes (databases, uploads) belong to the project, not the release: they survive
|
|
183
211
|
deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
|
|
184
|
-
`,
|
|
185
|
-
${
|
|
212
|
+
`,ut=String.raw`
|
|
213
|
+
${ct}
|
|
186
214
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
|
|
187
215
|
echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
|
|
188
216
|
exit 1
|
|
@@ -191,7 +219,7 @@ missing=""; for p in $(base_pkgs); do pkg_has "$p" || missing="$missing $p"; don
|
|
|
191
219
|
if [ -n "$missing" ]; then
|
|
192
220
|
log "installing base packages:$missing"
|
|
193
221
|
pkg_install $missing
|
|
194
|
-
|
|
222
|
+
{ svc enable fail2ban && svc start fail2ban; } >/dev/null 2>&1 || true
|
|
195
223
|
record "installed packages$missing"
|
|
196
224
|
fi
|
|
197
225
|
|
|
@@ -244,16 +272,16 @@ else
|
|
|
244
272
|
OWNER="$(id -un)"
|
|
245
273
|
fi
|
|
246
274
|
|
|
247
|
-
log "preparing ${
|
|
248
|
-
new_sites=$([ -d ${
|
|
249
|
-
new_traefik=$([ -d ${
|
|
250
|
-
$SUDO install -d -o "$OWNER" -g "$OWNER" ${
|
|
251
|
-
$SUDO chown "$OWNER:$OWNER" ${
|
|
252
|
-
$SUDO tee ${
|
|
253
|
-
${
|
|
254
|
-
# ${
|
|
255
|
-
[ -z "$new_sites" ] || record "created ${
|
|
256
|
-
[ -z "$new_traefik" ] || record "created ${
|
|
275
|
+
log "preparing ${V}"
|
|
276
|
+
new_sites=$([ -d ${H} ] || echo 1)
|
|
277
|
+
new_traefik=$([ -d ${U} ] || echo 1)
|
|
278
|
+
$SUDO install -d -o "$OWNER" -g "$OWNER" ${V} ${H} ${U}
|
|
279
|
+
$SUDO chown "$OWNER:$OWNER" ${G} 2>/dev/null || true
|
|
280
|
+
$SUDO tee ${V}/README >/dev/null <<'LANDFALL_README'
|
|
281
|
+
${lt}LANDFALL_README
|
|
282
|
+
# ${V} itself stays: it holds the journal. Each dir is only removed if setup created it.
|
|
283
|
+
[ -z "$new_sites" ] || record "created ${H}" "_as ${V} rmdir ${H}"
|
|
284
|
+
[ -z "$new_traefik" ] || record "created ${U}" "_as ${V} rm -rf ${U}"
|
|
257
285
|
|
|
258
286
|
DOCKER="$SUDO docker" ensure_network ${`web`}
|
|
259
287
|
|
|
@@ -267,16 +295,15 @@ if [ "$FIREWALL" = 1 ] && command -v ufw >/dev/null 2>&1; then
|
|
|
267
295
|
fi
|
|
268
296
|
|
|
269
297
|
if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
|
|
270
|
-
${
|
|
298
|
+
${st}
|
|
271
299
|
fi
|
|
272
300
|
log "bootstrap done"
|
|
273
|
-
`,
|
|
301
|
+
`,dt=String.raw`
|
|
274
302
|
set +e
|
|
275
|
-
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
276
303
|
kv() { printf '%s=%s
|
|
277
304
|
' "$1" "$2"; }
|
|
278
305
|
yn() { if "$@" >/dev/null 2>&1; then echo 1; else echo 0; fi; }
|
|
279
|
-
${
|
|
306
|
+
${rt}
|
|
280
307
|
kv os "$PRETTY_NAME"
|
|
281
308
|
kv os_id "$ID"
|
|
282
309
|
kv os_version "$VERSION_ID"
|
|
@@ -299,18 +326,18 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
|
|
|
299
326
|
else
|
|
300
327
|
kv user 0
|
|
301
328
|
fi
|
|
302
|
-
kv root_dir "$(yn test -d ${
|
|
303
|
-
kv sites "$(ls ${
|
|
329
|
+
kv root_dir "$(yn test -d ${H})"
|
|
330
|
+
kv sites "$(ls ${H} 2>/dev/null | wc -l | tr -d ' ')"
|
|
304
331
|
kv network "$(yn S docker network inspect ${`web`})"
|
|
305
|
-
kv proxy "$(grep -v '^#' ${
|
|
306
|
-
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if [ -f ${$e} ] && { [ -z "$pw" ] || [ "$pw" = no ]; }; then kv hardened 1; else kv hardened 0; fi
|
|
332
|
+
kv proxy "$(grep -v '^#' ${W} 2>/dev/null | tr '\n' ' ')"
|
|
333
|
+
kv ufw "$(ufw_state)"
|
|
334
|
+
# Without root sshd -T prints nothing: then our file is all there is to go by.
|
|
335
|
+
pw=$(sshd_passwords)
|
|
336
|
+
if [ -f ${Qe} ] && { [ -z "$pw" ] || [ "$pw" = no ]; }; then kv hardened 1; else kv hardened 0; fi
|
|
311
337
|
kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
|
|
312
|
-
kv password_users "$(
|
|
313
|
-
|
|
338
|
+
kv password_users "$(password_users)"
|
|
339
|
+
kv key_users "$(key_users)"
|
|
340
|
+
f=${U}/docker-compose.yml
|
|
314
341
|
if [ ! -f "$f" ]; then kv traefik_compose none
|
|
315
342
|
elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
|
|
316
343
|
else kv traefik_compose foreign; fi
|
|
@@ -337,14 +364,14 @@ else
|
|
|
337
364
|
S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
|
|
338
365
|
fi
|
|
339
366
|
exit 0
|
|
340
|
-
`,
|
|
367
|
+
`,ft=e=>e.replace(/\$/g,`$$$$`),pt=e=>JSON.stringify(e);function mt(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`,`--accesslog.format=json`,`--log.nocolor=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.http3&&t.push(`--entrypoints.websecure.http3=true`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return t.push(`--api.dashboard=true`,`--entrypoints.tunnel.address=${e.hostNetwork?`127.0.0.1`:``}:${Ye}`),n.push(`traefik.enable=true`,`traefik.http.routers.${Xe}.rule=PathPrefix(\`/\`)`,`traefik.http.routers.${Xe}.entrypoints=tunnel`,`traefik.http.routers.${Xe}.service=api@internal`,`traefik.http.routers.${Xe}.middlewares=tunnel-only`,"traefik.http.middlewares.tunnel-only.ipallowlist.sourcerange=127.0.0.1/32,${LANDFALL_GATEWAY:-127.0.0.1}/32"),e.dashboard&&n.push(`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${ft(e.dashboard.htpasswd)}`),`# managed by landfall — regenerate with \`lf traefik setup\`
|
|
341
368
|
services:
|
|
342
369
|
traefik:
|
|
343
370
|
image: ${e.image}
|
|
344
371
|
container_name: traefik
|
|
345
372
|
restart: unless-stopped
|
|
346
373
|
command:
|
|
347
|
-
${t.map(e=>` - ${
|
|
374
|
+
${t.map(e=>` - ${pt(e)}`).join(`
|
|
348
375
|
`)}
|
|
349
376
|
${e.hostNetwork?` network_mode: host
|
|
350
377
|
`:` ports:
|
|
@@ -357,24 +384,24 @@ ${e.http3?` - "443:443/udp"
|
|
|
357
384
|
- ./dynamic:/etc/traefik/dynamic:ro
|
|
358
385
|
${e.hostNetwork?``:` networks:
|
|
359
386
|
- web
|
|
360
|
-
`}${n.length?` labels:\n${n.map(e=>` - ${
|
|
387
|
+
`}${n.length?` labels:\n${n.map(e=>` - ${pt(e)}`).join(`
|
|
361
388
|
`)}\n`:``}${e.hostNetwork?``:`networks:
|
|
362
389
|
web:
|
|
363
390
|
external: true
|
|
364
|
-
`}`}let
|
|
365
|
-
`),a=await
|
|
366
|
-
`).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function
|
|
367
|
-
`).filter(e=>!/^Warning: Permanently added/.test(e)).pop()??``;e===0?a(`ok`,`key login works`):e===null?a(`down`,`no answer`):/resolve|not known|nodename|no address/i.test(t)?a(`down`,`host name not found`):/timed out|refused|no route|unreachable|network is down/i.test(t)?a(`down`,t.replace(/^ssh: /,``)):a(`login`,t||`ssh exited ${e}`)})})}async function
|
|
368
|
-
`)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=
|
|
369
|
-
`)}function
|
|
370
|
-
`):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),
|
|
371
|
-
`);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:
|
|
372
|
-
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(
|
|
373
|
-
`)),
|
|
374
|
-
`).some(t=>
|
|
391
|
+
`}`}let ht=!1;const gt=e=>ht=e,q=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,_t=(e=Te(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],vt=e=>((/^-|\s/.test(e.host)||e.user&&/^-|\s/.test(e.user))&&D(`not a server: ${e.user?`${e.user}@`:``}${e.host}`),e.user?`${e.user}@${e.host}`:e.host);function yt(e){let t=[..._t(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,F(e.key),`-o`,`IdentitiesOnly=yes`),t}let bt;function xt(){return bt||(bt=m(process.platform===`darwin`?`/tmp`:u(),`landfall-${d().uid}`),o.mkdirSync(bt,{recursive:!0,mode:448})),bt}function St(e,t={}){let n=[...yt(e),`-o`,`ServerAliveInterval=15`];return(t.batch||ht)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${xt()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`),n}const Ct=(e,t={})=>[...St(e,t),vt(e)];function wt(e){return new Promise(t=>{let r=[...yt(e),`-o`,`ControlPath=${xt()}/%C`,`-O`,`exit`,vt(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function Tt(e,t,r){return new Promise((i,a)=>{let o=n(e,t,{...r,stdio:[r.input===void 0?`inherit`:`pipe`,r.capture?`pipe`:`inherit`,ht||r.silent?`pipe`:`inherit`]}),s=``,c=``;o.stdout?.on(`data`,e=>s+=e),o.stderr?.on(`data`,e=>c+=e),o.on(`error`,a),o.on(`close`,e=>i({code:e??1,stdout:s,stderr:c})),r.input!==void 0&&o.stdin.end(r.input)})}const Et=`PATH="$PATH:/usr/local/sbin:/usr/local/bin"`;async function Dt(e,t=[]){let{code:n}=await Tt(`ssh`,[...Ct(e,{tty:process.stdin.isTTY}),...t.length?[`${Et};`,...t]:[]],{});return n}const Ot=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,kt={run:process.env.LANDFALL_RUN||Ot(),actor:`${d().username}@${l().split(`.`)[0]}`,cmd:``};async function J(e,t,n={}){let r={LANDFALL_RUN:kt.run,LANDFALL_ACTOR:kt.actor,LANDFALL_CMD:kt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${q(typeof t==`boolean`?+!!t:t)}`).join(`
|
|
392
|
+
`),a=await Tt(`ssh`,[...Ct(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${Et}\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
|
|
393
|
+
`).at(-1);D(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function At(e){let{code:t}=await Tt(`ssh`,[...Ct(e,{batch:!0,noMux:!0}),`true`],{capture:!0,silent:!0});return t===0}function jt(e,t=5){let r=Date.now(),i=[...yt(e),`-o`,`ConnectTimeout=${t}`,`-o`,`BatchMode=yes`,`-o`,`ControlPath=none`,`-T`,vt(e),`true`];return new Promise(e=>{let a=(t,n)=>e({state:t,detail:n,ms:Date.now()-r}),o=n(`ssh`,i,{stdio:[`ignore`,`ignore`,`pipe`]}),s=``;o.stderr.on(`data`,e=>s+=e);let c=setTimeout(()=>o.kill(),(t+5)*1e3);o.on(`error`,e=>(clearTimeout(c),a(`down`,e.message))),o.on(`close`,e=>{clearTimeout(c);let t=s.trim().split(`
|
|
394
|
+
`).filter(e=>!/^Warning: Permanently added/.test(e)).pop()??``;e===0?a(`ok`,`key login works`):e===null?a(`down`,`no answer`):/resolve|not known|nodename|no address/i.test(t)?a(`down`,`host name not found`):/timed out|refused|no route|unreachable|network is down/i.test(t)?a(`down`,t.replace(/^ssh: /,``)):a(`login`,t||`ssh exited ${e}`)})})}async function Mt(e){let{code:t}=await Tt(`ssh`,[...Ct(e,{batch:!0}),`test -d ${V}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const Nt=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`].flatMap(e=>[`--exclude`,e]);let Pt;async function Ft(){if(!Pt){let{stdout:e}=await Tt(`tar`,[`--version`],{capture:!0});Pt=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return Pt}let It;const Lt=()=>It??=Tt(`rsync`,[`--version`],{capture:!0,silent:!0}).then(e=>e.code===0,()=>!1);async function Rt(e,t,n,r){o.existsSync(t)||D(`directory not found: ${t}`);let i=async r=>(await zt(e,t,n),r);if(!r)return i(`tar`);if(!await Lt())return i(`tar:no-local-rsync`);let{stdout:a}=await J(e,`if ! command -v rsync >/dev/null; then echo no-rsync; elif [ -d ${q(r)} ]; then mkdir -p ${q(n)} && echo ok; fi`,{capture:!0}),s=a.trim();if(s===`no-rsync`)return i(`tar:no-server-rsync`);if(s!==`ok`)return i(`tar`);let c=[`ssh`,...St(e)].map(q).join(` `),{code:l}=await Tt(`rsync`,[`-rlptz`,`--copy-dest=${r}`,...Nt,`-e`,c,`${t.replace(/\/*$/,``)}/`,`${vt(e)}:${n}/`],{});if(l!==0)throw Error(`rsync failed (exit ${l})`);return`rsync`}async function zt(e,t,r){let i=[...await Ft(),`-czf`,`-`,...Nt,`-C`,t,`.`],a=`mkdir -p ${q(r)} && tar -xzof - -C ${q(r)}`;await new Promise((t,r)=>{let o=n(`tar`,i,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),s=n(`ssh`,[...Ct(e),a],{stdio:[`pipe`,`inherit`,`inherit`]});o.stdout.pipe(s.stdin),o.on(`error`,r),s.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(o),c(s)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Bt=(e,t,n=`644`)=>`printf %s ${q(Buffer.from(t).toString(`base64`))} | base64 -d > ${q(e)} && chmod ${n} ${q(e)}`,Vt=Te(`SSH_CONFIG`)??m(c(),`.ssh`,`config`),Ht=/* @__PURE__ */ new Map;function Y(e){let n=[...yt(e),`-G`,vt(e)],r=Ht.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return D(`ssh cannot resolve ${vt(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>F(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return Ht.set(n.join(`\0`),s),s}function Ut(e){return(e.key?[F(e.key)]:Y(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function Wt(e=Vt,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(`
|
|
395
|
+
`)){let e=r.trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!e)continue;let[,i,a]=e;if(i.toLowerCase()===`host`)n.push(...a.split(/\s+/).filter(e=>!/[*?!]/.test(e)));else if(i.toLowerCase()===`include`)for(let e of a.split(/\s+/)){let r=F(e),i=r.startsWith(`/`)?r:m(c(),`.ssh`,r);for(let e of Gt(i))n.push(...Wt(e,t))}}return[...new Set(n)]}function Gt(e){if(!/[*?[]/.test(e))return[e];let t=p(e);return o.existsSync(t)?o.globSync(f(e),{cwd:t}).map(e=>m(t,e)):[]}const Kt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],qt=(e,t)=>RegExp(`# >>> landfall:${tn(e)}\\n[\\s\\S]*?# <<< landfall:${tn(e)}${t}`),Jt=(e,t)=>e===`# >>> landfall:${t}`;function Yt(e){let[t,n]=Kt(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(`
|
|
396
|
+
`)}function Xt(e,t=Vt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`);je(e.alias);let i=Yt(e),a=qt(e.alias,`\\n?`),s=a.test(r)?r.replace(a,()=>i+`
|
|
397
|
+
`):`${i}\n\n${r}`;return o.mkdirSync(m(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),Ht.clear(),{created:n,block:i}}function Zt(e,t=Vt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
|
|
398
|
+
`);for(let i=0;i<r.length;i++){let a=r[i].trim().match(/^(\w+)\s*=?\s*(.+)$/);if(!a)continue;let o=a[1].toLowerCase();if(o===`host`&&a[2].split(/\s+/).includes(e)){let n=i+1;for(;n<r.length&&!/^\s*(host|match)\b/i.test(r[n])&&!r[n].startsWith(`# <<< landfall:`);)n++;for(;n>i+1&&/^\s*(#.*)?$/.test(r[n-1]);)n--;return{file:t,start:i,end:n,lines:r.slice(i,n),managed:Jt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=F(t);for(let t of Gt(r.startsWith(`/`)?r:m(c(),`.ssh`,r))){let r=Zt(e,t,n);if(r)return r}}}}function Qt(e,t){let n=Zt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
|
|
399
|
+
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(F(n)===t||!/[%$]/.test(n)&&!o.existsSync(F(n)))}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
|
|
400
|
+
`)),Ht.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function $t(e,t=Vt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=qt(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const en=(e,t=Vt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
|
|
401
|
+
`).some(t=>Jt(t,e)),tn=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),nn=String.raw`
|
|
375
402
|
set +e
|
|
376
403
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
377
|
-
${
|
|
404
|
+
${rt}
|
|
378
405
|
kv os "$PRETTY_NAME"
|
|
379
406
|
kv id "$ID"
|
|
380
407
|
kv version "$VERSION_ID"
|
|
@@ -397,12 +424,12 @@ kv ufw "$(command -v ufw >/dev/null 2>&1 && echo 1)"
|
|
|
397
424
|
# The source address of the default route; behind NAT that is a private one.
|
|
398
425
|
kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n1)"
|
|
399
426
|
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)"
|
|
400
|
-
kv reboot "$(
|
|
427
|
+
kv reboot "$(reboot_needed && echo 1)"
|
|
401
428
|
exit 0
|
|
402
|
-
`,
|
|
403
|
-
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,compose:n.compose??``,podman:n.podman??``,platform:n.platform??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,user:n.user??``,admin:n.admin===`1`,ufw:n.ufw===`1`,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:dn(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function rn(e){let{stdout:t}=await q(e,G+en,{capture:!0});return nn(t)}function an(e,t=!1){let n=ln[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:un,s=a(`setup`,e.pkg!==`none`,o);a(`admin`,r,`${e.user||`the login`} has no passwordless sudo and root's key login does not work`),a(`packages`,s&&r,s?i.admin:o);let c=e.podman?`only podman ${e.podman} here: landfall needs docker${n?` (${n.docker})`:``}`:n?n.docker:s?`no docker (or no access to it): lf setup installs it`:`no docker (or no access to it)`,l=a(`docker`,!!e.docker,c);return a(`compose`,l&&!!e.compose,l?`docker has no compose plugin (v2)`:i.docker),a(`firewall`,!on(e.platform,e.pkg,e.ufw)&&r,on(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(tn.map(e=>[e,!i[e]])),why:i}}function on(e,t,n){if(ln[e]?.firewall)return ln[e].firewall;if(!n&&t===`none`)return`no ufw, and ${un} to install it`}async function sn(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await J({...e,user:i})))break;return an(t,r)}const cn={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`},ln={synology:{name:`Synology DSM manages its packages, users, ssh and firewall itself`,docker:`install Container Manager in Package Center`,firewall:`DSM has its own firewall: Control Panel → Security → Firewall`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},un=`no apt, dnf, yum, apk or zypper`;function dn(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=ln[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${un}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=cn[`${e} ${t}`]??cn[`${e} ${o}`]??cn[`${e} ${a}`];if(!s)return{level:`ok`};let c=(Date.parse(s)-r.getTime())/864e5;return c<0?{level:`eol`,note:`${e} ${t} reached end of life on ${s}: no more security updates, upgrade the release`}:c<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${s}`}:{level:`ok`}}const fn=/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/,pn=e=>re(e)===4&&!fn.test(e);async function mn(e,t){let n=Y(e).hostname,r=re(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const hn=e=>e.find(pn)??e[0],gn=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function _n(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const X=e=>{let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)} ${t[n]}`},vn=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);return t?`${t}d ${n}h`:n?`${n}h ${Math.floor(e%3600/60)}m`:`${Math.floor(e/60)}m`},yn=e=>e.v!==2,bn=e=>e.project??e.name,xn=e=>yn(e)?void 0:e.releases.find(t=>t.id===e.current),Sn=e=>!!(e.port||e.own),Cn=e=>!!e.port&&!e.domains&&!e.own,wn=e=>e?.services.find(Cn),Tn=e=>e.adopted?void 0:wn(xn(e)),En=e=>`landfall-${e}`,Dn=e=>e.adopted?`adopted`:yn(e)?`old`:`managed`,On=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Dn(e)],kn=e=>/^up/i.test(e),An=e=>/^exited \(0\)/i.test(e);function jn(e){let t=xn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...wn(t)?e.domains:[],...n])]}const Mn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Nn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],Pn=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Fn=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),In=/^traefik\.http\.routers\.[^.]+\.rule$/,Ln=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Rn(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>In.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>Fn(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!Ne.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(Sn(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(Sn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Cn);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const zn=ft,Bn=`landfall-proxy`;function Vn(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function Hn(e,t,n=et){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${zn(bn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${zn(a.name)}:`),a.build&&r.push(` image: ${zn(Mn(e.name,a.name,t.id))}`);let o={};Sn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${zn(e)}: {}`),` ${Bn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Vn(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${zn(e)}: ${zn(dt(t))}`))}return i&&r.push(`networks:`,` ${Bn}:`,` name: ${zn(n.network)}`,` external: true`),r.join(`
|
|
429
|
+
`,rn=[`setup`,`packages`,`docker`,`compose`,`firewall`,`services`,`admin`];function an(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
|
|
430
|
+
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,compose:n.compose??``,podman:n.podman??``,platform:n.platform??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,user:n.user??``,admin:n.admin===`1`,ufw:n.ufw===`1`,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:pn(n.id??``,n.version??``,n.pkg||`none`,t,n.platform)}}async function on(e){let{stdout:t}=await J(e,K+nn,{capture:!0});return an(t)}function sn(e,t=!1){let n=dn[e.platform],r=e.admin||t,i={},a=(e,t,n)=>(t||(i[e]=n),t),o=n?n.name:fn,s=a(`setup`,e.pkg!==`none`,o);a(`admin`,r,`${e.user||`the login`} has no passwordless sudo and root's key login does not work`),a(`packages`,s&&r,s?i.admin:o);let c=e.podman?`only podman ${e.podman} here: landfall needs docker${n?` (${n.docker})`:``}`:n?n.docker:s?`no docker (or no access to it): lf setup installs it`:`no docker (or no access to it)`,l=a(`docker`,!!e.docker,c);return a(`compose`,l&&!!e.compose,l?`docker has no compose plugin (v2)`:i.docker),a(`firewall`,!cn(e.platform,e.pkg,e.ufw)&&r,cn(e.platform,e.pkg,e.ufw)??i.admin),a(`services`,e.init!==`other`,`no systemd or OpenRC`),{can:Object.fromEntries(rn.map(e=>[e,!i[e]])),why:i}}function cn(e,t,n){if(dn[e]?.firewall)return dn[e].firewall;if(!n&&t===`none`)return`no ufw, and ${fn} to install it`}async function ln(e,t){let n=Y(e).user,r=!1;for(let i of t.admin?[]:[`root`,e.admin])if(i&&i!==n&&(r=await At({...e,user:i})))break;return sn(t,r)}const un={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`},dn={synology:{name:`Synology DSM manages its packages, users, ssh and firewall itself`,docker:`install Container Manager in Package Center`,firewall:`DSM has its own firewall: Control Panel → Security → Firewall`,bans:`DSM bans addresses that keep failing to log in itself: Control Panel → Security → Protection → Auto Block`},steamos:{name:`SteamOS resets the system on every OS update`,docker:`install Docker in your home directory`}},fn=`no apt, dnf, yum, apk or zypper`;function pn(e,t,n,r=/* @__PURE__ */ new Date,i=``){if(n===`none`){let e=dn[i];return e?{level:`limited`,note:`${e.name}: landfall uses it as it is, without setup or package updates`}:{level:`unsupported`,note:`${fn}: setup cannot install packages`}}let a=t.split(`.`)[0],o=t.split(`.`).slice(0,2).join(`.`),s=un[`${e} ${t}`]??un[`${e} ${o}`]??un[`${e} ${a}`];if(!s)return{level:`ok`};let c=(Date.parse(s)-r.getTime())/864e5;return c<0?{level:`eol`,note:`${e} ${t} reached end of life on ${s}: no more security updates, upgrade the release`}:c<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${s}`}:{level:`ok`}}const mn=/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/,hn=e=>re(e)===4&&!mn.test(e);async function gn(e,t){let n=Y(e).hostname,r=re(n)?[n]:await h.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const _n=e=>e.find(hn)??e[0],vn=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function yn(e,t){return Promise.all(e.map(async e=>{let n=await h.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const X=e=>{let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)} ${t[n]}`},bn=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);return t?`${t}d ${n}h`:n?`${n}h ${Math.floor(e%3600/60)}m`:`${Math.floor(e/60)}m`},xn=e=>e.v!==2,Sn=e=>e.project??e.name,Cn=e=>xn(e)?void 0:e.releases.find(t=>t.id===e.current),wn=e=>!!(e.port||e.own),Tn=e=>!!e.port&&!e.domains&&!e.own,En=e=>e?.services.find(Tn),Dn=e=>e.adopted?void 0:En(Cn(e)),On=e=>`landfall-${e}`,kn=e=>e.adopted?`adopted`:xn(e)?`old`:`managed`,An=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[kn(e)],jn=e=>/^up/i.test(e),Mn=e=>/^exited \(0\)/i.test(e);function Nn(e){let t=Cn(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...En(t)?e.domains:[],...n])]}const Pn=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Fn=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],In=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],Ln=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),Rn=/^traefik\.http\.routers\.[^.]+\.rule$/,zn=/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/;function Bn(e,t,n=e=>e){let r=[],i=[],a=[];for(let[o,s]of Object.entries(e?.services??{})){let e=s[`x-landfall`]??{},c=Array.isArray(s.labels)?Object.fromEntries(s.labels.map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1)])):s.labels??{},l={name:o,networks:s.network_mode?[]:Object.keys(s.networks??{default:null})};s.build&&(l.build=!0);let u=Object.entries(c).filter(([e])=>Rn.test(e));if(typeof e!=`object`||Array.isArray(e))i.push(`${o}: x-landfall must be a mapping, like x-landfall: { port: 3000 }`);else if(u.length)l.own=!0,l.domains=[...new Set(u.flatMap(([,e])=>Ln(e)))],Object.keys(e).some(e=>e!==`publish`)&&a.push(`${o} has its own traefik router labels: its x-landfall routing is ignored`);else if(s[`x-landfall`]!==void 0){let t=Object.keys(e).filter(e=>![`port`,`domains`,`publish`].includes(e));t.length&&a.push(`${o}: x-landfall knows port, domains and publish, not ${t.join(`, `)}`);let r=(s.expose??[]).map(String),c=e.port??(r.length===1?r[0]:void 0),u=Number(c);if(c!==void 0&&Number.isInteger(u)&&u>=1&&u<=65535?l.port=u:e.port===void 0?(e.domains||!e.publish)&&i.push(`${o}: x-landfall needs the port Traefik should send requests to, like x-landfall: { port: 3000 }`):i.push(`${o}: x-landfall.port must be a port number, got ${JSON.stringify(e.port)}`),e.domains!==void 0){let t=(Array.isArray(e.domains)?e.domains:[e.domains]).map(e=>n(String(e).trim())),r=t.filter(e=>!Me.test(e));r.length&&i.push(`${o}: not a domain in x-landfall.domains: ${r.join(`, `)}`),t.length||i.push(`${o}: x-landfall.domains is empty; leave it out to serve the site's domains`),l.domains=t}}if(s.network_mode&&(wn(l)?i.push(`${o}: a routed service cannot use network_mode: ${s.network_mode}; Traefik reaches it over a network`):s.network_mode===`host`&&a.push(`${o} uses network_mode: host: it binds ports on the server itself`)),s.ports?.length&&!e.publish){let e=s.ports.map(e=>e.published?`${e.host_ip?e.host_ip+`:`:``}${e.published}:${e.target}`:String(e.target));i.push(`${o} publishes ${e.join(`, `)} on the server, past Traefik and the firewall. Move ports: to compose.override.yaml (local only)${l.port||l.own?``:` and route it with x-landfall: { port: ${s.ports[0].target} }`}, or allow it with x-landfall: { publish: true }`)}for(let e of s.volumes??[]){let n=String(e.source??``);if(e.type===`bind`&&!e.read_only&&(n===t||n.startsWith(t+`/`))){let r=n===t?`.`:`./`+n.slice(t.length+1);i.push(`${o} writes to ${r}, which stays behind in the release on the next deploy: use a named volume (e.g. ${o}-data:${e.target}), or mount it read-only (:ro)`)}}s.container_name&&a.push(`${o} sets container_name ${s.container_name}: names are global on the server, leave it out unless something needs it`),r.push(l)}r.some(wn)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Tn);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const Vn=pt,Hn=`landfall-proxy`;function Un(e,t,n,r,i=[]){let a=`${e}_${t.name.toLowerCase().replace(/\./g,`-`)}`,o={"traefik.enable":`true`,"traefik.docker.network":r.network,[`traefik.http.routers.${a}.rule`]:n.map(e=>`Host(\`${e}\`)`).join(` || `),[`traefik.http.routers.${a}.entrypoints`]:r.entrypoint,[`traefik.http.routers.${a}.tls.certresolver`]:r.resolver,[`traefik.http.routers.${a}.service`]:a,[`traefik.http.services.${a}.loadbalancer.server.port`]:String(t.port)};return i.length&&(o[`traefik.http.middlewares.${e}-auth.basicauth.users`]=i.join(`,`),o[`traefik.http.routers.${a}.middlewares`]=`${e}-auth`),o}function Wn(e,t,n=$e){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${Vn(Sn(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${Vn(a.name)}:`),a.build&&r.push(` image: ${Vn(Pn(e.name,a.name,t.id))}`);let o={};wn(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${Vn(e)}: {}`),` ${Hn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=Un(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${Vn(e)}: ${Vn(ft(t))}`))}return i&&r.push(`networks:`,` ${Hn}:`,` name: ${Vn(n.network)}`,` external: true`),r.join(`
|
|
404
431
|
`)+`
|
|
405
|
-
`}function
|
|
432
|
+
`}function Gn(e,t={}){let n=`# generated by landfall for a ${e===`static`?`static folder`:e===`image`?`prebuilt image`:`Dockerfile`}; \`lf init\` writes one you can edit into your project\nservices:\n`;return e===`static`?`${n} web:
|
|
406
433
|
image: nginx:alpine
|
|
407
434
|
restart: unless-stopped
|
|
408
435
|
volumes:
|
|
@@ -411,14 +438,14 @@ exit 0
|
|
|
411
438
|
x-landfall:
|
|
412
439
|
port: 80
|
|
413
440
|
`:`${n} app:
|
|
414
|
-
${e===`image`?`image: ${
|
|
441
|
+
${e===`image`?`image: ${Vn(t.image)}`:`build: .`}
|
|
415
442
|
restart: unless-stopped
|
|
416
443
|
env_file:
|
|
417
444
|
- path: .env
|
|
418
445
|
required: false
|
|
419
446
|
x-landfall:
|
|
420
447
|
port: ${t.port??3e3}
|
|
421
|
-
`}function
|
|
448
|
+
`}function Kn(e){return`# generated by landfall
|
|
422
449
|
server {
|
|
423
450
|
listen 80;
|
|
424
451
|
server_name _;
|
|
@@ -442,36 +469,207 @@ server {
|
|
|
442
469
|
error_page 404 /404.html;
|
|
443
470
|
location = /404.html { internal; }
|
|
444
471
|
}
|
|
445
|
-
`}const
|
|
446
|
-
`,{mode:384})}function
|
|
447
|
-
`,{mode:384})}const Z=e=>`${
|
|
472
|
+
`}const qn=[`dist`,`build`,`out`,`public`,`_site`,`site`],Jn=(e,t)=>e.match(RegExp(`\\b${t}\\s*[:=]\\s*["'\`]([^"'\`]+)["'\`]`))?.[1].replace(/^\.\//,``);function Yn(e){let t=t=>o.existsSync(m(e,t)),n=t=>{try{return o.readFileSync(m(e,t),`utf8`)}catch{return``}},r=e=>{for(let r of e)if(t(r))return n(r);return``},i=e=>[`ts`,`mts`,`js`,`mjs`,`cjs`].map(t=>`${e}.${t}`),a={};try{a=JSON.parse(n(`package.json`)||`{}`)}catch{}if(a.scripts?.build){let e=t(`bun.lock`)||t(`bun.lockb`)?`bun run build`:t(`pnpm-lock.yaml`)?`pnpm build`:t(`yarn.lock`)?`yarn build`:`npm run build`,o=e=>e in{...a.dependencies,...a.devDependencies},s=(t,n)=>({tool:t,dir:n,build:e});if(o(`next`))return s(`Next.js (output: 'export')`,Jn(r(i(`next.config`)),`distDir`)??`out`);if(o(`nuxt`))return s(`Nuxt (nuxt generate)`,`.output/public`);if(o(`astro`))return s(`Astro`,Jn(r(i(`astro.config`)),`outDir`)??`dist`);if(o(`@sveltejs/kit`))return s(`SvelteKit (adapter-static)`,Jn(r(i(`svelte.config`)),`pages`)??`build`);if(o(`@angular/core`)){let e=`dist`;try{let t=Object.values(JSON.parse(n(`angular.json`)).projects)[0]?.architect?.build,r=t?.options?.outputPath;typeof r==`string`?e=/:application$/.test(t.builder??``)?`${r}/browser`:r:r?.base&&(e=r.base)}catch{}return s(`Angular`,e)}return o(`gatsby`)?s(`Gatsby`,`public`):o(`@docusaurus/core`)?s(`Docusaurus`,`build`):o(`@11ty/eleventy`)?s(`Eleventy`,Jn(r([`.eleventy.js`,`eleventy.config.js`,`eleventy.config.mjs`,`eleventy.config.cjs`]),`output`)??`_site`):o(`react-scripts`)?s(`Create React App`,`build`):o(`@vue/cli-service`)?s(`Vue CLI`,Jn(n(`vue.config.js`),`outputDir`)??`dist`):o(`vite`)?s(`Vite`,Jn(r(i(`vite.config`)),`outDir`)??`dist`):o(`parcel`)?s(`Parcel`,`dist`):{build:e}}return[`hugo.toml`,`hugo.yaml`,`hugo.json`].some(t)?{tool:`Hugo`,dir:`public`,build:`hugo --minify`}:t(`_config.yml`)&&n(`Gemfile`).includes(`jekyll`)?{tool:`Jekyll`,dir:`_site`,build:`bundle exec jekyll build`}:t(`mkdocs.yml`)?{tool:`MkDocs`,dir:Jn(n(`mkdocs.yml`),`site_dir`)??`site`,build:`mkdocs build`}:{}}function Xn(e,t=Yn(e)){return t.dir?t.dir:o.existsSync(m(e,`index.html`))?`.`:qn.find(t=>o.existsSync(m(e,t,`index.html`)))??`.`}function Zn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const Qn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function $n(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return Fn.find(t=>o.existsSync(m(e,t)))}function er(e){let t=B(e),n=t?.name??Qn(f(e)),r=$n(e,t);if(r)return{path:e,name:n,kind:`compose`,compose:r,project:t};if(t?.image&&!t.static)return{path:e,name:n,kind:`image`,port:t.port,project:t};let i=t=>o.existsSync(m(e,t));if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??Zn(m(e,`Dockerfile`))??3e3,project:t};let a=Yn(e),s=t?.dir??Xn(e,a);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:t?.build??a.build,tool:!t?.dir||t.dir===a.dir?a.tool:void 0,project:t}}const tr=m(De,`projects.json`);function nr(){try{return JSON.parse(o.readFileSync(tr,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function rr(e){let t=ne(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...nr().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(De,{recursive:!0,mode:448}),o.writeFileSync(tr,JSON.stringify(n,null,2)+`
|
|
473
|
+
`,{mode:384})}function ir(e,t,n){let r=nr();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=B(i.path);e?.name===t&&qe(i.path,{...e,domains:n})}r.length&&o.writeFileSync(tr,JSON.stringify(r,null,2)+`
|
|
474
|
+
`,{mode:384})}const Z=e=>`${H}/${e}`;function ar(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||D(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const or=`
|
|
448
475
|
--8<--landfall--8<--
|
|
449
|
-
`;async function
|
|
450
|
-
P=${
|
|
451
|
-
printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${
|
|
452
|
-
for f in ${
|
|
476
|
+
`;async function sr(e,t){let n=Z(t),{stdout:r}=await J(e,[...[`${n}/site.json`,`${n}/.env`,W].map(e=>`cat ${q(e)} 2>/dev/null || true`),`[ ! -d ${q(n)} ] || echo 1`].join(`; printf %s ${q(or)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(or),c;if(i.trim())try{c=JSON.parse(i)}catch{D(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:tt(o)??$e,exists:s.trim()===`1`}}async function cr(e,t,n={}){let r=await sr(e,t);r.meta||D(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return xn(i)&&D(`${t} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`),n.deployed&&i.adopted&&D(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const lr=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),ur=(e,t,n)=>Dt(e,[`docker logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]),dr=e=>[...new Set([...e.matchAll(/(?:^|[\s>])(\d+)\/tcp/g)].map(e=>Number(e[1])))].sort((e,t)=>e-t);function fr(e){return pr(e).flatMap(e=>e.hosts)}function pr(e){return Object.entries(e).map(([e,t])=>[e.match(/^traefik\.http\.routers\.([^.]+)\.service$/)?.[1],t]).filter(([e,t])=>e&&t===`api@internal`).map(([t])=>({router:t,hosts:Ln(e[`traefik.http.routers.${t}.rule`]??``)}))}const mr=e=>e[`traefik.http.routers.${Xe}.service`]===`api@internal`;function hr(e,t,n,r=/* @__PURE__ */ new Set){let i=/* @__PURE__ */ new Map;for(let{name:a,labels:o}of e){if(a===n||o[`landfall.managed`]||o[`traefik.enable`]===`false`)continue;let e=o[`com.docker.compose.project`];if(e&&r.has(e))continue;let s=Object.entries(o).filter(([e])=>Rn.test(e)).flatMap(([,e])=>Ln(e));if(!s.length)continue;let c=e||a,l=i.get(c)??{name:c,domains:[],status:``,containers:[],dir:o[`com.docker.compose.project.working_dir`],files:o[`com.docker.compose.project.config_files`]};l.domains=[.../* @__PURE__ */ new Set([...l.domains,...s])],l.containers.push(a);let u=t.get(a)??`not running`;l.status&&jn(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function gr(e,t){if(!t.length)return`not running`;let n=Cn(e),r=new Set(n?.services.filter(wn).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!jn(e.status))??t.find(e=>!r.has(e.service)&&!jn(e.status)&&!Mn(e.status))??i[0]??t[0]).status}const _r=e=>xn(e)?On(e.name):Sn(e);async function vr(e){let{stdout:t}=await J(e,`printf 'READY\\t%s\\n' "$([ -d ${H} ] && command -v docker >/dev/null 2>&1 && echo 1)"
|
|
477
|
+
P=${et}
|
|
478
|
+
printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${W} ] && echo 1)"
|
|
479
|
+
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
|
|
453
480
|
docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.compose.service"}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null || true
|
|
454
481
|
ids=$(docker ps -aq 2>/dev/null || true)
|
|
455
482
|
# inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
|
|
456
483
|
[ -z "$ids" ] || docker inspect -f 'LABELS{{"\\t"}}{{.Name}}{{"\\t"}}{{json .Config.Labels}}' $ids 2>/dev/null || true`,{capture:!0}),n=/* @__PURE__ */ new Map,r=/* @__PURE__ */ new Map,i=[],a=[],o={ready:!1,traefik:``,proxy:`traefik`,adopted:!1,sites:[],others:[],dashboard:{hosts:[],tunnel:!1},unrouted:[]},s=[];for(let c of t.split(`
|
|
457
|
-
`)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{T(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:
|
|
458
|
-
`),{capture:!0}),[c,l=``]=s.split(
|
|
459
|
-
`),`record ${
|
|
460
|
-
`))}async function
|
|
461
|
-
`)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...
|
|
462
|
-
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${
|
|
484
|
+
`)){let[t,l=``,u=``,d=``,f=``,p=``,m=``]=c.split(` `);if(t===`READY`&&(o.ready=l===`1`),t===`TRAEFIK`&&(o.traefik=l,o.proxy=u||`traefik`,o.adopted=d===`1`),t===`SITE`)try{i.push(JSON.parse(c.slice(t.length+1)))}catch{T(`skipped a corrupt site.json on ${e.host}: ${c.slice(5,80)}`)}if(t===`CTR`&&(n.set(l,u),s.push({container:l,status:u,image:p,ports:dr(m),...d&&{project:d,service:f}}),d&&r.set(d,[...r.get(d)??[],{service:f||l,container:l,status:u}])),t===`LABELS`)try{a.push({name:l.replace(/^\//,``),labels:JSON.parse(u??`null`)??{}})}catch{}}o.others=hr(a,n,o.proxy,new Set(i.map(_r))),o.dashboard={hosts:[...new Set(a.flatMap(e=>fr(e.labels)))],tunnel:a.some(e=>e.name===o.proxy&&(mr(e.labels)||fr(e.labels).length>0))};let c=/* @__PURE__ */ new Set([o.proxy,...o.others.flatMap(e=>e.containers)]),l=new Set(i.map(_r));return o.unrouted=s.filter(e=>jn(e.status)&&!c.has(e.container)&&!(e.project&&l.has(e.project))).sort((e,t)=>e.container.localeCompare(t.container)),o.sites=i.map(e=>{let t=(r.get(_r(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:gr(e,t),services:t,domains:Nn(e),main:Dn(e)?.name,state:kn(e),kind:An(e)}}),o}async function yr(e,t,n,r={}){let i=`${Z(t)}/releases/${n}`,a=r.file?.replace(/^(\.\/)+/,``);a?.includes(`/`)&&D(`-f takes a compose file in the project folder itself, not ${r.file}`);let o=[...Fn,...In].filter(e=>e!==`compose.yaml`),{stdout:s}=await J(e,[K,`v=$(dc version --short 2>/dev/null | sed 's/^v//'); compose_ok "$v" || { echo "sites need docker compose 2.24 or newer, the server has \${v:-none}: run lf update --compose" >&2; exit 1; }`,`mkdir -p ${q(i)} && cd -P ${q(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Bt(e,t)),...a&&a!==`compose.yaml`?[`mv -f ${q(a)} compose.yaml`]:[],`rm -f ${o.map(q).join(` `)}`,...r.env===void 0?[]:[Bt(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; exit 1; }`,`cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>.config.err) || { cat .config.err >&2; rm -f .config.err; exit 1; }`,`cat .config.err >&2; rm -f .config.err`,`rm -f .env.next`,`pwd -P; printf %s ${q(or)}; printf '%s\\n' "$cfg"`].join(`
|
|
485
|
+
`),{capture:!0}),[c,l=``]=s.split(or),u;try{u=ie(l)}catch(e){D(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:c.trim(),config:u}}async function br(e,t){let n=Z(t.name);await J(e,[K,`[ -r ${q(t.adopted)} ] && [ -x ${q(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${q(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${q(n)} && cd ${q(n)}`,`ln -s ${q(t.adopted)} current`,Bt(`site.json`,JSON.stringify(t,null,2)+`
|
|
486
|
+
`),`record ${q(`adopted ${t.project} as site ${t.name}`)} ${q(`rm -rf ${n}`)}`].join(`
|
|
487
|
+
`))}async function xr(e,t,n){let r=Z(t);await J(e,`cd / && rm -rf ${q(`${r}/releases/${n}`)}; [ -f ${q(r+`/site.json`)} ] || { rmdir ${q(r+`/releases`)} 2>/dev/null; { [ -s ${q(r+`/.env`)} ] || rm -f ${q(r+`/.env`)}; }; rmdir ${q(r)} 2>/dev/null; } || true`)}const Sr=e=>[`log "starting ${e}"`,`(cd -P current && dc up -d --wait --wait-timeout 300 --remove-orphans --quiet-pull) || { log "${e} did not start:"; (cd -P current && dc ps -a >&2; dc logs --tail 30 >&2) || true; exit 1; }`],Cr=e=>`docker image rm ${q(e)} >/dev/null 2>&1 || true`;function wr(e,t,n={}){let r=Z(e.name),i=Cn(e),a=q(`releases/`+e.current),o=Sn(e),s=[`mkdir -p ${q(r)} && cd ${q(r)}`,`here=$(pwd -P)/`,`other=$(docker ps -a --filter ${q(`label=com.docker.compose.project=${o}`)} --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null | while read -r d; do case "$d/" in ("$here"*) ;; (*) echo "\${d:-a container without a folder}"; break ;; esac; done || true)`,`[ -z "$other" ] || { echo ${q(`a Compose project named ${o} already runs from`)} "$other:" ${q(`deploying would replace its containers. Pick another site name, or make it a site with lf adopt ${o}`)} >&2; exit 1; }`,n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Bt(`.env`,n.env,`600`),`(cd -P ${a} && ${Bt(`compose.override.yaml`,Wn(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&s.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&s.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),s.push(Bt(`site.json`,JSON.stringify(e,null,2)+`
|
|
488
|
+
`)),s.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...Sr(e.name)),s}const Tr=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>Pn(e.name,n.name,t.id)),Er=(e,t)=>[`rmf ${q(`${Z(e.name)}/releases/${t.id}`)}`,...Tr(e,t).map(Cr)];function Dr(e,t){let n=Z(e.name),r=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${q(n)}`,`log "removing the old container of ${e.name}"`,`dc -p ${q(On(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${q(On(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.filter(e=>e.id!==t).map(e=>`rm -rf ${q(`releases/`+e.id)}`),...r.map(Cr)]}async function Or(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await kr(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function kr(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=lr(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=Z(t.name),l=[K];if(r.takeover){l.push(`record ${q(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Dr(r.takeover,t.current),...wr(t,n,r)),await J(e,l.join(`
|
|
489
|
+
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${q(c+`/.env`)})`),!r.prev)u=`[ ! -d ${q(c)} ] || {\n${Mr(t,{keepDir:r.existed}).join(`
|
|
463
490
|
`)}\n`,d=`
|
|
464
|
-
}`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${
|
|
465
|
-
`+[...
|
|
466
|
-
`)}l.push(`record ${
|
|
467
|
-
`))}async function
|
|
468
|
-
`))}function
|
|
491
|
+
}`;else{let e=r.prev,i={...e,releases:e.releases.filter(e=>o.some(t=>t.id===e.id))},a=o.filter(t=>!e.releases.some(e=>e.id===t.id));u=`[ -d ${q(c)} ] || { log ${q(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
|
|
492
|
+
`+[...wr(i,n),...a.flatMap(e=>Er(t,e))].join(`
|
|
493
|
+
`)}l.push(`record ${q(r.what)} ${q(u)}${f?`"$u"`:``}${q(d)}`),l.push(...wr(t,n,r));for(let e of s)l.push(...Er(t,e));await J(e,l.join(`
|
|
494
|
+
`))}async function Ar(e,t,n,r=``){await J(e,`${K}\ncd ${q(Z(t))}\n${r}\ncd -P current && dc ${n}`)}const jr=(e,t,n)=>Dt(e,[`cd -P ${q(Z(t)+`/current`)} && docker compose logs --tail ${q(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?q(n.service):``}`]);function Mr(e,t={}){let n=Z(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Tr(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(q).join(` `);return[`cd ${q(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${q(_r(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(Cr),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${q(n)}`]]}async function Nr(e,t,n={}){let r=`removed site ${t.name} (${Nn(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await J(e,[K,...Mr(t,n),`record ${q(r)}`].join(`
|
|
495
|
+
`))}function Pr(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
|
|
469
496
|
`)){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(`
|
|
470
497
|
`)+`
|
|
471
|
-
`:``}const
|
|
472
|
-
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),
|
|
498
|
+
`:``}const Fr=e=>e.split(`
|
|
499
|
+
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`)),Ir=async e=>Lr(Fr(e===`-`?await y():o.readFileSync(e,`utf8`)));function Lr(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&D(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}let Rr;function zr(){if(Rr)return Rr;let e=o.mkdtempSync(m(u(),`landfall-`));return Rr=m(e,`askpass`),o.writeFileSync(Rr,`#!/bin/sh
|
|
500
|
+
printf '%s\\n' "$LANDFALL_PASSWORD"
|
|
501
|
+
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Rr}const Br=e=>({SSH_ASKPASS:zr(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Vr(e){if(e){let e=(await y()).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return Te(`PASSWORD`)||void 0}const Hr=e=>m(c(),`.ssh`,`landfall_${je(e)}`);function Ur(e){let t=Hr(e);return{path:t,created:Wr(t,`landfall-${e}`)}}function Wr(e,t,n=!1){return!o.existsSync(e)&&(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,e,`-C`,t,...n?[]:[`-N`,``]],{stdio:`inherit`}).status!==0&&D(`ssh-keygen failed`),!0)}async function Gr(e,t,n){let i=Y(t),a=t.key?F(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await At(t);if(!s&&!t.key&&o.existsSync(Hr(e))&&await At({...t,key:Hr(e)})&&(t.key=a=Hr(e),s=!0),s)C(`key login works`),a??=Ut(t);else if(n.check){T("no key login yet — `lf setup` would install a key first"),ue({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Ur(e);a=t.path,c=t.created,c&&C(`generated key ${I(a)}`)}let l=t.key&&F(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await At(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,...Br(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...yt(t),vt(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await At(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const Kr=e=>{for(let t of e)console.log(x.dim(` `)+t)};function qr(e,t,n,r){let i=Y(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Xt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${I(Vt)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),Kr(r.block.split(`
|
|
502
|
+
`)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==Ut({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Qt(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${I(s.file)} ${x.dim(`(backup: ${I(s.backup)})`)}`),Kr([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=I(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 Jr=String.raw`
|
|
503
|
+
set +e
|
|
504
|
+
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
505
|
+
SUDO=""; R=1
|
|
506
|
+
if [ "$(id -u)" -ne 0 ]; then if sudo -n true 2>/dev/null; then SUDO=sudo; else R=0; fi; fi
|
|
507
|
+
${ot}
|
|
508
|
+
kv root "$R"
|
|
509
|
+
kv user "$(id -un)"
|
|
510
|
+
[ -f /etc/synoinfo.conf ] && kv platform synology
|
|
511
|
+
kv hostname "$(hostname 2>/dev/null || cat /etc/hostname)"
|
|
512
|
+
tz=$(timedatectl show -p Timezone --value 2>/dev/null)
|
|
513
|
+
[ -n "$tz" ] || tz=$(cat /etc/timezone 2>/dev/null)
|
|
514
|
+
[ -n "$tz" ] || tz=$(readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')
|
|
515
|
+
kv timezone "$tz"
|
|
516
|
+
kv ntp "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
|
|
517
|
+
kv time "$(date '+%Y-%m-%d %H:%M %Z')"
|
|
518
|
+
if [ -d /run/systemd/system ]; then init=systemd; elif command -v rc-service >/dev/null 2>&1; then init=openrc; else init=other; fi
|
|
519
|
+
kv init "$init"
|
|
520
|
+
awk 'NR > 1 { print "SWAP\t" $1 "\t" $2 "\t" $3 * 1024 "\t" $4 * 1024 }' /proc/swaps 2>/dev/null
|
|
521
|
+
kv swappiness "$(cat /proc/sys/vm/swappiness 2>/dev/null)"
|
|
522
|
+
kv disk_free "$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 * 1024 }')"
|
|
523
|
+
kv mem "$(awk '/^MemTotal:/ { print $2 * 1024 }' /proc/meminfo)"
|
|
524
|
+
if command -v docker >/dev/null 2>&1; then
|
|
525
|
+
kv log_driver "$(docker info -f '{{.LoggingDriver}}' 2>/dev/null || S docker info -f '{{.LoggingDriver}}' 2>/dev/null)"
|
|
526
|
+
printf 'DAEMON\t'; { cat /etc/docker/daemon.json 2>/dev/null || S cat /etc/docker/daemon.json 2>/dev/null; } | tr -d '\n'; echo
|
|
527
|
+
fi
|
|
528
|
+
case "$init" in
|
|
529
|
+
systemd)
|
|
530
|
+
systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null \
|
|
531
|
+
| awk '{ d = ""; for (i = 5; i <= NF; i++) d = d (i > 5 ? " " : "") $i; print "SVC\t" $1 "\t" $2 "\t" $3 "\t" $4 "\t" d }'
|
|
532
|
+
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{ print "UF\t" $1 "\t" $2 }' ;;
|
|
533
|
+
openrc)
|
|
534
|
+
rc-status -a 2>/dev/null | awk '/\[/ { s = $0; sub(/.*\[[ \t]*/, "", s); sub(/[ \t]*\].*/, "", s); print "SVC\t" $1 "\t-\t" s "\t" s "\t" }' | sort -u ;;
|
|
535
|
+
esac
|
|
536
|
+
if command -v fail2ban-client >/dev/null 2>&1; then
|
|
537
|
+
if st=$(S fail2ban-client status 2>/dev/null); then
|
|
538
|
+
kv fail2ban running
|
|
539
|
+
for j in $(printf '%s\n' "$st" | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do
|
|
540
|
+
S fail2ban-client status "$j" 2>/dev/null | awk -v j="$j" '
|
|
541
|
+
/Currently banned:/ { c = $NF } /Total banned:/ { t = $NF } /Currently failed:/ { f = $NF }
|
|
542
|
+
/Banned IP list:/ { sub(/.*Banned IP list:[ \t]*/, ""); ips = $0 }
|
|
543
|
+
END { print "JAIL\t" j "\t" c + 0 "\t" t + 0 "\t" f + 0 "\t" ips }'
|
|
544
|
+
done
|
|
545
|
+
else kv fail2ban stopped; fi
|
|
546
|
+
else kv fail2ban missing; fi
|
|
547
|
+
# Security: what sshd really uses (sshd -T needs root), who has a password, firewall, updates.
|
|
548
|
+
S sshd -T 2>/dev/null | awk '$1 ~ /^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)$/ && !seen[$1]++ { print "sshd_" $1 "=" $2 }'
|
|
549
|
+
[ "$R" = 1 ] && kv password_users "$(password_users)"
|
|
550
|
+
${at}
|
|
551
|
+
kv pkg "$(pkg_mgr)"
|
|
552
|
+
kv ufw "$(ufw_state)"
|
|
553
|
+
${it}
|
|
554
|
+
kv reboot "$(reboot_needed && echo 1)"
|
|
555
|
+
if command -v apt-get >/dev/null 2>&1; then
|
|
556
|
+
if apt_auto_updates; then kv auto_updates on; else kv auto_updates off; fi
|
|
557
|
+
elif command -v dnf >/dev/null 2>&1 && [ "$init" = systemd ]; then
|
|
558
|
+
# dnf-automatic.timer only downloads unless automatic.conf says apply_updates.
|
|
559
|
+
if systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null \
|
|
560
|
+
|| { { systemctl is-enabled -q dnf-automatic.timer 2>/dev/null || systemctl is-enabled -q dnf5-automatic.timer 2>/dev/null; } \
|
|
561
|
+
&& grep -qiE '^[[:space:]]*apply_updates[[:space:]]*=[[:space:]]*(yes|true|1)' /etc/dnf/automatic.conf 2>/dev/null; }; then kv auto_updates on; else kv auto_updates off; fi
|
|
562
|
+
fi
|
|
563
|
+
# Keys of every user with a home: /root and /home/* (deploy users live there).
|
|
564
|
+
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
|
565
|
+
u=$(key_file_user "$f")
|
|
566
|
+
{ cat "$f" 2>/dev/null || S cat "$f" 2>/dev/null; } | while IFS= read -r l; do
|
|
567
|
+
case "$l" in ''|'#'*) ;; *) printf 'KEY\t%s\t%s\n' "$u" "$l" ;; esac
|
|
568
|
+
done
|
|
569
|
+
done
|
|
570
|
+
exit 0
|
|
571
|
+
`,Yr=[`docker`,`containerd`,`ssh`,`sshd`,`fail2ban`,`ufw`,`unattended-upgrades`,`cron`,`crond`,`systemd-timesyncd`,`chronyd`,`chrony`],Xr=e=>e.replace(/\.service$/,``),Zr=/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-\S+@openssh\.com)$/;function Qr(e){let t=[],n=``,r=!1;for(let i of e.trim())i===`"`&&(r=!r),/\s/.test(i)&&!r?(n&&t.push(n),n=``):n+=i;n&&t.push(n);let i=t.findIndex(e=>Zr.test(e));if(i<0||!t[i+1])return;let a=Buffer.from(t[i+1],`base64`);if(a.length)return{type:t[i],fingerprint:`SHA256:${ae(`sha256`).update(a).digest(`base64`).replace(/=+$/,``)}`,comment:t.slice(i+2).join(` `),options:t.slice(0,i).join(` `),line:e.trim()}}function $r(e){let t=/* @__PURE__ */ new Set;for(let n of Y(e).identityFiles)try{let e=Qr(o.readFileSync(`${n}.pub`,`utf8`));e&&t.add(e.fingerprint)}catch{}return t}function ei(e,t=/* @__PURE__ */ new Set){let n={},r={root:!1,user:``,hostname:``,timezone:``,time:``,init:`other`,swap:[],diskFree:0,mem:0,services:[],fail2ban:`missing`,jails:[],keys:[],security:{reboot:!1}},i=/* @__PURE__ */ new Map,a;for(let o of e.split(`
|
|
572
|
+
`)){let e=o.split(` `);if(e[0]===`SWAP`)r.swap.push({file:e[1],type:e[2],size:+e[3]||0,used:+e[4]||0});else if(e[0]===`DAEMON`)a=e.slice(1).join(` `);else if(e[0]===`SVC`&&e[1])r.services.push({unit:e[1],load:e[2],active:e[3],sub:e[4],description:e.slice(5).join(` `)});else if(e[0]===`UF`&&e[1])i.set(e[1],e[2]);else if(e[0]===`JAIL`)r.jails.push({name:e[1],banned:+e[2]||0,total:+e[3]||0,failed:+e[4]||0,ips:(e[5]??``).split(/\s+/).filter(Boolean)});else if(e[0]===`KEY`){let n=Qr(e.slice(2).join(` `));n&&r.keys.push({user:e[1],...n,...t.has(n.fingerprint)?{mine:!0}:{}})}else{let e=/^(\w+)=(.*)$/.exec(o);e&&(n[e[1]]=e[2])}}for(let e of r.services)i.has(e.unit)&&(e.enabled=i.get(e.unit));Object.assign(r,{root:n.root===`1`,user:n.user??``,hostname:n.hostname??``,timezone:n.timezone||`UTC`,ntp:n.ntp===`yes`||n.ntp!==`no`&&void 0,time:n.time??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,swappiness:n.swappiness?+n.swappiness:void 0,diskFree:+n.disk_free||0,mem:+n.mem||0,fail2ban:[`running`,`stopped`].includes(n.fail2ban)?n.fail2ban:`missing`});let o=[n.sshd_passwordauthentication,n.sshd_kbdinteractiveauthentication].filter(Boolean);if(r.security={passwordLogin:o.length?o.includes(`yes`):void 0,rootLogin:n.sshd_permitrootlogin||void 0,passwordUsers:n.password_users?.split(/\s+/).filter(Boolean),pkg:n.pkg||void 0,firewall:[`active`,`inactive`,`missing`].includes(n.ufw)?n.ufw:void 0,autoUpdates:n.auto_updates?n.auto_updates===`on`:void 0,reboot:n.reboot===`1`,platform:n.platform||void 0},n.platform===`synology`&&(r.managed=`DSM keeps this itself: change it in Control Panel (Regional Options, Info Center, Container Manager)`),n.log_driver!==void 0){let e={},t;try{e=a?.trim()?JSON.parse(a):{}}catch(e){t=`/etc/docker/daemon.json is not valid JSON: ${e.message}`}let i=e[`log-opts`]??{};r.docker={driver:n.log_driver||String(e[`log-driver`]??`json-file`),...i[`max-size`]?{maxSize:i[`max-size`]}:{},...i[`max-file`]?{maxFile:Number(i[`max-file`])}:{},daemon:e,...t?{daemonError:t}:{}}}return r}async function ti(e){let{stdout:t}=await J(e,Jr,{capture:!0});return ei(t,$r(e))}async function ni(e){let t=await ti(e);if(t.root)return t;let n=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==n&&await At({...e,user:t}))return ti({...e,user:t});return t}const ri=/^[A-Za-z0-9@_.:-]+$/;async function ii(e,t,n){ri.test(n)||D(`not a service name: ${n}`);let r=t===`start`?`stop`:t===`stop`?`start`:``,i=`${t} service ${n}`;await J(e,[K,`svc ${t} ${q(n)}`,`record ${q(i)}${r?` ${q(`svc ${r} ${q(n)}`)}`:``}`].join(`
|
|
573
|
+
`))}function ai(e,t){ri.test(e)||D(`not a service name: ${e}`);let n=`$([ "$(id -u)" = 0 ] || echo sudo -n)`,r=String(t.lines);return t.follow?`${n} journalctl -u ${q(e)} -n ${r} -f --no-pager 2>/dev/null || ${n} tail -n ${r} -f /var/log/${q(Xr(e))}.log /var/log/messages`:`${n} journalctl -u ${q(e)} -n ${r} --no-pager 2>/dev/null || ${n} tail -n ${r} /var/log/${q(Xr(e))}.log /var/log/messages 2>/dev/null`}const oi=`home=$(getent passwd "$KEY_USER" 2>/dev/null | cut -d: -f6); [ -n "$home" ] || home=$(awk -F: -v u="$KEY_USER" '$1 == u { print $6 }' /etc/passwd)
|
|
574
|
+
[ -n "$home" ] || { log "no user $KEY_USER"; exit 1; }
|
|
575
|
+
f="$home/.ssh/authorized_keys"`;async function si(e,t,n){Qr(n)||D(`not a public key: expected a line like ssh-ed25519 AAAA… comment`);let r=String.raw`
|
|
576
|
+
${oi}
|
|
577
|
+
if $SUDO grep -qxF -- "$KEY" "$f" 2>/dev/null; then log "already there"; exit 0; fi
|
|
578
|
+
$SUDO mkdir -p "$home/.ssh"; $SUDO chmod 700 "$home/.ssh"
|
|
579
|
+
printf '%s\n' "$KEY" | $SUDO tee -a "$f" >/dev/null
|
|
580
|
+
g=$(id -gn "$KEY_USER" 2>/dev/null || echo "$KEY_USER")
|
|
581
|
+
$SUDO chown "$KEY_USER:$g" "$home/.ssh" "$f"; $SUDO chmod 600 "$f"
|
|
582
|
+
record "authorized key $WHAT for $KEY_USER" "drop_line $(printf %q "$f") $(printf %q "$KEY")"
|
|
583
|
+
`,i=Qr(n);await J(e,K+r,{env:{KEY_USER:t,KEY:i.line,WHAT:i.comment||i.fingerprint}})}async function ci(e,t){let n=String.raw`
|
|
584
|
+
${oi}
|
|
585
|
+
$SUDO grep -qxF -- "$KEY" "$f" || { log "key not found in $f"; exit 1; }
|
|
586
|
+
drop_line "$f" "$KEY"
|
|
587
|
+
record "removed key $WHAT of $KEY_USER" "printf '%s\n' $(printf %q "$KEY") | \$SUDO tee -a $(printf %q "$f") >/dev/null"
|
|
588
|
+
`;await J(e,K+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const li=/^[0-9a-fA-F.:]+$/;async function ui(e,t,n){li.test(t)||D(`not an IP address: ${t}`);let r=n.map(e=>`\\$SUDO fail2ban-client set ${q(e)} banip ${q(t)} >/dev/null`).join(`; `);await J(e,[K,...n.map(e=>`$SUDO fail2ban-client set ${q(e)} unbanip ${q(t)} >/dev/null`),`record ${q(`unbanned ${t}`)} "${r}"`].join(`
|
|
589
|
+
`))}function di(e){let t=/^(\d+(?:\.\d+)?)\s*([mg])i?b?$/i.exec(e.trim());return t||D(`not a size: ${e} (e.g. 512M, 2G)`),Math.round(Number(t[1])*(t[2].toLowerCase()===`g`?1024:1))*1024*1024}const fi=`/swapfile none swap sw 0 0`,pi=e=>String.raw`
|
|
590
|
+
if [ -e /swapfile ]; then log "/swapfile exists already: lf swap off first"; exit 1; fi
|
|
591
|
+
mk() { # fallocate is instant but makes files swapon refuses on some filesystems (btrfs, older xfs): dd then
|
|
592
|
+
{ $SUDO fallocate -l ${e}M /swapfile 2>/dev/null || $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none; } &&
|
|
593
|
+
$SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile
|
|
594
|
+
}
|
|
595
|
+
if ! mk; then
|
|
596
|
+
$SUDO rm -f /swapfile
|
|
597
|
+
$SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none && $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile \
|
|
598
|
+
|| { $SUDO rm -f /swapfile; log "could not turn on swap (a container, or a filesystem without swap files?)"; exit 1; }
|
|
599
|
+
fi
|
|
600
|
+
record "created ${e} MB swap at /swapfile" '$SUDO swapoff /swapfile 2>/dev/null; $SUDO rm -f /swapfile'
|
|
601
|
+
if ! grep -qxF '${fi}' /etc/fstab 2>/dev/null; then
|
|
602
|
+
printf '%s\n' '${fi}' | $SUDO tee -a /etc/fstab >/dev/null
|
|
603
|
+
record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${fi}'"
|
|
604
|
+
fi
|
|
605
|
+
`,mi=(e,t)=>J(e,K+pi(Math.round(t/1024/1024))),hi=String.raw`
|
|
606
|
+
[ -e /swapfile ] || { log "no /swapfile: landfall only removes the swap file it made"; exit 1; }
|
|
607
|
+
$SUDO swapoff /swapfile
|
|
608
|
+
$SUDO rm -f /swapfile
|
|
609
|
+
drop_line /etc/fstab '${fi}'
|
|
610
|
+
record "removed the swap file /swapfile"
|
|
611
|
+
`,gi=e=>J(e,K+hi),_i=/^(?=.{1,63}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,vi=e=>`$SUDO hostnamectl set-hostname ${q(e)} 2>/dev/null || { printf '%s\\n' ${q(e)} | $SUDO tee /etc/hostname >/dev/null && $SUDO hostname ${q(e)}; }`;async function yi(e,t,n){_i.test(n)||D(`not a host name: ${n} (letters, digits and -, up to 63)`),await J(e,[K,vi(n),`record ${q(`hostname ${t} → ${n}`)} ${q(vi(t))}`,`if grep -qE '^127\\.0\\.1\\.1[[:space:]]' /etc/hosts; then`,` undo=$(saved /etc/hosts) || exit 1`,` $SUDO sed -i 's/^127\\.0\\.1\\.1[[:space:]].*/127.0.1.1\\t'${q(n)}'/' /etc/hosts`,` record "named ${n} in /etc/hosts" "$undo"`,`fi`].join(`
|
|
612
|
+
`))}const bi=/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/,xi=e=>`$SUDO timedatectl set-timezone ${q(e)} 2>/dev/null || { $SUDO ln -sf /usr/share/zoneinfo/${q(e)} /etc/localtime && printf '%s\\n' ${q(e)} | $SUDO tee /etc/timezone >/dev/null; }`;async function Si(e,t,n){bi.test(n)||D(`not a time zone: ${n} (e.g. Europe/Berlin, UTC)`),await J(e,[K,`[ -e /usr/share/zoneinfo/${q(n)} ] || { log ${q(`unknown time zone ${n} (or tzdata is not installed)`)}; exit 1; }`,xi(n),`record ${q(`time zone ${t} → ${n}`)} ${q(xi(t))}`].join(`
|
|
613
|
+
`))}const Ci=/^\d+[kmg]$/;function wi(e,t,n){let r={...e[`log-opts`]??{}};t?r[`max-size`]=t:delete r[`max-size`],t&&n?r[`max-file`]=String(n):delete r[`max-file`];let i={...e,"log-opts":r};return Object.keys(r).length||delete i[`log-opts`],i}const Ti=`$SUDO systemctl restart docker 2>/dev/null || $SUDO rc-service docker restart 2>/dev/null || $SUDO service docker restart`;async function Ei(e,t,n,r){let i=JSON.stringify(t,null,2)+`
|
|
614
|
+
`,a=String.raw`
|
|
615
|
+
$SUDO install -d /etc/docker
|
|
616
|
+
undo=$(saved /etc/docker/daemon.json) || exit 1
|
|
617
|
+
printf %s ${q(Buffer.from(i).toString(`base64`))} | base64 -d | $SUDO tee /etc/docker/daemon.json >/dev/null
|
|
618
|
+
${r?`log "restarting docker"\n${Ti}\nrecord ${q(n)} "$undo; ${Ti.replace(/\$/g,`\\$`)}"`:`record ${q(n)} "$undo"`}
|
|
619
|
+
`;await J(e,K+a)}const Di=[`ssh`,`fail2ban`,`updates`],Oi=e=>e.length>1?`${e.slice(0,-1).join(`, `)} and ${e.at(-1)}`:e[0],ki=e=>`${Oi(e)} ${e.length===1?`has`:`have`} no key yet and would be locked out: add one first (lf keys add <file.pub> -u ${e[0]}, or --generate to make a pair), then lf harden ssh.`,Ai=(e,t,n)=>e.filter(e=>e!==`root`&&!n.includes(e)&&!t.includes(e)),ji=(e,t)=>Ai(e.security.passwordUsers??[],e.keys.map(e=>e.user),[t]);async function Mi(e,t){let{stdout:n}=await J(e,`${K}\necho "$(password_users)"; echo "$(key_users)"`,{capture:!0}),[r=[],i=[]]=n.split(`
|
|
620
|
+
`).map(e=>e.split(/\s+/).filter(Boolean));return Ai(r,i,[t])}function Ni(e,t){let n=e.security,r=[`-s`,t.name],i=[],a=n.pkg===`apt-get`||n.pkg===`apk`,o=n.platform?dn[n.platform]:void 0;if(n.passwordLogin){let a=ji(e,t.loginUser);i.push({id:`password-login`,level:`risk`,title:n.rootLogin===`yes`?`SSH accepts passwords, root's too`:`SSH accepts passwords`,why:`Bots try passwords on every public server all day: one weak or reused password lets them in. A key cannot be guessed.${a.length?` ${ki(a)}`:``}`,fix:a.length?{label:`Make a key for ${a[0]}`,argv:[`keys`,`add`,`--generate`,`-u`,a[0],...r],user:a[0]}:{label:`Keys only`,argv:[`harden`,`ssh`,...r],confirm:`Only keys log in afterwards; lf undo turns passwords back on.`}})}n.firewall===`inactive`||n.firewall===`missing`&&a?i.push({id:`firewall`,level:`warn`,title:n.firewall===`missing`?`No firewall`:`Firewall is off`,why:`Every port a program opens is reachable from the internet. ufw lets in ssh, 80 and 443, and what you allow.`,fix:{label:`Turn it on`,argv:[`firewall`,`enable`,...r]}}):n.firewall===`missing`&&i.push({id:`firewall`,level:`info`,title:o?.firewall?`Firewall: the system's own`:`No ufw`,why:o?.firewall?`${o.firewall}. Let in only ssh, 80 and 443.`:`Use firewalld or your provider's firewall to let in only ssh, 80 and 443.`}),e.root&&e.fail2ban!==`running`&&(e.fail2ban===`stopped`||a)?i.push({id:`fail2ban`,level:n.passwordLogin?`warn`:`info`,title:e.fail2ban===`missing`?`No fail2ban`:`fail2ban is not running`,why:`It bans addresses that keep failing to log in: it slows password guessing and keeps the logs readable.`,fix:{label:e.fail2ban===`missing`?`Install it`:`Start it`,argv:[`harden`,`fail2ban`,...r]}}):e.fail2ban===`missing`&&o?.bans&&i.push({id:`fail2ban`,level:`info`,title:`Login bans: the system's own`,why:o.bans}),n.autoUpdates===!1&&i.push({id:`auto-updates`,level:`warn`,title:`No automatic security updates`,why:`Holes in OpenSSH, the kernel or libraries get fixed within days; without automatic updates they stay open until someone runs lf update.`,fix:{label:`Turn them on`,argv:[`harden`,`updates`,...r]}}),n.reboot&&i.push({id:`reboot`,level:`info`,title:`Reboot pending`,why:`Installed updates (a new kernel or libc) only take effect after a reboot.`,fix:{label:`Reboot`,argv:[`reboot`,...r]}});let s={risk:0,warn:1,info:2};return i.sort((e,t)=>s[e.level]-s[t.level])}function Pi(e){for(let t of e){let e=t.level===`risk`?x.red(`!`):t.level===`warn`?x.yellow(`!`):x.dim(`·`);console.log(`${e} ${x.bold(t.title)}\n ${x.dim(t.why)}${t.fix?`\n ${x.cyan(`lf ${t.fix.argv.join(` `)}`)}`:``}`)}}async function Fi(e,t,n){let r=n?await ti(n):await ni(t);return{...r,advice:Ni(r,{name:e,loginUser:Y(t).user})}}async function Ii(e,t,n){try{let r=(await Fi(e,t,n)).advice;if(ue({advice:r}),!r.length)return;console.log(`\n${x.bold(`Security`)}`),Pi(r)}catch{}}const Li=String.raw`
|
|
621
|
+
if [ "$H_SSH" = 1 ]; then
|
|
622
|
+
if [ -d /etc/ssh ]; then
|
|
623
|
+
${st}
|
|
624
|
+
else log "no /etc/ssh, ssh skipped"; fi
|
|
625
|
+
fi
|
|
626
|
+
if [ "$H_F2B" = 1 ]; then
|
|
627
|
+
if ! command -v fail2ban-client >/dev/null 2>&1; then
|
|
628
|
+
log "installing fail2ban"
|
|
629
|
+
pkg_install fail2ban || { echo "could not install fail2ban (RHEL-likes have it in EPEL)" >&2; exit 1; }
|
|
630
|
+
record "installed fail2ban"
|
|
631
|
+
fi
|
|
632
|
+
# Debian 12 logs to the journal only: the sshd jail's /var/log/auth.log never appears and fail2ban will not start.
|
|
633
|
+
jail=/etc/fail2ban/jail.d/00-landfall.local
|
|
634
|
+
if [ ! -e /var/log/auth.log ] && [ ! -e /var/log/secure ] && [ -d /run/systemd/system ] && [ ! -e $jail ]; then
|
|
635
|
+
printf '[sshd]\nbackend = systemd\n' | $SUDO tee $jail >/dev/null
|
|
636
|
+
record "fail2ban reads sshd's log from the journal" "\$SUDO rm -f $jail"
|
|
637
|
+
fi
|
|
638
|
+
if ! $SUDO fail2ban-client ping >/dev/null 2>&1; then
|
|
639
|
+
log "starting fail2ban"
|
|
640
|
+
# restart: a running fail2ban that does not answer gets the jail written above.
|
|
641
|
+
{ svc enable fail2ban; svc restart fail2ban; } >/dev/null 2>&1 || true
|
|
642
|
+
up=0; for _ in 1 2 3 4 5 6; do $SUDO fail2ban-client ping >/dev/null 2>&1 && { up=1; break; }; sleep 1; done
|
|
643
|
+
if [ $up = 1 ]; then record "started fail2ban" 'svc stop fail2ban; svc disable fail2ban'
|
|
644
|
+
else echo "fail2ban did not start; see: lf services logs fail2ban" >&2; exit 1; fi
|
|
645
|
+
fi
|
|
646
|
+
fi
|
|
647
|
+
if [ "$H_UPDATES" = 1 ]; then
|
|
648
|
+
case "$(pkg_mgr)" in
|
|
649
|
+
apt-get)
|
|
650
|
+
pkg_has unattended-upgrades || { log "installing unattended-upgrades"; pkg_install unattended-upgrades; record "installed unattended-upgrades"; }
|
|
651
|
+
if ! apt_auto_updates; then
|
|
652
|
+
log "turning on automatic security updates"
|
|
653
|
+
f=/etc/apt/apt.conf.d/20auto-upgrades
|
|
654
|
+
undo=$(saved $f)
|
|
655
|
+
printf '%s\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Unattended-Upgrade "1";' | $SUDO tee $f >/dev/null
|
|
656
|
+
record "turned on automatic security updates" "$undo"
|
|
657
|
+
fi ;;
|
|
658
|
+
dnf)
|
|
659
|
+
pkg_has dnf-automatic || { log "installing dnf-automatic"; pkg_install dnf-automatic && record "installed dnf-automatic"; }
|
|
660
|
+
if systemctl cat dnf-automatic-install.timer >/dev/null 2>&1; then
|
|
661
|
+
if ! systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null; then
|
|
662
|
+
$SUDO systemctl enable --now dnf-automatic-install.timer >/dev/null
|
|
663
|
+
record "turned on automatic updates" '$SUDO systemctl disable --now dnf-automatic-install.timer'
|
|
664
|
+
fi
|
|
665
|
+
else
|
|
666
|
+
echo "set apply_updates = yes in /etc/dnf/automatic.conf, then: systemctl enable --now dnf5-automatic.timer" >&2; exit 1
|
|
667
|
+
fi ;;
|
|
668
|
+
*) echo "automatic updates: landfall knows apt and dnf; on $(pkg_mgr) set them up by hand" >&2; exit 1 ;;
|
|
669
|
+
esac
|
|
670
|
+
fi
|
|
671
|
+
`;async function Ri(e,t){await J(e,K+Li,{env:{H_SSH:t.includes(`ssh`),H_F2B:t.includes(`fail2ban`),H_UPDATES:t.includes(`updates`)}})}function zi(e,t){/^[a-z_][a-z0-9_.-]*$/i.test(t)||D(`not a user name: ${t}`);let n=m(c(),`.ssh`,`${je(e)}_${t}`),r=pe(),i=Wr(n,`${t}@${e}`,r);return o.existsSync(`${n}.pub`)||D(`${n} exists without ${n}.pub; move it away or pass the public key`),{path:n,created:i,passphrase:i&&r,pub:o.readFileSync(`${n}.pub`,`utf8`).trim()}}async function Q(e,t){let{stdout:n}=await J(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==r&&await At({...e,user:t}))return{...e,user:t};return D(`${t} needs root or passwordless sudo; ${r} has neither and root's key login does not work`)}const Bi=String.raw`
|
|
473
672
|
set +e
|
|
474
|
-
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
475
673
|
m=$(pkg_mgr); echo "mgr=$m"
|
|
476
674
|
refresh() { [ "$REFRESH" = 1 ] && S true 2>/dev/null && "$@" >/dev/null 2>&1 && echo refreshed=1; }
|
|
477
675
|
case "$m" in
|
|
@@ -481,8 +679,7 @@ case "$m" in
|
|
|
481
679
|
dnf|yum)
|
|
482
680
|
refresh S $m -q makecache
|
|
483
681
|
$m -q -C check-update 2>/dev/null | awk 'NF == 3 && $1 ~ /\./ {print "pkg=" $1 "\t\t" $2 "\t" $3}'
|
|
484
|
-
$m -q -C updateinfo list --security 2>/dev/null | awk 'NF >= 3 {print "sec=" $3}'
|
|
485
|
-
command -v needs-restarting >/dev/null 2>&1 && { needs-restarting -r >/dev/null 2>&1; [ $? = 1 ] && echo reboot=1; } ;;
|
|
682
|
+
$m -q -C updateinfo list --security 2>/dev/null | awk 'NF >= 3 {print "sec=" $3}' ;;
|
|
486
683
|
apk)
|
|
487
684
|
refresh S apk update -q
|
|
488
685
|
apk version -l '<' 2>/dev/null | awk 'NR > 1 {print "apk=" $1 "\t" $3}' ;;
|
|
@@ -491,10 +688,10 @@ case "$m" in
|
|
|
491
688
|
# v | Repository | Name | Current Version | Available Version | Arch
|
|
492
689
|
zypper -q -n list-updates 2>/dev/null | awk -F' *[|] *' '$1 == "v" {print "pkg=" $3 "\t" $4 "\t" $5 "\t" $2}' ;;
|
|
493
690
|
esac
|
|
494
|
-
|
|
691
|
+
reboot_needed && echo reboot=1
|
|
495
692
|
exit 0
|
|
496
|
-
`;function
|
|
497
|
-
`)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function
|
|
693
|
+
`;function Vi(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
|
|
694
|
+
`)){let e=r.indexOf(`=`);if(e<0)continue;let[i,a]=[r.slice(0,e),r.slice(e+1)];if(i===`mgr`&&(t.mgr=a.trim()),i===`refreshed`&&(t.refreshed=!0),i===`reboot`&&(t.reboot=!0),i===`sec`&&n.push(a.trim()),i===`pkg`){let[e,n,r,i=``]=a.split(` `);t.updates.push({name:e,from:n||void 0,to:r,security:/security/i.test(i)})}if(i===`apk`){let[e,n]=a.split(` `),r=e.match(/^(.*)-(\d[^-]*-r\d+)$/);t.updates.push({name:r?.[1]??e,from:r?.[2],to:n,security:!1})}}for(let e of t.updates){let t=e.name.replace(/\.[^.]+$/,``);n.some(e=>e.startsWith(t+`-`))&&(e.security=!0)}return t}async function Hi(e,t=!0){let{stdout:n}=await J(e,K+Bi,{capture:!0,env:{REFRESH:t}});return Vi(n)}const Ui=String.raw`
|
|
498
695
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
|
|
499
696
|
keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
|
|
500
697
|
case "$(pkg_mgr)" in
|
|
@@ -508,14 +705,14 @@ case "$(pkg_mgr)" in
|
|
|
508
705
|
*) echo "no supported package manager" >&2; exit 1 ;;
|
|
509
706
|
esac
|
|
510
707
|
record "upgraded $WHAT"
|
|
511
|
-
if [ "$REBOOT" = 1 ] &&
|
|
708
|
+
if [ "$REBOOT" = 1 ] && reboot_needed; then
|
|
512
709
|
log "rebooting"
|
|
513
710
|
record "rebooted after upgrading"
|
|
514
711
|
# Detached, so this ssh session can end cleanly first.
|
|
515
712
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
516
713
|
fi
|
|
517
|
-
`;async function
|
|
518
|
-
${
|
|
714
|
+
`;async function Wi(e,t){await J(e,K+Ui,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Gi(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 Ki(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 qi(e){let t=Gi(e);return t.digest?t.digest:(await Ki(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function Ji(e){let t=Gi(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await Ki(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 Yi(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 Xi=/^(v?)(\d+(?:\.\d+){0,2})$/;function Zi(e,t){let n=Xi.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=Xi.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&Yi(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const Qi=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,$i=/* @__PURE__ */ new Map;function ea(e){return $i.has(e)||$i.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)),$i.get(e)}const ta=()=>ea(`moby/moby`),na=()=>ea(`docker/compose`);async function ra(e){let t=String.raw`
|
|
715
|
+
${K}
|
|
519
716
|
set +e
|
|
520
717
|
printf 'docker=%s
|
|
521
718
|
' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
|
@@ -527,11 +724,11 @@ printf 'ce=%s
|
|
|
527
724
|
' "$(pkg_has docker-ce && echo 1)"
|
|
528
725
|
printf 'syno=%s
|
|
529
726
|
' "$([ -f /etc/synoinfo.conf ] && echo 1)"
|
|
530
|
-
exit 0`,[{stdout:n},r,i]=await Promise.all([
|
|
531
|
-
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,synology:a.syno===`1`,latestDocker:r,latestCompose:i}}function
|
|
532
|
-
`)}const
|
|
727
|
+
exit 0`,[{stdout:n},r,i]=await Promise.all([J(e,t,{capture:!0}),ta(),na()]),a=Object.fromEntries(n.split(`
|
|
728
|
+
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,synology:a.syno===`1`,latestDocker:r,latestCompose:i}}function ia(e){let t=(e,t)=>!!e&&!!t&&Yi(e,t)<0,n=e.synology?` (comes with Container Manager updates in Package Center)`:void 0,r=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${n??(e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`)}`:void 0,i=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`&&!e.synology;return{dockerNote:r,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${i?``:n??(e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`)}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:i}}async function aa(e){await J(e,K+ct+`compose_install || { log "could not download docker compose"; exit 1; }
|
|
729
|
+
`)}const oa=String.raw`
|
|
533
730
|
set +e
|
|
534
|
-
P=${
|
|
731
|
+
P=${et}
|
|
535
732
|
img() { # img <kind> <name> <container> <dir> [service]
|
|
536
733
|
svc=; [ $# -lt 5 ] || svc=$5
|
|
537
734
|
x=$(docker inspect -f '{{.Config.Image}} {{.Image}}' "$3" 2>/dev/null) || { printf 'IMG\t%s\t%s\t%s\t%s\t%s\t\t\n' "$1" "$2" "$3" "$4" "$svc"; return 0; }
|
|
@@ -540,11 +737,11 @@ $x
|
|
|
540
737
|
EOF
|
|
541
738
|
printf 'IMG\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$svc" "$ref" "$(docker image inspect -f '{{range .RepoDigests}}{{.}} {{end}}' "$id" 2>/dev/null)"
|
|
542
739
|
}
|
|
543
|
-
if [ "$P" = traefik ] && grep -q "managed by landfall" ${
|
|
740
|
+
if [ "$P" = traefik ] && grep -q "managed by landfall" ${U}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${U}
|
|
544
741
|
else img proxy "$P" "$P" ""; fi
|
|
545
742
|
# All containers at once: <name> <service> <project>, the last two empty outside Compose.
|
|
546
743
|
PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
|
|
547
|
-
for f in ${
|
|
744
|
+
for f in ${H}/*/site.json; do
|
|
548
745
|
[ -f "$f" ] || continue
|
|
549
746
|
d=$(dirname "$f"); n=$(basename "$d")
|
|
550
747
|
# Sites of landfall before Compose sites: one container, its compose file next to site.json.
|
|
@@ -553,15 +750,15 @@ for f in ${V}/*/site.json; do
|
|
|
553
750
|
printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
|
|
554
751
|
done
|
|
555
752
|
exit 0
|
|
556
|
-
`;async function
|
|
557
|
-
`).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!
|
|
558
|
-
`))}function
|
|
559
|
-
`)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(
|
|
560
|
-
[ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function
|
|
753
|
+
`;async function sa(e){let{stdout:t}=await J(e,oa,{capture:!0}),n=t.split(`
|
|
754
|
+
`).filter(e=>e.startsWith(`IMG `)).map(e=>e.split(` `)),r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=async e=>{let t=e.slice(0,e.lastIndexOf(`:`));if(!Xi.test(Gi(e).tag))return;i.has(t)||i.set(t,Ji(e));let n=Zi(Gi(e).tag,await i.get(t));return n&&Qi(e,n)};return Promise.all(n.map(async([,e,t,n,i,o=``,s=``,c=``])=>{let l={name:t,container:n,dir:i,ref:s,service:o||void 0};if(e===`site`&&o&&s.startsWith(Pn(t,o,``)))return{...l,kind:`site`,state:`rebuild`,note:`built on the server: redeploy to rebuild`};if(!s)return{...l,kind:e,state:`missing`,note:`no container`};if(e===`proxy`)return{...l,kind:`proxy`,state:`foreign`,note:`not landfall's traefik: update it where it was set up`};if(s.includes(`@`))return{...l,kind:e,state:`pinned`,note:`pinned to a digest`};r.has(s)||r.set(s,qi(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Gi(e).tag.replace(/^v/,``).split(`.`)[0],m=f===`traefik`?`lf traefik setup --image ${d}`:`redeploy with it`,ee=d?{newer:d,note:`newer version ${d} (${p(d)===p(s)?``:`new major, read its upgrade notes; `}${m})`}:{};return u?{...l,kind:f,state:c.split(` `).some(e=>e.endsWith(`@${u}`))?`current`:`update`,...ee}:{...l,kind:f,state:`unknown`,note:`registry did not answer (private image?)`,...ee}}))}async function ca(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[K];for(let[e,t]of n){r.push(`log ${q(`pulling ${e}`)}`,`docker pull -q ${q(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${q(e)})`);for(let n of t){let t=n.service?` ${q(n.service)}`:``,i=`docker tag $old ${q(e)} && cd -P ${q(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${q(n.container)})`,`if [ "$old" = "$new" ]; then log ${q(`${n.name} already runs the newest ${e}`)}; else`,` record ${q(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${q(`recreating ${n.container}`)}`,` (cd -P ${q(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${q(n.container)} || exit 1`,`fi`)}}await J(e,r.join(`
|
|
755
|
+
`))}function la(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 ua(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
|
|
756
|
+
`)){let e=n.indexOf(`=`);if(e<0)continue;let r=n.slice(0,e),i=n.slice(e+1).trim();r===`listen`||r===`published`?t[r].push(la(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function da(e,t={}){let{stdout:n}=await J(e,K+dt,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return ua(n)}const fa=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),pa=e=>e.includes(`:`),ma=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,ha=[68,546,5353];function ga(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Ca(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 _a(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 va=(e,t)=>{let n=_a(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},ya=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[ma(e),t(e)])).values()];function ba(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=xa(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 xa(e,t){let n=e.values,r=e.rules.map(ga),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&ha.includes(e.port)||va(r,e),a=e=>!fa(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[pa(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...ya(o,e=>`${ma(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:ya(l,e=>`${ma(e)} (docker${pa(e.addr)?`, IPv6`:``})`)],kept:c?[]:ya(s.filter(e=>!l.includes(e)),ma)}}function Sa(e){let t=e.values,n=e.rules.map(ga),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[pa(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(fa(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=_a(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`&&ha.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=`${ma(e)} ${t?`docker`:e.proc}`,r={port:ma(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 Ca=(e=``)=>e.split(/\s+/).filter(Boolean);function wa(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&&Yi(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 Ta(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(wa(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,V,o?`done`:`todo`,o?`${s} site${s===1?``:`s`} (never touched by setup)`:`create dir and network`)),t.deployUser){let e=`deploy user ${t.deployUser}`;if(n.user!==`1`)i.push(r(`user`,e,`todo`,`create`));else{let t=[n.user_docker===`1`?``:`docker group`,n.user_key===`1`?``:`ssh key`].filter(Boolean);i.push(r(`user`,e,t.length?`todo`:`done`,t.length?`exists, add ${t.join(` + `)}`:``))}}if(t.firewall?n.ufw===`missing`?i.push(r(`firewall`,`firewall`,`done`,`ufw not available, skipped`)):n.ufw===`active`?i.push(r(`firewall`,`firewall`,`done`,`ufw active (landfall only adds ssh/80/443)`)):i.push(ba(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=Ai(Ca(n.password_users),Ca(n.key_users),[t.loginUser,t.deployUser]);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(Oa(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Ea(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 Da=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Oa(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=tt(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Da(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${U}/docker-compose.yml was not written by landfall`);let[i,a]=Ca(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Ea(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=Ca(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Ea(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Da(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 ka(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 Aa(e,t){let{stdout:n}=await J(e,`id=$(docker ps -q ${t.map(e=>`--filter ${q(e)}`).join(` `)} | head -n1)
|
|
757
|
+
[ -z "$id" ] || docker inspect -f '{{json .Name}}{{"\\t"}}{{json .HostConfig.NetworkMode}}{{"\\t"}}{{json .NetworkSettings.Networks}}{{"\\t"}}{{json .Config.Labels}}{{"\\t"}}{{json .Args}}{{"\\t"}}{{json .Config.ExposedPorts}}' "$id"`,{capture:!0}),r=n.trim();if(!r)return;let[i,a,o,s,c,l]=r.split(` `).map(e=>JSON.parse(e)),u=Object.fromEntries(Object.entries(o??{}).map(([e,t])=>[e,t?.IPAddress]).filter(([,e])=>e)),d=Object.keys(l??{}).map(e=>e.match(/^(\d+)\/tcp$/)?.[1]).filter(e=>!!e).map(Number).sort((e,t)=>e-t);return{name:String(i).replace(/^\//,``),host:a===`host`,ips:u,labels:s??{},args:c??[],ports:d}}function ja(e,t){if(e.host)return`127.0.0.1`;let n=e.ips[t]??Object.values(e.ips)[0];return n||D(`${e.name} has no IP address to tunnel to`),n}function Ma(e,t){let n=n=>e.labels[`traefik.http.routers.${t}.${n}`],r=new Map(e.args.flatMap(e=>{let t=e.match(/^--entrypoints\.([^.=]+)\.address=.*:(\d+)/i);return t?[[t[1].toLowerCase(),Number(t[2])]]:[]})),i=n(`entrypoints`)?.split(`,`).map(e=>e.trim().toLowerCase())??[...r.keys()],a=n(`tls`)===`true`||!!n(`tls.certresolver`),o=i.map(e=>r.get(e)).filter(e=>e!==void 0),s=o.find(e=>e===443===a)??o[0];return s===void 0?void 0:{port:s,tls:a}}const Na={25:`smtp`,110:`pop3`,143:`imap`,465:`smtps`,587:`smtp`,993:`imaps`,995:`pop3s`,3306:`mysql`,5432:`postgresql`,5672:`amqp`,6379:`redis`,11211:`memcached`,27017:`mongodb`},Pa=e=>Na[e]??`http`;async function Fa(e,t=`127.0.0.1`){let n=e=>new Promise(n=>{let r=g.createServer().once(`error`,()=>n(void 0));r.listen(e,t,()=>{let e=r.address().port;r.close(()=>n(e))})});return e&&await n(e)||await n(0)}const Ia=e=>new Promise(t=>{let n=g.connect(e,`127.0.0.1`);n.once(`connect`,()=>(n.destroy(),t(!0))),n.once(`error`,()=>t(!1))});function La(e,t,n,r){let i=n.tls?ce:v,a=v.createServer((e,r)=>{let a=i.request({host:`127.0.0.1`,port:t,servername:n.name,rejectUnauthorized:!1,method:e.method,path:e.url,headers:{...e.headers,host:n.name}},e=>(r.writeHead(e.statusCode??502,e.headers),e.pipe(r)));a.on(`error`,e=>r.headersSent?r.destroy():r.writeHead(502).end(`${n.name}: ${e.message}\n`)),e.pipe(a)});return new Promise((t,n)=>a.once(`error`,n).listen(e,r,()=>t(a)))}const Ra=()=>Object.values(s.networkInterfaces()).flatMap(e=>e??[]).filter(e=>e.family===`IPv4`&&!e.internal).map(e=>e.address);async function za(e,t,r,i){let a=i.lan?`0.0.0.0`:`127.0.0.1`,o=i.local===void 0?void 0:z(i.local,`--local`,1,65535),s=await Fa(o??(r>=1024?r:8080),a);o&&s!==o&&D(`local port ${o} is taken; pick another with --local`);let c=i.as?await Fa():s,l=[...yt(e),`-o`,`ControlPath=none`,`-o`,`BatchMode=yes`,`-o`,`ExitOnForwardFailure=yes`,`-o`,`ServerAliveInterval=15`],u=n(`ssh`,[...l,`-N`,`-L`,`${i.as?`127.0.0.1`:a}:${c}:${t}:${r}`,vt(e)],{stdio:[`ignore`,`inherit`,`inherit`]}),d=new Promise(e=>u.on(`close`,t=>e(t??1))),f=!1;for(let e of[`SIGINT`,`SIGTERM`,`SIGHUP`])process.once(e,()=>(f=!0,u.kill()));for(let e=0;e<100&&u.exitCode===null&&!await Ia(c);e++)await new Promise(e=>setTimeout(e,150));let p;if(u.exitCode===null){i.as&&(p=await La(s,c,i.as,a));let e=i.scheme??`http`,t=t=>`${e}://${t}:${s}${e===`http`?i.path??`/`:``}`;C(`${i.what}: ${x.cyan(t(`localhost`))}`);let n=i.lan?Ra():[];ue({url:t(`localhost`),lan:n.map(t)}),i.lan&&(w(n.length?`on your network: ${n.map(e=>x.cyan(t(e))).join(` `)}`:`on your network: this machine has no network address yet`),T(`anyone on that network reaches it too: close it when you are done`)),i.note&&w(x.dim(i.note)),w(x.dim(`the tunnel stays open until Ctrl-C`))}let m=await d;p?.close(),!f&&m!==0&&D(`the tunnel closed (ssh exit ${m})`)}async function Ba(e,t){return`${e}:${(await se.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const Va=e=>e.slice(0,e.indexOf(`:`)),Ha=`admin`;async function Ua(e,t,n=`user`){Pe(e,n);let r=t??await _e(`Password for ${e}:`);return r.length<8&&D(`use a password of at least 8 characters`),Ba(e,r)}function Wa(e,t){return{email:t.email??e.email??Ce().email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork),http3:(t.http3??e.http3)!==!1}}const Ga=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.http3?`HTTP/3`:`no HTTP/3`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Ka(e,t,n){let r=Wa(t,n);Re(r.image),r.email&&Fe(r.email);let{email:i,staging:a}=r;S(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Ve(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){Me.test(c)||D(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=Pe(n.dashboard?n.dashboardUser:o?.user??n.dashboardUser,`--dashboard-user`);s=n.dashboardPassword??oe(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Ba(e,s)}}let l=mt({...r,dashboard:o});r.hostNetwork&&await Ya(t,r.http3),await J(t,`${K}
|
|
561
758
|
ensure_network web
|
|
562
|
-
[ -w ${
|
|
563
|
-
mkdir -p ${
|
|
564
|
-
${
|
|
759
|
+
[ -w ${U} ] || { $SUDO install -d -o "$(id -un)" ${U}; }
|
|
760
|
+
mkdir -p ${U}/letsencrypt ${Je} && cd ${U}
|
|
761
|
+
${Bt(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
|
|
565
762
|
tls:
|
|
566
763
|
options:
|
|
567
764
|
default:
|
|
@@ -572,11 +769,11 @@ tls:
|
|
|
572
769
|
gw=$(docker network inspect web -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\\n' | grep -m1 '[.]' || true)
|
|
573
770
|
printf 'LANDFALL_GATEWAY=%s\\n' "\${gw:-127.0.0.1}" > .env
|
|
574
771
|
if [ -e docker-compose.yml ]; then
|
|
575
|
-
${
|
|
772
|
+
${Bt(`.docker-compose.yml.new`,l)}
|
|
576
773
|
# .landfall.sha256: what landfall wrote last. A different file was edited by hand.
|
|
577
774
|
if [ -f .landfall.sha256 ] && ! sha256sum -c --status .landfall.sha256 2>/dev/null && ! cmp -s .docker-compose.yml.new docker-compose.yml && [ -z "${n.force?`1`:``}" ]; then
|
|
578
775
|
diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
|
|
579
|
-
echo "${
|
|
776
|
+
echo "${U}/docker-compose.yml was edited by hand; the diff above is what landfall would change. Keep your edits: change dynamic/ or the flags instead; overwrite them: lf traefik setup --force" >&2
|
|
580
777
|
exit 1
|
|
581
778
|
fi
|
|
582
779
|
# Same config again: nothing to journal.
|
|
@@ -584,48 +781,48 @@ if [ -e docker-compose.yml ]; then
|
|
|
584
781
|
else
|
|
585
782
|
u=$(saved docker-compose.yml)
|
|
586
783
|
mv .docker-compose.yml.new docker-compose.yml
|
|
587
|
-
record ${
|
|
784
|
+
record ${q(`reconfigured traefik (${Ga(r)})`)} "cd ${U} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
|
|
588
785
|
fi
|
|
589
786
|
else
|
|
590
|
-
${
|
|
787
|
+
${Bt(`docker-compose.yml`,l)}
|
|
591
788
|
# Certificates in letsencrypt/ stay, a later install reuses them.
|
|
592
|
-
record ${
|
|
789
|
+
record ${q(`installed traefik (${Ga(r)})`)} "cd ${U} && dc down --remove-orphans && rm -f docker-compose.yml .env .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
|
|
593
790
|
fi
|
|
594
791
|
# Written now: a start that fails below must not make landfall's own file look edited by hand.
|
|
595
792
|
sha256sum docker-compose.yml > .landfall.sha256
|
|
596
793
|
log "starting traefik"
|
|
597
794
|
dc up -d --remove-orphans --quiet-pull
|
|
598
795
|
sleep 2 # traefik exits shortly after start on a bad config
|
|
599
|
-
ensure_running traefik`);let u=
|
|
600
|
-
docker network inspect ${
|
|
601
|
-
[ -w ${
|
|
602
|
-
u=$(saved ${
|
|
603
|
-
${
|
|
604
|
-
record ${
|
|
605
|
-
f=${
|
|
796
|
+
ensure_running traefik`);let u=L();u.servers[e]&&(Object.assign(u.servers[e],{email:i===Ce().email?void 0:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,http3:r.http3?void 0:!1,dashboard:o}),Oe(u)),C(`traefik running (${Ga(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&w(x.dim(`host network: ufw now decides who reaches 80/443`)),o&&(C(`dashboard: https://${o.host}`),w(s?`user ${x.bold(o.user)} password ${x.bold(s)}`:x.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function qa(e,t){S(`using traefik ${x.bold(t.container)} for lf sites`),await J(e,`${K}
|
|
797
|
+
docker network inspect ${q(t.network)} >/dev/null 2>&1 || { echo ${q(`docker network ${t.network} does not exist`)} >&2; exit 1; }
|
|
798
|
+
[ -w ${V} ] || $SUDO install -d -o "$(id -un)" ${V}
|
|
799
|
+
u=$(saved ${W})
|
|
800
|
+
${Bt(W,nt(t))}
|
|
801
|
+
record ${q(`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 ${W} to let landfall run its own traefik again`))}async function Ja(e){let{stdout:t}=await J(e,`[ ! -f ${W} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${W})"
|
|
802
|
+
f=${U}/docker-compose.yml
|
|
606
803
|
[ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
|
|
607
|
-
for d in ${
|
|
608
|
-
`)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function
|
|
804
|
+
for d in ${H}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
|
|
805
|
+
`)){let[t,r]=e.split(/[\t ]/);t===`proxy`&&(n.proxy=r||`?`),t===`compose`&&(n.compose=r),t===`site`&&n.sites.push(r)}return n}async function Ya(e,t){let n=[`80/tcp`,`443/tcp`,...t?[`443/udp`]:[]],r;try{r=await Q(e,`checking ufw`)}catch{return T(`can't check ufw without root; if it is on, allow ${n.join(`, `)} (lf firewall add)`)}await J(r,`${K}
|
|
609
806
|
command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" || exit 0
|
|
610
807
|
${n.map(e=>`ufw_add allow ${e}`).join(`
|
|
611
|
-
`)}`)}async function
|
|
612
|
-
cd ${
|
|
808
|
+
`)}`)}async function Xa(e,t={}){S(`removing traefik`),await J(e,`${K}
|
|
809
|
+
cd ${U}
|
|
613
810
|
log "stopping traefik"
|
|
614
811
|
dc down --remove-orphans
|
|
615
812
|
# One assignment each: an assignment's status is only its last $(...), so a failed backup would go unnoticed.
|
|
616
|
-
u1=$(saved ${
|
|
617
|
-
u2=$(saved ${
|
|
618
|
-
u3=$(saved ${
|
|
813
|
+
u1=$(saved ${U}/docker-compose.yml)
|
|
814
|
+
u2=$(saved ${U}/.landfall.sha256)
|
|
815
|
+
u3=$(saved ${Je}/landfall.yml)
|
|
619
816
|
u="$u1; $u2; $u3"
|
|
620
|
-
rm -f docker-compose.yml .landfall.sha256 ${
|
|
817
|
+
rm -f docker-compose.yml .landfall.sha256 ${Je}/landfall.yml
|
|
621
818
|
${t.certs?`rmf letsencrypt`:``}
|
|
622
819
|
# Certificates removed with --certs come back from Let's Encrypt on the next start.
|
|
623
|
-
record ${
|
|
820
|
+
record ${q(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${U} && dc up -d --remove-orphans"`)}const Za={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},Qa=j({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:M,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${Ze})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},http3:{type:`boolean`,desc:`HTTP/3 over QUIC on 443/udp, on by default; remembered (--no-http3 turns it off)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:Ha},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await da(r),a=Oa(i);if(a.status===`conflict`&&D(`not touching ${n}: ${a.detail}`),a.proxy&&D(`${n} already has its own traefik: ${a.detail}. landfall does not reconfigure it (lf traefik use)`),Wa(r,{hostNetwork:t[`host-network`]}).hostNetwork){let e=i.listen.find(e=>e.port===8080&&e.proc!==`traefik`);e&&D(`port ${Ye} is in use by ${e.proc}; traefik on the host network needs it for lf tunnel`)}await Ka(n,r,{email:t.email&&Fe(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),$a=j({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
|
|
624
821
|
|
|
625
822
|
landfall reads network, entrypoint and cert resolver from the container's flags;
|
|
626
|
-
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:M,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=
|
|
823
|
+
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:M,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=R(t.server),i=Ea((await da(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 qa(r,s)}}),eo=j({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:M,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await Ja(r);if(i.proxy)return await k(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||D(`cancelled`),await J(r,`${K}\nu=$(saved ${W})\n_as ${V} rm -f ${W}\nrecord ${q(`sites stop using traefik ${i.proxy}`)} "$u"`),C(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),w(x.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&D(`${U}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return C(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await O(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Xa(r,{certs:t.certs}),C(`traefik removed${t.certs?``:x.dim(` (certificates stay for a later install; --certs deletes them)`)}`),w(x.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),to=j({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:M},async run(e,t){let{server:n}=R(t.server);await J(n,`P=${et}
|
|
627
824
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
|
|
628
|
-
[ -f ${
|
|
825
|
+
[ -f ${W} ] && echo "your own traefik, landfall only routes sites through it (${W})"
|
|
629
826
|
docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
|
|
630
827
|
echo
|
|
631
828
|
echo "routes:"
|
|
@@ -649,219 +846,39 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
|
|
|
649
846
|
printf " %s %s (%s)\\n", n, x, s
|
|
650
847
|
}
|
|
651
848
|
}' || true
|
|
652
|
-
done | sort`)}}),
|
|
653
|
-
Requests are left out unless --access: lf traffic sums them up.`,options:{server:M,...
|
|
849
|
+
done | sort`)}}),no=j({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).
|
|
850
|
+
Requests are left out unless --access: lf traffic sums them up.`,options:{server:M,...Za,access:{type:`boolean`,desc:`include the access log (a JSON line per request)`}},async run(e,t){let{server:n}=R(t.server);if(t.access)return ur(n,et,t);let r=z(t.tail,`--tail`,1),i=`awk '!/^\\{.*"DownstreamStatus":/ { print; fflush() }'`;return Dt(n,[t.follow?`docker logs --tail ${r} -f ${et} 2>&1 | ${i}`:`docker logs --tail ${Math.max(r*50,5e3)} ${et} 2>&1 | ${i} | tail -n ${r}`])}}),ro=j({name:`traefik restart`,summary:`Restart traefik.`,options:{server:M},async run(e,t){let{server:n}=R(t.server);await J(n,`${K}\ndocker restart ${et} >/dev/null\nrecord "restarted traefik"`),C(`traefik restarted`)}}),io=j({name:`traefik dashboard`,summary:`Open Traefik's dashboard on localhost through an SSH tunnel.
|
|
654
851
|
|
|
655
852
|
No domain or password needed: landfall's Traefik serves the dashboard to the server
|
|
656
853
|
itself, and only there. To also serve it on a domain behind a password:
|
|
657
854
|
lf traefik setup --dashboard traefik (traefik.<main domain>).
|
|
658
855
|
|
|
659
856
|
A Traefik of your own works too when it routes the dashboard to a domain: the tunnel
|
|
660
|
-
goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:M,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=
|
|
857
|
+
goes to that entrypoint and sends the domain along (log in with its password).`,options:{server:M,local:{type:`string`,short:`l`,desc:`local port (default: 8080, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`},port:{type:`string`,desc:`with a Traefik of your own: the port of its API (api.insecure: 8080)`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await Ja(r),a=await Aa(r,[`name=^${i.proxy??`traefik`}$`]);a||D(`traefik is not running on ${n}${i.proxy?``:` (lf traefik setup)`}`);let o=fr(a.labels),s=o.length?`also at ${o.map(e=>`https://${e}`).join(`, `)} (with its password)`:void 0,c=t.port?z(t.port,`--port`,1,65535):!i.proxy&&mr(a.labels)?Ye:void 0,l=`traefik dashboard of ${n}`;if(c)return za(r,ja(a,`web`),c,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:s});let u=pr(a.labels).flatMap(e=>e.hosts[0]?[{host:e.hosts[0],at:Ma(a,e.router)}]:[]).find(e=>e.at);u||D(i.proxy?`${i.proxy} is your own traefik and serves no dashboard on a domain; if it serves its API (api.insecure), pass its port: --port 8080`:`traefik on ${n} was set up by an older landfall: \`lf traefik setup -s ${n}\` adds the dashboard tunnel`);let{host:d,at:f}=u;await za(r,ja(a,`web`),f.port,{local:t.local,lan:t.lan,path:`/dashboard/`,what:l,note:`through ${d}'s router (it asks for its login)`,as:{name:d,tls:f.tls}})}});function ao(e,t){let n=B(process.cwd()),r=e??n?.name;return r||D(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...R(t??n?.server)}}const oo=e=>jn(e)?x.green(e):Mn(e)?x.dim(e):x.red(e),so=j({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=await vr(r),{sites:a,others:o}=i;if(t.json)return console.log(JSON.stringify(t.others?i:a,null,2));if(o.length&&(console.log(x.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),A([[x.dim(`NAME`),x.dim(`DOMAINS`),x.dim(`FOLDER`),x.dim(`STATUS`)],...o.map(e=>[e.name,e.domains.join(`, `),e.dir??x.dim(e.containers.join(`, `)),oo(e.status)])]),a.length&&console.log()),!a.length)return console.log(`no landfall sites on ${n} yet — ${x.bold(`lf deploy --domain example.com`)}`);A([[x.dim(`NAME`),x.dim(`KIND`),x.dim(`DOMAINS`),x.dim(`SERVICES`),x.dim(`RELEASE`),x.dim(`UPDATED`),x.dim(`STATUS`)],...a.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[x.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||x.dim(`-`),e.current||x.dim(`-`),ge(e.updatedAt),oo(t)])])}}),co=j({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:M},async run([e],t){let{site:n,server:r}=ao(e,t.server),i=(await vr(r)).sites.find(e=>e.meta.name===n);i||D(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${x.bold(a.name)} ${i.kind} ${oo(i.status)}`);for(let e of i.domains)console.log(` ${x.cyan(`https://`+e)}`);if(console.log(x.dim(` ${a.adopted??Z(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${x.bold(`Services`)}`),A(i.services.map(e=>[` ${e.service}`,x.dim(e.container),oo(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${x.bold(`Releases`)}`);for(let e of lr(a)){let t=e.id===a.current?x.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${x.dim(ge(e.at))} ${x.dim(e.services.map(e=>e.name).join(`, `))}${n.length?x.dim(` → ${n.join(`, `)}`):``}`)}}}}),lo=j({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:M,...Za},async run([e,t],n){let{site:r,server:i}=ao(e,n.server);return jr(i,r,{...n,service:t})}}),uo=(e,t,n,r,i)=>j({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:M},async run([a,o],s){let{site:c,server:l}=ao(a,s.server);await cr(l,c);let u=o?` ${q(o)}`:``,d=`record ${q(`${e} ${c}${o?` `+o:``}`)}`,f=`[ -n "$(cd -P current && dc ps -q --status running${u} 2>/dev/null)" ]`,p=r?`if ${i?``:`! `}${f}; then ${d} ${q(`cd -P ${Z(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Ar(l,c,t+u,p),C(`${o?`${c} ${o}`:c} ${n}`)}}),fo=uo(`restart`,`restart`,`restarted`),po=uo(`stop`,`stop`,`stopped`,`start`,!0),mo=uo(`start`,`up -d --wait`,`started`,`stop`,!1),ho=j({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:M,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=ao(e,t.server),{meta:i,proxy:a}=await cr(r,n,{deployed:`roll back`}),o=lr(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&D(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||D(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await Or(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),C(`${n} rolled back to ${x.bold(c.id)}`)}}),go=j({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:M,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||D("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=R(t.server),i=await vr(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Sn(t.meta)===e);D(t?`${e} is already the site ${t.meta.name}`:`no Compose project "${e}" with Traefik routes on ${r}. See \`lf sites\``)}let o=a;o.dir||D(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=ar(t.name??Qn(e));i.sites.some(e=>e.meta.name===s)&&D(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...Fn,...In].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&D(`${e} was started with ${l.join(`, `)}; rename it to compose.yaml in ${o.dir} (or adopt it after moving it there)`);let u=(/* @__PURE__ */ new Date()).toISOString();await br(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),C(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(x.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),_o=j({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
|
|
661
858
|
|
|
662
859
|
Named volumes (databases, uploads) stay, so deploying the site again finds its data;
|
|
663
|
-
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:M,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=
|
|
664
|
-
`));let s=
|
|
665
|
-
record ${
|
|
666
|
-
${
|
|
860
|
+
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:M,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||D(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=ao(e,t.server),{meta:a}=await sr(r,n);a||D(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Nn(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await O(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await Nr(r,o,{volumes:t.volumes}),C(`removed ${n}${t.volumes?` and its volumes`:x.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),vo=j({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:M,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`},"env-file":{type:`string`,desc:`also set the KEY=VALUE lines of this file; - reads stdin (keeps values out of ps)`}},async run(e,t){let[n]=e.filter(e=>!e.includes(`=`)),r={...t[`env-file`]?await Ir(t[`env-file`]):{},...Lr(e.filter(e=>e.includes(`=`)))},{site:i,server:a}=ao(n,t.server),{env:o}=await cr(a,i,{deployed:`manage the .env of`});if(!Object.keys(r).length&&!t.unset?.length)return void process.stdout.write(o||x.dim(`(empty)
|
|
861
|
+
`));let s=Pr(o,r,t.unset??[]),c=[...Object.keys(r),...(t.unset??[]).map(e=>`-${e}`)];await Ar(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
|
|
862
|
+
record ${q(`env ${i} ${c.join(` `)}`)} "cd ${Z(i)} && $u && cd -P current && dc up -d"
|
|
863
|
+
${Bt(`.env`,s,`600`)}`),C(`updated env of ${i}`)}}),yo=j({name:`tunnel`,args:`[name] [service]`,summary:`Open a site on localhost through an SSH tunnel, straight to its container.
|
|
667
864
|
|
|
668
865
|
Nothing changes on the server: no port is published and its domains stay as they are,
|
|
669
866
|
so it also works for a site with no domain. Traefik is bypassed: no HTTPS and no login.
|
|
670
867
|
Works for any Compose project or container on the server too, not only landfall's sites.
|
|
671
|
-
The tunnel stays open until Ctrl-C.`,options:{server:M,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=
|
|
672
|
-
record ${
|
|
868
|
+
The tunnel stays open until Ctrl-C.`,options:{server:M,port:{type:`string`,desc:`container port (default: the one Traefik routes to)`},local:{type:`string`,short:`l`,desc:`local port (default: the container port, else a free one)`},lan:{type:`boolean`,desc:`listen on this machine's network too: open it from a phone on the same Wi-Fi`}},async run([e,t],n){let{site:r,server:i}=ao(e,n.server),{meta:a,proxy:o}=await sr(i,r);if(!a)return bo(i,r,t,o.network,n);xn(a)&&D(`${r} was deployed by an older landfall: run \`lf deploy\` in its project once to move it to a Compose project`);let s=Cn(a),c=t?s?.services.find(e=>e.name===t):Dn(a)??s?.services.find(wn);t&&s&&!c&&D(`${r} has no service ${t} (${s.services.map(e=>e.name).join(`, `)})`);let l=c?.name??t;l||D(`which service of ${r}? lf tunnel ${r} <service>`);let u=await Aa(i,[`label=com.docker.compose.project=${Sn(a)}`,`label=com.docker.compose.service=${l}`]);u||D(`${l} of ${r} is not running (lf start ${r})`),await xo(i,u,o.network,n.port??c?.port,{local:n.local,lan:n.lan,what:`${r}${t?` (${l})`:``}`})}});async function bo(e,t,n,r,i){let a=[`label=com.docker.compose.project=${t}`,...n?[`label=com.docker.compose.service=${n}`]:[]],o=await Aa(e,[...a,`label=traefik.enable=true`])??await Aa(e,a)??(n?void 0:await Aa(e,[`name=^/?${t}$`]));o||D(`no site, Compose project or container "${t}"${n?` with a service ${n}`:``} running on ${e.host}. See \`lf sites list\``);let s=o.labels[`com.docker.compose.service`];await xo(e,o,r,i.port,{local:i.local,lan:i.lan,what:`${t}${s&&s!==t?` (${s})`:``}`})}async function xo(e,t,n,r,i){let a=Object.entries(t.labels).find(([e])=>zn.test(e))?.[1],o=r??a??t.ports[0];o===void 0&&D(`which port of ${t.name}? pass --port`);let s=z(String(o),`--port`,1,65535);r===void 0&&a===void 0&&t.ports.length>1&&w(x.dim(`${t.name} also exposes ${t.ports.slice(1).join(`, `)}: pick one with --port`));let c=Pa(s);await za(e,ja(t,n),s,{...i,scheme:c,note:c===`http`?`straight to the container: no HTTPS, no login, and the app sees Host: ${i.lan?`localhost or this machine's address`:`localhost`}`:`straight to the container: point a ${c} client at it, with the container's own credentials`})}async function So(e){let t=e,n=await da(t);n.values.sudo!==`1`&&(t=await Q(e,`the firewall`),n=await da(t));let r=n.values,i=Number(r.ssh_port)||Y(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(ga),ports:Sa(n),blocked:xa(n,[i]).blocked,probe:n}}const Co=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,wo=/^[0-9a-fA-F.:]+(\/\d+)?$/;function To(e){Co.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&&!wo.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 Eo=e=>`ufw_add ${e.map(q).join(` `)}`,Do=e=>e[0]===`deny`?`${Eo([`prepend`,...e])} 2>/dev/null || ${Eo(e)}`:Eo(e),Oo=(e,t)=>`${t?`$SUDO ufw insert ${t} ${e.spec} >/dev/null 2>&1 || `:``}$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`,ko=(e,t)=>`$SUDO ufw --force delete ${e.spec} >/dev/null
|
|
869
|
+
record ${q(`ufw delete ${e.spec}`)} ${q(Oo(e,t))}`,Ao=(e,t)=>e.rules.slice(0,t).some(e=>e.from?.includes(`:`))?void 0:t,jo=e=>ga(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `));function Mo(e){let t=cn(e.probe.values.platform??``,e.probe.values.pkg??``,e.ufw!==`missing`);t&&D(t)}const No=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function Po(e,t,n){if(e.ufw!==`active`)return;let r=_a(t,{proto:`tcp`,port:e.sshPort})?.rule;if(r&&(r.action===`allow`||r.action===`limit`)&&!r.from)return;let i=r?.from?`only ${r.from} could still reach ssh port ${e.sshPort}`:`this blocks ssh port ${e.sshPort} and can lock you out`;n||await he(`${i}. Do it anyway?`)||D(`${i} (--force to do it anyway)`)}function Fo(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 Io={server:M,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function Lo(e){return j({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...Io,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=To({action:e,port:t,from:n.from,comment:n.comment}),i=await So(R(n.server).server);Mo(i),e===`deny`&&await Po(i,[jo(r),...i.rules],n.force),await J(i.login,`${K}\n${No}\n${Do(r)}`),C(`${r.join(` `)}${i.ufw===`active`?``:x.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const Ro=Lo(`allow`),zo=Lo(`deny`),Bo=j({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server);if(t.json)return void console.log(JSON.stringify(await Sc(r),null,2));let i=await So(r),a=i.ufw===`active`?x.green(`on`):i.ufw===`missing`?x.yellow(`not installed`):x.yellow(`off`);S(`firewall on ${n}: ${a}${i.ufw===`active`?x.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${x.bold(`Rules`)}`),i.rules.length||w(x.dim(`none`)),A(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?x.green(e.action):x.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${x.bold(`Listening ports`)}`);let o={open:x.green,limited:x.yellow,blocked:x.red,local:x.dim};A(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)]));let s=cn(i.probe.values.platform??``,i.probe.values.pkg??``,i.ufw!==`missing`);s?(console.log(),w(x.dim(s))):i.ufw!==`active`&&(console.log(),i.blocked.length?T(`enabling would block ${i.blocked.join(`, `)}: allow them first`):w(x.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),Vo=j({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:M,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=R(t.server),i=await So(r);if(Mo(i),i.ufw===`active`)return C(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await he(`${e}. Turn it on anyway?`)||D(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await Po({...i,ufw:`active`},[...i.rules,jo([`allow`,`${i.sshPort}/tcp`])],t.force),await J(i.login,`${K}
|
|
673
870
|
command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
|
|
674
|
-
${
|
|
675
|
-
ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),
|
|
676
|
-
${
|
|
677
|
-
${
|
|
678
|
-
${u} || { ${
|
|
679
|
-
|
|
680
|
-
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),Ba}const Ha=e=>({SSH_ASKPASS:Va(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function Ua(e){if(e){let e=(await y()).replace(/\r?\n$/,``);return e||D(`--password-stdin: nothing on stdin`),e}return Te(`PASSWORD`)||void 0}const Wa=e=>m(c(),`.ssh`,`landfall_${Me(e)}`);function Ga(e){let t=Wa(e);return{path:t,created:Ka(t,`landfall-${e}`)}}function Ka(e,t,n=!1){return!o.existsSync(e)&&(o.mkdirSync(m(c(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,e,`-C`,t,...n?[]:[`-N`,``]],{stdio:`inherit`}).status!==0&&D(`ssh-keygen failed`),!0)}async function qa(e,t,n){let i=Y(t),a=t.key?Oe(t.key):void 0;if(a&&!o.existsSync(a)&&D(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&D(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await J(t);if(!s&&!t.key&&o.existsSync(Wa(e))&&await J({...t,key:Wa(e)})&&(t.key=a=Wa(e),s=!0),s)C(`key login works`),a??=Vt(t);else if(n.check){T("no key login yet — `lf setup` would install a key first"),ue({needsKey:!0});return}let c=!1;if(!a&&!n.check){let t=Ga(e);a=t.path,c=t.created,c&&C(`generated key ${F(a)}`)}let l=t.key&&Oe(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await J(t))){S(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...Ha(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...vt(t),_t(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await J(t)||D(`could not log in with the key — check host, user and password`),C(`key installed`)}return a}const Ja=e=>{for(let t of e)console.log(x.dim(` `)+t)};function Ya(e,t,n,r){let i=Y(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=Jt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});C(`${r.created?`created`:`updated`} ${F(zt)} with ${x.bold(`Host ${e}`)} — try \`ssh ${e}\``),Ja(r.block.split(`
|
|
681
|
-
`)),a.host=e,delete a.user,delete a.port,delete a.key}else{t.user&&(a.user=t.user),r.port&&(a.port=Number(r.port));let o=r.keyGiven||n!==Vt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?Xt(e,n):void 0;s?(C(`added the key to your ${x.bold(`Host ${e}`)} in ${F(s.file)} ${x.dim(`(backup: ${F(s.backup)})`)}`),Ja([...s.removed.map(e=>x.red(`- ${e}`)),x.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=F(n)),r.ownAlias&&w(x.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Xa=String.raw`
|
|
682
|
-
set +e
|
|
683
|
-
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
684
|
-
S=""; R=1
|
|
685
|
-
if [ "$(id -u)" -ne 0 ]; then if sudo -n true 2>/dev/null; then S="sudo -n"; else R=0; fi; fi
|
|
686
|
-
kv root "$R"
|
|
687
|
-
kv user "$(id -un)"
|
|
688
|
-
[ -f /etc/synoinfo.conf ] && kv platform synology
|
|
689
|
-
kv hostname "$(hostname 2>/dev/null || cat /etc/hostname)"
|
|
690
|
-
tz=$(timedatectl show -p Timezone --value 2>/dev/null)
|
|
691
|
-
[ -n "$tz" ] || tz=$(cat /etc/timezone 2>/dev/null)
|
|
692
|
-
[ -n "$tz" ] || tz=$(readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')
|
|
693
|
-
kv timezone "$tz"
|
|
694
|
-
kv ntp "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
|
|
695
|
-
kv time "$(date '+%Y-%m-%d %H:%M %Z')"
|
|
696
|
-
if [ -d /run/systemd/system ]; then init=systemd; elif command -v rc-service >/dev/null 2>&1; then init=openrc; else init=other; fi
|
|
697
|
-
kv init "$init"
|
|
698
|
-
awk 'NR > 1 { print "SWAP\t" $1 "\t" $2 "\t" $3 * 1024 "\t" $4 * 1024 }' /proc/swaps 2>/dev/null
|
|
699
|
-
kv swappiness "$(cat /proc/sys/vm/swappiness 2>/dev/null)"
|
|
700
|
-
kv disk_free "$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 * 1024 }')"
|
|
701
|
-
kv mem "$(awk '/^MemTotal:/ { print $2 * 1024 }' /proc/meminfo)"
|
|
702
|
-
if command -v docker >/dev/null 2>&1; then
|
|
703
|
-
kv log_driver "$(docker info -f '{{.LoggingDriver}}' 2>/dev/null || $S docker info -f '{{.LoggingDriver}}' 2>/dev/null)"
|
|
704
|
-
printf 'DAEMON\t'; { cat /etc/docker/daemon.json 2>/dev/null || $S cat /etc/docker/daemon.json 2>/dev/null; } | tr -d '\n'; echo
|
|
705
|
-
fi
|
|
706
|
-
case "$init" in
|
|
707
|
-
systemd)
|
|
708
|
-
systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null \
|
|
709
|
-
| awk '{ d = ""; for (i = 5; i <= NF; i++) d = d (i > 5 ? " " : "") $i; print "SVC\t" $1 "\t" $2 "\t" $3 "\t" $4 "\t" d }'
|
|
710
|
-
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{ print "UF\t" $1 "\t" $2 }' ;;
|
|
711
|
-
openrc)
|
|
712
|
-
rc-status -a 2>/dev/null | awk '/\[/ { s = $0; sub(/.*\[[ \t]*/, "", s); sub(/[ \t]*\].*/, "", s); print "SVC\t" $1 "\t-\t" s "\t" s "\t" }' | sort -u ;;
|
|
713
|
-
esac
|
|
714
|
-
if command -v fail2ban-client >/dev/null 2>&1; then
|
|
715
|
-
if st=$($S fail2ban-client status 2>/dev/null); then
|
|
716
|
-
kv fail2ban running
|
|
717
|
-
for j in $(printf '%s\n' "$st" | sed -n 's/.*Jail list:[[:space:]]*//p' | tr ',' ' '); do
|
|
718
|
-
$S fail2ban-client status "$j" 2>/dev/null | awk -v j="$j" '
|
|
719
|
-
/Currently banned:/ { c = $NF } /Total banned:/ { t = $NF } /Currently failed:/ { f = $NF }
|
|
720
|
-
/Banned IP list:/ { sub(/.*Banned IP list:[ \t]*/, ""); ips = $0 }
|
|
721
|
-
END { print "JAIL\t" j "\t" c + 0 "\t" t + 0 "\t" f + 0 "\t" ips }'
|
|
722
|
-
done
|
|
723
|
-
else kv fail2ban stopped; fi
|
|
724
|
-
else kv fail2ban missing; fi
|
|
725
|
-
# Security: what sshd really uses (sshd -T needs root), who has a password, firewall, updates.
|
|
726
|
-
$S sshd -T 2>/dev/null | awk '$1 ~ /^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)$/ && !seen[$1]++ { print "sshd_" $1 "=" $2 }'
|
|
727
|
-
[ "$R" = 1 ] && kv password_users "$($S awk -F: '$2 ~ /^\$/ { printf "%s ", $1 }' /etc/shadow 2>/dev/null)"
|
|
728
|
-
${at}
|
|
729
|
-
kv pkg "$(pkg_mgr)"
|
|
730
|
-
if command -v ufw >/dev/null 2>&1; then kv ufw "$($S ufw status 2>/dev/null | awk 'NR == 1 { print $2 }')"; else kv ufw missing; fi
|
|
731
|
-
kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
|
|
732
|
-
if command -v apt-get >/dev/null 2>&1; then
|
|
733
|
-
if dpkg -s unattended-upgrades >/dev/null 2>&1 && apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; then kv auto_updates on; else kv auto_updates off; fi
|
|
734
|
-
elif command -v dnf >/dev/null 2>&1 && [ "$init" = systemd ]; then
|
|
735
|
-
# dnf-automatic.timer only downloads unless automatic.conf says apply_updates.
|
|
736
|
-
if systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null \
|
|
737
|
-
|| { { systemctl is-enabled -q dnf-automatic.timer 2>/dev/null || systemctl is-enabled -q dnf5-automatic.timer 2>/dev/null; } \
|
|
738
|
-
&& grep -qiE '^[[:space:]]*apply_updates[[:space:]]*=[[:space:]]*(yes|true|1)' /etc/dnf/automatic.conf 2>/dev/null; }; then kv auto_updates on; else kv auto_updates off; fi
|
|
739
|
-
fi
|
|
740
|
-
# Keys of every user with a home: /root and /home/* (deploy users live there).
|
|
741
|
-
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
|
742
|
-
u=$(printf %s "$f" | sed 's#^/root/.*#root#; s#^/home/\([^/]*\)/.*#\1#')
|
|
743
|
-
{ cat "$f" 2>/dev/null || $S cat "$f" 2>/dev/null; } | while IFS= read -r l; do
|
|
744
|
-
case "$l" in ''|'#'*) ;; *) printf 'KEY\t%s\t%s\n' "$u" "$l" ;; esac
|
|
745
|
-
done
|
|
746
|
-
done
|
|
747
|
-
exit 0
|
|
748
|
-
`,Za=[`docker`,`containerd`,`ssh`,`sshd`,`fail2ban`,`ufw`,`unattended-upgrades`,`cron`,`crond`,`systemd-timesyncd`,`chronyd`,`chrony`],Qa=e=>e.replace(/\.service$/,``),$a=/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-\S+@openssh\.com)$/;function eo(e){let t=[],n=``,r=!1;for(let i of e.trim())i===`"`&&(r=!r),/\s/.test(i)&&!r?(n&&t.push(n),n=``):n+=i;n&&t.push(n);let i=t.findIndex(e=>$a.test(e));if(i<0||!t[i+1])return;let a=Buffer.from(t[i+1],`base64`);if(a.length)return{type:t[i],fingerprint:`SHA256:${ae(`sha256`).update(a).digest(`base64`).replace(/=+$/,``)}`,comment:t.slice(i+2).join(` `),options:t.slice(0,i).join(` `),line:e.trim()}}function to(e){let t=/* @__PURE__ */ new Set;for(let n of Y(e).identityFiles)try{let e=eo(o.readFileSync(`${n}.pub`,`utf8`));e&&t.add(e.fingerprint)}catch{}return t}function no(e,t=/* @__PURE__ */ new Set){let n={},r={root:!1,user:``,hostname:``,timezone:``,time:``,init:`other`,swap:[],diskFree:0,mem:0,services:[],fail2ban:`missing`,jails:[],keys:[],security:{reboot:!1}},i=/* @__PURE__ */ new Map,a;for(let o of e.split(`
|
|
749
|
-
`)){let e=o.split(` `);if(e[0]===`SWAP`)r.swap.push({file:e[1],type:e[2],size:+e[3]||0,used:+e[4]||0});else if(e[0]===`DAEMON`)a=e.slice(1).join(` `);else if(e[0]===`SVC`&&e[1])r.services.push({unit:e[1],load:e[2],active:e[3],sub:e[4],description:e.slice(5).join(` `)});else if(e[0]===`UF`&&e[1])i.set(e[1],e[2]);else if(e[0]===`JAIL`)r.jails.push({name:e[1],banned:+e[2]||0,total:+e[3]||0,failed:+e[4]||0,ips:(e[5]??``).split(/\s+/).filter(Boolean)});else if(e[0]===`KEY`){let n=eo(e.slice(2).join(` `));n&&r.keys.push({user:e[1],...n,...t.has(n.fingerprint)?{mine:!0}:{}})}else{let e=/^(\w+)=(.*)$/.exec(o);e&&(n[e[1]]=e[2])}}for(let e of r.services)i.has(e.unit)&&(e.enabled=i.get(e.unit));Object.assign(r,{root:n.root===`1`,user:n.user??``,hostname:n.hostname??``,timezone:n.timezone||`UTC`,ntp:n.ntp===`yes`||n.ntp!==`no`&&void 0,time:n.time??``,init:[`systemd`,`openrc`].includes(n.init)?n.init:`other`,swappiness:n.swappiness?+n.swappiness:void 0,diskFree:+n.disk_free||0,mem:+n.mem||0,fail2ban:[`running`,`stopped`].includes(n.fail2ban)?n.fail2ban:`missing`});let o=[n.sshd_passwordauthentication,n.sshd_kbdinteractiveauthentication].filter(Boolean);if(r.security={passwordLogin:o.length?o.includes(`yes`):void 0,rootLogin:n.sshd_permitrootlogin||void 0,passwordUsers:n.password_users?.split(/\s+/).filter(Boolean),pkg:n.pkg||void 0,firewall:[`active`,`inactive`,`missing`].includes(n.ufw)?n.ufw:void 0,autoUpdates:n.auto_updates?n.auto_updates===`on`:void 0,reboot:n.reboot===`1`},n.platform===`synology`&&(r.managed=`DSM keeps this itself: change it in Control Panel (Regional Options, Info Center, Container Manager)`),n.log_driver!==void 0){let e={},t;try{e=a?.trim()?JSON.parse(a):{}}catch(e){t=`/etc/docker/daemon.json is not valid JSON: ${e.message}`}let i=e[`log-opts`]??{};r.docker={driver:n.log_driver||String(e[`log-driver`]??`json-file`),...i[`max-size`]?{maxSize:i[`max-size`]}:{},...i[`max-file`]?{maxFile:Number(i[`max-file`])}:{},daemon:e,...t?{daemonError:t}:{}}}return r}async function ro(e){let{stdout:t}=await q(e,Xa,{capture:!0});return no(t,to(e))}async function io(e){let t=await ro(e);if(t.root)return t;let n=Y(e).user;for(let t of[`root`,e.admin])if(t&&t!==n&&await J({...e,user:t}))return ro({...e,user:t});return t}const ao=String.raw`
|
|
750
|
-
svc() { # svc <verb> <name>
|
|
751
|
-
if [ -d /run/systemd/system ]; then $SUDO systemctl "$1" "$2"
|
|
752
|
-
elif command -v rc-service >/dev/null 2>&1; then $SUDO rc-service "$2" "$1"
|
|
753
|
-
else log "no systemd or OpenRC: cannot $1 $2"; return 1; fi
|
|
754
|
-
}
|
|
755
|
-
`,oo=/^[A-Za-z0-9@_.:-]+$/;async function so(e,t,n){oo.test(n)||D(`not a service name: ${n}`);let r=t===`start`?`stop`:t===`stop`?`start`:``,i=`${t} service ${n}`;await q(e,[G,ao,`svc ${t} ${K(n)}`,`record ${K(i)}${r?` ${K(`${ao}\nsvc ${r} ${K(n)}`)}`:``}`].join(`
|
|
756
|
-
`))}function co(e,t){oo.test(e)||D(`not a service name: ${e}`);let n=`$([ "$(id -u)" = 0 ] || echo sudo -n)`,r=String(t.lines);return t.follow?`${n} journalctl -u ${K(e)} -n ${r} -f --no-pager 2>/dev/null || ${n} tail -n ${r} -f /var/log/${K(Qa(e))}.log /var/log/messages`:`${n} journalctl -u ${K(e)} -n ${r} --no-pager 2>/dev/null || ${n} tail -n ${r} /var/log/${K(Qa(e))}.log /var/log/messages 2>/dev/null`}const lo=`home=$(getent passwd "$KEY_USER" 2>/dev/null | cut -d: -f6); [ -n "$home" ] || home=$(awk -F: -v u="$KEY_USER" '$1 == u { print $6 }' /etc/passwd)
|
|
757
|
-
[ -n "$home" ] || { log "no user $KEY_USER"; exit 1; }
|
|
758
|
-
f="$home/.ssh/authorized_keys"`;async function uo(e,t,n){eo(n)||D(`not a public key: expected a line like ssh-ed25519 AAAA… comment`);let r=String.raw`
|
|
759
|
-
${lo}
|
|
760
|
-
if $SUDO grep -qxF -- "$KEY" "$f" 2>/dev/null; then log "already there"; exit 0; fi
|
|
761
|
-
$SUDO mkdir -p "$home/.ssh"; $SUDO chmod 700 "$home/.ssh"
|
|
762
|
-
printf '%s\n' "$KEY" | $SUDO tee -a "$f" >/dev/null
|
|
763
|
-
g=$(id -gn "$KEY_USER" 2>/dev/null || echo "$KEY_USER")
|
|
764
|
-
$SUDO chown "$KEY_USER:$g" "$home/.ssh" "$f"; $SUDO chmod 600 "$f"
|
|
765
|
-
record "authorized key $WHAT for $KEY_USER" "drop_line $(printf %q "$f") $(printf %q "$KEY")"
|
|
766
|
-
`,i=eo(n);await q(e,G+r,{env:{KEY_USER:t,KEY:i.line,WHAT:i.comment||i.fingerprint}})}async function fo(e,t){let n=String.raw`
|
|
767
|
-
${lo}
|
|
768
|
-
$SUDO grep -qxF -- "$KEY" "$f" || { log "key not found in $f"; exit 1; }
|
|
769
|
-
drop_line "$f" "$KEY"
|
|
770
|
-
record "removed key $WHAT of $KEY_USER" "printf '%s\n' $(printf %q "$KEY") | \$SUDO tee -a $(printf %q "$f") >/dev/null"
|
|
771
|
-
`;await q(e,G+n,{env:{KEY_USER:t.user,KEY:t.line,WHAT:t.comment||t.fingerprint}})}const po=/^[0-9a-fA-F.:]+$/;async function mo(e,t,n){po.test(t)||D(`not an IP address: ${t}`);let r=n.map(e=>`\\$SUDO fail2ban-client set ${K(e)} banip ${K(t)} >/dev/null`).join(`; `);await q(e,[G,...n.map(e=>`$SUDO fail2ban-client set ${K(e)} unbanip ${K(t)} >/dev/null`),`record ${K(`unbanned ${t}`)} "${r}"`].join(`
|
|
772
|
-
`))}function ho(e){let t=/^(\d+(?:\.\d+)?)\s*([mg])i?b?$/i.exec(e.trim());return t||D(`not a size: ${e} (e.g. 512M, 2G)`),Math.round(Number(t[1])*(t[2].toLowerCase()===`g`?1024:1))*1024*1024}const go=`/swapfile none swap sw 0 0`,_o=e=>String.raw`
|
|
773
|
-
if [ -e /swapfile ]; then log "/swapfile exists already: lf swap off first"; exit 1; fi
|
|
774
|
-
mk() { # fallocate is instant but makes files swapon refuses on some filesystems (btrfs, older xfs): dd then
|
|
775
|
-
{ $SUDO fallocate -l ${e}M /swapfile 2>/dev/null || $SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none; } &&
|
|
776
|
-
$SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile
|
|
777
|
-
}
|
|
778
|
-
if ! mk; then
|
|
779
|
-
$SUDO rm -f /swapfile
|
|
780
|
-
$SUDO dd if=/dev/zero of=/swapfile bs=1M count=${e} status=none && $SUDO chmod 600 /swapfile && $SUDO mkswap /swapfile >/dev/null && $SUDO swapon /swapfile \
|
|
781
|
-
|| { $SUDO rm -f /swapfile; log "could not turn on swap (a container, or a filesystem without swap files?)"; exit 1; }
|
|
782
|
-
fi
|
|
783
|
-
record "created ${e} MB swap at /swapfile" '$SUDO swapoff /swapfile 2>/dev/null; $SUDO rm -f /swapfile'
|
|
784
|
-
if ! grep -qxF '${go}' /etc/fstab 2>/dev/null; then
|
|
785
|
-
printf '%s\n' '${go}' | $SUDO tee -a /etc/fstab >/dev/null
|
|
786
|
-
record "added /swapfile to /etc/fstab" "drop_line /etc/fstab '${go}'"
|
|
787
|
-
fi
|
|
788
|
-
`,vo=(e,t)=>q(e,G+_o(Math.round(t/1024/1024))),yo=String.raw`
|
|
789
|
-
[ -e /swapfile ] || { log "no /swapfile: landfall only removes the swap file it made"; exit 1; }
|
|
790
|
-
$SUDO swapoff /swapfile
|
|
791
|
-
$SUDO rm -f /swapfile
|
|
792
|
-
drop_line /etc/fstab '${go}'
|
|
793
|
-
record "removed the swap file /swapfile"
|
|
794
|
-
`,bo=e=>q(e,G+yo),xo=/^(?=.{1,63}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,So=e=>`$SUDO hostnamectl set-hostname ${K(e)} 2>/dev/null || { printf '%s\\n' ${K(e)} | $SUDO tee /etc/hostname >/dev/null && $SUDO hostname ${K(e)}; }`;async function Co(e,t,n){xo.test(n)||D(`not a host name: ${n} (letters, digits and -, up to 63)`),await q(e,[G,So(n),`record ${K(`hostname ${t} → ${n}`)} ${K(So(t))}`,`if grep -qE '^127\\.0\\.1\\.1[[:space:]]' /etc/hosts; then`,` undo=$(saved /etc/hosts) || exit 1`,` $SUDO sed -i 's/^127\\.0\\.1\\.1[[:space:]].*/127.0.1.1\\t'${K(n)}'/' /etc/hosts`,` record "named ${n} in /etc/hosts" "$undo"`,`fi`].join(`
|
|
795
|
-
`))}const wo=/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)*$/,To=e=>`$SUDO timedatectl set-timezone ${K(e)} 2>/dev/null || { $SUDO ln -sf /usr/share/zoneinfo/${K(e)} /etc/localtime && printf '%s\\n' ${K(e)} | $SUDO tee /etc/timezone >/dev/null; }`;async function Eo(e,t,n){wo.test(n)||D(`not a time zone: ${n} (e.g. Europe/Berlin, UTC)`),await q(e,[G,`[ -e /usr/share/zoneinfo/${K(n)} ] || { log ${K(`unknown time zone ${n} (or tzdata is not installed)`)}; exit 1; }`,To(n),`record ${K(`time zone ${t} → ${n}`)} ${K(To(t))}`].join(`
|
|
796
|
-
`))}const Do=/^\d+[kmg]$/;function Oo(e,t,n){let r={...e[`log-opts`]??{}};t?r[`max-size`]=t:delete r[`max-size`],t&&n?r[`max-file`]=String(n):delete r[`max-file`];let i={...e,"log-opts":r};return Object.keys(r).length||delete i[`log-opts`],i}const ko=`$SUDO systemctl restart docker 2>/dev/null || $SUDO rc-service docker restart 2>/dev/null || $SUDO service docker restart`;async function Ao(e,t,n,r){let i=JSON.stringify(t,null,2)+`
|
|
797
|
-
`,a=String.raw`
|
|
798
|
-
$SUDO install -d /etc/docker
|
|
799
|
-
undo=$(saved /etc/docker/daemon.json) || exit 1
|
|
800
|
-
printf %s ${K(Buffer.from(i).toString(`base64`))} | base64 -d | $SUDO tee /etc/docker/daemon.json >/dev/null
|
|
801
|
-
${r?`log "restarting docker"\n${ko}\nrecord ${K(n)} "$undo; ${ko.replace(/\$/g,`\\$`)}"`:`record ${K(n)} "$undo"`}
|
|
802
|
-
`;await q(e,G+a)}const jo=[`ssh`,`fail2ban`,`updates`],Mo=e=>e.length>1?`${e.slice(0,-1).join(`, `)} and ${e.at(-1)}`:e[0],No=e=>`${Mo(e)} ${e.length===1?`has`:`have`} no key yet and would be locked out: add one first (lf keys add <file.pub> -u ${e[0]}, or --generate to make a pair), then lf harden ssh.`;function Po(e,t){let n=new Set(e.keys.map(e=>e.user));return(e.security.passwordUsers??[]).filter(e=>e!==`root`&&e!==t&&!n.has(e))}function Fo(e,t){let n=e.security,r=[`-s`,t.name],i=[],a=n.pkg===`apt-get`||n.pkg===`apk`;if(n.passwordLogin){let a=Po(e,t.loginUser);i.push({id:`password-login`,level:`risk`,title:n.rootLogin===`yes`?`SSH accepts passwords, root's too`:`SSH accepts passwords`,why:`Bots try passwords on every public server all day: one weak or reused password lets them in. A key cannot be guessed.${a.length?` ${No(a)}`:``}`,fix:a.length?{label:`Make a key for ${a[0]}`,argv:[`keys`,`add`,`--generate`,`-u`,a[0],...r],user:a[0]}:{label:`Keys only`,argv:[`harden`,`ssh`,...r],confirm:`Only keys log in afterwards; lf undo turns passwords back on.`}})}n.firewall===`inactive`||n.firewall===`missing`&&a?i.push({id:`firewall`,level:`warn`,title:n.firewall===`missing`?`No firewall`:`Firewall is off`,why:`Every port a program opens is reachable from the internet. ufw lets in ssh, 80 and 443, and what you allow.`,fix:{label:`Turn it on`,argv:[`firewall`,`enable`,...r]}}):n.firewall===`missing`&&i.push({id:`firewall`,level:`info`,title:`No ufw`,why:`Use firewalld or your provider's firewall to let in only ssh, 80 and 443.`}),e.root&&e.fail2ban!==`running`&&(e.fail2ban===`stopped`||a)&&i.push({id:`fail2ban`,level:n.passwordLogin?`warn`:`info`,title:e.fail2ban===`missing`?`No fail2ban`:`fail2ban is not running`,why:`It bans addresses that keep failing to log in: it slows password guessing and keeps the logs readable.`,fix:{label:e.fail2ban===`missing`?`Install it`:`Start it`,argv:[`harden`,`fail2ban`,...r]}}),n.autoUpdates===!1&&i.push({id:`auto-updates`,level:`warn`,title:`No automatic security updates`,why:`Holes in OpenSSH, the kernel or libraries get fixed within days; without automatic updates they stay open until someone runs lf update.`,fix:{label:`Turn them on`,argv:[`harden`,`updates`,...r]}}),n.reboot&&i.push({id:`reboot`,level:`info`,title:`Reboot pending`,why:`Installed updates (a new kernel or libc) only take effect after a reboot.`,fix:{label:`Reboot`,argv:[`reboot`,...r]}});let o={risk:0,warn:1,info:2};return i.sort((e,t)=>o[e.level]-o[t.level])}function Io(e){for(let t of e){let e=t.level===`risk`?x.red(`!`):t.level===`warn`?x.yellow(`!`):x.dim(`·`);console.log(`${e} ${x.bold(t.title)}\n ${x.dim(t.why)}${t.fix?`\n ${x.cyan(`lf ${t.fix.argv.join(` `)}`)}`:``}`)}}async function Lo(e,t,n){let r=n?await ro(n):await io(t);return{...r,advice:Fo(r,{name:e,loginUser:Y(t).user})}}async function Ro(e,t,n){try{let r=(await Lo(e,t,n)).advice;if(ue({advice:r}),!r.length)return;console.log(`\n${x.bold(`Security`)}`),Io(r)}catch{}}const zo=String.raw`
|
|
803
|
-
if [ "$H_SSH" = 1 ]; then
|
|
804
|
-
if [ -d /etc/ssh ]; then
|
|
805
|
-
${ot}
|
|
806
|
-
else log "no /etc/ssh, ssh skipped"; fi
|
|
807
|
-
fi
|
|
808
|
-
if [ "$H_F2B" = 1 ]; then
|
|
809
|
-
if ! command -v fail2ban-client >/dev/null 2>&1; then
|
|
810
|
-
log "installing fail2ban"
|
|
811
|
-
pkg_install fail2ban || { echo "could not install fail2ban (RHEL-likes have it in EPEL)" >&2; exit 1; }
|
|
812
|
-
record "installed fail2ban"
|
|
813
|
-
fi
|
|
814
|
-
# Debian 12 logs to the journal only: the sshd jail's /var/log/auth.log never appears and fail2ban will not start.
|
|
815
|
-
jail=/etc/fail2ban/jail.d/00-landfall.local
|
|
816
|
-
if [ ! -e /var/log/auth.log ] && [ ! -e /var/log/secure ] && [ -d /run/systemd/system ] && [ ! -e $jail ]; then
|
|
817
|
-
printf '[sshd]\nbackend = systemd\n' | $SUDO tee $jail >/dev/null
|
|
818
|
-
record "fail2ban reads sshd's log from the journal" "\$SUDO rm -f $jail"
|
|
819
|
-
fi
|
|
820
|
-
if ! $SUDO fail2ban-client ping >/dev/null 2>&1; then
|
|
821
|
-
log "starting fail2ban"
|
|
822
|
-
if [ -d /run/systemd/system ]; then
|
|
823
|
-
$SUDO systemctl enable fail2ban >/dev/null 2>&1 || true; $SUDO systemctl restart fail2ban || true
|
|
824
|
-
undo='$SUDO systemctl disable --now fail2ban'
|
|
825
|
-
else
|
|
826
|
-
$SUDO rc-update add fail2ban default >/dev/null 2>&1 || true; $SUDO rc-service fail2ban start >/dev/null || true
|
|
827
|
-
undo='$SUDO rc-service fail2ban stop; $SUDO rc-update del fail2ban default'
|
|
828
|
-
fi
|
|
829
|
-
up=0; for _ in 1 2 3 4 5 6; do $SUDO fail2ban-client ping >/dev/null 2>&1 && { up=1; break; }; sleep 1; done
|
|
830
|
-
if [ $up = 1 ]; then record "started fail2ban" "$undo"
|
|
831
|
-
else echo "fail2ban did not start; see: lf services logs fail2ban" >&2; exit 1; fi
|
|
832
|
-
fi
|
|
833
|
-
fi
|
|
834
|
-
if [ "$H_UPDATES" = 1 ]; then
|
|
835
|
-
case "$(pkg_mgr)" in
|
|
836
|
-
apt-get)
|
|
837
|
-
pkg_has unattended-upgrades || { log "installing unattended-upgrades"; pkg_install unattended-upgrades; record "installed unattended-upgrades"; }
|
|
838
|
-
if ! apt-config dump | grep '^APT::Periodic::Unattended-Upgrade "1"' >/dev/null; then
|
|
839
|
-
log "turning on automatic security updates"
|
|
840
|
-
f=/etc/apt/apt.conf.d/20auto-upgrades
|
|
841
|
-
undo=$(saved $f)
|
|
842
|
-
printf '%s\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Unattended-Upgrade "1";' | $SUDO tee $f >/dev/null
|
|
843
|
-
record "turned on automatic security updates" "$undo"
|
|
844
|
-
fi ;;
|
|
845
|
-
dnf)
|
|
846
|
-
pkg_has dnf-automatic || { log "installing dnf-automatic"; pkg_install dnf-automatic && record "installed dnf-automatic"; }
|
|
847
|
-
if systemctl cat dnf-automatic-install.timer >/dev/null 2>&1; then
|
|
848
|
-
if ! systemctl is-enabled -q dnf-automatic-install.timer 2>/dev/null; then
|
|
849
|
-
$SUDO systemctl enable --now dnf-automatic-install.timer >/dev/null
|
|
850
|
-
record "turned on automatic updates" '$SUDO systemctl disable --now dnf-automatic-install.timer'
|
|
851
|
-
fi
|
|
852
|
-
else
|
|
853
|
-
echo "set apply_updates = yes in /etc/dnf/automatic.conf, then: systemctl enable --now dnf5-automatic.timer" >&2; exit 1
|
|
854
|
-
fi ;;
|
|
855
|
-
*) echo "automatic updates: landfall knows apt and dnf; on $(pkg_mgr) set them up by hand" >&2; exit 1 ;;
|
|
856
|
-
esac
|
|
857
|
-
fi
|
|
858
|
-
`;async function Bo(e,t){await q(e,G+zo,{env:{H_SSH:t.includes(`ssh`),H_F2B:t.includes(`fail2ban`),H_UPDATES:t.includes(`updates`)}})}function Vo(e,t){/^[a-z_][a-z0-9_.-]*$/i.test(t)||D(`not a user name: ${t}`);let n=m(c(),`.ssh`,`${Me(e)}_${t}`),r=pe(),i=Ka(n,`${t}@${e}`,r);return o.existsSync(`${n}.pub`)||D(`${n} exists without ${n}.pub; move it away or pass the public key`),{path:n,created:i,passphrase:i&&r,pub:o.readFileSync(`${n}.pub`,`utf8`).trim()}}const Ho=e=>e.steps.filter(e=>e.undo&&!e.undone);function Uo(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
|
|
859
|
-
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}const Wo=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Go=RegExp(`${Wo(V)}/[A-Za-z0-9._-]+|${Wo(H)}`,`g`);function Ko(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(Go))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function qo(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Ko(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=Ho(n).flatMap(e=>[...Ko(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Jo(e){let{stdout:t}=await q(e,`cat ${W} 2>/dev/null || sudo -n cat ${W} 2>/dev/null || true`,{capture:!0});return Uo(t)}function Yo(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||D(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||D(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Xo(e,t){let n=Y(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await q(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await J(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Zo=String.raw`
|
|
871
|
+
${Eo([`allow`,`${i.sshPort}/tcp`])}
|
|
872
|
+
ufw_enable`),C(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),Ho=j({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:M},async run(e,t){let{name:n,server:r}=R(t.server),i=await So(r);if(i.ufw!==`active`)return C(`firewall on ${n} is already off`);await k(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||D(`cancelled`),await J(i.login,`${K}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),C(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),Uo=j({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:M,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||D(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=R(t.server),i=await So(r),{rule:a,n:o}=Fo(i,e);await Po(i,i.rules.filter(e=>e!==a),t.force),await O(`Remove rule ${o} (${a.spec}) on ${n}?`),await J(i.login,`${K}\n${No}\n${ko(a,Ao(i,o))}`),C(`removed rule ${o}: ${a.spec}`)}}),Wo=j({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...Io,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 So(R(t.server).server),{rule:r,n:i}=Fo(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=To({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=Ao(n,i),l=n.rules.filter(e=>e!==r);await Po(n,c?n.rules.map(e=>e===r?jo(s):e):a===`deny`?[jo(s),...l]:[...l,jo(s)],t.force);let u=c&&c<n.rules.length?`${Eo([`insert`,String(c),...s])} || ${Do(s)}`:Do(s);await J(n.login,`${K}
|
|
873
|
+
${No}
|
|
874
|
+
${ko(r,c)}
|
|
875
|
+
${u} || { ${Oo(r,c)}; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),C(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}}),Go=e=>e.steps.filter(e=>e.undo&&!e.undone);function Ko(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
|
|
876
|
+
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}const qo=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),Jo=RegExp(`${qo(H)}/[A-Za-z0-9._-]+|${qo(U)}`,`g`);function Yo(e){let t=/* @__PURE__ */ new Set;for(let n of e.matchAll(Jo))t.add(n[0]);for(let n of e.matchAll(/restore_file \S+ '?(\/[^\s';]+)/g))t.add(n[1]);return t}function Xo(e,t,n){let r=new Set(n.filter(e=>e.undo&&!e.undone).flatMap(e=>[...Yo(e.undo)])),i=[];for(let n of e.filter(e=>e.n>t)){let e=Go(n).flatMap(e=>[...Yo(e.undo)]).find(e=>r.has(e));e&&i.push({run:n,subject:e})}return i}async function Zo(e){let{stdout:t}=await J(e,`cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null || true`,{capture:!0});return Ko(t)}function Qo(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 $o(e,t){let n=Y(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await J(e,`sudo -n true 2>/dev/null && echo yes || true`,{capture:!0});if(r.trim()===`yes`)return{login:e,sudo:!0};let i={...e,user:`root`};return await At(i)?{login:i}:D(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const es=String.raw`
|
|
860
877
|
undo_step() { # undo_step <line> <undo shell, base64>
|
|
861
878
|
# Two undos at once must not both revert a step: the second waits here, then finds it done.
|
|
862
|
-
[ -e ${
|
|
863
|
-
if [ -r ${
|
|
864
|
-
if awk -F'\t' -v l="$1" '$6 == "undo" && $7 == l {f = 1} END {exit !f}' ${
|
|
879
|
+
[ -e ${V}/.undo.lock ] || touch ${V}/.undo.lock 2>/dev/null || true
|
|
880
|
+
if [ -r ${V}/.undo.lock ] && command -v flock >/dev/null 2>&1; then exec 9<${V}/.undo.lock; flock 9; fi
|
|
881
|
+
if awk -F'\t' -v l="$1" '$6 == "undo" && $7 == l {f = 1} END {exit !f}' ${G} 2>/dev/null; then
|
|
865
882
|
echo "journal line $1 was already undone" >&2
|
|
866
883
|
else
|
|
867
884
|
set +e
|
|
@@ -872,9 +889,9 @@ undo_step() { # undo_step <line> <undo shell, base64>
|
|
|
872
889
|
_journal undo "$1" ""
|
|
873
890
|
fi
|
|
874
891
|
exec 9<&-
|
|
875
|
-
}`;async function
|
|
876
|
-
`),{sudo:c}),r.push(s),t=i}return n.length}function
|
|
877
|
-
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=
|
|
892
|
+
}`;async function ts(e,t){let n=t.filter(e=>e.undo&&!e.undone).sort((e,t)=>t.line-e.line),r=[e];for(let t=0;t<n.length;){let i=t;for(;i<n.length&&n[i].user===n[t].user;)i++;let a=n.slice(t,i),o=a.map(e=>`undo_step ${e.line} ${q(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await $o(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map(wt)),await J(s,[K,es,...o].join(`
|
|
893
|
+
`),{sudo:c}),r.push(s),t=i}return n.length}function ns(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 rs=e=>e.trim().replace(/\\(.)/g,`$1`),is=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function as(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await So(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(`
|
|
894
|
+
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend |insert (\d+) )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=ga(rs(e[1])),n=i.findIndex(e=>is(e,t));n>=0&&i.splice(n,1)}else if(n){let e=ga(rs(n[3]));n[2]?i.splice(Number(n[2])-1,0,e):i=n[1]?[e,...i]:[...i,e]}else/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await Po({...r,ufw:a},i,n)}const os=e=>`${x.bold(`#${e.n}`.padEnd(5))} ${x.dim(ge(e.time).padEnd(9))} ${x.bold(e.cmd||`?`)} ${x.dim(`${e.actor} · ${e.id}`)}`,ss=j({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:M,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server);if(t.json)return console.log(JSON.stringify(await kc(r,Number(t.n)||20),null,2));let i=(await Zo(r)).reverse().slice(0,Number(t.n)||20);if(!i.length)return w(`nothing in ${G} on ${n} yet`);for(let e of i)console.log(os(e)),e.steps.forEach((e,t)=>console.log(ns(e,t)));let a=i.find(e=>Go(e).length);a&&console.log(x.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),cs=j({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:M,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule, or a later run changed the same site or file`}},async run([e],t){let{name:n,server:r}=R(t.server),i=await Zo(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let r=e?Qo(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Go(e.run).length);if(!r)return C(`nothing to undo on ${n}`);({steps:a}=r),o=`#${r.run.n} ${r.run.cmd} (${ge(r.run.time)})`;let s=Xo(i,r.run.n,a);s.length&&!t.force&&D(`later runs changed the same things: ${s.map(e=>`#${e.run.n} ${e.run.cmd} (${e.subject})`).join(`, `)}. Undo those first, newest first, or pass --force`);let c=i.filter(e=>e.n>r.run.n&&Go(e).length);c.length&&T(`later runs build on it and stay: ${c.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return C(`nothing left to undo in ${o}`);S(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${x.dim(`· ${e.what} stays (not reversible)`)}`);await as(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await O(l,n):await k(l,!1)||D(`cancelled`),await ts(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`))}}),ls=/(^|\.)(github\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sr\.ht|dev\.azure\.com|visualstudio\.com)$|^(git|gitlab|gitea|forgejo)\./i;function us(e){if(ls.test(e))return!0;try{let t=Y({host:e});return t.user===`git`||ls.test(t.hostname)}catch{return!1}}const ds=/^(localhost|127(\.\d+){3}|::1|0:0:0:0:0:0:0:1)$/i;function fs(e){if(ds.test(e))return!0;try{return ds.test(Y({host:e}).hostname)}catch{return!1}}function ps(){let e=L(),t=Object.keys(e.servers).map(t=>({name:t,registered:!0,isDefault:e.default===t})),n=Wt().filter(t=>!e.servers[t]&&!us(t)&&!fs(t)).map(e=>({name:e,registered:!1,isDefault:!1}));return[...t,...n]}const ms=/* @__PURE__ */ new Map;async function hs(e,t=!1){let n={},r=[...e];return await Promise.all(Array.from({length:Math.min(8,e.length)},async()=>{for(let e=r.shift();e!==void 0;e=r.shift()){let r=ms.get(e);if(t||!r||Date.now()-r.at>3e4){let t=(async()=>{try{return await jt(R(e).server)}catch(e){return{state:`down`,detail:e.message,ms:0}}})();ms.set(e,r={at:Date.now(),r:t})}n[e]=await r.r}})),n}function gs(e){try{let t=Y(R(e).server);return`${t.user}@${t.hostname}${t.port===22?``:`:${t.port}`}`}catch{return`cannot resolve host`}}function _s(e){let[t,n]=e;return e.includes(`--check`)||e.includes(`--key-only`)?!1:t===`firewall`?[`enable`,`disable`,`allow`,`deny`,`update`,`rm`,`remove`].includes(n):t===`traefik`?n===`setup`||n===`use`:t===`env`?e.some(e=>e.includes(`=`)||e===`-u`||e===`--unset`):t===`domains`?e.some(e=>[`-a`,`--add`,`--rm`,`--set`].includes(e)):t===`auth`?e.some(e=>[`-u`,`--user`,`--rm-user`,`--off`].includes(e)):t===`update`||t===`upgrade`||[`setup`,`deploy`,`rollback`,`stop`,`start`,`restart`,`adopt`].includes(t)}const vs=j({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
|
|
878
895
|
|
|
879
896
|
Without --host, <name> is used as an ssh alias from ~/.ssh/config,
|
|
880
897
|
so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
@@ -882,25 +899,25 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
|
882
899
|
With --host lf logs in with --key, or installs its own key
|
|
883
900
|
(~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
|
|
884
901
|
to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
|
|
885
|
-
The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&
|
|
902
|
+
The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||D(`usage: lf server add <name> [--host ...]`);let n=t.domain&&Ie(t.domain),r=L(),i=r.servers[e],a=Wt().includes(e)&&!en(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 Vr(t[`password-stdin`]),c=await Gr(e,o,{check:!s&&!pe(),password:s}),l=c?qr(e,o,c,{existing:i,sshConfig:t[`ssh-config`],ownAlias:a,port:t.port,keyGiven:!!t.key}):{...i,...o};l.key||delete l.key,l.user||delete l.user,l.port||delete l.port,t.email&&(l.email=Fe(t.email)),n&&(l.domains=ze(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),Oe(r),C(`added ${x.bold(e)}${r.default===e?x.dim(` (default)`):``}`),c?(w(x.dim(`next: lf setup ${e} --check shows what setup would install`)),await Ii(e,l)):w(`then: ${x.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),ys=j({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.
|
|
886
903
|
|
|
887
904
|
Git hosts (github.com, gitlab.*, User git …) are left out: they are not servers.
|
|
888
905
|
--check logs in to each with its key (5s timeout, 8 at a time):
|
|
889
906
|
ok key login works
|
|
890
907
|
login sshd answers but refuses the key (lf setup installs one)
|
|
891
|
-
down no ssh: host off, wrong port, firewall, or name not found`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},check:{type:`boolean`,short:`c`,desc:`try an ssh login to each and show which are reachable`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=
|
|
892
|
-
Any ~/.ssh/config alias works too (--all to list them).`))}}),
|
|
908
|
+
down no ssh: host off, wrong port, firewall, or name not found`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`},check:{type:`boolean`,short:`c`,desc:`try an ssh login to each and show which are reachable`},json:{type:`boolean`,desc:`machine-readable output (what the web UI lists)`}},async run(e,t){let n=L(),r=ps().filter(e=>e.registered||t.all),i=e=>`${e.user?e.user+`@`:``}${e.host}${e.port?`:`+e.port:``}`,a=t.check?await hs(r.map(e=>e.name)):{};if(t.json){console.log(JSON.stringify(r.map(e=>({...e,target:e.registered?i(n.servers[e.name]):e.name,...t.check&&{reach:a[e.name]}})),null,2));return}if(!r.length){console.log(`no servers yet — ${x.bold(`lf server add <ssh-alias>`)} or ${x.bold(`lf setup <name> --host <ip>`)}`);return}let o={ok:x.green,login:x.yellow,down:x.red},s=[[x.dim(`NAME`),x.dim(`TARGET`),x.dim(`SOURCE`),...t.check?[x.dim(`STATUS`)]:[]]];for(let e of r){let t=a[e.name];s.push([e.name+(e.isDefault?x.green(` *`):``),e.registered?i(n.servers[e.name]):e.name,e.registered?`landfall`:en(e.name)?`ssh (landfall)`:`ssh`,...t?[`${o[t.state](t.state)} ${x.dim(t.state===`ok`?`${t.ms}ms`:t.detail)}`]:[]])}A(s),t.all||console.log(x.dim(`
|
|
909
|
+
Any ~/.ssh/config alias works too (--all to list them).`))}}),bs=j({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||D(`usage: lf server rm <name>`),!L().servers[e]&&!en(e)&&D(`unknown server "${e}"`),await O(`Forget server ${e}? Nothing on the server changes.`),ke(e),t[`ssh-config`]&&$t(e)&&C(`removed Host ${e} from ~/.ssh/config`),C(`removed ${e}`)}}),xs=/^(setup|traefik setup|traefik use)( |$)/,Ss=j({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${V} and what setup changed.
|
|
893
910
|
|
|
894
911
|
Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
|
|
895
912
|
adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
|
|
896
913
|
alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
|
|
897
|
-
and deletes ${
|
|
898
|
-
Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+
|
|
899
|
-
`)),l.length&&(S(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await
|
|
900
|
-
`)}:null}}}const
|
|
914
|
+
and deletes ${V}, its journal included. Then forgets the server locally.
|
|
915
|
+
Docker, packages, landfall's key for root and rules from \`lf firewall\` stay.`,options:{volumes:{type:`boolean`,desc:`also delete the sites' named volumes (their data)`},"keep-setup":{type:`boolean`,desc:`keep the deploy user, ufw and ssh hardening; only remove sites, traefik and `+V},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||D(`usage: lf server uninstall <name>`);let{name:n,server:r}=R(e),i=await $o(r,`root`),[a,s,c]=await Promise.all([vr(r),Ja(r),Zo(r)]);s.compose===`foreign`&&D(`${U}/docker-compose.yml was not written by landfall: move it out of ${V} first`);let l=t[`keep-setup`]?[]:c.filter(e=>xs.test(e.cmd)).flatMap(Go).filter(e=>!e.undo.includes(V));S(`uninstall landfall from ${x.bold(n)}`);for(let e of a.sites){let n=e.meta.adopted?`forget ${e.meta.name} (keeps running from ${e.meta.adopted})`:`delete ${e.meta.name} (${e.domains.join(`, `)||`no domains`})`;console.error(` ${x.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${x.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${x.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${x.cyan(`↺`)} ${e.what}`);console.error(` ${x.red(`-`)} delete ${V}, its journal included`),console.error(` ${x.red(`-`)} forget ${n} locally`),console.error(` ${x.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await as(r,l,t.force),await O(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[K];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${q(Z(e.name))}`]:Mr(e,{volumes:t.volumes});u.push(`log ${q(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${q(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${U} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await J(r,u.join(`
|
|
916
|
+
`)),l.length&&(S(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await ts(r,l)),await J(i.login,`rm -rf ${V}`,{sudo:i.sudo}),C(`landfall removed from ${n}`),ke(n),$t(n)&&C(`removed Host ${n} from ~/.ssh/config`),C(`forgot ${n}`),o.existsSync(Hr(n))&&w(x.dim(`${I(Hr(n))} stays: root on ${n} still accepts it`))}}),Cs=j({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||D(`usage: lf server use <name>`),Ae(e),C(`default server: ${x.bold(e)}`)}}),ws=j({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=R(e);return Dt(r,n)}}),Ts=j({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=R(e);if(t.json)return void console.log(JSON.stringify(await wc(n,r),null,2));let i=await on(r),a=await ln(r,i),o=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,s=i.mem.total-i.mem.available;console.log(x.bold(n)+x.dim(` (${r.host})`));let c=[[`os`,`${i.os}${i.pkg===`none`?``:x.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${bn(i.uptime)}${i.reboot?x.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${X(s)} / ${X(i.mem.total)}${o(s,i.mem.total)}`],[`disk`,`${X(i.disk.used)} / ${X(i.disk.total)}${o(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`login`,`${i.user}${a.can.admin?i.admin?x.dim(` · root or sudo`):x.dim(` · root's key for admin work`):x.yellow(` · no sudo`)}`],[`init`,i.init===`other`?x.dim(`no systemd or OpenRC`):i.init],[`docker`,i.docker?`${i.docker}${i.compose?x.dim(` · compose ${i.compose}`):x.yellow(` · no compose`)}`:a.why.docker],[`firewall`,i.ufw?`ufw`:a.can.firewall?x.dim(`none (lf setup installs ufw)`):x.dim(a.why.firewall)]];for(let[e,t]of c)console.log(`${x.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`eol`||i.support.level===`unsupported`?T:w)(i.support.note),i.docker&&(console.log(),await J(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Es(e){let t=L(),n=t.servers[e],r=n??{host:e},i=Y(r),a=Zt(r.host),s=Wa(r,{});return{name:e,isDefault:t.default===e,configFile:I(P),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],tunnelOnly:we(r),tunnelOnlyOwn:r.tunnelOnly!==void 0,traefik:{...s,summary:Ga(s),dashboard:r.dashboard?.host??null},ssh:{hostname:i.hostname,user:i.user,port:i.port,keys:(r.key?[r.key]:i.identityFiles).map(e=>({file:I(e),exists:o.existsSync(F(e))})),block:a?{file:I(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
|
|
917
|
+
`)}:null}}}const Ds=j({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
|
|
901
918
|
the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
|
|
902
|
-
and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=
|
|
903
|
-
`))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function
|
|
919
|
+
and the Host block in ~/.ssh/config. Nothing connects to the server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=R(e);if(t.json)return void console.log(JSON.stringify(await Cc(n,r),null,2));let i=Es(n),a=(e,t)=>console.log(` ${x.dim(e.padEnd(10))} ${t}`);if(console.log(`${x.bold(n)}${i.isDefault?x.dim(` (default)`):``}`),console.log(`\n${x.bold(`landfall`)} ${x.dim(i.configFile)}`),!i.saved)a(`entry`,x.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(i.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&e!==`tunnelOnly`&&a(e,String(t));i.domains.length||a(`domain`,x.dim(`none (lf server domain ${n} example.com)`)),i.domains.forEach((e,t)=>a(t?``:i.domains.length>1?`domains`:`domain`,t?e:`${e} ${x.dim(`(primary: new sites get <site>.${e})`)}`)),a(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${i.tunnelOnlyOwn?``:x.dim(` (landfall default, lf config)`)}`),a(`traefik`,`${i.traefik.summary}${i.traefik.email?`, ${i.traefik.email}`:``} ${x.dim(`(when landfall installs it)`)}`),a(`dashboard`,i.traefik.dashboard?`https://${i.traefik.dashboard}`:x.dim(`off`)),console.log(`\n${x.bold(`ssh`)} ${x.dim(`ssh -G ${i.saved?.host??n}`)}`),a(`login`,`${i.ssh.user}@${i.ssh.hostname}:${i.ssh.port}`);for(let e of i.ssh.keys)a(`key`,e.exists?e.file:`${e.file} ${x.dim(`(missing)`)}`);if(i.ssh.block){console.log(`\n${x.bold(`ssh config`)} ${x.dim(`${i.ssh.block.file}:${i.ssh.block.line}${i.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of i.ssh.block.text.split(`
|
|
920
|
+
`))console.log(` ${e}`)}else console.log(`\n${x.bold(`ssh config`)} ${x.dim(`no Host block; ssh uses its defaults`)}`)}});async function Os(e,t){let n=await gn(e),[r,i]=await yn([t,`landfall-${oe(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function ks(e){let{stdout:t}=await J(e,`docker ps -q | xargs -r docker inspect -f '{{range $k, $v := .Config.Labels}}{{println $v}}{{end}}' 2>/dev/null || true`,{capture:!0});return[...t.matchAll(/Host\(`([^`]+)`\)/g)].map(e=>e[1])}const As=async e=>We(await ks(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),js=j({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
|
|
904
921
|
|
|
905
922
|
The first main domain is the primary: \`lf deploy\` serves a new site at
|
|
906
923
|
<site>.<primary>, \`--domain blog\` means blog.<primary>, and
|
|
@@ -916,10 +933,10 @@ the server's sites already use.
|
|
|
916
933
|
--tunnel-only makes new sites deploy with no domain unless -d gives one:
|
|
917
934
|
nothing is served on the internet, \`lf tunnel <site>\` reaches them.
|
|
918
935
|
--no-tunnel-only goes back to <site>.<primary>. Existing sites keep theirs.
|
|
919
|
-
Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=
|
|
936
|
+
Without either, the server follows landfall's default (\`lf config --tunnel-only\`).`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=L(),r=e.length>0&&!(e=>!!n.servers[e]||Wt().includes(e))(e[0])&&Me.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=R(i),c=t[`tunnel-only`],l=!!(a.length||t.add||t.rm||t.primary||t.clear);if(c!==void 0){let e=n.servers[o]??={host:o};if(c===(Ce().tunnelOnly??!1)?delete e.tunnelOnly:e.tunnelOnly=c,Oe(n),s.tunnelOnly=e.tunnelOnly,C(c?`new sites on ${x.bold(o)} get no domain: reach them with lf tunnel, or deploy with -d`:`new sites on ${x.bold(o)} get a domain again`),!l)return}if(l){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(Ie),i=t.clear?[]:a.length?a.map(Ie):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(Ie)])].filter(e=>!r.includes(e)),i=ze(i,t.primary&&Ie(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&T(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await O(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,Oe(n),s.domains=e.domains,C(i.length?`main domains of ${x.bold(o)}: ${i.map((e,t)=>t?e:x.bold(e)).join(`, `)}${i.length>1?x.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let u=s.domains??[];if(!u.length){let e=await As(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:u,suggest:e}));w(`${o} has no main domain yet`);for(let t of e)w(`${t.hosts} routed hosts use ${x.bold(t.domain)} — ${x.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||w(`set one: ${x.bold(`lf server domain ${o} example.com`)}`);return}let d=await Promise.all(u.map(e=>Os(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:u.map((e,t)=>({domain:e,primary:t===0,...d[t]}))},null,2));let f=Math.max(...u.map(e=>e.length))+2,p=[];u.forEach((e,t)=>{let n=d[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`);console.log(`${e.domain.padEnd(f)} ${r}${e===n.apex&&t===0&&u.length>1?x.dim(` primary`):``}`),e.ok||p.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),p.length&&console.log(x.dim(`
|
|
920
937
|
add these DNS records at your DNS provider:
|
|
921
938
|
`)+p.join(`
|
|
922
|
-
`));let m=
|
|
939
|
+
`));let m=Be(s);w(x.dim(`sites: lf deploy -d blog → blog.${m} · dashboard: lf traefik setup --dashboard traefik`))}}),Ms=String.raw`
|
|
923
940
|
set +e
|
|
924
941
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
|
925
942
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
@@ -952,18 +969,18 @@ if [ -n "$D" ]; then
|
|
|
952
969
|
docker ps -a --format 'CTR\t{{.Names}}\t{{.Status}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.Image}}'
|
|
953
970
|
ids=$(docker ps -aq)
|
|
954
971
|
[ -z "$ids" ] || docker inspect -f 'RST{{"\t"}}{{.Name}}{{"\t"}}{{.RestartCount}}' $ids 2>/dev/null
|
|
955
|
-
for f in ${
|
|
972
|
+
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
|
|
956
973
|
fi
|
|
957
974
|
exit 0
|
|
958
|
-
`,
|
|
959
|
-
`)){let e=t.split(` `),f=e[0];if(/^T[12]$/.test(f))r[f[1]-1]=Number(e[1]);else if(/^CPU[12]$/.test(f))i[f[3]-1]=e[1].trim().split(/\s+/).slice(1).map(Number);else if(/^NET[12]$/.test(f))a[f[3]-1].set(e[1],[Number(e[2]),Number(e[3])]);else if(f===`PROC`)o.push({pid:Number(e[1]),ticks:Number(e[2]),rss:Number(e[3]),name:e.slice(4).join(` `)});else if(f===`DISK`)s.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024});else if(f===`STAT`)c.set(e[1],e);else if(f===`RST`)l.set(e[1].replace(/^\//,``),Number(e[2])||0);else if(f===`CTR`)u.push(e);else if(f===`SITE`)try{let e=JSON.parse(t.slice(5));d.set(
|
|
975
|
+
`,Ns={b:1,kb:1e3,mb:1e6,gb:1e9,tb:0xe8d4a51000,kib:1024,mib:1024**2,gib:1024**3,tib:1024**4};function Ps(e){let t=/^([\d.]+)\s*([a-z]*)$/i.exec(e.trim());return t?Math.round(Number(t[1])*(Ns[(t[2]||`b`).toLowerCase()]??1)):0}const Fs=(e=``)=>e.split(` / `).map(Ps),Is=/^(lo|veth|docker|br-|virbr|cni|flannel|cali|vxlan)/;function Ls(e,t=/* @__PURE__ */ new Date){let n={},r=[],i=[],a=[/* @__PURE__ */ new Map,/* @__PURE__ */ new Map],o=[],s=[],c=/* @__PURE__ */ new Map,l=/* @__PURE__ */ new Map,u=[],d=/* @__PURE__ */ new Map;for(let t of e.split(`
|
|
976
|
+
`)){let e=t.split(` `),f=e[0];if(/^T[12]$/.test(f))r[f[1]-1]=Number(e[1]);else if(/^CPU[12]$/.test(f))i[f[3]-1]=e[1].trim().split(/\s+/).slice(1).map(Number);else if(/^NET[12]$/.test(f))a[f[3]-1].set(e[1],[Number(e[2]),Number(e[3])]);else if(f===`PROC`)o.push({pid:Number(e[1]),ticks:Number(e[2]),rss:Number(e[3]),name:e.slice(4).join(` `)});else if(f===`DISK`)s.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024});else if(f===`STAT`)c.set(e[1],e);else if(f===`RST`)l.set(e[1].replace(/^\//,``),Number(e[2])||0);else if(f===`CTR`)u.push(e);else if(f===`SITE`)try{let e=JSON.parse(t.slice(5));d.set(_r(e),e.name)}catch{}else t.includes(`=`)&&(n[t.slice(0,t.indexOf(`=`))]=t.slice(t.indexOf(`=`)+1).trim())}let f=Number(n.cpus)||1,p=(e=[])=>e.slice(0,8).reduce((e,t)=>e+(t||0),0),m=(e=[])=>(e[3]||0)+(e[4]||0),ee=p(i[1])-p(i[0]),te=m(i[1])-m(i[0]),ne=r[1]-r[0]||1,h=Number(n.page)||4096,[g=0,re=0,ie=0,_=0]=(n.mem??``).split(/\s+/).map(e=>Number(e)*1024||0),ae=e=>Math.round(e*10)/10;return{at:t.toISOString(),uptime:Math.round(r[1]||0),cpus:f,load:(n.load??``).split(/\s+/).map(e=>Number(e)||0).concat([0,0,0]).slice(0,3),cpu:ee>0?ae(100*(ee-te)/ee):0,mem:{total:g,available:re,swapTotal:ie,swapFree:_},disks:s,net:[...a[1]].filter(([e])=>!Is.test(e)&&a[0].has(e)).map(([e,[t,n]])=>({iface:e,rx:Math.max(0,Math.round((t-a[0].get(e)[0])/ne)),tx:Math.max(0,Math.round((n-a[0].get(e)[1])/ne))})),procs:o.map(e=>({pid:e.pid,name:e.name,cpu:ee>0?ae(100*e.ticks*f/ee):0,rss:e.rss*h})).sort((e,t)=>t.cpu-e.cpu||t.rss-e.rss),docker:n.docker===`1`,containers:u.map(([,e,t=``,n=``,r=``,i=``])=>{let a=c.get(e)??[],[o,s]=Fs(a[3]),[u,f]=Fs(a[4]),[p,m]=Fs(a[5]);return{name:e,...d.has(n)&&{site:d.get(n)},...n&&{project:n,service:r},image:i,status:t,cpu:parseFloat(a[2]??``)||0,mem:o||0,memLimit:s||0,net:{rx:u||0,tx:f||0},block:{read:p||0,write:m||0},pids:Number(a[6])||0,restarts:l.get(e)??0}}).sort((e,t)=>(e.site??e.project??`~`).localeCompare(t.site??t.project??`~`)||e.name.localeCompare(t.name))}}async function Rs(e){let{stdout:t}=await J(e,Ms,{capture:!0});return Ls(t)}const zs=String.raw`
|
|
960
977
|
set +e
|
|
961
978
|
S=""; [ "$(id -u)" -eq 0 ] || { sudo -n true 2>/dev/null && S="sudo -n"; }
|
|
962
979
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
963
980
|
kv root "$([ "$(id -u)" -eq 0 ] || [ -n "$S" ] && echo 1)"
|
|
964
981
|
df -Pk 2>/dev/null | awk 'NR > 1 && $1 ~ /^\// && $6 !~ /^\/(snap|boot)(\/|$)/ && !seen[$1]++ { print "DISK\t" $6 "\t" $2 "\t" $3 "\t" $4 }'
|
|
965
|
-
kv landfall "$($S du -sk ${
|
|
966
|
-
for d in ${
|
|
982
|
+
kv landfall "$($S du -sk ${V} 2>/dev/null | cut -f1)"
|
|
983
|
+
for d in ${H}/*/; do
|
|
967
984
|
[ -d "$d" ] || continue
|
|
968
985
|
n=$(basename "$d")
|
|
969
986
|
printf 'SITE\t%s\t%s\t%s\t%s\n' "$n" "$($S du -sk "$d" 2>/dev/null | cut -f1)" "$($S du -sk "$d/releases" 2>/dev/null | cut -f1)" "$(ls -1 "$d/releases" 2>/dev/null | wc -l)"
|
|
@@ -979,36 +996,36 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
|
979
996
|
done
|
|
980
997
|
fi
|
|
981
998
|
exit 0
|
|
982
|
-
`;function
|
|
983
|
-
`)){let e=r.split(` `);e[0]===`DISK`?n.disks.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024}):e[0]===`SITE`?n.sites.push({name:e[1],size:(+e[2]||0)*1024,releasesSize:(+e[3]||0)*1024,releases:+e[4]||0}):e[0]===`DF`?n.dockerDf.push({type:e[1],count:+e[2]||0,active:+e[3]||0,size:
|
|
999
|
+
`;function Bs(e){let t={},n={root:!1,disks:[],landfall:0,sites:[],journald:0,docker:!1,dockerDf:[],volumes:[],logs:[]};for(let r of e.split(`
|
|
1000
|
+
`)){let e=r.split(` `);e[0]===`DISK`?n.disks.push({mount:e[1],total:e[2]*1024,used:e[3]*1024,available:e[4]*1024}):e[0]===`SITE`?n.sites.push({name:e[1],size:(+e[2]||0)*1024,releasesSize:(+e[3]||0)*1024,releases:+e[4]||0}):e[0]===`DF`?n.dockerDf.push({type:e[1],count:+e[2]||0,active:+e[3]||0,size:Ps(e[4]??``),reclaimable:Ps((e[5]??``).split(` `)[0])}):e[0]===`VOL`?n.volumes.push({name:e[1],links:+e[2]||0,size:Ps(e[3]??``)}):e[0]===`LOG`?n.logs.push({container:e[1],...e[2]&&{size:e[2]*1024}}):r.includes(`=`)&&(t[r.slice(0,r.indexOf(`=`))]=r.slice(r.indexOf(`=`)+1).trim())}n.root=t.root===`1`,n.docker=t.docker===`1`,n.landfall=(+t.landfall||0)*1024;let r=/^([\d.]+)([BKMGT]?)/i.exec(t.journald??``);return n.journald=r?Math.round(Number(r[1])*1024**`BKMGT`.indexOf((r[2]||`B`).toUpperCase())):0,n.sites.sort((e,t)=>t.size-e.size),n.volumes.sort((e,t)=>t.size-e.size),n.logs.sort((e,t)=>(t.size??-1)-(e.size??-1)),n}async function Vs(e){let{stdout:t}=await J(e,zs,{capture:!0});return Bs(t)}const Hs=String.raw`
|
|
984
1001
|
set +e
|
|
985
1002
|
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 || { echo "NODOCKER"; exit 0; }
|
|
986
1003
|
docker image ls -a --no-trunc --format 'IMG\t{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}'
|
|
987
1004
|
ids=$(docker ps -aq)
|
|
988
1005
|
[ -z "$ids" ] || docker inspect -f 'USED{{"\t"}}{{.Image}}' $ids 2>/dev/null
|
|
989
1006
|
# The undo shell is the last field, base64: decode it to find the image IDs it puts back.
|
|
990
|
-
{ cat ${
|
|
1007
|
+
{ cat ${G} 2>/dev/null || sudo -n cat ${G} 2>/dev/null; } | awk -F'\t' '{ print $NF }' \
|
|
991
1008
|
| while IFS= read -r b; do [ -n "$b" ] && printf %s "$b" | base64 -d 2>/dev/null; echo; done \
|
|
992
1009
|
| grep -o 'sha256:[0-9a-f]\{64\}' | sort -u | sed 's/^/KEEP\t/'
|
|
993
|
-
for f in ${
|
|
1010
|
+
for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\t'; tr -d '\n' < "$f"; echo; done
|
|
994
1011
|
docker system df --format 'DF\t{{.Type}}\t{{.Reclaimable}}' 2>/dev/null
|
|
995
1012
|
exit 0
|
|
996
|
-
`,
|
|
997
|
-
`)){let e=t.split(` `);if(e[0]===`NODOCKER`)n.docker=!1;else if(e[0]===`IMG`&&e[1]){let t=r.get(e[1])??{id:e[1],tags:[],size:
|
|
1013
|
+
`,Us=e=>e.images.filter(e=>!e.keep),Ws=(e,t)=>e.images.filter(e=>e.keep===t);function Gs(e,t=!1){let n={docker:!0,all:t,images:[],buildCache:0},r=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Set,a=/* @__PURE__ */ new Set,o=/* @__PURE__ */ new Set;for(let t of e.split(`
|
|
1014
|
+
`)){let e=t.split(` `);if(e[0]===`NODOCKER`)n.docker=!1;else if(e[0]===`IMG`&&e[1]){let t=r.get(e[1])??{id:e[1],tags:[],size:Ps(e[4]??``),created:e[5]??``};e[2]&&e[2]!==`<none>`&&e[3]&&e[3]!==`<none>`&&t.tags.push(`${e[2]}:${e[3]}`),r.set(e[1],t)}else if(e[0]===`USED`&&e[1])i.add(e[1]);else if(e[0]===`KEEP`&&e[1])a.add(e[1]);else if(e[0]===`SITE`)try{let t=JSON.parse(e.slice(1).join(` `));for(let e of t.releases??[])for(let n of e.services??[])n.build&&o.add(Pn(t.name,n.name,e.id))}catch{}else e[0]===`DF`&&/build/i.test(e[1]??``)&&(n.buildCache=Ps((e[2]??``).split(` `)[0]))}for(let e of r.values())e.keep=i.has(e.id)?`used`:e.tags.some(e=>o.has(e))?`release`:a.has(e.id)?`undo`:e.tags.length&&!t?`tagged`:void 0,n.images.push(e);return n.images.sort((e,t)=>t.size-e.size),n}async function Ks(e,t=!1){let{stdout:n}=await J(e,Hs,{capture:!0});return Gs(n,t)}async function qs(e,t){let n=Us(t),r=n.flatMap(e=>e.tags.length?e.tags:[e.id]),{stdout:i}=await J(e,[K,`set +e`,`failed=0`,...r.map(e=>`docker image rm ${q(e)} >/dev/null 2>&1 || { printf 'FAILED\\t%s\\n' ${q(e)}; failed=$((failed + 1)); }`),`docker builder prune -f${t.all?`a`:``} >/dev/null 2>&1 || true`,`record ${q(`pruned ${n.length} images and the build cache`)}`,`exit 0`].join(`
|
|
998
1015
|
`),{capture:!0}),a=i.split(`
|
|
999
|
-
`).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function
|
|
1000
|
-
`).map(e=>e.trim()).filter(Boolean)}const
|
|
1016
|
+
`).filter(e=>e.startsWith(`FAILED `)).map(e=>e.slice(7));return{images:n.length,failed:a}}async function Js(e){let{stdout:t}=await J(e,`docker ps --format '{{.Names}}' 2>/dev/null || sudo -n docker ps --format '{{.Names}}' 2>/dev/null || true`,{capture:!0});return t.split(`
|
|
1017
|
+
`).map(e=>e.trim()).filter(Boolean)}const Ys=String.raw`
|
|
1001
1018
|
log "rebooting"
|
|
1002
1019
|
record "rebooted"
|
|
1003
1020
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
1004
|
-
`,
|
|
1021
|
+
`,Xs=e=>new Promise(t=>setTimeout(t,e));async function Zs(e,t){let n=await Js(e).catch(()=>[]),r=Date.now();await J(e,K+Ys),await wt(e),t.onWait?.(`down`);let i=r+t.timeout*1e3;for(;Date.now()<i&&Date.now()-r<6e4&&await At(e);)await Xs(2e3);t.onWait?.(`ssh`);let a=!1;for(;Date.now()<i&&!(a=await At(e));)await Xs(5e3);if(!a)return{back:!1,seconds:Math.round((Date.now()-r)/1e3),missing:n};let o=Math.round((Date.now()-r)/1e3);t.onWait?.(`containers`);let s=n,c=Math.min(i,Date.now()+6e4);for(;;){let t=new Set(await Js(e).catch(()=>[]));if(s=n.filter(e=>!t.has(e)),!s.length||Date.now()>=c)break;await Xs(5e3)}return{back:!0,seconds:o,missing:s}}function Qs(e){let t=/^(\d+)\s*([smhd])$/i.exec(e.trim());if(!t)return D(`not a time span: ${e} (use e.g. 30m, 24h or 7d)`);let n=Number(t[1])*{s:1,m:60,h:3600,d:86400}[t[2].toLowerCase()];return(n<60||n>2592e3)&&D(`--since must be between 1m and 30d, not ${e}`),n}const $s=e=>e%86400==0?`${e/86400}d`:e%3600==0?`${e/3600}h`:`${Math.round(e/60)}m`,ec=e=>e<=21600?16:13,tc=`for f in ${H}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done`,nc=e=>String.raw`
|
|
1005
1022
|
set +e
|
|
1006
|
-
P=${
|
|
1023
|
+
P=${et}
|
|
1007
1024
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
|
|
1008
|
-
[ -f ${
|
|
1009
|
-
${
|
|
1025
|
+
[ -f ${W} ] && echo OWN
|
|
1026
|
+
${tc}
|
|
1010
1027
|
T=$(printf '\t')
|
|
1011
|
-
docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${
|
|
1028
|
+
docker logs --since ${e}s "$P" 2>&1 | LC_ALL=C awk -v B=${ec(e)} '
|
|
1012
1029
|
function str(k) { if (!match($0, "\"" k "\":\"[^\"]*")) return ""; v = substr($0, RSTART + length(k) + 4, RLENGTH - length(k) - 4); sub(/\\$/, "", v); return v }
|
|
1013
1030
|
function num(k) { return match($0, "\"" k "\":-?[0-9]+") ? substr($0, RSTART + length(k) + 3, RLENGTH - length(k) - 3) + 0 : 0 }
|
|
1014
1031
|
/^\{.*"RequestHost":/ {
|
|
@@ -1037,13 +1054,13 @@ END {
|
|
|
1037
1054
|
for (x in E) { split(x, a, SUBSEP); print "E\t" a[1] "\t" a[2] "\t" E[x] }
|
|
1038
1055
|
}' | LC_ALL=C sort -t"$T" -k1,1 -k2,2 -k4,4nr | awk -F'\t' '($1 != "P" && $1 != "I" && $1 != "E") || n[$1 FS $2]++ < 10'
|
|
1039
1056
|
exit 0
|
|
1040
|
-
`,
|
|
1041
|
-
`))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of
|
|
1042
|
-
`)){let e=t.split(` `);if(e[0]===`NOPROXY`)s.proxy=!1;else if(e[0]===`OWN`)s.own=!0;else if(e[0]===`N`)s.format=Number(e[1])?`json`:Number(e[2])?`common`:`none`,e[3]&&(s.from=e[3],s.to=e[4]);else if(e[0]===`C`){let t=o(e[1]);t.codes[e[2]]=(t.codes[e[2]]??0)+Number(e[3]),t.bytes+=Number(e[4])||0}else if(e[0]===`L`)o(e[1]).hist[e[2]]=Number(e[3]);else if(e[0]===`S`){let t=
|
|
1057
|
+
`,rc=[`2xx`,`3xx`,`4xx`,`5xx`],ic=()=>({"2xx":0,"3xx":0,"4xx":0,"5xx":0}),ac=e=>rc[Math.floor(e/100)-2]??null;function oc(e,t){let n=Object.keys(e).map(Number).sort((e,t)=>e-t),r=n.reduce((t,n)=>t+e[n],0),i=0;for(let a of n)if(i+=e[a],i>=t*r)return a?Math.round(2**(a/4)):1;return 0}function sc(e,t){let n=ec(e),r=n===16?6e4:36e5,i=Math.floor(t.getTime()/r)*r,a=[];for(let t=i-Math.ceil(e/(r/1e3))*r+r;t<=i;t+=r)a.push(new Date(t).toISOString().slice(0,n));return a}function cc(e,t){let n={},r={},i=ic(),a=/* @__PURE__ */ new Map,o=/* @__PURE__ */ new Map,s=/* @__PURE__ */ new Map,c=new Map(t.map(e=>[e,ic()])),l=0,u=0;for(let t of e){l+=t.requests,u+=t.bytes;for(let[e,r]of Object.entries(t.hist))n[e]=(n[e]??0)+r;for(let[e,n]of Object.entries(t.codes))r[e]=(r[e]??0)+n;for(let e of rc)i[e]+=t.classes[e];for(let e of t.paths){let t=a.get(e.path)??{count:0,errors:0};a.set(e.path,{count:t.count+e.count,errors:t.errors+e.errors})}for(let e of t.ips)o.set(e.ip,(o.get(e.ip)??0)+e.count);for(let e of t.errors)s.set(`${e.status} ${e.path}`,(s.get(`${e.status} ${e.path}`)??0)+e.count);for(let e of t.series){let t=c.get(e.at);if(t)for(let n of rc)t[n]+=e[n]}}let d=e=>e.sort((e,t)=>t.count-e.count).slice(0,10);return{requests:l,bytes:u,classes:i,codes:r,p50:oc(n,.5),p95:oc(n,.95),paths:d([...a].map(([e,t])=>({path:e,...t}))),ips:d([...o].map(([e,t])=>({ip:e,count:t}))),errors:d([...s].map(([e,t])=>({status:Number(e.slice(0,e.indexOf(` `))),path:e.slice(e.indexOf(` `)+1),count:t}))),series:[...c].map(([e,t])=>({at:e,...t}))}}function lc(e,t){return e.get(t)??e.get(`*.${t.slice(t.indexOf(`.`)+1)}`)}function uc(e){let t=/* @__PURE__ */ new Map;for(let n of e.split(`
|
|
1058
|
+
`))if(n.startsWith(`SITE `))try{let e=JSON.parse(n.slice(5));for(let n of Nn(e))t.set(n.toLowerCase(),e.name)}catch{}return t}function dc(e,t,n=/* @__PURE__ */ new Date){let r=sc(t,n),i=uc(e),a=/* @__PURE__ */ new Map,o=e=>{let t=a.get(e);return t||a.set(e,t={host:e,...lc(i,e)&&{site:lc(i,e)},codes:{},bytes:0,hist:{},paths:[],ips:[],errors:[],series:/* @__PURE__ */ new Map}),t},s={at:n.toISOString(),since:t,bucket:ec(t)===16?`minute`:`hour`,proxy:!0,own:!1,format:`none`,total:cc([],r),sites:[],hosts:[]};for(let t of e.split(`
|
|
1059
|
+
`)){let e=t.split(` `);if(e[0]===`NOPROXY`)s.proxy=!1;else if(e[0]===`OWN`)s.own=!0;else if(e[0]===`N`)s.format=Number(e[1])?`json`:Number(e[2])?`common`:`none`,e[3]&&(s.from=e[3],s.to=e[4]);else if(e[0]===`C`){let t=o(e[1]);t.codes[e[2]]=(t.codes[e[2]]??0)+Number(e[3]),t.bytes+=Number(e[4])||0}else if(e[0]===`L`)o(e[1]).hist[e[2]]=Number(e[3]);else if(e[0]===`S`){let t=ac(Number(e[3])*100);if(!t)continue;let n=o(e[1]).series,r=n.get(e[2])??ic();r[t]+=Number(e[4]),n.set(e[2],r)}else if(e[0]===`P`)o(e[1]).paths.push({path:e[2],count:Number(e[3]),errors:Number(e[4])||0});else if(e[0]===`I`)o(e[1]).ips.push({ip:e[2],count:Number(e[3])});else if(e[0]===`E`){let t=e[2].indexOf(` `);o(e[1]).errors.push({status:Number(e[2].slice(0,t)),path:e[2].slice(t+1),count:Number(e[3])})}}return s.hosts=[...a.values()].map(e=>{let t=ic();for(let[n,r]of Object.entries(e.codes)){let e=ac(Number(n));e&&(t[e]+=r)}let n=Object.values(e.codes).reduce((e,t)=>e+t,0);return{...e,requests:n,classes:t,p50:oc(e.hist,.5),p95:oc(e.hist,.95),series:r.map(t=>({at:t,...e.series.get(t)??ic()}))}}).sort((e,t)=>t.requests-e.requests),s.total=cc(s.hosts,r),s.sites=[...Map.groupBy(s.hosts.filter(e=>e.site),e=>e.site)].map(([e,t])=>{let{paths:n,ips:i,errors:a,series:o,...s}=cc(t,r);return{site:e,hosts:t.map(e=>e.host),...s}}).sort((e,t)=>t.requests-e.requests),s}async function fc(e,t){let{stdout:n}=await J(e,nc(t),{capture:!0});return dc(n,t)}function pc(e,t){let n=e.hosts.filter(e=>e.site===t||e.host===t);return n.length?cc(n,e.total.series.map(e=>e.at)):null}const mc=e=>String.raw`
|
|
1043
1060
|
set +e
|
|
1044
|
-
P=${
|
|
1061
|
+
P=${et}
|
|
1045
1062
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo NOPROXY; exit 0; fi
|
|
1046
|
-
${
|
|
1063
|
+
${tc}
|
|
1047
1064
|
readf() { cat "$1" 2>/dev/null || sudo -n cat "$1" 2>/dev/null; }
|
|
1048
1065
|
for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
|
|
1049
1066
|
[ -d "$d" ] || [ -f "$d" ] || continue
|
|
@@ -1065,18 +1082,18 @@ for d in $(docker inspect -f '{{range .Mounts}}{{.Source}} {{end}}' "$P"); do
|
|
|
1065
1082
|
done
|
|
1066
1083
|
docker logs --since ${e}s "$P" 2>&1 | grep -v '^{' | grep -i acme | grep -E ' ERR |level=error' | tail -n 100 | sed 's/^/LOG\t/'
|
|
1067
1084
|
exit 0
|
|
1068
|
-
`,
|
|
1069
|
-
`)){let e=r.split(` `);if(e[0]===`NOPROXY`)n.proxy=!1;else if(e[0]===`ACME`)n.acme.push(e[1]);else if(e[0]===`DENIED`)n.denied.push(e[1]);else if(e[0]===`CERT`){let r=
|
|
1085
|
+
`,hc=/\x1b\[[0-9;]*m/g;function gc(e){let t=e.replace(hc,``),n=/^(\d{4}-\d\d-\d\dT[\d:.]+(?:Z|[+-]\d\d:?\d\d))/.exec(t)?.[1]??/time="([^"]+)"/.exec(t)?.[1],r=[],i=/domains=(\[[^\]]*\])/.exec(t)?.[1];if(i)try{r=JSON.parse(i)}catch{}r.length||(r=(/domains \[([^\]]*)\]/.exec(t)?.[1]??``).split(/[\s,]+/).filter(Boolean));let a=/error="((?:[^"\\]|\\.)*)"/.exec(t)?.[1]?.replace(/\\"/g,`"`)??/msg="((?:[^"\\]|\\.)*)"/.exec(t)?.[1]??t.replace(/^\S+\s+(ERR\s+)?/,``);return r.length||a?{...n&&{at:n},domains:r,error:a.trim()}:null}function _c(e,t,n,r=/* @__PURE__ */ new Date){try{let i=new _(Buffer.from(e,`base64`)),a=(i.subjectAltName??``).split(/,\s*/).filter(e=>e.startsWith(`DNS:`)).map(e=>e.slice(4).toLowerCase()),o=/CN=([^\n,]+)/.exec(i.subject)?.[1],s=new Date(i.validTo),c=[/O=([^\n]+)/.exec(i.issuer)?.[1],/CN=([^\n]+)/.exec(i.issuer)?.[1]].filter(Boolean).join(` · `);return{domains:a.length?a:o?[o.toLowerCase()]:[],expires:s.toISOString(),days:Math.floor((s.getTime()-r.getTime())/864e5),issuer:c,staging:/staging|fake|\(STAGING\)/i.test(i.issuer),source:t,...n&&{path:n}}}catch{return null}}const vc=(e,t)=>e===t||e.startsWith(`*.`)&&t.slice(t.indexOf(`.`)+1)===e.slice(2)&&t.indexOf(`.`)>0;function yc(e,t=/* @__PURE__ */ new Date){let n={at:t.toISOString(),proxy:!0,acme:[],denied:[],certs:[],errors:[],domains:[]};for(let r of e.split(`
|
|
1086
|
+
`)){let e=r.split(` `);if(e[0]===`NOPROXY`)n.proxy=!1;else if(e[0]===`ACME`)n.acme.push(e[1]);else if(e[0]===`DENIED`)n.denied.push(e[1]);else if(e[0]===`CERT`){let r=_c(e[2],e[1]===`acme`?`acme`:`file`,e[1]===`acme`?void 0:e[1],t);r&&n.certs.push(r)}else if(e[0]===`LOG`){let e=gc(r.slice(4));e&&n.errors.push(e)}}return n.certs.sort((e,t)=>(e.domains[0]??``).localeCompare(t.domains[0]??``)||t.days-e.days),n.domains=[...uc(e)].filter(([e])=>!e.startsWith(`*.`)).map(([e,t])=>{let r=n.certs.filter(t=>t.domains.some(t=>vc(t,e))).sort((e,t)=>t.days-e.days)[0],i=n.errors.filter(t=>t.domains.includes(e)).at(-1);return{domain:e,site:t,...r&&{cert:r},...i&&{error:i}}}).sort((e,t)=>e.site.localeCompare(t.site)||e.domain.localeCompare(t.domain)),n}async function bc(e,t=604800){let{stdout:n}=await J(e,mc(t),{capture:!0});return yc(n)}function xc(e,t={}){let[n,r]=t.resolve?/^[^:]*(:[^:]*)?$|^\[/.test(t.resolve)?/^\[?([^\]]+?)\]?(?::(\d+))?$/.exec(t.resolve)?.slice(1)??[]:[t.resolve]:[],i=performance.now();return new Promise(a=>{let o=ce.request({host:n||e,port:Number(r)||443,servername:e,path:`/`,method:`GET`,headers:{host:e,"user-agent":`landfall-check`},rejectUnauthorized:!1,agent:!1,timeout:t.timeout??1e4},t=>{let n=Math.round(performance.now()-i),r=t.socket,s=r.getPeerCertificate(),c=s?.valid_to?{valid:r.authorized,...!r.authorized&&{error:String(r.authorizationError??`not trusted`)},expires:new Date(s.valid_to).toISOString(),days:Math.floor((new Date(s.valid_to).getTime()-Date.now())/864e5),issuer:[s.issuer?.O,s.issuer?.CN].filter(Boolean).join(` · `)}:void 0;t.resume(),o.destroy();let l=t.statusCode??0;a({domain:e,ok:l<500&&!!c?.valid&&c.days>=7,status:l,ms:n,...c&&{tls:c}})});o.on(`timeout`,()=>o.destroy(/* @__PURE__ */ Error(`timed out`))),o.on(`error`,t=>a({domain:e,ok:!1,error:t?.code??t?.message??String(t)})),o.end()})}async function Sc(e){let{login:t,probe:n,...r}=await So(e);return{...r,rules:r.rules.map((e,t)=>({n:t+1,...e})),asRoot:Y(t).user===`root`}}async function Cc(e,t){let n=Es(e),[r,i]=await Promise.all([Promise.all(n.domains.map(e=>Os(t,e))),As(t)]);return{...n,dns:r,suggest:i}}async function wc(e,t){let n=await on(t),[r,i]=await Promise.all([gn(t,n),ln(t,n)]),a=_n(r);return{name:e,...n,...i,ips:r,sslipIp:a??null,sslipSuffix:a?vn(``,a).slice(1):null}}const Tc=e=>Rs(e),Ec=e=>Vs(e),Dc=e=>Ks(e);async function Oc(e,t){return Fi(e,t)}async function kc(e,t){return(await Zo(e)).reverse().slice(0,t).map(e=>({...e,pending:Go(e).length}))}async function Ac(e,t=!1){let n=await Hi(e,t),r=await ra(e);return{packages:n,images:await sa(e),engine:{...r,...ia(r)}}}async function jc(e,t){let n=await gn(e),[r,i]=await Promise.all([yn(t,n),Promise.all(t.map(e=>xc(e)))]);return{ips:n,domains:r.map((e,t)=>({...e,https:i[t]}))}}const Mc=(e,t)=>fc(e,t),Nc=e=>bc(e);async function Pc(e,t,n,r,i,a){let o=await vr(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&T(`${t.join(`, `)} ${t.length>1?`are`:`is`} also routed to ${e.name}${e.dir?` (${e.dir})`:``}, which landfall did not deploy: Traefik sends visitors to only one of them. Remove the labels there to hand ${t.length>1?`them`:`it`} over.`)}let s=o.sites.map(e=>e.meta).filter(e=>e.name!==n&&Nn(e).some(e=>r.includes(e)));for(let e of s){let t=Nn(e).filter(e=>r.includes(e)),i=Dn(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=xn(e)?`redeploy it first (lf deploy) so it can hand them over`:e.adopted?`its compose files in ${e.adopted} route ${i.length>1?`them`:`it`}`:`its compose.yaml routes ${i.length>1?`them`:`it`} (x-landfall.domains)`;D(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&D(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await O(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await Or(e,a,i,`moved ${a.domains.filter(e=>r.includes(e)).join(`, `)} from ${a.name} to ${n}`,e=>void(e.domains=e.domains.filter(e=>!r.includes(e)))),ir(t,a.name,a.domains),w(x.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const Fc=j({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
|
|
1070
1087
|
|
|
1071
1088
|
Without flags it lists and checks; --add/--rm/--set change the site (undoable)
|
|
1072
|
-
and update .landfall.json of remembered projects.`,options:{server:M,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=
|
|
1089
|
+
and update .landfall.json of remembered projects.`,options:{server:M,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=ao(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await cr(i,n,a?{deployed:`change the domains of`}:{});if(!a){let{ips:e,domains:r}=await jc(i,Nn(o));if(t.json)return void console.log(JSON.stringify({site:n,ips:e,domains:r},null,2));for(let e of r){let t=e.ok?x.green(`dns ok`):e.ips.length?x.yellow(`dns → ${e.ips.join(`, `)}`):x.red(`no dns`),{status:n,error:r,tls:i}=e.https,a=n?i&&!i.valid?x.red(`https ${n}, certificate: ${i.error}`):(n<400?x.green:n===401?x.cyan:x.yellow)(`https ${n}`):x.red(`https ${r}`);console.log(`${x.cyan(`https://${e.domain}`)} ${t} ${a}`)}r.some(e=>!e.ok)&&console.log(x.dim(`\npoint an A record at ${e.join(` or `)} for each domain`));return}Dn(o)||D(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Ve(e.trim(),i)).filter(Boolean),l=t.set?c(t.set):[...o.domains];l=[.../* @__PURE__ */ new Set([...l,...c(t.add)])].filter(e=>!c(t.rm).includes(e));let u=l.filter(e=>!Me.test(e));u.length&&D(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&T(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return C(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await O(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await Pc(i,r,n,p,s))(),await Or(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),ir(r,n,l);let m=B(process.cwd());m?.name===n&&qe(process.cwd(),{...m,domains:l}),l.length?C(`${n} serves ${l.map(e=>x.cyan(`https://${e}`)).join(`, `)}`):C(`${n} serves no domain now ${x.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await gn(i);for(let e of await yn(p,ee))e.ok||T(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),Ic=j({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
|
|
1073
1090
|
|
|
1074
1091
|
lf auth my-site --user anna add or change a login (asks the password)
|
|
1075
1092
|
lf auth my-site --rm-user anna remove a login
|
|
1076
1093
|
lf auth my-site --off remove all logins: the site is public again
|
|
1077
|
-
lf auth my-site show who can log in`,options:{server:M,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=
|
|
1094
|
+
lf auth my-site show who can log in`,options:{server:M,user:{type:`string`,short:`u`,desc:`login to add or change (default with --password: admin)`},password:{type:`string`,short:`p`,desc:`its password (asked when missing; visible in ps, prefer --password-stdin)`},"password-stdin":{type:`boolean`,desc:`read the password from stdin`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=ao(e,t.server),{meta:i,proxy:a}=await cr(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(Va),s=t[`password-stdin`]?(await y()).replace(/\r?\n$/,``):t.password,c=t.user??(s?`admin`:void 0);if(!c&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${x.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>x.bold(e)).join(`, `)}`);return}let l=t.off?[]:[...i.auth??[]],u=new Set(t[`rm-user`]??[]);for(let e of u)o.includes(e)||T(`no login ${e} on ${n}`);if(c&&u.add(c),l=l.filter(e=>!u.has(Va(e))),c&&l.push(await Ua(c,s)),l.join(`
|
|
1078
1095
|
`)===(i.auth??[]).join(`
|
|
1079
|
-
`))return C(`nothing to change`);let d=l.map(
|
|
1096
|
+
`))return C(`nothing to change`);let d=l.map(Va),f=o.filter(e=>!d.includes(e));d.length?f.length&&await O(`Remove the login${f.length>1?`s`:``} ${f.join(`, `)} from ${n}?`):await O(`Remove every login from ${n}? Anyone can open it then.`),await Or(r,i,a,d.length?`login for ${n}: ${d.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=l.length?l:void 0)),C(d.length?`${n} asks for a login (${d.join(`, `)})`:`${n} is public again`)}}),Lc=j({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:M},async run([e],t){let{site:n,server:r}=ao(e,t.server),{meta:i}=await cr(r,n),a=Nn(i)[0];a||D(`${n} serves no domain`);let o=`https://${a}`;console.log(x.cyan(o)),ve(o)}}),Rc=[`tunnel-only`,`email`,`keep`,`default`];function zc(){return{local:[{path:I(P),what:`servers and these defaults`},{path:I(m(De,`projects.json`)),what:`folders deployed from here (web UI Recent)`},{path:I(m(De,`ui-token`)),what:`web UI login token`},{path:I(m(De,`uploads`)),what:`folders dropped into the web UI, removed after a day`},{path:I(Vt),what:`Host <name> blocks between # >>> landfall:<name> markers`},{path:`~/.ssh/landfall_<name>`,what:`ssh key per server (lf keygen, lf setup)`},{path:`./.landfall.json`,what:`a project's deploy settings, saved by lf deploy`}],server:[{path:`${V}/README`,what:`how to run it all by hand, without landfall`},{path:`${H}/<site>`,what:`a site: .env, site.json, current -> releases/<id>`},{path:U,what:`landfall's Traefik: docker-compose.yml, letsencrypt/`},{path:Je,what:`Traefik file provider; files you add here are kept`},{path:W,what:`only when sites use a Traefik the server already had`},{path:G,what:`every change with its undo (lf log, lf undo)`},{path:Qe,what:`ssh hardening from lf setup`},{path:`user deploy`,what:`lf setup's deploy user (--deploy-user), in group docker`},{path:`packages`,what:`Docker + compose plugin, ufw, fail2ban, unattended-upgrades`},{path:`.<file>.landfall-*`,what:`backups next to each file landfall changed`}]}}function Bc(){let e=L(),t=e.defaults??{};return{configFile:I(P),default:e.default??null,defaults:t,tunnelOnly:t.tunnelOnly??!1,email:t.email??null,keep:t.keep??5,locations:zc()}}const Vc=j({name:`config`,summary:`Show or change landfall's defaults for every server.
|
|
1080
1097
|
|
|
1081
1098
|
A server's own setting wins over these (lf server config shows which applies):
|
|
1082
1099
|
--tunnel-only new sites get no domain unless -d gives one; reach them with lf tunnel
|
|
@@ -1085,7 +1102,7 @@ A server's own setting wins over these (lf server config shows which applies):
|
|
|
1085
1102
|
--keep releases a deploy keeps for rollback (per deploy: lf deploy --keep)
|
|
1086
1103
|
--default the server commands use without --server (same as lf server use)
|
|
1087
1104
|
|
|
1088
|
-
--unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${
|
|
1105
|
+
--unset <key> goes back to landfall's built-in value.`,options:{"tunnel-only":{type:`boolean`,desc:`new sites get no domain unless -d gives one (--no-tunnel-only)`},email:{type:`string`,desc:`Let's Encrypt contact e-mail`},keep:{type:`string`,desc:`releases to keep for rollback (built in: 5)`},default:{type:`string`,desc:`default server`},unset:{type:`string`,multiple:!0,desc:`forget a setting: ${Rc.join(`, `)}`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=t.unset??[],r=n.filter(e=>!Rc.includes(e));if(r.length&&D(`cannot unset ${r.join(`, `)}: one of ${Rc.join(`, `)}`),t[`tunnel-only`]!==void 0||t.email!==void 0||t.keep!==void 0||t.default!==void 0||n.length>0){t.default!==void 0&&Ae(t.default);let e=L(),r=e.defaults??={};t[`tunnel-only`]!==void 0&&(r.tunnelOnly=t[`tunnel-only`]),t.email!==void 0&&(r.email=Fe(t.email)),t.keep!==void 0&&(r.keep=z(t.keep,`--keep`,1)),n.includes(`tunnel-only`)&&delete r.tunnelOnly,n.includes(`email`)&&delete r.email,n.includes(`keep`)&&delete r.keep,n.includes(`default`)&&delete e.default,Object.keys(r).length||delete e.defaults,Oe(e),C(`saved`)}let i=Bc();if(t.json)return void console.log(JSON.stringify(i,null,2));let a=e=>i.defaults[e]===void 0?x.dim(` (built in)`):``,o=(e,t)=>console.log(` ${x.dim(e.padEnd(12))} ${t}`);console.log(`${x.bold(`landfall defaults`)} ${x.dim(i.configFile)}`),o(`default`,i.default??x.dim(`none (lf config --default <server>)`)),o(`new sites`,`${i.tunnelOnly?`no domain, tunnel only`:`get a domain`}${a(`tunnelOnly`)}`),o(`e-mail`,i.email??x.dim(`none`)),o(`keep`,`${i.keep} releases${a(`keep`)}`);for(let[e,t]of[[`on this machine`,i.locations.local],[`on each server`,i.locations.server]]){console.log(`\n${x.bold(e)}`);let n=Math.max(...t.map(e=>e.path.length));for(let e of t)console.log(` ${e.path.padEnd(n)} ${x.dim(e.what)}`)}}}),Hc=j({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||D(`usage: lf keygen <name>`);let{path:t,created:n}=Ur(e);C(`${n?`created`:`key exists:`} ${I(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Uc(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?T(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await he(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):T(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function Wc(e,t){if(S(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await me(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(w(x.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await k(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&Ce().email===void 0){let e=(await me(`Let's Encrypt e-mail (optional)`,``)).trim();t.email=e?Fe(e):void 0}let n=Be(e);t.dashboard??=await me(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function Gc(e,t){let n=Object.fromEntries(Object.entries(Kc.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return Kc.run([e],{...n,...t},[])}const Kc=j({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
|
|
1089
1106
|
|
|
1090
1107
|
Turns a fresh box into a deploy target (safe to re-run):
|
|
1091
1108
|
1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
|
|
@@ -1105,7 +1122,7 @@ missing. Steps that would break services already running there are skipped:
|
|
|
1105
1122
|
a Traefik already running there is used instead (lf traefik use)
|
|
1106
1123
|
Use --check to only see that report, --force to apply firewall/hardening anyway.
|
|
1107
1124
|
In a terminal, a first Traefik install asks for image, host network, e-mail and
|
|
1108
|
-
dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${
|
|
1125
|
+
dashboard unless given as flags.`,options:{host:{type:`string`,desc:`IP or hostname of a new server`},user:{type:`string`,short:`u`,desc:`initial login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port`},key:{type:`string`,short:`i`,desc:`use this private key instead of generating one`},"deploy-user":{type:`string`,desc:`user landfall deploys as (empty = keep login user)`,default:`deploy`},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},harden:{type:`boolean`,desc:`disable ssh password auth (--no-harden to skip)`,default:!0},firewall:{type:`boolean`,desc:`enable ufw (--no-firewall to skip)`,default:!0},traefik:{type:`boolean`,desc:`install traefik (--no-traefik to skip)`,default:!0},"ssh-config":{type:`boolean`,desc:`write Host block to ~/.ssh/config (--no-ssh-config)`,default:!0},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain> (lf server domain)`},dashboard:{type:`string`,desc:"traefik dashboard domain (`traefik` = traefik.<main domain>)"},image:{type:`string`,desc:`traefik image, remembered (default: ${Ze})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},http3:{type:`boolean`,desc:`traefik serves HTTP/3 on 443/udp, on by default; remembered (--no-http3)`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||D(`usage: lf setup <name> [--host <ip>]`);let n=L(),r=n.servers[e],i=Wt().includes(e)&&!en(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};if(!t.user&&t[`deploy-user`]&&Y(a).user===t[`deploy-user`]){let e;for(let t of[`root`,r?.admin])!e&&t&&await At({...a,user:t})&&(e=t);e?a.user=e:T(`${t[`deploy-user`]} can't change the system and root's key login does not work; pass -u <a sudo user> if setup fails`)}let s=Y(a);S(`setting up ${x.bold(e)} ${x.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>S(`[${++c}/5] ${e}`);l(`ssh key`);let u=await Gr(e,a,{check:t.check,password:await Vr(t[`password-stdin`])});if(!u||t[`key-only`])return;let d=o.readFileSync(`${u}.pub`,`utf8`).trim(),f=t[`deploy-user`]===s.user?``:t[`deploy-user`];l(`checking what is already set up`);let p={PUBKEY:d,DEPLOY_USER:f},[m,ee,te]=await Promise.all([da(a,p),ta(),na()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let ne=Number(m.values.ssh_port)||s.port,h=Ta(m,{sshPort:ne,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),g=h.find(e=>e.part===`traefik`),re={email:t.email&&Fe(t.email),staging:t.staging,image:t.image,hostNetwork:t[`host-network`],http3:t.http3,dashboard:t.dashboard,dashboardUser:Ha},ie=g&&!g.proxy&&g.status!==`conflict`&&g.status!==`off`;ie&&(g.detail+=x.dim(` · ${Ga(Wa(r??a,re))}`));let _=pn(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`,void 0,m.values.platform);w(`os: ${m.values.os??`unknown`}${m.values.pkg?x.dim(` · ${m.values.pkg}`):``}`),_.note&&(_.level===`eol`||_.level===`unsupported`?T:w)(_.note),ka(h),ue({checks:h.map(({part:e,label:t,status:n,detail:r})=>({part:e,label:t,status:n,detail:r}))});let ae=m.values.pkg===`none`&&`setup does not run here (${_.note}). Use \`lf server add ${e}\` to deploy to it as it is`;if(t.check)return void(ae&&T(ae));ae&&D(ae);let oe=await Uc(h,t.force),se=ie&&g.status===`todo`&&pe(),v=t.domain?Ie(t.domain):r&&Be(r);if(v===void 0&&se){let e=await me(`main domain for sites, e.g. example.com (optional)`,``);v=e.trim()?Ie(e):void 0}se&&(await Wc({...r??a,domains:ze(r?.domains,v)},re),w(`traefik: ${Ga(Wa(r??a,re))}${re.dashboard?`, dashboard ${re.dashboard}`:``}`));let ce=h.filter(e=>e.status!==`done`&&oe.has(e.part)).length;if(ce&&!await k(`apply the ${ce} change${ce===1?``:`s`} above?`))return;let y=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];h.some(e=>y.includes(e.part)&&e.status!==`done`&&oe.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await J(a,K+ut,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:ne,HARDEN:oe.has(`harden`),FIREWALL:oe.has(`firewall`),HTTP3:(re.http3??r?.http3)!==!1}}),C(`server bootstrapped`)):(l(`bootstrap`),C(`already bootstrapped, nothing to change`));let le={...a};if(f){let e={...a,user:f};await At(e)?(C(`deploy user ${x.bold(f)} ready`),le=e):T(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let b=qr(e,le,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});v&&(b.domains=ze(b.domains,v)),s.user!==`root`&&Y(le).user!==s.user&&(b.admin=s.user),n.servers[e]=b,n.default??=e,Oe(n),l(`traefik`),g?.proxy?oe.has(`traefik`)&&g.status===`todo`?await qa(b,g.proxy):C(g.detail):oe.has(`traefik`)?await Ka(e,b,re):w(x.dim(`skipped`)),v?(console.log(`\n${x.green(`Done.`)} Point ${x.bold(v)} and ${x.bold(`*.${v}`)} (A records) at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${x.dim(`# https://blog.${v}`)}`),console.log(` lf server domain ${e} ${x.dim(`# check the DNS`)}\n`)):(console.log(`\n${x.green(`Done.`)} Point a domain's A record at ${x.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`)),await Ii(e,b,a)}}),qc={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Jc(e,t){if(!t)return e;let n=Va(t);return[...(e??[]).filter(e=>Va(e)!==n),t]}const Yc=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Xc(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Yc(new Date(n)));)n+=1e3;return Yc(new Date(n))}const Zc=j({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
|
|
1109
1126
|
|
|
1110
1127
|
Kinds:
|
|
1111
1128
|
compose the folder has a compose.yaml: its services run on the server as one
|
|
@@ -1119,15 +1136,15 @@ so \`docker compose\` works there without landfall. Settings are saved to
|
|
|
1119
1136
|
./.landfall.json, so later deploys are just \`lf deploy\`.
|
|
1120
1137
|
|
|
1121
1138
|
A server landfall has not set up yet is set up first (\`lf setup\`), asking
|
|
1122
|
-
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:M,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env (- reads stdin)`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback (default: 5, lf config --keep)`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=
|
|
1123
|
-
`)}`),Se={id:ye,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);N=[];let c=!1;if(
|
|
1139
|
+
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:M,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},"no-domain":{type:`boolean`,desc:`serve no domain: reach it with lf tunnel until lf domains --add gives it one`},file:{type:`string`,short:`f`,desc:`compose file to deploy (default: compose.yaml)`},spa:{type:`boolean`,desc:`single-page app: unknown paths serve index.html`},image:{type:`string`,desc:`deploy a docker image instead of files`},port:{type:`string`,desc:`container port (image/dockerfile; default: EXPOSE or 3000)`},static:{type:`boolean`,desc:`force static even if a Dockerfile or compose file exists`},env:{type:`string`,short:`e`,multiple:!0,desc:`set KEY=VALUE in the site's .env`},"env-file":{type:`string`,desc:`merge a local .env file into the site's .env (- reads stdin)`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback (default: 5, lf config --keep)`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},host:{type:`string`,desc:`IP or hostname of a new server: set it up as --server first`},user:{type:`string`,short:`u`,desc:`with --host: initial login user (default: root)`},"ssh-port":{type:`string`,desc:`with --host: ssh port`},"password-stdin":{type:`boolean`,desc:`setting up first: read the login password from stdin (also: $LANDFALL_PASSWORD)`},"auth-user":{type:`string`,desc:`protect the site with this login (HTTP basic auth, as lf auth; default with --auth-password: admin)`},"auth-password":{type:`string`,desc:`password of the login (asked when only --auth-user is given)`}},async run([e],t){let n=process.cwd(),i=B(n)??{};i.name&&w(x.dim(`using ${Ge}`));let{name:a,server:s}=R(t.server??i.server);t.host&&!t.server&&!i.server&&!L().default&&D(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await Mt(s);if(c!==`ready`){let e=c===`bare`?`is not set up by landfall yet`:`does not let landfall log in with a key`;!t.host&&!(pe()&&await k(`${a} ${e}. Set it up now (lf setup)?`))&&D(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await Gc(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=R(a),await Mt(s)!==`ready`&&D(`${a} is still not set up`)}let l=ar(t.name??i.name??Qn(f(n))),u=z(t.keep??Ce().keep??5,`--keep`,1),d=!!t[`no-domain`]||(t.domain?.includes(!1)??!1),h=t.domain?.filter(e=>typeof e==`string`);d&&h?.length&&D(`--no-domain and --domain exclude each other`);let g=(h?.length?h:i.domains??[]).map(e=>Ve(e,s)).filter(e=>!Me.test(e));g.length&&D(`not a domain: ${g.join(`, `)}`);let re=t[`auth-user`]??(t[`auth-password`]?`admin`:void 0),ie=re?await Ua(re,t[`auth-password`],`--auth-user`):void 0,_=t.build??i.build;if(_){S(`building locally: ${_}`);let e=r(_,{shell:!0,stdio:`inherit`});e.status!==0&&D(`build failed (exit ${e.status})`)}let ae=t.static??i.static,oe=t.image??(ae?void 0:i.image),se=oe&&Re(oe),v=ne(n,e??i.dir??`.`),ce=se||ae?void 0:t.file??$n(v,i),y=ce&&ee(ce);y?.includes(`/`)&&D(`${ce} is not in the folder deployed; deploy its folder instead: lf deploy ${p(m(te(n,v)||`.`,y))} -f ${f(y)}`),y&&!o.existsSync(m(v,y))&&D(`no ${y} in ${te(n,v)||`.`}`);let le=Yn(n),b=y||se?v:ne(n,e??i.dir??Xn(n,le)),E=y?`compose`:se?`image`:!ae&&o.existsSync(m(b,`Dockerfile`))?`dockerfile`:`static`,de=E===`static`&&!e&&!i.dir?le.tool:void 0;de&&w(x.dim(`publishing ${te(n,b)}: where ${de} builds the site (another folder: lf deploy <dir>)`)),E===`static`&&!o.existsSync(m(b,`index.html`))&&T(`no index.html in ${te(n,b)||`.`}${de&&!_?`: build first, or let deploy do it (--build "${le.build}")`:``}`);let fe=qc[E].port?z(t.port??i.port??(E===`dockerfile`?Zn(m(b,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,he=E===`static`&&!!(t.spa??i.spa);S(`deploying ${x.bold(l)} (${E}) to ${x.bold(a)}`);let O=await sr(s,l),{env:A,proxy:ge,exists:_e}=O,ve=O.meta&&xn(O.meta)?O.meta:void 0,j=ve?void 0:O.meta;j?.adopted&&D(`${l} is adopted (${j.adopted}): deploy it there with docker compose, or pick another --name`),ve?T(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):j&&j.kind!==E&&w(x.dim(`site was ${j.kind}, now ${E}`));let M;(t.env?.length||t[`env-file`])&&(M=A,t[`env-file`]&&(M=Pr(M,await Ir(t[`env-file`]))),t.env?.length&&(M=Pr(M,Lr(t.env))));let ye=Xc((O.meta?.releases??[]).map(e=>e.id)),be=`${Z(l)}/releases/${ye}`,xe=E===`compose`?{}:{"compose.yaml":Gn(E,{port:fe,image:se}),...E===`static`&&{"nginx.conf":Kn(he)}},Se,N,Te;try{let e=qc[E].files;if(e!==void 0){S(`uploading ${te(n,b)||`.`}`);let t=j?.kind===E?j.current:void 0,r=await Rt(s,b,be+e,t&&`${Z(l)}/current${e}`);r===`rsync`?w(x.dim(`sent only what changed since release ${t}`)):r===`tar:no-local-rsync`?w(x.dim(`sent every file: install rsync on this machine to send only what changed`)):r===`tar:no-server-rsync`&&w(x.dim(`sent every file: ${a} has no rsync, \`lf setup ${a}\` installs it to send only what changed`))}let t=await yr(s,l,ye,{file:y,generated:xe,env:M}),r=Bn(t.config,t.dir,e=>Ve(e,s));for(let e of r.warnings)T(e);r.errors.length&&D(`${y??`compose.yaml`} cannot be deployed:\n${r.errors.map(e=>` - ${e}`).join(`
|
|
1140
|
+
`)}`),Se={id:ye,at:(/* @__PURE__ */ new Date()).toISOString(),services:r.services};let o=r.services.flatMap(e=>e.domains??[]);N=[];let c=!1;if(En(Se)){c=!h?.length&&!!i.domains?.length,N=d?[]:[h,i.domains,j?.domains,ve?.domains].find(e=>e?.length)??[];let e=d||!N.length&&(!!j&&!!Dn(j)&&!j.domains.length||i.domains?.length===0);if(!N.length&&!e&&we(s)&&(e=!0),!N.length&&!e){let t=Be(s),n=t?void 0:_n(await gn(s)),r=await me(`Domain(s) to serve (comma separated; none: reach it by tunnel only):`,t?`${l}.${t}`:n?vn(l,n):void 0);e=r===`none`,e||(N=r.split(/[\s,]+/).filter(Boolean))}N=[...new Set(N.map(e=>Ve(e,s)))];let t=N.filter(e=>!Me.test(e)),n=N.filter(e=>o.includes(e));!N.length&&!e&&D(`at least one --domain is required`),e&&w(x.dim(`${l} serves no domain: reach it with lf tunnel ${l}, give it one with lf domains ${l} --add <domain>`)),t.length&&D(`not a domain: ${t.join(`, `)}`),n.length&&D(`${n.join(`, `)} is already routed by x-landfall.domains`)}else h?.length&&T(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);Te=await Pc(s,a,l,[...N,...o],ge,c?`${Ge} lists it; deploy with -d to move it`:void 0)}catch(e){throw await xr(s,l,ye).catch(()=>{}),e}let Ee=(/* @__PURE__ */ new Date()).toISOString(),De={v:2,name:l,kind:E,domains:N,auth:Jc(j?.auth??ve?.auth,ie),current:ye,releases:[...j?.releases??[],Se],createdAt:j?.createdAt??ve?.createdAt??Ee,updatedAt:Ee};await Te(),await kr(s,De,ge,{env:M,build:!0,pull:!0,keep:u,prev:j,takeover:ve,existed:!j&&!ve&&_e,what:`deploy ${l} release ${ye}${j?``:` (new site, ${Nn(De).join(`, `)})`}`});let P=Nn(De);if(t.save){let e={name:l,server:a,domains:N};if(E===`image`)Object.assign(e,{image:se,port:fe});else{let r=te(n,b)||`.`;E===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=y)):r!==Xn(n,le)&&(e.dir=r),E===`dockerfile`&&(e.port=fe),he&&(e.spa=!0),ae&&E===`static`&&(e.static=!0)}_&&(e.build=_),qe(n,e),rr({path:n,name:l,server:a,domains:P,kind:E})}C(`release ${x.bold(ye)} live`);for(let e of P)console.log(` ${x.cyan(`https://${e}`)}`);P[0]&&ue({url:`https://${P[0]}`}),P[0]&&await Qc(P[0])}});async function Qc(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 $c=j({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
|
|
1124
1141
|
|
|
1125
1142
|
compose.yaml what runs on the server; x-landfall routes a service through Traefik
|
|
1126
1143
|
compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
|
|
1127
1144
|
publishes the port on localhost
|
|
1128
1145
|
|
|
1129
1146
|
\`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
|
|
1130
|
-
files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||D(`${e} is not a folder`);let r=
|
|
1147
|
+
files are never overwritten.`,options:{port:{type:`string`,desc:`container port (default: EXPOSE, the saved port or 3000)`},postgres:{type:`boolean`,desc:`add a Postgres service with a volume; the app gets DATABASE_URL`}},async run([e],t){let n=ne(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||D(`${e} is not a folder`);let r=er(n);r.kind===`compose`&&D(`${r.compose} exists already: add x-landfall: { port: <port> } to the service Traefik should serve`);let i={"compose.yaml":``,"compose.override.yaml":``},a=Object.keys(i).filter(e=>o.existsSync(m(n,e)));a.length&&D(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:z(t.port??r.port??3e3,`--port`,1,65535),c=r.kind===`static`?8080:s,l=!!t.postgres,u;if(r.kind===`static`){let e=r.dir??`dist`;r.dir||w(x.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
|
|
1131
1148
|
image: nginx:alpine
|
|
1132
1149
|
restart: unless-stopped
|
|
1133
1150
|
volumes:
|
|
@@ -1172,31 +1189,31 @@ services:
|
|
|
1172
1189
|
${d}:
|
|
1173
1190
|
ports:
|
|
1174
1191
|
- "127.0.0.1:${c}:${s}"
|
|
1175
|
-
`;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),
|
|
1176
|
-
no access to docker: containers not shown (run lf setup)`))}const
|
|
1177
|
-
With a site, only its containers.`,options:{server:M,watch:{type:`boolean`,short:`w`,desc:`refresh until Ctrl-C`},interval:{type:`string`,desc:`seconds between refreshes with --watch`,default:`5`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=
|
|
1178
|
-
no access to docker: images, volumes and logs not shown`)),t.sites.length&&(console.log(`\n${x.bold(`Sites`)} ${x.dim(`(${
|
|
1192
|
+
`;for(let[e,t]of Object.entries(i))o.writeFileSync(m(n,e),t);let f=te(process.cwd(),n)||`.`;C(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(m(n,`.env`))&&w(`locally: put POSTGRES_PASSWORD=… in ${m(f,`.env`)} (keep it out of git)`),w(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),w(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),el=(e,t)=>t?Math.round(e/t*100):0;function tl(e){let t=Math.max(0,Math.min(10,Math.round(e/10)));return(e>=90?x.red:e>=70?x.yellow:x.green)(`█`.repeat(t))+x.dim(`░`.repeat(10-t))}const nl=e=>`${X(e)}/s`,rl=e=>jn(e)?/unhealthy|restarting/i.test(e)?x.yellow(e):x.green(e):Mn(e)?x.dim(e):x.red(e);function il(e,t,n){let r=n?t.containers.filter(e=>e.site===n||e.project===n):t.containers;if(n&&!r.length&&D(`no containers of "${n}" on ${e}. See \`lf ls\``),!n){let n=t.mem.total-t.mem.available,r=t.mem.swapTotal-t.mem.swapFree,i=[[`cpu`,tl(t.cpu),`${t.cpu}%`,x.dim(`${t.cpus} cores · load ${t.load.join(` `)}`)],[`memory`,tl(el(n,t.mem.total)),`${el(n,t.mem.total)}%`,x.dim(`${X(n)} / ${X(t.mem.total)}`)],...t.mem.swapTotal?[[`swap`,tl(el(r,t.mem.swapTotal)),`${el(r,t.mem.swapTotal)}%`,x.dim(`${X(r)} / ${X(t.mem.swapTotal)}`)]]:[],...t.disks.map(e=>[`disk ${e.mount}`,tl(el(e.used,e.total)),`${el(e.used,e.total)}%`,x.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)]),...t.net.map(e=>[`net ${e.iface}`,``,``,`↓ ${nl(e.rx)} ↑ ${nl(e.tx)}`])];console.log(x.bold(e)+x.dim(` · up ${bn(t.uptime)}`)),A(i.map(([e,...t])=>[x.dim(e),...t])),t.procs.length&&(console.log(`\n${x.bold(`Processes`)} ${x.dim(`(cpu: 100% = one core)`)}`),A([[`pid`,`cpu`,`memory`,`name`].map(x.dim),...t.procs.slice(0,8).map(e=>[String(e.pid),`${e.cpu}%`,X(e.rss),e.name])]))}t.docker?r.length&&(console.log(`\n${x.bold(`Containers`)}`),A([[`site`,`container`,`cpu`,`memory`,`net in/out since start`,`restarts`,`status`].map(x.dim),...r.map(e=>[e.site??x.dim(e.project||`-`),e.name,jn(e.status)?`${Math.round(e.cpu*10)/10}%`:``,jn(e.status)?`${X(e.mem)}${e.memLimit&&e.memLimit<t.mem.total?x.dim(` (limit ${X(e.memLimit)})`):``}`:``,jn(e.status)?`↓ ${X(e.net.rx)} ↑ ${X(e.net.tx)}`:``,e.restarts?x.yellow(String(e.restarts)):x.dim(`0`),rl(e.status)])])):console.log(x.dim(`
|
|
1193
|
+
no access to docker: containers not shown (run lf setup)`))}const al=j({name:`stats`,args:`[site]`,summary:`Show live CPU, memory, disk, network, the busiest processes and each container's usage.
|
|
1194
|
+
With a site, only its containers.`,options:{server:M,watch:{type:`boolean`,short:`w`,desc:`refresh until Ctrl-C`},interval:{type:`string`,desc:`seconds between refreshes with --watch`,default:`5`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server);if(t.json){t.watch&&D(`--watch and --json do not go together: run lf stats --json in a loop`),console.log(JSON.stringify(await Rs(r),null,2));return}if(!t.watch)return il(n,await Rs(r),e);let i=z(t.interval,`--interval`,1,3600)*1e3;for(;;){let t=await Rs(r);process.stdout.isTTY&&process.stdout.write(`\x1B[H\x1B[2J`),il(n,t,e),console.log(x.dim(`\n${(/* @__PURE__ */ new Date()).toLocaleTimeString()} · every ${i/1e3}s · Ctrl-C to stop`)),await new Promise(e=>setTimeout(e,i))}}});function ol(e,t){console.log(x.bold(e)),A(t.disks.map(e=>[x.dim(e.mount),tl(el(e.used,e.total)),`${el(e.used,e.total)}%`,x.dim(`${X(e.used)} / ${X(e.total)}, ${X(e.available)} free`)])),t.docker&&t.dockerDf.length?(console.log(`\n${x.bold(`Docker`)}`),A([[``,`count`,`in use`,`size`,`reclaimable`].map(x.dim),...t.dockerDf.map(e=>[e.type,String(e.count),String(e.active),X(e.size),e.reclaimable?x.yellow(X(e.reclaimable)):x.dim(`0`)])])):t.docker||console.log(x.dim(`
|
|
1195
|
+
no access to docker: images, volumes and logs not shown`)),t.sites.length&&(console.log(`\n${x.bold(`Sites`)} ${x.dim(`(${V} ${X(t.landfall)})`)}`),A([[`site`,`files`,`releases`].map(x.dim),...t.sites.map(e=>[e.name,X(e.size),`${e.releases} · ${X(e.releasesSize)}`])]));let n=t.volumes.filter(e=>e.size>0).slice(0,10);n.length&&(console.log(`\n${x.bold(`Volumes`)} ${x.dim(`(largest)`)}`),A([[`volume`,`size`,`used by`].map(x.dim),...n.map(e=>[e.name,X(e.size),e.links?String(e.links):x.yellow(`nothing`)])]));let r=t.logs.filter(e=>(e.size??0)>0).slice(0,10);r.length&&(console.log(`\n${x.bold(`Container logs`)} ${x.dim(`(largest)`)}`),A(r.map(e=>[e.container,X(e.size)])));let i=[t.journald?`systemd journal: ${X(t.journald)}`:``,t.root?``:`sizes below root-owned folders and container logs need root or passwordless sudo: they may be missing`].filter(Boolean);i.length&&console.log(`
|
|
1179
1196
|
`+i.map(e=>x.dim(e)).join(`
|
|
1180
|
-
`))}const
|
|
1181
|
-
With a site (or a domain), only its requests.`,options:{server:M,since:{type:`string`,desc:`how far back: 30m, 24h, 7d (up to what the container log still holds)`,default:`24h`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=
|
|
1182
|
-
lf traffic <site> for its paths, errors and clients`))}});function
|
|
1183
|
-
Exits 1 when one fails: use it from cron or CI.`,options:{server:M,resolve:{type:`string`,desc:`connect to this ip[:port] instead of what DNS says (test before DNS points here)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=e.filter(
|
|
1184
|
-
Keeps images any container uses (stopped ones too), images of site releases (for rollback) and the ones undo needs. Volumes and containers stay.`,options:{server:M,all:{type:`boolean`,desc:`also remove unused tagged images (pulled again when needed) and all build cache`},"dry-run":{type:`boolean`,desc:`only show what would go`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=
|
|
1185
|
-
`)[0]:e.trim();return
|
|
1197
|
+
`))}const sl=j({name:`disk`,summary:`Show where a server's disk space goes: filesystems, Docker images, volumes, build cache, sites and container logs.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=await Vs(r);if(t.json)return void console.log(JSON.stringify(i,null,2));ol(n,i)}}),cl=e=>e.toLocaleString(`en-US`),ll=e=>e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`;function ul(e){let t=e.map(e=>e[`2xx`]+e[`3xx`]+e[`4xx`]+e[`5xx`]),n=Math.max(1,...t),r=Math.ceil(e.length/100),i=``;for(let a=0;a<e.length;a+=r){let o=t.slice(a,a+r).reduce((e,t)=>e+t,0)/r,s=e.slice(a,a+r).some(e=>e[`5xx`]),c=o?`▁▂▃▄▅▆▇█`[Math.min(7,Math.ceil(o/n*8)-1)]:` `;i+=s?x.red(c):x.cyan(c)}return i}const dl=e=>[cl(e.requests),e.classes[`4xx`]?x.yellow(cl(e.classes[`4xx`])):x.dim(`0`),e.classes[`5xx`]?x.red(cl(e.classes[`5xx`])):x.dim(`0`),e.requests?ll(e.p50):``,e.requests?ll(e.p95):``,X(e.bytes)];function fl(e,t){A([[`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(x.dim),dl(e)]),console.log(`\n${x.dim(`requests per ${t}`)} ${ul(e.series)}`),e.paths.length&&(console.log(`\n${x.bold(`Paths`)}`),A(e.paths.map(e=>[cl(e.count),e.errors?x.yellow(`${cl(e.errors)} failed`):``,e.path]))),e.errors.length&&(console.log(`\n${x.bold(`Errors`)}`),A(e.errors.map(e=>[cl(e.count),e.status>=500?x.red(String(e.status)):x.yellow(String(e.status)),e.path]))),e.ips.length&&(console.log(`\n${x.bold(`Clients`)}`),A(e.ips.map(e=>[cl(e.count),e.ip])));let n=Object.entries(e.codes).sort((e,t)=>t[1]-e[1]);n.length&&console.log(`\n${x.dim(`status codes`)} ${n.map(([e,t])=>`${e} ${cl(t)}`).join(x.dim(` · `))}`)}const pl=j({name:`traffic`,args:`[site]`,summary:`Show requests, errors, response times, busiest paths and clients from Traefik's access log.
|
|
1198
|
+
With a site (or a domain), only its requests.`,options:{server:M,since:{type:`string`,desc:`how far back: 30m, 24h, 7d (up to what the container log still holds)`,default:`24h`},json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=Qs(t.since),a=await fc(r,i);a.proxy||D(`no traefik on ${n} (lf traefik setup)`);let o=e?pc(a,e):null;if(t.json)return void console.log(JSON.stringify(e?{site:e,since:a.since,bucket:a.bucket,format:a.format,from:a.from,to:a.to,...o??{}}:a,null,2));if(a.format===`common`)return T(a.own?`${n}'s traefik writes its access log as plain text: add --accesslog.format=json to its command to see traffic here`:`traefik on ${n} writes its access log as plain text: \`lf traefik setup${t.server?` -s ${n}`:``}\` switches it to JSON (traefik restarts for a moment)`);let s=$s(i);if(a.format===`none`)return w(`no requests on ${n} in the last ${s}${a.own?x.dim(` (or its traefik has no access log: --accesslog=true --accesslog.format=json)`):``}`);let c=a.from&&new Date(a.from).getTime()-(Date.now()-i*1e3)>(a.bucket===`hour`?36e5:6e4)*2;if(console.log(`${x.bold(n)}${x.dim(` · last ${s}${c?` · the log reaches back to ${new Date(a.from).toLocaleString()}`:``}`)}`),e)return o||D(`no requests for "${e}" on ${n} in the last ${s}`),fl(o,a.bucket);let l=a.hosts.filter(e=>!e.site);A([[`site`,`requests`,`4xx`,`5xx`,`p50`,`p95`,`sent`].map(x.dim),...a.sites.map(e=>[x.bold(e.site),...dl(e)]),...l.slice(0,8).map(e=>[x.dim(e.host),...dl(e)]),...l.length>8?[[x.dim(`${l.length-8} more hosts`),...dl(ml(l.slice(8)))]]:[],[x.dim(`total`),...dl(a.total)]]),console.log(`\n${x.dim(`requests per ${a.bucket}`)} ${ul(a.total.series)}`),a.total.errors.length&&(console.log(`\n${x.bold(`Errors`)}`),A(a.total.errors.slice(0,5).map(e=>[cl(e.count),e.status>=500?x.red(String(e.status)):x.yellow(String(e.status)),e.path]))),console.log(x.dim(`
|
|
1199
|
+
lf traffic <site> for its paths, errors and clients`))}});function ml(e){let t={requests:0,bytes:0,classes:{"2xx":0,"3xx":0,"4xx":0,"5xx":0},codes:{},p50:0,p95:0};for(let n of e){t.requests+=n.requests,t.bytes+=n.bytes;for(let e of Object.keys(t.classes))t.classes[e]+=n.classes[e]}return t}function hl(e){if(!e)return x.red(`none`);let t=`${e.days} days`;return e.days<7?x.red(e.days<0?`expired`:t):e.staging?x.yellow(`${t}, staging (not trusted)`):e.days<30?x.yellow(t):x.green(t)}const gl=j({name:`certs`,summary:`Show each site domain's HTTPS certificate: when it expires, who issued it, and why getting one failed.`,options:{server:M,since:{type:`string`,desc:`how far back to look for certificate errors`,default:`7d`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=await bc(r,Qs(t.since));if(t.json)return void console.log(JSON.stringify(i,null,2));if(i.proxy||D(`no traefik on ${n} (lf traefik setup)`),i.domains.length){A([[`domain`,`site`,`certificate`,`expires`,`issuer`].map(x.dim),...i.domains.map(e=>[e.domain,x.dim(e.site),hl(e.cert),e.cert?e.cert.expires.slice(0,10):``,x.dim(e.cert?.issuer??``)])]);for(let e of i.domains.filter(e=>e.error&&(!e.cert||e.cert.days<30)))T(`${e.domain}: ${e.error.error}${e.error.at?x.dim(` (${new Date(e.error.at).toLocaleString()})`):``}`)}else w(`no site domains on ${n}`);let a=new Set(i.domains.map(e=>e.cert)),o=i.certs.filter(e=>!a.has(e));o.length&&(console.log(`\n${x.bold(`Other certificates`)}`),A(o.map(e=>[e.domains.join(` `),hl(e),x.dim(e.path??`acme.json`)])));let s=i.errors.filter(e=>!e.domains.some(e=>i.domains.some(t=>t.domain===e))).slice(-3);for(let e of s)T(`${e.domains.join(` `)||`acme`}: ${e.error}`);i.denied.length&&T(`${i.denied.join(`, `)} needs root to read: ${n}'s login has no passwordless sudo`)}}),_l=e=>e.includes(`.`)&&Me.test(e.toLowerCase()),vl=j({name:`check`,args:`[site|domain…]`,summary:`Request each site over HTTPS from this machine: status, response time and days left on the certificate.
|
|
1200
|
+
Exits 1 when one fails: use it from cron or CI.`,options:{server:M,resolve:{type:`string`,desc:`connect to this ip[:port] instead of what DNS says (test before DNS points here)`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=e.filter(_l).map(e=>({domain:e,site:void 0})),r=e.filter(e=>!_l(e));if(!e.length||r.length){let{name:e,server:i}=R(t.server??B(process.cwd())?.server),a=await vr(i);for(let t of r)a.sites.some(e=>e.meta.name===t)||D(`no site "${t}" on ${e}. See \`lf ls\``);let o=a.sites.filter(e=>!r.length||r.includes(e.meta.name));if(n=[...n,...o.flatMap(e=>e.domains.filter(e=>!e.startsWith(`*.`)).map(t=>({domain:t,site:e.meta.name})))],!n.length)return w(`no site on ${e} has a domain`)}let i=await Promise.all(n.map(async e=>({...await xc(e.domain,{resolve:t.resolve}),...e.site&&{site:e.site}})));return t.json?console.log(JSON.stringify(i,null,2)):A([[``,`domain`,`site`,`status`,`time`,`certificate`].map(x.dim),...i.map(e=>[e.ok?x.green(`✓`):x.red(`✗`),e.domain,x.dim(e.site??``),e.error?x.red(e.error):(e.status??0)>=500?x.red(String(e.status)):String(e.status),e.ms===void 0?``:e.ms>1e3?x.yellow(ll(e.ms)):ll(e.ms),e.tls?e.tls.valid?e.tls.days<7?x.red(`${e.tls.days} days`):e.tls.days<30?x.yellow(`${e.tls.days} days`):x.green(`${e.tls.days} days`):x.red(e.tls.error??`not trusted`):``])]),+!i.every(e=>e.ok)}});function yl(e,t){let n=Us(t),r=[[Ws(t,`release`).length,`for rollback`],[Ws(t,`undo`).length,`for undo`],[Ws(t,`tagged`).length,`tagged but unused (--all removes them)`]].filter(([e])=>e);n.length?(console.log(`${x.bold(e)}: ${n.length} unused images`),A([[`image`,`size`,`created`].map(x.dim),...n.slice(0,20).map(e=>[e.tags.join(`, `)||x.dim(e.id.replace(`sha256:`,``).slice(0,12)),X(e.size),x.dim(e.created.split(` `)[0])])]),n.length>20&&console.log(x.dim(`… ${n.length-20} more`))):console.log(`${x.bold(e)}: no unused images`),console.log(`build cache: ${t.buildCache?X(t.buildCache):x.dim(`0`)}${t.all?``:x.dim(` reclaimable (--all also removes cache still in use)`)}`),r.length&&console.log(x.dim(`kept: ${r.map(([e,t])=>`${e} ${t}`).join(`, `)}`))}const bl=j({name:`prune`,summary:`Free disk space: remove unused Docker images and the build cache.
|
|
1201
|
+
Keeps images any container uses (stopped ones too), images of site releases (for rollback) and the ones undo needs. Volumes and containers stay.`,options:{server:M,all:{type:`boolean`,desc:`also remove unused tagged images (pulled again when needed) and all build cache`},"dry-run":{type:`boolean`,desc:`only show what would go`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=await Ks(r,!!t.all);i.docker||D(`no access to docker on ${n} (run lf setup)`);let a=Us(i);if(t[`dry-run`]||t.json&&!a.length&&!i.buildCache)return void(t.json?console.log(JSON.stringify(i,null,2)):yl(n,i));if(t.json||yl(n,i),!a.length&&!i.buildCache)return C(`nothing to clean up`);let o=a.reduce((e,t)=>e+t.size,0)+i.buildCache;if(!await k(`\nRemove ${a.length} images and the build cache (up to ${X(o)})?`,!0))return;t.json||S(`cleaning up`);let s=await qs(r,i);if(t.json)return void console.log(JSON.stringify({...i,removed:s.images-s.failed.length,failed:s.failed},null,2));for(let e of s.failed)T(`${e}: could not remove it (in use?)`);C(`removed ${s.images-s.failed.length} images and the build cache`)}}),xl=j({name:`reboot`,summary:`Reboot a server, wait until it is back and check its containers run again.`,options:{server:M,timeout:{type:`string`,desc:`seconds to wait for it to come back`,default:`300`}},async run(e,t){let{name:n,server:r}=R(t.server??B(process.cwd())?.server),i=z(t.timeout,`--timeout`,30,3600),a=await Q(r,`rebooting`);if(!await k(`Reboot ${n}? Its sites are offline for a minute or two.`,!1))return;let o=await Zs(a,{timeout:i,onWait:e=>S(e===`down`?`rebooting ${n}`:e===`ssh`?`waiting for ssh`:`waiting for containers`)});o.back||D(`${n} did not answer within ${i}s. Check its console at the provider`),C(`${n} is back after ${o.seconds}s`),o.missing.length?T(`not running again: ${o.missing.join(`, `)}. See lf stats -s ${n}`):w(`all containers that ran before run again`)}}),$=e=>R(e.server??B(process.cwd())?.server),Sl={type:`boolean`,desc:`machine-readable output`},Cl=e=>e.active===`failed`||e.active===`crashed`,wl=e=>e.active===`active`||e.active===`started`,Tl=e=>Yr.includes(Xr(e.unit));function El(e){return Cl(e)?x.red(e.sub||e.active):wl(e)?x.green(e.sub||e.active):x.dim(e.sub||e.active)}function Dl(e){e.root||w(x.dim(`${e.user} has no passwordless sudo: other users' keys, bans and daemon.json may be missing`))}const Ol=j({name:`system`,summary:`Show a server's basics: host name, time zone, swap, Docker log limits, failed services, SSH keys and bans.`,options:{server:M,json:Sl},async run(e,t){let{name:n,server:r}=$(t),i=await ni(r);if(t.json)return void console.log(JSON.stringify(i,null,2));let a=i.swap.reduce((e,t)=>e+t.size,0),o=i.docker;A([[x.dim(`host name`),i.hostname],[x.dim(`time zone`),`${i.timezone}${i.ntp===!1?x.yellow(` · clock not synced`):i.ntp?x.dim(` · synced`):``}`],[x.dim(`swap`),a?`${X(a)}${x.dim(` (${i.swap.map(e=>e.file).join(`, `)})`)}`:x.yellow(`none`)],[x.dim(`docker logs`),o?o.maxSize?`${o.driver}, ${o.maxSize} × ${o.maxFile??1} per container`:o.driver===`json-file`?x.yellow(`${o.driver}, unlimited`):o.driver:x.dim(`no docker`)],[x.dim(`services`),i.init===`other`?x.dim(`no systemd or OpenRC`):`${i.services.filter(wl).length} running${i.services.some(Cl)?x.red(` · ${i.services.filter(Cl).length} failed`):``}`],[x.dim(`ssh keys`),`${i.keys.length}`],[x.dim(`fail2ban`),i.fail2ban===`running`?`${i.jails.reduce((e,t)=>e+t.banned,0)} banned now`:x.dim(i.fail2ban===`missing`?`not installed`:`stopped`)]]),o?.daemonError&&T(o.daemonError);for(let e of i.services.filter(Cl))T(`${Xr(e.unit)} failed: lf services logs ${Xr(e.unit)} -s ${n}`);!a&&i.mem&&i.mem<4*1024**3&&w(x.dim(`little memory and no swap: lf swap 2G adds some`)),o&&!o.maxSize&&o.driver===`json-file`&&w(x.dim(`container logs grow without limit: lf log-limit 10m caps them`)),Dl(i)}}),kl=j({name:`services`,summary:`List services: the ones landfall relies on and failed ones; --all for every one.`,options:{server:M,all:{type:`boolean`,desc:`every service, inactive ones too`},json:Sl},async run(e,t){let{name:n,server:r}=$(t),i=await ti(r);if(t.json)return void console.log(JSON.stringify(i.services,null,2));i.init===`other`&&D(`no systemd or OpenRC on ${n}`);let a=t.all?i.services:i.services.filter(e=>Cl(e)||Tl(e)||wl(e)&&i.init===`openrc`),o=e=>Cl(e)?0:Tl(e)?1:2;a.sort((e,t)=>o(e)-o(t)||e.unit.localeCompare(t.unit)),A([[`service`,`state`,...i.init===`systemd`?[`boot`]:[],`description`].map(x.dim),...a.map(e=>[Xr(e.unit),El(e),...i.init===`systemd`?[x.dim(e.enabled??``)]:[],x.dim(e.description)])]),t.all||w(x.dim(`${i.services.length-a.length} more: lf services --all`))}});function Al(e){return j({name:`services ${e}`,args:`<service>`,summary:`${e[0].toUpperCase()}${e.slice(1)} a service (systemd or OpenRC).${e===`restart`?``:` Undo ${e===`stop`?`starts`:`stops`} it again.`}`,options:{server:M,force:{type:`boolean`,desc:`even ssh`}},async run([t],n){t||D(`usage: lf services ${e} <service>`),ri.test(t)||D(`not a service name: ${t}`);let{name:r,server:i}=$(n),a=Xr(t);if(e===`stop`&&/^sshd?$/.test(a)&&!n.force&&!await he(`Stopping ${a} can lock you out of ${r}. Do it anyway?`)&&D(`stopping ${a} can lock you out (--force to do it anyway)`),/^(docker|containerd)$/.test(a)&&e!==`start`&&!await k(`${e} ${a} on ${r}? Every site goes down${e===`stop`?` until it is started again`:` for a moment`}.`,!1))return;let o=await Q(i,`${e}ing a service`);S(`${e} ${a} on ${r}`),await ii(o,e,t),C(`${a}: ${e===`stop`?`stopped`:e===`start`?`started`:`restarted`}`)}})}const jl=Al(`restart`),Ml=Al(`start`),Nl=Al(`stop`),Pl=j({name:`services logs`,args:`<service>`,summary:`Show a service's log (journald, else its log file).`,options:{server:M,follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},async run([e],t){e||D(`usage: lf services logs <service>`);let{server:n}=$(t);return Dt(n,[ai(e,{lines:z(t.tail,`--tail`,1,1e5),follow:t.follow})])}}),Fl=e=>e.fingerprint.replace(/^SHA256:/,``).slice(0,12),Il=j({name:`keys`,summary:`List the SSH keys that may log in, per user. ★ marks the key this machine uses.`,options:{server:M,json:Sl},async run(e,t){let{server:n}=$(t),r=await ni(n);if(t.json)return void console.log(JSON.stringify(r.keys,null,2));r.keys.length||w(x.dim(`no authorized keys found`)),A([[``,`user`,`key`,`comment`].map(x.dim),...r.keys.map(e=>[e.mine?x.green(`★`):``,e.user,`${e.type.replace(/^ssh-/,``)} ${x.dim(Fl(e))}`,`${e.comment||x.dim(`(no comment)`)}${e.options?x.dim(` [${e.options}]`):``}`])]),Dl(r)}});function Ll(e){let t=F(e),n=!/^\S+ AAAA/.test(e)&&o.existsSync(t)?o.readFileSync(t,`utf8`).trim().split(`
|
|
1202
|
+
`)[0]:e.trim();return Qr(n)||D(`not a public key: ${e} (a line like ssh-ed25519 AAAA… or a .pub file)`),n}const Rl=j({name:`keys add`,args:`[key|file.pub]`,summary:`Let another SSH key log in (to the login user, or --user).
|
|
1186
1203
|
|
|
1187
1204
|
With --generate lf makes a new pair at ~/.ssh/<server>_<user> (asking for a
|
|
1188
1205
|
passphrase in a terminal) and adds its public key: hand the private key to
|
|
1189
|
-
whoever logs in as that user, or keep it if that is you.`,options:{server:M,user:{type:`string`,short:`u`,desc:`whose authorized_keys (default: the login user)`},generate:{type:`boolean`,desc:`make a new key pair here instead of passing one`}},async run([e],t){!e&&!t.generate&&D(`usage: lf keys add <key|file.pub>, or --generate`);let{name:n,server:r}=$(t),i=Y(r),a=t.user??i.user,o=t.generate?
|
|
1190
|
-
Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:M,files:{type:`string`,desc:`rotated files per container`,default:`3`},restart:{type:`boolean`,desc:`restart docker now (sites go down for a moment); without, it applies on its next start`},json:
|
|
1206
|
+
whoever logs in as that user, or keep it if that is you.`,options:{server:M,user:{type:`string`,short:`u`,desc:`whose authorized_keys (default: the login user)`},generate:{type:`boolean`,desc:`make a new key pair here instead of passing one`}},async run([e],t){!e&&!t.generate&&D(`usage: lf keys add <key|file.pub>, or --generate`);let{name:n,server:r}=$(t),i=Y(r),a=t.user??i.user,o=t.generate?zi(n,a):void 0,s=o?o.pub:Ll(e);await si(await Q(r,`adding a key`),a,s);let c=Qr(s);C(`${c.comment||Fl(c)} may log in to ${n} as ${a}`),o&&(w(`${o.created?`private key`:`used the existing key`}: ${I(o.path)}`),w(`log in: ssh -i ${I(o.path)}${i.port===22?``:` -p ${i.port}`} ${a}@${i.hostname}`),o.created&&!o.passphrase&&w(x.dim(`it has no passphrase; add one with ssh-keygen -p -f ${I(o.path)}`)),ue({keyFile:o.path}))}}),zl=j({name:`keys rm`,args:`<fingerprint|comment>`,summary:"Remove an SSH key: by (part of) its fingerprint or its comment, as `lf keys` shows them.",options:{server:M,user:{type:`string`,short:`u`,desc:`only this user's keys`},force:{type:`boolean`,desc:`even the key this machine logs in with`}},async run([e],t){e||D(`usage: lf keys rm <fingerprint|comment>`);let{name:n,server:r}=$(t),i=await ni(r),a=e.replace(/^SHA256:/,``),o=i.keys.filter(n=>(!t.user||n.user===t.user)&&(n.comment===e||a.length>=6&&n.fingerprint.slice(7).startsWith(a)));o.length||D(`no key ${e}${t.user?` for ${t.user}`:``} on ${n}: see lf keys`),o.length>1&&D(`${e} matches ${o.length} keys (${o.map(e=>`${e.user} ${Fl(e)}`).join(`, `)}): give more of the fingerprint or --user`);let s=o[0];s.mine&&!t.force&&!await he(`${s.comment||Fl(s)} is the key this machine logs in with: removing it can lock you out. Do it anyway?`)&&D(`that is the key this machine logs in with (--force to remove it anyway)`),await O(`Remove key ${s.comment||Fl(s)} of ${s.user} on ${n}?`),await ci(await Q(r,`removing a key`),s),C(`removed ${s.comment||Fl(s)} from ${s.user}`)}}),Bl=j({name:`bans`,summary:`Show what fail2ban blocks: banned IPs per jail.`,options:{server:M,json:Sl},async run(e,t){let{name:n,server:r}=$(t),i=await ni(r);if(t.json)return void console.log(JSON.stringify({fail2ban:i.fail2ban,jails:i.jails},null,2));if(i.fail2ban!==`running`)return!i.root&&i.fail2ban===`stopped`&&D(`${i.user} cannot read fail2ban without passwordless sudo`),w(i.fail2ban===`missing`?`fail2ban is not installed on ${n}`:`fail2ban is not running on ${n}`);for(let e of i.jails){console.log(`${x.bold(e.name)}: ${e.banned} banned now${x.dim(` · ${e.total} in total · ${e.failed} failing`)}`);for(let t of e.ips)console.log(` ${t}`)}i.jails.some(e=>e.ips.length)&&w(x.dim(`lf unban <ip> lets one in again`))}}),Vl=j({name:`unban`,args:`<ip>`,summary:`Let a banned IP in again (all jails that ban it). Undo bans it again.`,options:{server:M},async run([e],t){e||D(`usage: lf unban <ip>`);let{name:n,server:r}=$(t),i=await Q(r,`unbanning`),a=(await ti(i)).jails.filter(t=>t.ips.includes(e)).map(e=>e.name);a.length||D(`${e} is not banned on ${n}`),await ui(i,e,a),C(`unbanned ${e} (${a.join(`, `)})`)}}),Hl=j({name:`swap`,args:`[size|off]`,summary:`Show swap, add a swap file (e.g. 2G) or remove the one landfall made (off).`,options:{server:M,json:Sl},async run([e],t){let{name:n,server:r}=$(t),i=await ti(r);if(!e){if(t.json)return void console.log(JSON.stringify({swap:i.swap,swappiness:i.swappiness,mem:i.mem},null,2));if(!i.swap.length)return w(`no swap on ${n}${x.dim(` · ${X(i.mem)} memory · lf swap 2G adds a swap file`)}`);A([[`file`,`size`,`used`].map(x.dim),...i.swap.map(e=>[e.file,X(e.size),X(e.used)])]);return}i.managed&&D(i.managed);let a=await Q(r,`changing swap`);if(e===`off`){let e=i.swap.find(e=>e.file===`/swapfile`);return e?.used&&T(`${X(e.used)} of it is in use: it moves back to memory`),await O(`Remove the swap file /swapfile on ${n}?`),S(`turning swap off`),await gi(a),C(`removed /swapfile`)}let o=di(e);o<64*1024**2&&D(`at least 64M`),i.diskFree&&o>i.diskFree-2*1024**3&&D(`only ${X(i.diskFree)} free on /: keep 2 GB besides the swap file`),S(`adding ${X(o)} swap at /swapfile`),await mi(a,o),C(`${X(o)} swap on ${n}`)}}),Ul=j({name:`hostname`,args:`[name]`,summary:`Show or change the host name.`,options:{server:M},async run([e],t){let{name:n,server:r}=$(t),i=await ti(r);if(!e)return void console.log(i.hostname);if(e===i.hostname)return C(`already ${e}`);i.managed&&D(i.managed),await yi(await Q(r,`renaming the host`),i.hostname,e),C(`${n} is now called ${e}${x.dim(` (the shell prompt shows it on the next login)`)}`)}}),Wl=j({name:`timezone`,args:`[zone]`,summary:`Show or change the time zone (e.g. Europe/Berlin, UTC). Cron jobs and logs follow it.`,options:{server:M},async run([e],t){let{name:n,server:r}=$(t),i=await ti(r);if(!e)return void console.log(`${i.timezone}${x.dim(` · ${i.time}`)}`);if(e===i.timezone)return C(`already ${e}`);i.managed&&D(i.managed),await Si(await Q(r,`changing the time zone`),i.timezone,e),C(`${n}: time zone ${e}`)}}),Gl=j({name:`log-limit`,args:`[size|off]`,summary:`Show or cap how much log Docker keeps per container (e.g. 10m, with --files 3 rotated files).
|
|
1207
|
+
Applies to containers created afterwards: redeploy (or lf restart) sites to pick it up.`,options:{server:M,files:{type:`string`,desc:`rotated files per container`,default:`3`},restart:{type:`boolean`,desc:`restart docker now (sites go down for a moment); without, it applies on its next start`},json:Sl},async run([e],t){let{name:n,server:r}=$(t),i=await ti(r),a=i.docker;if(a||D(`no docker on ${n}`),!e){if(t.json)return void console.log(JSON.stringify({driver:a.driver,maxSize:a.maxSize,maxFile:a.maxFile},null,2));console.log(a.maxSize?`${a.driver}: ${a.maxSize} × ${a.maxFile??1} per container`:`${a.driver}: ${x.yellow(`no limit`)}`);return}i.managed&&D(i.managed),a.daemonError&&D(`${a.daemonError}: fix it first`);let o=e===`off`?void 0:e.toLowerCase();o&&!Ci.test(o)&&D(`not a size: ${e} (e.g. 10m, 500k, 1g)`);let s=z(t.files,`--files`,1,100);[`json-file`,`local`].includes(a.driver)||T(`docker logs to ${a.driver}: the limit only takes effect for json-file and local`);let c=!!t.restart&&await k(`Restart docker on ${n}? Every site goes down for a moment.`,!1);await Ei(await Q(r,`changing docker's log limit`),wi(a.daemon,o,s),o?`docker log limit ${o} × ${s}`:`docker log limit off`,c),C(o?`docker keeps at most ${o} × ${s} log per container`:`docker log limit removed`),w(x.dim(c?`new containers get it: redeploy or lf restart sites to apply it to them`:`takes effect when docker restarts (--restart, or a reboot), for containers created after that`))}}),Kl=j({name:`security`,summary:`Check a server's security (ssh passwords, firewall, fail2ban, automatic updates, pending reboot) and say how to fix what is open.`,options:{server:M,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=$(t),{advice:i,...a}=await Fi(n,r);if(t.json)return void console.log(JSON.stringify({...a.security,fail2ban:a.fail2ban,advice:i},null,2));let o=a.security;A([[x.dim(`ssh login`),o.passwordLogin===void 0?x.dim(`unknown`):o.passwordLogin?x.red(`passwords and keys`):x.green(`keys only`)],[x.dim(`root login`),o.rootLogin===void 0?x.dim(`unknown`):o.rootLogin===`yes`?x.yellow(`yes`):o.rootLogin===`no`?`no`:`key only`],[x.dim(`firewall`),o.firewall===void 0?x.dim(`unknown`):o.firewall===`active`?x.green(`on`):x.yellow(o.firewall===`missing`?`no ufw`:`off`)],[x.dim(`fail2ban`),a.fail2ban===`running`?x.green(`running`):x.yellow(a.fail2ban===`missing`?`not installed`:`stopped`)],[x.dim(`auto updates`),o.autoUpdates===void 0?x.dim(`unknown`):o.autoUpdates?x.green(`on`):x.yellow(`off`)],[x.dim(`reboot`),o.reboot?x.yellow(`pending`):`not needed`]]),ue({advice:i}),console.log(),i.length?Pi(i):C(`nothing to fix`),Dl(a)}}),ql=j({name:`harden`,args:`[ssh|fail2ban|updates…]`,summary:`Close the usual holes; all three without arguments:
|
|
1191
1208
|
ssh keys only: no passwords, root by key only
|
|
1192
1209
|
fail2ban install and start it: bans addresses that keep failing to log in
|
|
1193
1210
|
updates install security updates automatically (apt, dnf)
|
|
1194
|
-
Each change is journaled: lf undo takes it back.`,options:{server:M,force:{type:`boolean`,desc:`turn ssh passwords off even if users without a key get locked out`}},async run(e,t){let n=e.filter(e=>!
|
|
1195
|
-
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-Bbkw_Rxx.mjs`);return n({port:t.port===void 0?void 0:
|
|
1211
|
+
Each change is journaled: lf undo takes it back.`,options:{server:M,force:{type:`boolean`,desc:`turn ssh passwords off even if users without a key get locked out`}},async run(e,t){let n=e.filter(e=>!Di.includes(e));n.length&&D(`unknown: ${n.join(`, `)} (ssh, fail2ban or updates)`);let r=e.length?e:[...Di],{name:i,server:a}=$(t),o=await Q(a,`hardening`);if(r.includes(`ssh`)&&!t.force){let e=await Mi(o,Y(a).user);e.length&&D(`${ki(e)} Or --force.`)}(!r.includes(`ssh`)||await k(`Only keys log in to ${i} afterwards (lf undo turns passwords back on). Go on?`))&&(await Ri(o,r),C(`${i}: ${r.join(`, `)} hardened`))}}),Jl=j({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
|
|
1212
|
+
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on (default: 4777, else the next free one)`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-Bbkw_Rxx.mjs`);return n({port:t.port===void 0?void 0:z(t.port,`--port`,1,65535),open:t.open})}}),Yl={current:x.green,update:x.yellow,unknown:x.dim,pinned:x.dim,rebuild:x.dim,foreign:x.dim,missing:x.red};function Xl(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return w(`packages: no supported package manager`);let r=e.updates.length?`${x.bold(String(e.updates.length))} package updates${n?`, ${x.yellow(`${n} security`)}`:``}`:x.green(`packages up to date`);console.log(`${r}${e.reboot?x.yellow(` · reboot required`):``}${e.refreshed?``:x.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);A(i.map(e=>[` `+e.name,x.dim(e.from??``),`→ `+e.to,e.security?x.yellow(`security`):``])),i.length<e.updates.length&&console.log(x.dim(` … ${e.updates.length-i.length} more (--all)`))}function Zl(e){e.length&&(console.log(`\n${x.bold(`Images`)}`),A(e.map(e=>[` `+e.name,x.dim(e.ref||`-`),Yl[e.state](e.state===`update`?`newer image`:e.state),x.dim(e.note??``)])))}function Ql(e){let t=ia(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 $l=j({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:M,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=R(t.server);if(t.json)return void console.log(JSON.stringify(await Ac(n,t.refresh),null,2));let r=await Hi(n,t.refresh),i=await sa(n),a=await ra(n);Xl(r,t.all),Ql(a),Zl(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}`)),ia(a).composeUpdate&&console.log(x.dim(`newest compose: lf update --compose${o}`))}}),eu=j({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
|
|
1196
1213
|
|
|
1197
1214
|
Without --packages/--images both are updated. Package upgrades cannot be undone;
|
|
1198
1215
|
image updates can (\`lf undo\` puts the old image back). --compose replaces the
|
|
1199
|
-
compose plugin setup installed with the newest release (undoable too).`,options:{server:M,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf/yum); implies --packages`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=
|
|
1200
|
-
`);e=n.pop(),e.length>
|
|
1216
|
+
compose plugin setup installed with the newest release (undoable too).`,options:{server:M,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf/yum); implies --packages`},reboot:{type:`boolean`,desc:`reboot afterwards if the upgrade needs it`},images:{type:`boolean`,desc:`pull newer images and recreate their containers`},compose:{type:`boolean`,desc:`update the compose plugin landfall installed to the newest release`}},async run(e,t){let{name:n,server:r}=R(t.server);if(t.compose){(e.length||t.packages||t.images)&&D(`--compose goes alone`);let i=await Q(r,`updating docker compose`),a=await ra(i),o=ia(a);if(!o.composeUpdate)return void(o.composeNote?w(o.composeNote):C(`compose ${a.compose} is the newest`));await k(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(S(`downloading docker compose`),await aa(i),C(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images&&!t.security&&!t.reboot;e.length&&t.packages&&D(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await Q(r,`upgrading packages`),i=await Hi(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?T(`no supported package manager, skipping packages`):a.length?(Xl({...i,updates:a},!1),t.security&&(i.mgr===`apk`||i.mgr===`zypper`)&&D(`${i.mgr} has no security-only upgrades here; drop --security`),await k(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(S(`upgrading packages on ${n}`),await Wi(e,{only:t.security?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),C(`packages upgraded`),t.reboot&&w(`rebooting if required; give it a minute`))):C(t.security?`no security updates`:`packages up to date`)}if(o){let t=await sa(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?(Zl(a),await k(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(S(`updating images`),await ca(r,a),C(`images updated (lf undo puts the old ones back)`))):C(`images up to date`)}}}),tu=()=>[process.execPath,...process.execArgv,process.argv[1]];function nu(){let e=Number(process.env.LANDFALL_PARENT);delete process.env.LANDFALL_PARENT,e&&setInterval(()=>{if(process.ppid!==e)try{process.kill(-process.pid,`SIGTERM`)}catch{process.exit(1)}},1e3).unref()}const ru=5e3,iu=1e4,au=e=>e.length>iu?e.slice(0,iu)+`…`:e;function ou(e,t,r={}){let i={...process.env,NO_COLOR:`1`,LANDFALL_REPORT:`1`,LANDFALL_PARENT:String(process.pid),...r.env};r.password&&Object.assign(i,Br(r.password));let a=n(tu()[0],[...tu().slice(1),...e],{stdio:[r.stdin===void 0?`ignore`:`pipe`,`pipe`,`pipe`],env:i,cwd:r.cwd,detached:!0});a.stdin?.on(`error`,()=>{}).end(r.stdin);let o={argv:e,lines:[],dropped:0,report:{},stop(){if(o.code===void 0){o.stopped=!0;try{process.kill(-a.pid,`SIGTERM`)}catch{}}}},s,c=()=>s??=setTimeout(()=>(s=void 0,t()),50),l=()=>{let e=``;return{push(t){let n=(e+t).split(`
|
|
1217
|
+
`);e=n.pop(),e.length>iu&&(n.push(e),e=``);for(let e of n)if(e.startsWith(``))try{Object.assign(o.report,JSON.parse(e.slice(1)))}catch{}else o.lines.push(au(e.slice(e.lastIndexOf(`\r`,e.length-2)+1).replace(/\r$/,``)));o.lines.length>ru&&(o.dropped+=o.lines.splice(0,o.lines.length-ru).length),c()},flush:()=>e&&o.lines.push(au(e))}},u=l(),d=l();return a.stdout.setEncoding(`utf8`).on(`data`,u.push),a.stderr.setEncoding(`utf8`).on(`data`,d.push),a.on(`error`,e=>{o.lines.push(`error: ${e.message}`),o.code=1,t()}),a.on(`close`,e=>{u.flush(),d.flush(),o.code=o.stopped?null:e??1,clearTimeout(s),t()}),o}var su=`0.6.3`;const cu=[Kc,$c,Zc,so,co,lo,vo,ho,fo,po,mo,_o,go,yo,Fc,Ic,Lc,$l,eu,vs,ys,Ts,al,sl,pl,gl,vl,bl,xl,Ol,Kl,ql,kl,jl,Ml,Nl,Pl,Il,Rl,zl,Bl,Vl,Hl,Ul,Wl,Gl,Ds,js,Cs,bs,Ss,ws,Qa,$a,to,no,ro,eo,io,Bo,Vo,Ho,Ro,zo,Wo,Uo,ss,cs,Hc,Vc,Jl],lu={ls:`sites list`,sites:`sites list`,servers:`server list`,"server ls":`server list`,"server ssh":`ssh`,"sites logs":`logs`,"sites env":`env`,"sites rollback":`rollback`,"sites restart":`restart`,"sites stop":`stop`,"sites start":`start`,"sites ls":`sites list`,"sites remove":`sites rm`,"server remove":`server rm`,uninstall:`server uninstall`,"traefik remove":`traefik rm`,traefik:`traefik status`,firewall:`firewall status`,"firewall remove":`firewall rm`,log:`history`,revert:`undo`,web:`ui`,info:`server info`,"sites domains":`domains`,"sites auth":`auth`,"sites open":`open`,"sites tunnel":`tunnel`,upgrade:`update`,"server stats":`stats`,"server disk":`disk`,"server traffic":`traffic`,"server certs":`certs`,"sites check":`check`,"server prune":`prune`,"server reboot":`reboot`,"server system":`system`,"services ls":`services`,"services list":`services`,"keys ls":`keys`,"keys list":`keys`,"keys remove":`keys rm`,"bans unban":`unban`,"server swap":`swap`,"server hostname":`hostname`,"server timezone":`timezone`},uu=new Map(cu.map(e=>[e.name,e]));function du(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=uu.get(lu[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function fu(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`stats`,`disk`,`prune`,`reboot`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`System`,[`system`,`services`,`services restart`,`services start`,`services stop`,`services logs`,`keys`,`keys add`,`keys rm`,`bans`,`unban`,`swap`,`hostname`,`timezone`,`log-limit`]],[`Traefik`,[`traefik setup`,`traefik status`,`traefik dashboard`,`traefik logs`,`traefik restart`,`traefik rm`]],[`Firewall`,[`firewall status`,`firewall enable`,`firewall disable`,`firewall allow`,`firewall deny`,`firewall update`,`firewall rm`]],[`Sites`,[`deploy`,`sites list`,`sites info`,`domains`,`auth`,`open`,`tunnel`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Monitoring`,[`traffic`,`certs`,`check`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`landfall`,[`config`,`ui`]]],t=[`${x.bold(`landfall`)} ${x.dim(su)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${x.bold(`Usage:`)} lf <command> [options] ${x.dim(`lf <command> --help for details`)}`,` lf ui ${x.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,x.bold(n));for(let e of r){let n=uu.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
|
|
1201
1218
|
`)[0]}`)}}t.push(``,x.bold(`Quick start`),` lf setup vps --check ${x.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${x.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${x.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${x.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${x.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${x.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${x.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${x.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${x.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${x.dim(`# what landfall changed, and revert the last of it`)}`,``,x.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
|
|
1202
|
-
`))}async function
|
|
1219
|
+
`))}async function pu(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return fu(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(su),0;let{cmd:t,rest:n}=du(e);if(!t)return console.error(`${x.red(`error:`)} unknown command "${e.join(` `)}"\n`),fu(),2;let{values:r,positionals:i,rest:a}=xe(t,n);if(r.help)return console.log(Se(t)),0;fe(!!r.yes),kt.cmd=N(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function mu(e){try{return await pu(e)}catch(e){return e instanceof E?(console.error(`${x.red(`error:`)} ${e.message}`),/--force\b/.test(e.message)&&ue({force:e.message})):console.error(e),1}}nu(),mu(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{H as A,be as B,nr as C,G as D,Ze as E,De as F,ve as H,F as I,L,P as M,Me as N,W as O,Ne as P,B as R,er as S,gt as T,x as V,_s as _,Cc as a,gs as b,wc as c,Dc as d,Tc as f,Qs as g,Ac as h,Nc as i,U as j,V as k,Sc as l,Mc as m,ou as n,Ec as o,Oc as p,Bc as r,jc as s,su as t,kc as u,ps as v,Ot as w,vr as x,hs as y,R as z};
|