landfall 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +152 -152
- package/dist/index.html +14 -12
- package/dist/server-BqVugweK.mjs +5 -0
- package/package.json +1 -1
- package/dist/server-BrAinE0R.mjs +0 -5
package/dist/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as
|
|
3
|
-
`)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new
|
|
4
|
-
`)}}function
|
|
5
|
-
`)}function
|
|
6
|
-
`,{mode:384})}function
|
|
7
|
-
`)}const
|
|
2
|
+
import{parseArgs as e}from"node:util";import{execFileSync as t,spawn as n,spawnSync as r}from"node:child_process";import{createInterface as i}from"node:readline/promises";import a from"string-width";import o from"node:fs";import{homedir as s,hostname as c,tmpdir as l,userInfo as u}from"node:os";import{basename as d,dirname as f,join as p,relative as m,resolve as ee}from"node:path";import{promises as te}from"node:dns";import{isIP as h}from"node:net";import{parse as g}from"yaml";import{randomBytes as _}from"node:crypto";import v from"bcryptjs";const ne=process.stdout.isTTY&&!process.env.NO_COLOR,y=e=>t=>ne?`\x1b[${e}m${t}\x1b[0m`:t,b={bold:y(`1`),dim:y(`2`),red:y(`31`),green:y(`32`),yellow:y(`33`),blue:y(`34`),cyan:y(`36`)},x=e=>console.error(`${b.cyan(`==>`)} ${b.bold(e)}`),S=e=>console.error(`${b.green(`✓`)} ${e}`),C=e=>console.error(` ${e}`),w=e=>console.error(`${b.yellow(`!`)} ${e}`);var T=class extends Error{};const E=e=>{throw new T(e)};let D=!1;const O=e=>D=e,re=()=>!D&&!!process.stdin.isTTY;async function ie(e,t){if(D||!process.stdin.isTTY){if(t!==void 0)return t;E(`missing value for "${e}" (no TTY; pass it as a flag)`)}let n=i({input:process.stdin,output:process.stderr});try{return(await n.question(`${b.blue(`?`)} ${e}${t?b.dim(` (${t})`):``} `)).trim()||t||``}finally{n.close()}}async function ae(e){return D||!process.stdin.isTTY?!1:A(e,!1)}async function k(e,t){D||(process.stdin.isTTY||E(`${e} Pass -y to confirm (no terminal to ask).`),t?await ie(`${e} Type ${b.bold(t)} to confirm:`)!==t&&E(`cancelled`):await A(e,!1)||E(`cancelled`))}async function A(e,t=!0){return D?!0:process.stdin.isTTY?(await ie(`${e} ${t?`[Y/n]`:`[y/N]`}`,t?`y`:`n`)).toLowerCase().startsWith(`y`):t}function j(e){if(!e.length)return;let t=e[0].map((t,n)=>Math.max(...e.map(e=>a(e[n]??``))));for(let n of e)console.log(n.map((e,n)=>e+` `.repeat(t[n]-a(e))).join(` `).trimEnd())}const M=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 oe(e){(D||!process.stdin.isTTY)&&E(`missing value for "${e}" (no TTY; pass it as a flag)`),process.stderr.write(`${b.blue(`?`)} ${e} `);let t=process.stdin;t.setRawMode(!0),t.resume();let n=``;try{return await new Promise((e,r)=>{let i=a=>{for(let o of a.toString(`utf8`)){if(o===`\r`||o===`
|
|
3
|
+
`)return t.off(`data`,i),e(n);if(o===``)return t.off(`data`,i),r(new T(`cancelled`));o===``||o===`\b`?n=n.slice(0,-1):n+=o}};t.on(`data`,i)})}finally{t.setRawMode(!1),t.pause(),process.stderr.write(`
|
|
4
|
+
`)}}function se(e){let t=process.platform===`darwin`?`open`:process.platform===`win32`?`explorer`:`xdg-open`;n(t,[e],{stdio:`ignore`,detached:!0}).on(`error`,()=>{}).unref()}const N=e=>e,P={type:`string`,short:`s`,desc:`server name or ssh alias`},ce={help:{type:`boolean`,short:`h`,desc:`show this help`},yes:{type:`boolean`,short:`y`,desc:`assume yes / defaults for prompts`}};function le(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({...ce,...t.options}))o[e]=r;try{let{values:t,positionals:n}=e({args:i,options:o,allowPositionals:!0,allowNegative:!0});return{values:t,positionals:n,rest:a}}catch(e){return E(`${e.message}\n\n${ue(t)}`)}}function ue(e){let t=[`${b.bold(`Usage:`)} lf ${e.name}${e.args?` `+e.args:``} [options]`,``,e.summary];t.push(``,b.bold(`Options:`));let n=Object.entries({...e.options,yes:ce.yes}).map(([e,t])=>{let n=`${t.short?`-${t.short}, `:` `}--${e}${t.type===`string`?` <${e}>`:``}`,r=t.default!==void 0&&t.default!==!1?b.dim(` (default: ${t.default})`):``;return[n,t.desc+(t.multiple?b.dim(` (repeatable)`):``)+r]}),r=Math.max(...n.map(e=>e[0].length));for(let[e,i]of n)t.push(` ${e.padEnd(r)} ${i}`);return t.join(`
|
|
5
|
+
`)}function de(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 F=e=>process.env[`LANDFALL_${e}`],fe=process.env.XDG_CONFIG_HOME??p(s(),`.config`),pe=F(`CONFIG_DIR`)??p(fe,`landfall`),I=p(pe,`config.json`),L=e=>e.startsWith(`~/`)?p(s(),e.slice(2)):e,R=e=>{let t=s();return e.startsWith(t+`/`)?`~`+e.slice(t.length):e};function z(){try{let e=JSON.parse(o.readFileSync(I,`utf8`));e.servers??={};for(let t of Object.values(e.servers))t.domain&&(t.domains=[.../* @__PURE__ */ new Set([t.domain,...t.domains??[]])]),delete t.domain;return e}catch(e){return e.code===`ENOENT`?{servers:{}}:E(`cannot read ${I}: ${e.message}`)}}function me(e){o.mkdirSync(f(I),{recursive:!0,mode:448}),o.writeFileSync(I,JSON.stringify(e,null,2)+`
|
|
6
|
+
`,{mode:384})}function he(e){let t=z();delete t.servers[e],t.default===e&&delete t.default,me(t)}function ge(e){let t=z();t.servers[e]??={host:e},t.default=e,me(t)}function B(e){let t=z();if(e??=t.default,!e){let n=Object.keys(t.servers);n.length===1?e=n[0]:n.length===0?E("no server given. Use --server <ssh-alias> or `lf server add`"):E(`several servers configured (${n.join(`, `)}); pick one with --server or \`lf server use\``)}let n=t.servers[e]??{host:e};return{name:e,server:n}}const V=/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i,_e=e=>{let t=e.trim().toLowerCase().replace(/^\*\./,``);return V.test(t)||E(`not a domain: ${e}`),t},ve=/^[a-z0-9][a-z0-9._\-/:@]*$/i,ye=e=>ve.test(e)?e:E(`not an image: ${e}`);function be(e,t,n,r=2**53-1){let i=typeof e==`number`?e:/^\s*\d+\s*$/.test(String(e))?Number(e):NaN;return(!Number.isInteger(i)||i<n||i>r)&&E(`${t} must be a whole number from ${n}${r<2**53-1?` to ${r}`:` up`}, got ${e}`),i}const xe=(e,t)=>t?[.../* @__PURE__ */ new Set([t,...e??[]])]:e,Se=e=>e.domains?.[0];function Ce(e,t){let n=Se(t);return e===`@`?n??E("`@` means the main domain, but none is set: lf server domain <server> <domain>"):!e.includes(`.`)&&n?`${e.toLowerCase()}.${n}`:e.toLowerCase()}const we=/* @__PURE__ */ new Set([`sslip.io`,`nip.io`,`traefik.me`,`localhost`]),Te=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 Ee(e){let t=/* @__PURE__ */ new Map;for(let n of new Set(e.map(e=>e.toLowerCase()))){if(!V.test(n))continue;let e=Te(n);we.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 De=`.landfall.json`,Oe=e=>p(e,De);function ke(e){try{return JSON.parse(o.readFileSync(Oe(e),`utf8`))}catch{return}}function Ae(e,t){o.writeFileSync(p(e,De),JSON.stringify(t,null,2)+`
|
|
7
|
+
`)}const H=`/opt/landfall`,U=`${H}/sites`,W=`${H}/traefik`,je=`${W}/dynamic`,Me=`traefik:v3`,G=`${H}/proxy.env`,K=`${H}/journal.log`,Ne=`/etc/ssh/sshd_config.d/00-landfall.conf`,Pe={container:`traefik`,network:`web`,entrypoint:`websecure`,resolver:`le`},Fe=`"$(sed -n 's/^container=//p' ${G} 2>/dev/null | grep . || echo traefik)"`;function Ie(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 Le=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`,q=String.raw`
|
|
8
8
|
log() { printf '\033[36m ->\033[0m %s\n' "$*" >&2; }
|
|
9
9
|
SUDO=""; if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
|
10
10
|
# Files a container wrote may be root's.
|
|
@@ -65,19 +65,19 @@ _clean() { printf %s "$1" | tr '\t\n' ' '; }
|
|
|
65
65
|
_journal() { # _journal <do|undo> <what> <undo shell>
|
|
66
66
|
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")")
|
|
67
67
|
line="$line$(printf %s "$3" | base64 | tr -d '\n')"
|
|
68
|
-
if [ -w ${
|
|
69
|
-
printf '%s\n' "$line" >> ${
|
|
68
|
+
if [ -w ${K} ] || { [ ! -e ${K} ] && [ -w ${H} ]; }; then
|
|
69
|
+
printf '%s\n' "$line" >> ${K}
|
|
70
70
|
elif [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then false
|
|
71
71
|
else
|
|
72
|
-
{ $SUDO install -d ${
|
|
73
|
-
fi || { log "warning: could not write ${
|
|
72
|
+
{ $SUDO install -d ${H} && printf '%s\n' "$line" | $SUDO tee -a ${K} >/dev/null; } 2>/dev/null
|
|
73
|
+
fi || { log "warning: could not write ${K}"; return 0; }
|
|
74
74
|
# A root (or sudo) first entry would leave the file root-owned, and deploy users could no longer
|
|
75
|
-
# append; it belongs to whoever owns ${
|
|
76
|
-
[ -O ${
|
|
75
|
+
# append; it belongs to whoever owns ${H}.
|
|
76
|
+
[ -O ${K} ] && [ "$(id -u)" -ne 0 ] || $([ -z "$SUDO" ] || echo sudo -n) chown "$(stat -c %u:%g ${H})" ${K} 2>/dev/null || true
|
|
77
77
|
}
|
|
78
78
|
# record <what> [undo shell]: journal a change; no undo shell = not reversible.
|
|
79
79
|
record() { _journal do "$1" "$(shift; printf %s "$*")"; }
|
|
80
|
-
# Run as $SUDO only when <dir> is not writable: deploy users own ${
|
|
80
|
+
# Run as $SUDO only when <dir> is not writable: deploy users own ${H} and need no sudo.
|
|
81
81
|
_as() { if [ -w "$1" ]; then shift; "$@"; else shift; $SUDO "$@"; fi; }
|
|
82
82
|
# saved <file>: back the file up, print the shell that puts it back (or removes it if it was new).
|
|
83
83
|
saved() {
|
|
@@ -117,7 +117,7 @@ ufw_enable() {
|
|
|
117
117
|
*inactive*) $SUDO ufw --force enable >/dev/null; record "enabled ufw" '$SUDO ufw disable >/dev/null' ;;
|
|
118
118
|
esac
|
|
119
119
|
}
|
|
120
|
-
`,
|
|
120
|
+
`,Re=String.raw`
|
|
121
121
|
compose_install() {
|
|
122
122
|
arch=$(uname -m); case "$arch" in armv7l) arch=armv7 ;; armv6l) arch=armv6 ;; esac
|
|
123
123
|
f=${`/usr/local/lib/docker/cli-plugins/docker-compose`}
|
|
@@ -128,27 +128,27 @@ compose_install() {
|
|
|
128
128
|
$SUDO mv $f.new $f
|
|
129
129
|
record "installed docker compose $(docker compose version --short 2>/dev/null) to $f" "$undo"
|
|
130
130
|
}
|
|
131
|
-
`,
|
|
131
|
+
`,ze=`This server is run by landfall (https://www.npmjs.com/package/landfall).
|
|
132
132
|
Everything here works without it: it is plain Docker Compose.
|
|
133
133
|
|
|
134
|
-
${
|
|
135
|
-
${
|
|
134
|
+
${W}/ Traefik: docker-compose.yml, letsencrypt/ (certificates), dynamic/ (file provider)
|
|
135
|
+
${U}/<site>/ one Compose project per site
|
|
136
136
|
.env the site's environment (lf env), mode 600
|
|
137
137
|
site.json landfall's notes: domains, releases
|
|
138
138
|
current -> releases/<id> the live release
|
|
139
139
|
releases/<id>/compose.yaml the site's compose file as deployed
|
|
140
140
|
releases/<id>/compose.override.yaml landfall's: Traefik labels, proxy network, project name
|
|
141
|
-
${
|
|
141
|
+
${K} every change landfall made, with the shell that undoes it
|
|
142
142
|
|
|
143
143
|
Run a site by hand:
|
|
144
|
-
cd ${
|
|
144
|
+
cd ${U}/<site>/current && docker compose ps (logs, restart, up -d, down …)
|
|
145
145
|
Roll back by hand:
|
|
146
|
-
cd ${
|
|
146
|
+
cd ${U}/<site> && ln -sfn releases/<id> current && cd -P current && docker compose up -d
|
|
147
147
|
|
|
148
148
|
Named volumes (databases, uploads) belong to the project, not the release: they survive
|
|
149
149
|
deploys and rollbacks. "lf sites rm" keeps them; "lf sites rm --volumes" deletes them.
|
|
150
|
-
`,
|
|
151
|
-
${
|
|
150
|
+
`,Be=String.raw`
|
|
151
|
+
${Re}
|
|
152
152
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then
|
|
153
153
|
echo "setup needs root or passwordless sudo for $(id -un) (try: lf setup <name> --user root)" >&2
|
|
154
154
|
exit 1
|
|
@@ -210,16 +210,16 @@ else
|
|
|
210
210
|
OWNER="$(id -un)"
|
|
211
211
|
fi
|
|
212
212
|
|
|
213
|
-
log "preparing ${
|
|
214
|
-
new_sites=$([ -d ${
|
|
215
|
-
new_traefik=$([ -d ${
|
|
216
|
-
$SUDO install -d -o "$OWNER" -g "$OWNER" ${
|
|
217
|
-
$SUDO chown "$OWNER:$OWNER" ${
|
|
218
|
-
$SUDO tee ${
|
|
219
|
-
${
|
|
220
|
-
# ${
|
|
221
|
-
[ -z "$new_sites" ] || record "created ${
|
|
222
|
-
[ -z "$new_traefik" ] || record "created ${
|
|
213
|
+
log "preparing ${H}"
|
|
214
|
+
new_sites=$([ -d ${U} ] || echo 1)
|
|
215
|
+
new_traefik=$([ -d ${W} ] || echo 1)
|
|
216
|
+
$SUDO install -d -o "$OWNER" -g "$OWNER" ${H} ${U} ${W}
|
|
217
|
+
$SUDO chown "$OWNER:$OWNER" ${K} 2>/dev/null || true
|
|
218
|
+
$SUDO tee ${H}/README >/dev/null <<'LANDFALL_README'
|
|
219
|
+
${ze}LANDFALL_README
|
|
220
|
+
# ${H} itself stays: it holds the journal. Each dir is only removed if setup created it.
|
|
221
|
+
[ -z "$new_sites" ] || record "created ${U}" "_as ${H} rmdir ${U}"
|
|
222
|
+
[ -z "$new_traefik" ] || record "created ${W}" "_as ${H} rm -rf ${W}"
|
|
223
223
|
|
|
224
224
|
DOCKER="$SUDO docker" ensure_network ${`web`}
|
|
225
225
|
|
|
@@ -238,10 +238,10 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
|
|
|
238
238
|
if [ "$($SUDO sshd -T 2>/dev/null | awk '$1 == "permitrootlogin" {print $2}')" = no ]; then root_login=no; fi
|
|
239
239
|
# 00- so it wins over cloud-init's 50-cloud-init.conf: sshd keeps the first value it reads.
|
|
240
240
|
want=$(printf '%s\n' 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' "PermitRootLogin $root_login")
|
|
241
|
-
if [ "$($SUDO cat ${
|
|
241
|
+
if [ "$($SUDO cat ${Ne} 2>/dev/null || true)" != "$want" ]; then
|
|
242
242
|
$SUDO install -d /etc/ssh/sshd_config.d
|
|
243
|
-
undo=$(saved ${
|
|
244
|
-
printf '%s\n' "$want" | $SUDO tee ${
|
|
243
|
+
undo=$(saved ${Ne})
|
|
244
|
+
printf '%s\n' "$want" | $SUDO tee ${Ne} >/dev/null
|
|
245
245
|
if ! grep -qE '^\s*Include\s+/etc/ssh/sshd_config.d' /etc/ssh/sshd_config; then
|
|
246
246
|
undo="$undo; $(saved /etc/ssh/sshd_config)"
|
|
247
247
|
printf 'Include /etc/ssh/sshd_config.d/*.conf\n%s' "$($SUDO cat /etc/ssh/sshd_config)" | $SUDO tee /etc/ssh/sshd_config >/dev/null
|
|
@@ -256,7 +256,7 @@ if [ "$HARDEN" = 1 ] && [ -d /etc/ssh ]; then
|
|
|
256
256
|
fi
|
|
257
257
|
fi
|
|
258
258
|
log "bootstrap done"
|
|
259
|
-
`,
|
|
259
|
+
`,Ve=String.raw`
|
|
260
260
|
set +e
|
|
261
261
|
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
262
262
|
kv() { printf '%s=%s
|
|
@@ -285,15 +285,15 @@ if [ -n "$DEPLOY_USER" ] && id "$DEPLOY_USER" >/dev/null 2>&1; then
|
|
|
285
285
|
else
|
|
286
286
|
kv user 0
|
|
287
287
|
fi
|
|
288
|
-
kv root_dir "$(yn test -d ${
|
|
289
|
-
kv sites "$(ls ${
|
|
288
|
+
kv root_dir "$(yn test -d ${U})"
|
|
289
|
+
kv sites "$(ls ${U} 2>/dev/null | wc -l | tr -d ' ')"
|
|
290
290
|
kv network "$(yn S docker network inspect ${`web`})"
|
|
291
|
-
kv proxy "$(grep -v '^#' ${
|
|
291
|
+
kv proxy "$(grep -v '^#' ${G} 2>/dev/null | tr '\n' ' ')"
|
|
292
292
|
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
|
|
293
|
-
kv hardened "$(yn test -f ${
|
|
293
|
+
kv hardened "$(yn test -f ${Ne})"
|
|
294
294
|
kv password_auth "$(S sshd -T 2>/dev/null | awk '$1 == "passwordauthentication" {print $2}')"
|
|
295
295
|
kv password_users "$(S awk -F: '$2 ~ /^\$/ {printf "%s ", $1}' /etc/shadow 2>/dev/null)"
|
|
296
|
-
f=${
|
|
296
|
+
f=${W}/docker-compose.yml
|
|
297
297
|
if [ ! -f "$f" ]; then kv traefik_compose none
|
|
298
298
|
elif grep -q "managed by landfall" "$f"; then kv traefik_compose landfall
|
|
299
299
|
else kv traefik_compose foreign; fi
|
|
@@ -320,14 +320,14 @@ else
|
|
|
320
320
|
S netstat -ltunp 2>/dev/null | awk '$1 ~ /^tcp/ {print "listen=tcp " $4 " " $7} $1 ~ /^udp/ {print "listen=udp " $4 " " $6}'
|
|
321
321
|
fi
|
|
322
322
|
exit 0
|
|
323
|
-
`,
|
|
323
|
+
`,He=e=>e.replace(/\$/g,`$$$$`),Ue=e=>JSON.stringify(e);function We(e){let t=[`--providers.docker=true`,`--providers.docker.exposedbydefault=false`,`--providers.docker.network=web`,`--providers.file.directory=/etc/traefik/dynamic`,`--entrypoints.web.address=:80`,`--entrypoints.web.http.redirections.entrypoint.to=websecure`,`--entrypoints.web.http.redirections.entrypoint.scheme=https`,`--entrypoints.websecure.address=:443`,`--entrypoints.websecure.http.tls.certresolver=le`,`--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json`,`--certificatesresolvers.le.acme.httpchallenge.entrypoint=web`,`--log.level=INFO`,`--accesslog=true`];e.email&&t.push(`--certificatesresolvers.le.acme.email=${e.email}`),e.staging&&t.push(`--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory`);let n=[];return e.dashboard&&(t.push(`--api.dashboard=true`),n.push(`traefik.enable=true`,`traefik.http.routers.dashboard.rule=Host(\`${e.dashboard.host}\`)`,`traefik.http.routers.dashboard.entrypoints=websecure`,`traefik.http.routers.dashboard.service=api@internal`,`traefik.http.routers.dashboard.middlewares=dashboard-auth`,`traefik.http.middlewares.dashboard-auth.basicauth.users=${He(e.dashboard.htpasswd)}`)),`# managed by landfall — regenerate with \`lf traefik setup\`
|
|
324
324
|
services:
|
|
325
325
|
traefik:
|
|
326
326
|
image: ${e.image}
|
|
327
327
|
container_name: traefik
|
|
328
328
|
restart: unless-stopped
|
|
329
329
|
command:
|
|
330
|
-
${t.map(e=>` - ${
|
|
330
|
+
${t.map(e=>` - ${Ue(e)}`).join(`
|
|
331
331
|
`)}
|
|
332
332
|
${e.hostNetwork?` network_mode: host
|
|
333
333
|
`:` ports:
|
|
@@ -339,20 +339,20 @@ ${e.hostNetwork?` network_mode: host
|
|
|
339
339
|
- ./dynamic:/etc/traefik/dynamic:ro
|
|
340
340
|
${e.hostNetwork?``:` networks:
|
|
341
341
|
- web
|
|
342
|
-
`}${n.length?` labels:\n${n.map(e=>` - ${
|
|
342
|
+
`}${n.length?` labels:\n${n.map(e=>` - ${Ue(e)}`).join(`
|
|
343
343
|
`)}\n`:``}${e.hostNetwork?``:`networks:
|
|
344
344
|
web:
|
|
345
345
|
external: true
|
|
346
|
-
`}`}let
|
|
347
|
-
`),a=await
|
|
348
|
-
`).at(-1);
|
|
349
|
-
`)){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=
|
|
350
|
-
`)}function
|
|
351
|
-
`):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),
|
|
352
|
-
`);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:
|
|
353
|
-
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(!o.existsSync(
|
|
354
|
-
`)),
|
|
355
|
-
`).some(t=>
|
|
346
|
+
`}`}let Ge=!1;const Ke=e=>Ge=e,J=e=>`'${String(e).replace(/'/g,`'\\''`)}'`,qe=(e=F(`SSH_CONFIG`))=>e?[`-F`,o.existsSync(e)?e:`/dev/null`]:[],Je=e=>e.user?`${e.user}@${e.host}`:e.host;function Ye(e){let t=[...qe(),`-o`,`StrictHostKeyChecking=accept-new`];return e.port&&t.push(`-p`,String(e.port)),e.key&&t.push(`-i`,L(e.key),`-o`,`IdentitiesOnly=yes`),t}let Xe;function Ze(){return Xe||(Xe=p(process.platform===`darwin`?`/tmp`:l(),`landfall-${u().uid}`),o.mkdirSync(Xe,{recursive:!0,mode:448})),Xe}function Qe(e,t={}){let n=[...Ye(e),`-o`,`ServerAliveInterval=15`];return(t.batch||Ge)&&n.push(`-o`,`BatchMode=yes`,`-o`,`ConnectTimeout=10`),t.noMux?n.push(`-o`,`ControlPath=none`):n.push(`-o`,`ControlMaster=auto`,`-o`,`ControlPath=${Ze()}/%C`,`-o`,`ControlPersist=60`),n.push(t.tty?`-t`:`-T`,Je(e)),n}function $e(e){return new Promise(t=>{let r=[...Ye(e),`-o`,`ControlPath=${Ze()}/%C`,`-O`,`exit`,Je(e)];n(`ssh`,r,{stdio:`ignore`}).on(`close`,()=>t()).on(`error`,()=>t())})}function et(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`,Ge?`pipe`:`inherit`]}),s=``,c=``;o.stdout?.on(`data`,e=>s+=e),o.stderr?.on(`data`,e=>c+=e),o.on(`error`,a),o.on(`close`,e=>i({code:e??1,stdout:s,stderr:c})),r.input!==void 0&&o.stdin.end(r.input)})}async function tt(e,t=[]){let{code:n}=await et(`ssh`,[...Qe(e,{tty:process.stdin.isTTY}),...t],{});return n}const nt=()=>`${Date.now().toString(36)}${Math.random().toString(36).slice(2,6)}`,rt={run:process.env.LANDFALL_RUN||nt(),actor:`${u().username}@${c().split(`.`)[0]}`,cmd:``};async function Y(e,t,n={}){let r={LANDFALL_RUN:rt.run,LANDFALL_ACTOR:rt.actor,LANDFALL_CMD:rt.cmd,...n.env},i=Object.entries(r).filter(([,e])=>e!==void 0).map(([e,t])=>`${e}=${J(typeof t==`boolean`?+!!t:t)}`).join(`
|
|
347
|
+
`),a=await et(`ssh`,[...Qe(e),n.sudo?`sudo -n bash -s`:`bash -s`],{input:`set -euo pipefail\n${i}\n${t}\n`,capture:n.capture});if(a.code!==0){let t=a.stderr.trim().split(`
|
|
348
|
+
`).at(-1);E(`remote command failed on ${e.host} (exit ${a.code})${t?`: ${t}`:``}`)}return a}async function X(e){let{code:t}=await et(`ssh`,[...Qe(e,{batch:!0,noMux:!0}),`true`],{capture:!0});return t===0}async function it(e){let{code:t}=await et(`ssh`,[...Qe(e,{batch:!0}),`test -d ${H}`],{capture:!0});return t===0?`ready`:t===255?`no-login`:`bare`}const at=[`.git`,`node_modules`,`.DS_Store`,`.landfall.json`,`.env`,`.env.*`,`compose.override.*`,`docker-compose.override.*`];let ot;async function st(){if(!ot){let{stdout:e}=await et(`tar`,[`--version`],{capture:!0});ot=e.includes(`bsdtar`)?[`--no-xattrs`,`--no-mac-metadata`,`--no-acls`]:[]}return ot}async function ct(e,t,r,i=at){o.existsSync(t)||E(`directory not found: ${t}`);let a=[...await st(),`-czf`,`-`,...i.flatMap(e=>[`--exclude`,e]),`-C`,t,`.`],s=`mkdir -p ${J(r)} && tar -xzof - -C ${J(r)}`;await new Promise((t,r)=>{let i=n(`tar`,a,{stdio:[`ignore`,`pipe`,`inherit`],env:{...process.env,COPYFILE_DISABLE:`1`}}),o=n(`ssh`,[...Qe(e),s],{stdio:[`pipe`,`inherit`,`inherit`]});i.stdout.pipe(o.stdin),i.on(`error`,r),o.on(`error`,r);let c=e=>new Promise(t=>e.on(`close`,e=>t(e??1)));Promise.all([c(i),c(o)]).then(([e,n])=>{e===0?n===0?t():r(/* @__PURE__ */ Error(`upload failed (exit ${n})`)):r(/* @__PURE__ */ Error(`tar failed (exit ${e})`))})})}const Z=(e,t,n=`644`)=>`printf %s ${J(Buffer.from(t).toString(`base64`))} | base64 -d > ${J(e)} && chmod ${n} ${J(e)}`,lt=F(`SSH_CONFIG`)??p(s(),`.ssh`,`config`),ut=/* @__PURE__ */ new Map;function Q(e){let n=[...Ye(e),`-G`,Je(e)],r=ut.get(n.join(`\0`));if(r)return r;let i;try{i=t(`ssh`,n,{encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]})}catch(t){return E(`ssh cannot resolve ${Je(e)}: ${String(t.stderr??t.message).trim()}`)}let a=e=>i.match(RegExp(`^${e} (.+)$`,`m`))?.[1]??``,o=[...i.matchAll(/^identityfile (.+)$/gm)].map(e=>L(e[1])),s={hostname:a(`hostname`),user:a(`user`),port:Number(a(`port`)||22),identityFiles:o};return ut.set(n.join(`\0`),s),s}function dt(e){return(e.key?[L(e.key)]:Q(e).identityFiles).find(e=>o.existsSync(e)&&o.existsSync(`${e}.pub`))}function ft(e=lt,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(`
|
|
349
|
+
`)){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=L(e),i=r.startsWith(`/`)?r:p(s(),`.ssh`,r);for(let e of pt(i))n.push(...ft(e,t))}}return[...new Set(n)]}function pt(e){if(!/[*?[]/.test(e))return[e];let t=f(e);return o.existsSync(t)?o.globSync(d(e),{cwd:t}).map(e=>p(t,e)):[]}const mt=e=>[`# >>> landfall:${e}`,`# <<< landfall:${e}`],ht=(e,t)=>RegExp(`# >>> landfall:${Ct(e)}\\n[\\s\\S]*?# <<< landfall:${Ct(e)}${t}`),gt=(e,t)=>e===`# >>> landfall:${t}`;function _t(e){let[t,n]=mt(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(`
|
|
350
|
+
`)}function vt(e,t=lt){let n=!o.existsSync(t),r=n?``:o.readFileSync(t,`utf8`),i=_t(e),a=ht(e.alias,`\\n?`),s=a.test(r)?r.replace(a,i+`
|
|
351
|
+
`):`${i}\n\n${r}`;return o.mkdirSync(p(t,`..`),{recursive:!0,mode:448}),o.writeFileSync(t,s,{mode:384}),ut.clear(),{created:n,block:i}}function yt(e,t=lt,n=/* @__PURE__ */ new Set){if(n.has(t)||!o.existsSync(t))return;n.add(t);let r=o.readFileSync(t,`utf8`).split(`
|
|
352
|
+
`);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:gt(r[i-1],e)}}if(o===`include`)for(let t of a[2].split(/\s+/)){let r=L(t);for(let t of pt(r.startsWith(`/`)?r:p(s(),`.ssh`,r))){let r=yt(e,t,n);if(r)return r}}}}function bt(e,t){let n=yt(e);if(!n)return;let r=o.readFileSync(n.file,`utf8`).split(`
|
|
353
|
+
`),i=r.slice(n.start+1,n.end),a=e=>e.trim().match(/^identityfile\s*=?\s*(.+)$/i)?.[1].replace(/^"(.*)"$/,`$1`),s=i.filter(e=>{let n=a(e);return n!==void 0&&(!o.existsSync(L(n))||L(n)===t)}),c=`${i.find(e=>e.trim())?.match(/^\s*/)?.[0]||` `}IdentityFile ${t}`,l=i.filter(e=>!s.includes(e));r.splice(n.start+1,i.length,c,...l);let u=`${n.file}.landfall-bak`;return o.existsSync(u)||o.copyFileSync(n.file,u),o.writeFileSync(n.file,r.join(`
|
|
354
|
+
`)),ut.clear(),{file:n.file,removed:s.map(e=>e.trim()),added:c.trim(),backup:u}}function xt(e,t=lt){if(!o.existsSync(t))return!1;let n=o.readFileSync(t,`utf8`),r=ht(e,`\\n*`);return r.test(n)?(o.writeFileSync(t,n.replace(r,``)),!0):!1}const St=(e,t=lt)=>o.existsSync(t)&&o.readFileSync(t,`utf8`).split(`
|
|
355
|
+
`).some(t=>gt(t,e)),Ct=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),wt=String.raw`
|
|
356
356
|
set +e
|
|
357
357
|
kv() { printf '%s=%s\n' "$1" "$2"; }
|
|
358
358
|
PRETTY_NAME=unknown; ID=; VERSION_ID=; ID_LIKE=; . /etc/os-release 2>/dev/null
|
|
@@ -374,10 +374,10 @@ kv ip4 "$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\
|
|
|
374
374
|
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)"
|
|
375
375
|
kv reboot "$([ -f /var/run/reboot-required ] && echo 1)"
|
|
376
376
|
exit 0
|
|
377
|
-
`;function
|
|
378
|
-
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:
|
|
377
|
+
`;function Tt(e,t=/* @__PURE__ */ new Date){let n={};for(let t of e.split(`
|
|
378
|
+
`)){let e=t.indexOf(`=`);e>0&&(n[t.slice(0,e)]=t.slice(e+1).trim())}let r=(e=``)=>e.split(/\s+/).map(e=>Number(e)*1024),[i=0,a=0]=r(n.mem),[o=0,s=0,c=0]=r(n.disk);return{os:n.os||`unknown`,id:n.id??``,version:n.version??``,like:n.like??``,arch:n.arch??``,kernel:n.kernel??``,uptime:Math.round(Number(n.uptime)||0),cpus:Number(n.cpus)||0,load:n.load??``,mem:{total:i||0,available:a||0},disk:{total:o||0,used:s||0,available:c||0},pkg:n.pkg||`none`,docker:n.docker??``,ip4:n.ip4??``,ip6:n.ip6??``,reboot:n.reboot===`1`,support:Ot(n.id??``,n.version??``,n.pkg||`none`,t)}}async function Et(e){let{stdout:t}=await Y(e,q+wt,{capture:!0});return Tt(t)}const Dt={"ubuntu 18.04":`2023-05-31`,"ubuntu 20.04":`2025-05-31`,"ubuntu 22.04":`2027-06-01`,"ubuntu 23.10":`2024-07-11`,"ubuntu 24.04":`2029-05-31`,"ubuntu 24.10":`2025-07-10`,"ubuntu 25.04":`2026-01-15`,"ubuntu 25.10":`2026-07-09`,"ubuntu 26.04":`2031-05-31`,"debian 9":`2022-06-30`,"debian 10":`2024-06-30`,"debian 11":`2026-08-31`,"debian 12":`2028-06-30`,"debian 13":`2030-06-30`,"centos 7":`2024-06-30`,"centos 8":`2021-12-31`,"rhel 8":`2029-05-31`,"rhel 9":`2032-05-31`,"rocky 8":`2029-05-31`,"rocky 9":`2032-05-31`,"almalinux 8":`2029-05-31`,"almalinux 9":`2032-05-31`,"alpine 3.17":`2024-11-22`,"alpine 3.18":`2025-05-09`,"alpine 3.19":`2025-11-01`,"alpine 3.20":`2026-04-01`,"alpine 3.21":`2026-11-01`,"alpine 3.22":`2027-05-01`};function Ot(e,t,n,r=/* @__PURE__ */ new Date){if(n===`none`)return{level:`unsupported`,note:`no apt, dnf, yum, apk or zypper: setup cannot install packages`};let i=t.split(`.`)[0],a=t.split(`.`).slice(0,2).join(`.`),o=Dt[`${e} ${t}`]??Dt[`${e} ${a}`]??Dt[`${e} ${i}`];if(!o)return{level:`ok`};let s=(Date.parse(o)-r.getTime())/864e5;return s<0?{level:`eol`,note:`${e} ${t} reached end of life on ${o}: no more security updates, upgrade the release`}:s<90?{level:`ok`,note:`${e} ${t} reaches end of life on ${o}`}:{level:`ok`}}const kt=/^(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])\.)/,At=e=>h(e)===4&&!kt.test(e);async function jt(e,t){let n=Q(e).hostname,r=h(n)?[n]:await te.resolve4(n).catch(()=>[]);return[.../* @__PURE__ */ new Set([...r,...t?.ip4?[t.ip4]:[]])]}const Mt=e=>e.find(At)??e[0],Nt=(e,t)=>`${e}.${t.replace(/\./g,`-`)}.sslip.io`;async function Pt(e,t){return Promise.all(e.map(async e=>{let n=await te.resolve4(e).catch(()=>[]);return{domain:e,ips:n,ok:n.some(e=>t.includes(e))}}))}const Ft=e=>{let t=[`B`,`K`,`M`,`G`,`T`],n=0;for(;e>=1024&&n<t.length-1;)e/=1024,n++;return`${e<10&&n?e.toFixed(1):Math.round(e)}${t[n]}`},It=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`},Lt=e=>e.v!==2,Rt=e=>e.project??e.name,zt=e=>Lt(e)?void 0:e.releases.find(t=>t.id===e.current),Bt=e=>!!(e.port||e.own),Vt=e=>!!e.port&&!e.domains&&!e.own,Ht=e=>e?.services.find(Vt),Ut=e=>e.adopted?void 0:Ht(zt(e)),Wt=e=>`landfall-${e}`,Gt=e=>e.adopted?`adopted`:Lt(e)?`old`:`managed`,Kt=e=>({managed:e.kind,adopted:`adopted`,old:`${e.kind} (old)`})[Gt(e)],qt=e=>/^up/i.test(e),Jt=e=>/^exited \(0\)/i.test(e);function Yt(e){let t=zt(e);if(!t)return e.domains;let n=t.services.flatMap(e=>e.domains??[]);return[.../* @__PURE__ */ new Set([...Ht(t)?e.domains:[],...n])]}const Xt=(e,t,n)=>`${e}-${t.toLowerCase()}:${n}`,Zt=[`compose.yaml`,`compose.yml`,`docker-compose.yaml`,`docker-compose.yml`],Qt=[`compose.override.yaml`,`compose.override.yml`,`docker-compose.override.yaml`,`docker-compose.override.yml`],$t=e=>[...e.matchAll(/\bHost\(([^)]*)\)/g)].flatMap(e=>[...e[1].matchAll(/[`"]([^`"]+)[`"]/g)].map(e=>e[1].toLowerCase())),en=/^traefik\.http\.routers\.[^.]+\.rule$/;function tn(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])=>en.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])=>$t(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=>!V.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&&(Bt(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(Bt)||i.push(`no service is routed: add x-landfall: { port: <port> } to the one Traefik should serve`);let o=r.filter(Vt);o.length>1&&i.push(`${o.map(e=>e.name).join(` and `)} both serve the site's domains: give all but one their own x-landfall.domains`);let s=/* @__PURE__ */ new Map;for(let e of r)for(let t of e.domains??[])s.has(t)&&i.push(`${t} is routed to both ${s.get(t)} and ${e.name}`),s.set(t,e.name);return{services:r,errors:i,warnings:a}}const nn=Ue,rn=`landfall-proxy`;function an(e,t,n,r,i=[]){let a=`${e}-${t.name.toLowerCase()}`,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 on(e,t,n=Pe){let r=[`# generated by landfall: rewritten on every deploy, rollback and domain change. Edit compose.yaml instead.`,`name: ${nn(Rt(e))}`,`services:`],i=!1;for(let a of t.services){r.push(` ${nn(a.name)}:`),a.build&&r.push(` image: ${nn(Xt(e.name,a.name,t.id))}`);let o={};Bt(a)&&(i=!0,r.push(` networks:`,...a.networks.map(e=>` ${nn(e)}: {}`),` ${rn}: {}`),a.own?o[`traefik.docker.network`]=n.network:(a.domains??e.domains).length&&(o=an(e.name,a,a.domains??e.domains,n,e.auth))),o[`landfall.managed`]=`true`,o[`landfall.site`]=e.name,r.push(` labels:`,...Object.entries(o).map(([e,t])=>` ${nn(e)}: ${nn(He(t))}`))}return i&&r.push(`networks:`,` ${rn}:`,` name: ${nn(n.network)}`,` external: true`),r.join(`
|
|
379
379
|
`)+`
|
|
380
|
-
`}function
|
|
380
|
+
`}function sn(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:
|
|
381
381
|
image: nginx:alpine
|
|
382
382
|
restart: unless-stopped
|
|
383
383
|
volumes:
|
|
@@ -386,14 +386,14 @@ exit 0
|
|
|
386
386
|
x-landfall:
|
|
387
387
|
port: 80
|
|
388
388
|
`:`${n} app:
|
|
389
|
-
${e===`image`?`image: ${
|
|
389
|
+
${e===`image`?`image: ${nn(t.image)}`:`build: .`}
|
|
390
390
|
restart: unless-stopped
|
|
391
391
|
env_file:
|
|
392
392
|
- path: .env
|
|
393
393
|
required: false
|
|
394
394
|
x-landfall:
|
|
395
395
|
port: ${t.port??3e3}
|
|
396
|
-
`}function
|
|
396
|
+
`}function cn(e){return`# generated by landfall
|
|
397
397
|
server {
|
|
398
398
|
listen 80;
|
|
399
399
|
server_name _;
|
|
@@ -417,33 +417,33 @@ server {
|
|
|
417
417
|
error_page 404 /404.html;
|
|
418
418
|
location = /404.html { internal; }
|
|
419
419
|
}
|
|
420
|
-
`}const
|
|
421
|
-
`,{mode:384})}function
|
|
422
|
-
`,{mode:384})}const $=e=>`${
|
|
420
|
+
`}const ln=[`dist`,`build`,`out`,`public`,`_site`,`site`];function un(e){return o.existsSync(p(e,`index.html`))?`.`:ln.find(t=>o.existsSync(p(e,t,`index.html`)))??`.`}function dn(e){let t=o.readFileSync(e,`utf8`).match(/^\s*EXPOSE\s+(\d+)/im);return t?Number(t[1]):void 0}const fn=e=>e.toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,63)||`site`;function pn(e,t){if(t?.compose)return t.compose;if(!(t?.image||t?.static))return Zt.find(t=>o.existsSync(p(e,t)))}function mn(e){let t=ke(e),n=t?.name??fn(d(e)),r=pn(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(p(e,t)),a=t?.build;if(!a&&i(`package.json`))try{JSON.parse(o.readFileSync(p(e,`package.json`),`utf8`)).scripts?.build&&(a=i(`bun.lock`)||i(`bun.lockb`)?`bun run build`:i(`pnpm-lock.yaml`)?`pnpm build`:i(`yarn.lock`)?`yarn build`:`npm run build`)}catch{}if(!t?.static&&i(`Dockerfile`))return{path:e,name:n,kind:`dockerfile`,port:t?.port??dn(p(e,`Dockerfile`))??3e3,project:t};let s=t?.dir??un(e);return{path:e,name:n,kind:`static`,dir:s!==`.`||i(`index.html`)?s:void 0,build:a,project:t}}const hn=p(pe,`projects.json`);function gn(){try{return JSON.parse(o.readFileSync(hn,`utf8`)).filter(e=>o.existsSync(e.path))}catch{return[]}}function _n(e){let t=ee(e.path),n=[{...e,path:t,at:(/* @__PURE__ */ new Date()).toISOString()},...gn().filter(e=>e.path!==t)].slice(0,50);o.mkdirSync(pe,{recursive:!0,mode:448}),o.writeFileSync(hn,JSON.stringify(n,null,2)+`
|
|
421
|
+
`,{mode:384})}function vn(e,t,n){let r=gn();for(let i of r.filter(n=>n.server===e&&n.name===t)){i.domains=n;let e=ke(i.path);e?.name===t&&Ae(i.path,{...e,domains:n})}r.length&&o.writeFileSync(hn,JSON.stringify(r,null,2)+`
|
|
422
|
+
`,{mode:384})}const $=e=>`${U}/${e}`;function yn(e){return/^[a-z0-9][a-z0-9-]{0,62}$/.test(e)||E(`invalid site name "${e}" (use lowercase letters, digits, dashes)`),e}const bn=`
|
|
423
423
|
--8<--landfall--8<--
|
|
424
|
-
`;async function
|
|
425
|
-
P=${
|
|
426
|
-
printf 'TRAEFIK\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P"
|
|
427
|
-
for f in ${
|
|
424
|
+
`;async function xn(e,t){let n=$(t),{stdout:r}=await Y(e,[...[`${n}/site.json`,`${n}/.env`,G].map(e=>`cat ${J(e)} 2>/dev/null || true`),`[ ! -d ${J(n)} ] || echo 1`].join(`; printf %s ${J(bn)}; `),{capture:!0}),[i,a=``,o=``,s=``]=r.split(bn),c;if(i.trim())try{c=JSON.parse(i)}catch{E(`corrupt ${n}/site.json on ${e.host}`)}return{meta:c,env:a,proxy:Ie(o)??Pe,exists:s.trim()===`1`}}async function Sn(e,t,n={}){let r=await xn(e,t);r.meta||E(`no site "${t}" on ${e.host}. See \`lf sites list\``);let i=r.meta;return Lt(i)&&E(`${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&&E(`${t} is adopted (${i.adopted}): landfall does not ${n.deployed} it; change its compose files there`),r}const Cn=e=>[...e.releases].sort((e,t)=>t.id.localeCompare(e.id)),wn=(e,t,n)=>tt(e,[`docker logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${t}`]);function Tn(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])=>en.test(e)).flatMap(([,e])=>$t(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&&qt(u)||(l.status=u),i.set(c,l)}return[...i.values()].sort((e,t)=>e.name.localeCompare(t.name))}function En(e,t){if(!t.length)return`not running`;let n=zt(e),r=new Set(n?.services.filter(Bt).map(e=>e.name)),i=t.filter(e=>r.has(e.service));return(i.find(e=>!qt(e.status))??t.find(e=>!r.has(e.service)&&!qt(e.status)&&!Jt(e.status))??i[0]??t[0]).status}const Dn=e=>Lt(e)?Wt(e.name):Rt(e);async function On(e){let{stdout:t}=await Y(e,`printf 'READY\\t%s\\n' "$([ -d ${U} ] && command -v docker >/dev/null 2>&1 && echo 1)"
|
|
425
|
+
P=${Fe}
|
|
426
|
+
printf 'TRAEFIK\\t%s\\t%s\\t%s\\n' "$(docker ps -a --filter "name=^$P$" --format '{{.Status}}' 2>/dev/null || true)" "$P" "$([ -f ${G} ] && echo 1)"
|
|
427
|
+
for f in ${U}/*/site.json; do [ -f "$f" ] || continue; printf 'SITE\\t'; tr -d '\\n' < "$f"; echo; done
|
|
428
428
|
docker ps -a --format 'CTR\\t{{.Names}}\\t{{.Status}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Label "com.docker.compose.service"}}' 2>/dev/null || true
|
|
429
429
|
ids=$(docker ps -aq 2>/dev/null || true)
|
|
430
430
|
# inspect, unlike ps, does not expand \\t: hence {{"\\t"}}.
|
|
431
|
-
[ -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`,sites:[],others:[]};for(let s of t.split(`
|
|
432
|
-
`)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{
|
|
433
|
-
`),{capture:!0}),[s,c=``]=o.split(
|
|
434
|
-
`),`record ${
|
|
435
|
-
`))}async function
|
|
436
|
-
`),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${
|
|
437
|
-
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${
|
|
431
|
+
[ -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:[]};for(let s of t.split(`
|
|
432
|
+
`)){let[t,c=``,l=``,u=``,d=``]=s.split(` `);if(t===`READY`&&(o.ready=c===`1`),t===`TRAEFIK`&&(o.traefik=c,o.proxy=l||`traefik`,o.adopted=u===`1`),t===`SITE`)try{i.push(JSON.parse(s.slice(t.length+1)))}catch{w(`skipped a corrupt site.json on ${e.host}: ${s.slice(5,80)}`)}if(t===`CTR`&&(n.set(c,l),u&&r.set(u,[...r.get(u)??[],{service:d||c,container:c,status:l}])),t===`LABELS`)try{a.push({name:c.replace(/^\//,``),labels:JSON.parse(l??`null`)??{}})}catch{}}return o.others=Tn(a,n,o.proxy,new Set(i.map(Dn))),o.sites=i.map(e=>{let t=(r.get(Dn(e))??[]).sort((e,t)=>e.service.localeCompare(t.service));return{meta:e,status:En(e,t),services:t,domains:Yt(e),main:Ut(e)?.name,state:Gt(e),kind:Kt(e)}}),o}async function kn(e,t,n,r={}){let i=`${$(t)}/releases/${n}`,a=[...Zt,...Qt].filter(e=>e!==`compose.yaml`),{stdout:o}=await Y(e,[q,`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 ${J(i)} && cd -P ${J(i)}`,`[ -e ../../.env ] || { touch ../../.env && chmod 600 ../../.env; }`,`ln -sfn ../../.env .env`,...Object.entries(r.generated??{}).map(([e,t])=>Z(e,t)),...r.file&&r.file!==`compose.yaml`?[`mv -f ${J(r.file)} compose.yaml`]:[],`rm -f ${a.map(J).join(` `)}`,...r.env===void 0?[]:[Z(`.env.next`,r.env,`600`)],`[ -f compose.yaml ] || { echo "no compose.yaml in the upload" >&2; cfg=; false; } && cfg=$(dc ${r.env===void 0?``:`--env-file .env.next `}-f compose.yaml config 2>&1) || {`,` printf '%s\\n' "$cfg" >&2; exit 1`,`}`,`rm -f .env.next`,`pwd -P; printf %s ${J(bn)}; printf '%s\\n' "$cfg"`].join(`
|
|
433
|
+
`),{capture:!0}),[s,c=``]=o.split(bn),l;try{l=g(c)}catch(e){E(`could not read \`docker compose config\` of ${t}: ${e?.message??e}`)}return{dir:s.trim(),config:l}}async function An(e,t){let n=$(t.name);await Y(e,[q,`[ -r ${J(t.adopted)} ] && [ -x ${J(t.adopted)} ] || { echo "$(id -un) cannot read ${t.adopted}; adopt it as a user who can (lf server config)" >&2; exit 1; }`,`[ ! -e ${J(n)} ] || { echo "${n} exists already" >&2; exit 1; }`,`mkdir -p ${J(n)} && cd ${J(n)}`,`ln -s ${J(t.adopted)} current`,Z(`site.json`,JSON.stringify(t,null,2)+`
|
|
434
|
+
`),`record ${J(`adopted ${t.project} as site ${t.name}`)} ${J(`rm -rf ${n}`)}`].join(`
|
|
435
|
+
`))}async function jn(e,t,n){let r=$(t);await Y(e,`cd / && rm -rf ${J(`${r}/releases/${n}`)}; [ -f ${J(r+`/site.json`)} ] || { rmdir ${J(r+`/releases`)} 2>/dev/null; rm -f ${J(r+`/.env`)}; rmdir ${J(r)} 2>/dev/null; } || true`)}const Mn=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; }`],Nn=e=>`docker image rm ${J(e)} >/dev/null 2>&1 || true`;function Pn(e,t,n={}){let r=$(e.name),i=zt(e),a=J(`releases/`+e.current),o=[`mkdir -p ${J(r)} && cd ${J(r)}`,Z(`site.json`,JSON.stringify(e,null,2)+`
|
|
436
|
+
`),n.env===void 0?`[ -e .env ] || { touch .env && chmod 600 .env; }`:Z(`.env`,n.env,`600`),`(cd -P ${a} && ${Z(`compose.override.yaml`,on(e,i,t))}) || exit 1`];return n.build&&i.services.some(e=>e.build)&&o.push(`log "building ${e.name}"`,`(cd -P ${a} && dc build -q) || exit 1`),n.pull&&o.push(`log "pulling images of ${e.name}"`,`(cd -P ${a} && dc pull -q --ignore-buildable) || exit 1`),o.push(`ln -sfn ${a} current.new && mv -Tf current.new current`,...Mn(e.name)),o}const Fn=(e,t)=>(t.services??[]).filter(e=>e.build).map(n=>Xt(e.name,n.name,t.id)),In=(e,t)=>[`rmf ${J(`${$(e.name)}/releases/${t.id}`)}`,...Fn(e,t).map(Nn)];function Ln(e){let t=$(e.name),n=e.kind===`dockerfile`?e.releases.map(e=>e.image).filter(Boolean):[];return[`cd ${J(t)}`,`log "removing the old container of ${e.name}"`,`dc -p ${J(Wt(e.name))} down --remove-orphans >/dev/null 2>&1 || docker rm -f ${J(Wt(e.name))} >/dev/null 2>&1 || true`,`rm -f docker-compose.yml nginx.conf`,...e.releases.map(e=>`rm -rf ${J(`releases/`+e.id)}`),...n.map(Nn)]}async function Rn(e,t,n,r,i){let a=structuredClone(t);i(t),t.updatedAt=(/* @__PURE__ */ new Date()).toISOString(),await zn(e,t,n,{keep:t.releases.length,prev:a,what:r})}async function zn(e,t,n,r){let i=Math.max(Number.isInteger(r.keep)?r.keep:5,2),a=Cn(t),o=a.filter((e,n)=>n<i||e.id===t.current||e.id===r.prev?.current),s=a.filter(e=>!o.includes(e));t.releases=o;let c=$(t.name),l=[q];if(r.takeover){l.push(`record ${J(`${r.what} (moved from landfall's old site layout, cannot be undone)`)}`,...Pn(t,n,r),...Ln(r.takeover)),await Y(e,l.join(`
|
|
437
|
+
`));return}let u,d=``,f=r.prev?r.env!==void 0:!!r.existed;if(f&&l.push(`u=$(saved ${J(c+`/.env`)})`),!r.prev)u=`[ ! -d ${J(c)} ] || {\n${Hn(t,{keepDir:r.existed}).join(`
|
|
438
438
|
`)}\n`,d=`
|
|
439
|
-
}`;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 ${
|
|
440
|
-
`+[...
|
|
441
|
-
`)}l.push(`record ${
|
|
442
|
-
`))}async function
|
|
443
|
-
`))}function
|
|
439
|
+
}`;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 ${J(c)} ] || { log ${J(`${t.name} was removed since, nothing to put back`)}; exit 0; }\n`,d=`
|
|
440
|
+
`+[...Pn(i,n),...a.flatMap(e=>In(t,e))].join(`
|
|
441
|
+
`)}l.push(`record ${J(r.what)} ${J(u)}${f?`"$u"`:``}${J(d)}`),l.push(...Pn(t,n,r));for(let e of s)l.push(...In(t,e));await Y(e,l.join(`
|
|
442
|
+
`))}async function Bn(e,t,n,r=``){await Y(e,`${q}\ncd ${J($(t))}\n${r}\ncd -P current && dc ${n}`)}const Vn=(e,t,n)=>tt(e,[`cd -P ${J($(t)+`/current`)} && docker compose logs --tail ${J(n.tail??`100`)} ${n.follow?`-f `:``}${n.service?J(n.service):``}`]);function Hn(e,t={}){let n=$(e.name),r=t.volumes?` -v`:``,i=e.adopted?[]:e.releases.flatMap(t=>Fn(e,t)),a=[`site.json`,`current`,`current.new`,...e.releases.map(e=>`releases/${e.id}`)].map(J).join(` `);return[`cd ${J(n)}`,`{ [ -e current ] && (cd -P current && dc down --remove-orphans${r}); } >/dev/null 2>&1 || dc -p ${J(Dn(e))} down --remove-orphans${r} >/dev/null 2>&1 || true`,...i.map(Nn),...t.keepDir?[`rmf ${a}`,`rmdir releases 2>/dev/null || true`]:[`cd /`,`rmf ${J(n)}`]]}async function Un(e,t,n={}){let r=`removed site ${t.name} (${Yt(t).join(`, `)||`no domains`})${n.volumes?` and its volumes`:``}`;await Y(e,[q,...Hn(t,n),`record ${J(r)}`].join(`
|
|
443
|
+
`))}function Wn(e,t,n=[]){let r=[],i=new Map(Object.entries(t));for(let t of e.split(`
|
|
444
444
|
`)){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(`
|
|
445
445
|
`)+`
|
|
446
|
-
`:``}function
|
|
446
|
+
`:``}function Gn(e){let t={};for(let n of e){let e=n.indexOf(`=`);e<1&&E(`invalid env "${n}", expected KEY=VALUE`),t[n.slice(0,e)]=n.slice(e+1)}return t}async function Kn(e,t){let{stdout:n}=await Y(e,`{ [ "$(id -u)" = 0 ] || sudo -n true 2>/dev/null; } && echo yes || true`,{capture:!0});if(n.trim()===`yes`)return e;let r={...e,user:`root`};return Q(e).user!==`root`&&await X(r)?r:E(`${t} needs root or passwordless sudo; ${Q(e).user} has neither and root's key login does not work`)}const qn=String.raw`
|
|
447
447
|
set +e
|
|
448
448
|
S() { if [ -n "$SUDO" ]; then sudo -n "$@"; else "$@"; fi; }
|
|
449
449
|
m=$(pkg_mgr); echo "mgr=$m"
|
|
@@ -463,8 +463,8 @@ case "$m" in
|
|
|
463
463
|
esac
|
|
464
464
|
[ -f /var/run/reboot-required ] && echo reboot=1
|
|
465
465
|
exit 0
|
|
466
|
-
`;function
|
|
467
|
-
`)){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
|
|
466
|
+
`;function Jn(e){let t={mgr:`none`,updates:[],refreshed:!1,reboot:!1},n=[];for(let r of e.split(`
|
|
467
|
+
`)){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 Yn(e,t=!0){let{stdout:n}=await Y(e,q+qn,{capture:!0,env:{REFRESH:t}});return Jn(n)}const Xn=String.raw`
|
|
468
468
|
if [ -n "$SUDO" ] && ! sudo -n true 2>/dev/null; then echo "upgrading packages needs root or passwordless sudo" >&2; exit 1; fi
|
|
469
469
|
keep="-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"
|
|
470
470
|
case "$(pkg_mgr)" in
|
|
@@ -484,8 +484,8 @@ if [ "$REBOOT" = 1 ] && { [ -f /var/run/reboot-required ] || { command -v needs-
|
|
|
484
484
|
# Detached, so this ssh session can end cleanly first.
|
|
485
485
|
(sleep 2; $SUDO systemctl reboot 2>/dev/null || $SUDO reboot) >/dev/null 2>&1 &
|
|
486
486
|
fi
|
|
487
|
-
`;async function
|
|
488
|
-
${
|
|
487
|
+
`;async function Zn(e,t){await Y(e,q+Xn,{env:{ONLY:(t.only??[]).join(` `),WHAT:t.what,REBOOT:!!t.reboot}})}function Qn(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 $n(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 er(e){let t=Qn(e);return t.digest?t.digest:(await $n(t,`manifests/${t.tag}`,`HEAD`))?.headers.get(`docker-content-digest`)||void 0}async function tr(e){let t=Qn(e),n=[],r=`tags/list?n=1000`;for(let e=0;r&&e<10;e++){let e=await $n(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 nr(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 rr=/^(v?)(\d+(?:\.\d+){0,2})$/;function ir(e,t){let n=rr.exec(e);if(!n)return;let r=n[2].split(`.`).length,i=n[2];for(let e of t){let t=rr.exec(e);t&&t[1]===n[1]&&t[2].split(`.`).length===r&&nr(t[2],i)>0&&(i=t[2])}return i===n[2]?void 0:n[1]+i}const ar=(e,t)=>`${e.slice(0,e.lastIndexOf(`:`))}:${t}`,or=/* @__PURE__ */ new Map;function sr(e){return or.has(e)||or.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)),or.get(e)}const cr=()=>sr(`moby/moby`),lr=()=>sr(`docker/compose`);async function ur(e){let t=String.raw`
|
|
488
|
+
${q}
|
|
489
489
|
set +e
|
|
490
490
|
printf 'docker=%s
|
|
491
491
|
' "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
|
@@ -495,11 +495,11 @@ printf 'path=%s
|
|
|
495
495
|
' "$(docker info --format '{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}' 2>/dev/null)"
|
|
496
496
|
printf 'ce=%s
|
|
497
497
|
' "$(pkg_has docker-ce && echo 1)"
|
|
498
|
-
exit 0`,[{stdout:n},r,i]=await Promise.all([
|
|
499
|
-
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function
|
|
500
|
-
`)}const
|
|
498
|
+
exit 0`,[{stdout:n},r,i]=await Promise.all([Y(e,t,{capture:!0}),cr(),lr()]),a=Object.fromEntries(n.split(`
|
|
499
|
+
`).map(e=>[e.slice(0,e.indexOf(`=`)),e.slice(e.indexOf(`=`)+1).trim()]));return{docker:a.docker??``,compose:a.compose??``,composePath:a.path??``,dockerCe:a.ce===`1`,latestDocker:r,latestCompose:i}}function dr(e){let t=(e,t)=>!!e&&!!t&&nr(e,t)<0,n=t(e.docker,e.latestDocker)?`docker ${e.docker}, ${e.latestDocker} is current${e.dockerCe?` (comes with the package upgrades)`:` (distro package: switch to Docker's repo for the newest)`}`:void 0,r=t(e.compose,e.latestCompose)&&e.composePath===`/usr/local/lib/docker/cli-plugins/docker-compose`;return{dockerNote:n,composeNote:e.compose?t(e.compose,e.latestCompose)?`compose ${e.compose}, ${e.latestCompose} is current${r?``:e.dockerCe?` (comes with the package upgrades)`:` (from ${e.composePath||`the distro`})`}`:void 0:`docker compose missing: lf setup installs it`,composeUpdate:r}}async function fr(e){await Y(e,q+Re+`compose_install || { log "could not download docker compose"; exit 1; }
|
|
500
|
+
`)}const pr=String.raw`
|
|
501
501
|
set +e
|
|
502
|
-
P=${
|
|
502
|
+
P=${Fe}
|
|
503
503
|
img() { # img <kind> <name> <container> <dir> [service]
|
|
504
504
|
svc=; [ $# -lt 5 ] || svc=$5
|
|
505
505
|
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; }
|
|
@@ -508,11 +508,11 @@ $x
|
|
|
508
508
|
EOF
|
|
509
509
|
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)"
|
|
510
510
|
}
|
|
511
|
-
if [ "$P" = traefik ] && grep -q "managed by landfall" ${
|
|
511
|
+
if [ "$P" = traefik ] && grep -q "managed by landfall" ${W}/docker-compose.yml 2>/dev/null; then img traefik traefik traefik ${W}
|
|
512
512
|
else img proxy "$P" "$P" ""; fi
|
|
513
513
|
# All containers at once: <name> <service> <project>, the last two empty outside Compose.
|
|
514
514
|
PS=$(docker ps -a --format '{{.Names}} {{.Label "com.docker.compose.service"}} {{.Label "com.docker.compose.project"}}' 2>/dev/null)
|
|
515
|
-
for f in ${
|
|
515
|
+
for f in ${U}/*/site.json; do
|
|
516
516
|
[ -f "$f" ] || continue
|
|
517
517
|
d=$(dirname "$f"); n=$(basename "$d")
|
|
518
518
|
# Sites of landfall before Compose sites: one container, its compose file next to site.json.
|
|
@@ -521,13 +521,13 @@ for f in ${W}/*/site.json; do
|
|
|
521
521
|
printf '%s\n' "$PS" | while read -r c svc pr; do [ "$pr" != "$p" ] || img site "$n" "$c" "$d/current" "$svc"; done
|
|
522
522
|
done
|
|
523
523
|
exit 0
|
|
524
|
-
`;async function
|
|
525
|
-
`).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(
|
|
526
|
-
`))}function
|
|
527
|
-
`)){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(
|
|
524
|
+
`;async function mr(e){let{stdout:t}=await Y(e,pr,{capture:!0}),n=t.split(`
|
|
525
|
+
`).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(!rr.test(Qn(e).tag))return;i.has(t)||i.set(t,tr(e));let n=ir(Qn(e).tag,await i.get(t));return n&&ar(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(Xt(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,er(s));let[u,d]=await Promise.all([r.get(s),a(s)]),f=e,p=e=>Qn(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 hr(e,t){let n=/* @__PURE__ */ new Map;for(let e of t)n.set(e.ref,[...n.get(e.ref)??[],e]);let r=[q];for(let[e,t]of n){r.push(`log ${J(`pulling ${e}`)}`,`docker pull -q ${J(e)} >/dev/null`,`new=$(docker image inspect -f '{{.Id}}' ${J(e)})`);for(let n of t){let t=n.service?` ${J(n.service)}`:``,i=`docker tag $old ${J(e)} && cd -P ${J(n.dir)} && dc up -d${t}`;r.push(`old=$(docker inspect -f '{{.Image}}' ${J(n.container)})`,`if [ "$old" = "$new" ]; then log ${J(`${n.name} already runs the newest ${e}`)}; else`,` record ${J(`updated ${n.name} to the newest ${e}`)} "${i.replace(/"/g,`\\"`)}"`,` log ${J(`recreating ${n.container}`)}`,` (cd -P ${J(n.dir)} && dc up -d --quiet-pull${t}) && ensure_running ${J(n.container)} || exit 1`,`fi`)}}await Y(e,r.join(`
|
|
526
|
+
`))}function gr(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 _r(e){let t={values:{},listen:[],published:[],rules:[]};for(let n of e.split(`
|
|
527
|
+
`)){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(gr(i)):r===`ufw_rule`?t.rules.push(i):t.values[r]=i}return t}async function vr(e,t={}){let{stdout:n}=await Y(e,q+Ve,{env:{PUBKEY:t.PUBKEY??``,DEPLOY_USER:t.DEPLOY_USER??``},capture:!0});return _r(n)}const yr=e=>/^(127\.|\[?::1\]?(%|$)|localhost)/.test(e),br=e=>e.includes(`:`),xr=e=>e.proto===`tcp`?`${e.port}`:`${e.port}/udp`,Sr=[68,546,5353];function Cr(e){let t=e.match(/^(.*?)\s+comment\s+'(.*)'$/),n=Ar(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 wr(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 Tr=(e,t)=>{let n=wr(e,t)?.rule;return!!n&&(n.action===`allow`||n.action===`limit`)&&!n.from},Er=(e,t)=>[...new Map([...e].sort((e,t)=>e.port-t.port||e.proto.localeCompare(t.proto)).map(e=>[xr(e),t(e)])).values()];function Dr(e,t){let n=(e,t)=>({part:`firewall`,label:`firewall`,status:e,detail:t}),{blocked:r,kept:i}=Or(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 Or(e,t){let n=e.values,r=e.rules.map(Cr),i=e=>e.proto===`tcp`&&t.includes(e.port)||e.proto===`udp`&&Sr.includes(e.port)||Tr(r,e),a=e=>!yr(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[br(e.addr)?`docker_nat6`:`docker_nat4`]!==`1`),u=e.published.length>0||e.listen.some(e=>e.proc===`docker-proxy`);return{blocked:[...Er(o,e=>`${xr(e)} (${e.proc})`),...c?u?[`every published container port, 80/443 too (docker uses nftables)`]:[]:Er(l,e=>`${xr(e)} (docker${br(e.addr)?`, IPv6`:``})`)],kept:c?[]:Er(s.filter(e=>!l.includes(e)),xr)}}function kr(e){let t=e.values,n=e.rules.map(Cr),r=t.ufw===`active`,i=e=>t.docker_fw!==`nftables`&&t[br(e.addr)?`docker_nat6`:`docker_nat4`]===`1`,a=(e,a)=>{if(yr(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=wr(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`&&Sr.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=`${xr(e)} ${t?`docker`:e.proc}`,r={port:xr(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 Ar=(e=``)=>e.split(/\s+/).filter(Boolean);function jr(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&&nr(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 Mr(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(jr(n));let o=n.root_dir===`1`&&n.network===`1`,s=Number(n.sites||0);if(i.push(r(`layout`,H,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(Dr(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=Ar(n.password_users).filter(e=>e!==`root`&&e!==t.loginUser&&e!==t.deployUser);i.push(e.length&&n.password_auth!==`no`?r(`harden`,`ssh hardening`,`conflict`,`${e.join(`, `)} would lose ssh password login`):r(`harden`,`ssh hardening`,`todo`,`keys only`))}return t.traefik?i.push(Fr(e)):i.push(r(`traefik`,`traefik`,`off`)),i}function Nr(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 Pr=e=>`${e.container} (network ${e.network}, entrypoint ${e.entrypoint}, resolver ${e.resolver})`;function Fr(e){let t=e.values,n=(e,t,n)=>({part:`traefik`,label:`traefik`,status:e,detail:t,proxy:n}),r=Ie(t.proxy??``);if(r)return n(`done`,`sites use your traefik ${Pr(r)}`,r);if(t.traefik_compose===`foreign`)return n(`conflict`,`${W}/docker-compose.yml was not written by landfall`);let[i,a]=Ar(t.traefik_container),o=i!==void 0&&a===`/opt/landfall/traefik`;if(i&&!o){let e=Nr(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=Ar(t[`publish_${r}`]).filter(e=>!(o&&e===`traefik`));if(i.length){let e=Nr(t.foreign_proxy);return e?.proxy?n(`todo`,`use your traefik ${Pr(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 Ir(e){let t={done:b.green(`✓`),todo:b.cyan(`→`),conflict:b.yellow(`!`),off:b.dim(`-`)},n=Math.max(...e.map(e=>e.label.length));for(let r of e){let e=r.status===`off`?b.dim(`disabled`):r.status===`conflict`?b.yellow(r.detail):b.dim(r.detail);C(`${t[r.status]} ${r.label.padEnd(n)} ${e}`)}}async function Lr(e,t){return`${e}:${(await v.hash(t,10)).replace(/^\$2b\$/,`$2y$`)}`}const Rr=e=>e.slice(0,e.indexOf(`:`));async function zr(e,t,n=`user`){/^[A-Za-z0-9._@-]{1,64}$/.test(e)||E(`${n} may only have letters, digits and . _ @ -`);let r=t??await oe(`Password for ${e}:`);return r.length<8&&E(`use a password of at least 8 characters`),Lr(e,r)}function Br(e,t){return{email:t.email??e.email,staging:!!(t.staging??e.staging),image:t.image||e.traefikImage||`traefik:v3`,hostNetwork:!!(t.hostNetwork??e.hostNetwork)}}const Vr=e=>[e.image,e.hostNetwork?`host network`:`ports 80/443 published`,e.staging&&`LE staging`].filter(Boolean).join(`, `);async function Hr(e,t,n){let r=Br(t,n);ye(r.image);let{email:i,staging:a}=r;x(`installing traefik`);let o=n.dashboard===`off`?void 0:t.dashboard,s,c=n.dashboard&&n.dashboard!==`off`?Ce(n.dashboard,t):n.dashboardPassword&&o?.host;if(c){V.test(c)||E(`not a dashboard domain: ${c}${c.includes(`.`)?``:` (set a main domain first: lf server domain)`}`);let e=n.dashboard?n.dashboardUser:o?.user??n.dashboardUser;s=n.dashboardPassword??_(12).toString(`base64url`),o={host:c,user:e,htpasswd:await Lr(e,s)}}let l=We({...r,dashboard:o});await Y(t,`${q}
|
|
528
528
|
ensure_network web
|
|
529
|
-
[ -w ${
|
|
530
|
-
mkdir -p ${
|
|
529
|
+
[ -w ${W} ] || { $SUDO install -d -o "$(id -un)" ${W}; }
|
|
530
|
+
mkdir -p ${W}/letsencrypt ${je} && cd ${W}
|
|
531
531
|
${Z(`dynamic/landfall.yml`,`# managed by landfall — rewritten by \`lf traefik setup\`; other files in this folder are yours
|
|
532
532
|
tls:
|
|
533
533
|
options:
|
|
@@ -540,7 +540,7 @@ if [ -e docker-compose.yml ]; then
|
|
|
540
540
|
# .landfall.sha256: what landfall wrote last. A different file was edited by hand.
|
|
541
541
|
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
|
|
542
542
|
diff -u docker-compose.yml .docker-compose.yml.new >&2; rm -f .docker-compose.yml.new
|
|
543
|
-
echo "${
|
|
543
|
+
echo "${W}/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
|
|
544
544
|
exit 1
|
|
545
545
|
fi
|
|
546
546
|
# Same config again: nothing to journal.
|
|
@@ -548,40 +548,40 @@ if [ -e docker-compose.yml ]; then
|
|
|
548
548
|
else
|
|
549
549
|
u=$(saved docker-compose.yml)
|
|
550
550
|
mv .docker-compose.yml.new docker-compose.yml
|
|
551
|
-
record ${
|
|
551
|
+
record ${J(`reconfigured traefik (${Vr(r)})`)} "cd ${W} && $u && sha256sum docker-compose.yml > .landfall.sha256 && dc up -d --remove-orphans"
|
|
552
552
|
fi
|
|
553
553
|
else
|
|
554
554
|
${Z(`docker-compose.yml`,l)}
|
|
555
555
|
# Certificates in letsencrypt/ stay, a later install reuses them.
|
|
556
|
-
record ${
|
|
556
|
+
record ${J(`installed traefik (${Vr(r)})`)} "cd ${W} && dc down --remove-orphans && rm -f docker-compose.yml .landfall.sha256 dynamic/landfall.yml && { rmdir dynamic 2>/dev/null || true; }"
|
|
557
557
|
fi
|
|
558
558
|
log "starting traefik"
|
|
559
559
|
dc up -d --remove-orphans --quiet-pull
|
|
560
560
|
sleep 2 # traefik exits shortly after start on a bad config
|
|
561
561
|
ensure_running traefik
|
|
562
|
-
sha256sum docker-compose.yml > .landfall.sha256`);let u=
|
|
562
|
+
sha256sum docker-compose.yml > .landfall.sha256`);let u=z();u.servers[e]&&(Object.assign(u.servers[e],{email:i,staging:a||void 0,traefikImage:r.image===`traefik:v3`?void 0:r.image,hostNetwork:r.hostNetwork||void 0,dashboard:o}),me(u)),S(`traefik running (${Vr(r)}) — HTTPS certificates from Let's Encrypt`),r.hostNetwork&&C(b.dim(`host network: ufw now decides who reaches 80/443 (landfall's firewall allows both)`)),o&&(S(`dashboard: https://${o.host}`),C(s?`user ${b.bold(o.user)} password ${b.bold(s)}`:b.dim(`user ${o.user}, password unchanged (--dashboard-password to set a new one)`)))}async function Ur(e,t){x(`using traefik ${b.bold(t.container)} for lf sites`),await Y(e,`${q}
|
|
563
563
|
docker network inspect ${t.network} >/dev/null 2>&1 || { echo "docker network ${t.network} does not exist" >&2; exit 1; }
|
|
564
|
-
[ -w ${
|
|
565
|
-
u=$(saved ${
|
|
566
|
-
${Z(
|
|
567
|
-
record ${
|
|
568
|
-
f=${
|
|
564
|
+
[ -w ${H} ] || $SUDO install -d -o "$(id -un)" ${H}
|
|
565
|
+
u=$(saved ${G})
|
|
566
|
+
${Z(G,Le(t))}
|
|
567
|
+
record ${J(`sites use traefik ${t.container}`)} "$u"`),S(`sites join network ${b.bold(t.network)} with entrypoint ${b.bold(t.entrypoint)} and resolver ${b.bold(t.resolver)}`),C(b.dim(`redeploy existing sites to switch them over; delete ${G} to let landfall run its own traefik again`))}async function Wr(e){let{stdout:t}=await Y(e,`[ ! -f ${G} ] || printf 'proxy\\t%s\\n' "$(sed -n 's/^container=//p' ${G})"
|
|
568
|
+
f=${W}/docker-compose.yml
|
|
569
569
|
[ ! -f $f ] || { grep -q "managed by landfall" $f && echo compose ours || echo compose foreign; }
|
|
570
|
-
for d in ${
|
|
571
|
-
`)){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
|
|
572
|
-
cd ${
|
|
570
|
+
for d in ${U}/*/; do [ -d "$d" ] && printf 'site\\t%s\\n' "$(basename "$d")"; done; true`,{capture:!0}),n={sites:[]};for(let e of t.split(`
|
|
571
|
+
`)){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 Gr(e,t={}){x(`removing traefik`),await Y(e,`${q}
|
|
572
|
+
cd ${W}
|
|
573
573
|
log "stopping traefik"
|
|
574
574
|
dc down --remove-orphans
|
|
575
|
-
u="$(saved ${
|
|
576
|
-
rm -f docker-compose.yml .landfall.sha256 ${
|
|
575
|
+
u="$(saved ${W}/docker-compose.yml); $(saved ${W}/.landfall.sha256); $(saved ${je}/landfall.yml)"
|
|
576
|
+
rm -f docker-compose.yml .landfall.sha256 ${je}/landfall.yml
|
|
577
577
|
${t.certs?`rmf letsencrypt`:``}
|
|
578
578
|
# Certificates removed with --certs come back from Let's Encrypt on the next start.
|
|
579
|
-
record ${
|
|
579
|
+
record ${J(`removed traefik${t.certs?` and its certificates`:``}`)} "$u; cd ${W} && dc up -d --remove-orphans"`)}const Kr={follow:{type:`boolean`,short:`f`,desc:`follow`},tail:{type:`string`,short:`n`,desc:`lines`,default:`100`}},qr=N({name:`traefik setup`,summary:`Install or reconfigure Traefik (reverse proxy + automatic HTTPS) on a server. Safe to re-run; refuses if ports 80/443 belong to something else.`,options:{server:P,email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},staging:{type:`boolean`,desc:`Let's Encrypt staging CA, remembered (--no-staging to switch back)`},image:{type:`string`,desc:`traefik image, remembered (default: ${Me})`},"host-network":{type:`boolean`,desc:`run on the host network: real client IPs, ufw applies; remembered (--no-host-network)`},dashboard:{type:`string`,desc:"serve the traefik dashboard on this domain (basic auth), remembered; `traefik` = traefik.<main domain>, `off` removes it"},"dashboard-user":{type:`string`,desc:`dashboard user`,default:`admin`},"dashboard-password":{type:`string`,desc:`dashboard password (default: generated)`},force:{type:`boolean`,desc:`overwrite a docker-compose.yml that was edited by hand`}},async run(e,t){let{name:n,server:r}=B(t.server),i=Fr(await vr(r));i.status===`conflict`&&E(`not touching ${n}: ${i.detail}`),i.proxy&&E(`${n} already has its own traefik: ${i.detail}. landfall does not reconfigure it (lf traefik use)`),await Hr(n,r,{email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:t[`dashboard-user`],dashboardPassword:t[`dashboard-password`],force:t.force})}}),Jr=N({name:`traefik use`,args:`[container]`,summary:`Deploy sites through a Traefik that already runs on the server (yours, not landfall's).
|
|
580
580
|
|
|
581
581
|
landfall reads network, entrypoint and cert resolver from the container's flags;
|
|
582
|
-
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:
|
|
582
|
+
pass them when it is configured by a file. That Traefik is never changed.`,options:{server:P,network:{type:`string`,desc:`docker network traefik watches (providers.docker.network)`},entrypoint:{type:`string`,desc:`HTTPS entrypoint (the one on :443)`},resolver:{type:`string`,desc:`certificate resolver`}},async run([e],t){let{name:n,server:r}=B(t.server),i=Nr((await vr(r)).values.foreign_proxy),a=e??i?.name;a||E(`no traefik container publishes port 443 on ${n}; name it: lf traefik use <container>`);let o=i?.name===a?i:void 0,s={container:a,network:t.network??o?.proxy?.network,entrypoint:t.entrypoint??o?.proxy?.entrypoint,resolver:t.resolver??o?.proxy?.resolver},c=[`network`,`entrypoint`,`resolver`].filter(e=>!s[e]);c.length&&E(`could not read the ${c.join(`, `)} of ${a}; pass ${c.map(e=>`--${e}`).join(` `)}`),await Ur(r,s)}}),Yr=N({name:`traefik rm`,summary:"Stop landfall's Traefik and remove its config. Sites stay but are unreachable until `lf traefik setup`.\n\nCertificates in letsencrypt/ stay for a later install unless --certs. Undo with `lf undo`.\nWith a Traefik of your own (lf traefik use), landfall only stops routing sites through it.",options:{server:P,certs:{type:`boolean`,desc:`also delete the Let's Encrypt certificates`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await Wr(r);if(i.proxy)return await A(`Stop routing sites on ${n} through your traefik ${i.proxy}? It keeps running.`,!1)||E(`cancelled`),await Y(r,`${q}\nu=$(saved ${G})\n_as ${H} rm -f ${G}\nrecord ${J(`sites stop using traefik ${i.proxy}`)} "$u"`),S(`landfall no longer uses ${i.proxy}; it keeps running and keeps routing the sites until they are redeployed`),C(b.dim(`lf traefik setup installs landfall's own traefik`));if(i.compose===`foreign`&&E(`${W}/docker-compose.yml was not written by landfall; not touching it`),!i.compose)return S(`no traefik of landfall's on ${n}`);let a=i.sites.length?` ${i.sites.join(`, `)} will be unreachable until \`lf traefik setup\`.`:``;await k(`Stop and remove traefik${t.certs?` and its certificates`:``} on ${n}?${a}`),await Gr(r,{certs:t.certs}),S(`traefik removed${t.certs?``:b.dim(` (certificates stay for a later install; --certs deletes them)`)}`),C(b.dim(`lf undo puts it back, lf traefik setup installs it again`))}}),Xr=N({name:`traefik status`,summary:`Show traefik container state and the routes it serves.`,options:{server:P},async run(e,t){let{server:n}=B(t.server);await Y(n,`P=${Fe}
|
|
583
583
|
if ! docker inspect "$P" >/dev/null 2>&1; then echo "traefik is not installed (lf traefik setup)"; exit 0; fi
|
|
584
|
-
[ -f ${
|
|
584
|
+
[ -f ${G} ] && echo "your own traefik, landfall only routes sites through it (${G})"
|
|
585
585
|
docker ps -a --filter "name=^$P$" --format '{{.Names}} {{.Image}} {{.Status}} network {{.Networks}}'
|
|
586
586
|
echo
|
|
587
587
|
echo "routes:"
|
|
@@ -605,26 +605,26 @@ docker ps --filter label=traefik.enable=true --format '{{.Names}}' | while read
|
|
|
605
605
|
printf " %s %s (%s)\\n", n, x, s
|
|
606
606
|
}
|
|
607
607
|
}' || true
|
|
608
|
-
done | sort`)}}),
|
|
608
|
+
done | sort`)}}),Zr=N({name:`traefik logs`,summary:`Show traefik logs (certificate errors show up here).`,options:{server:P,...Kr},async run(e,t){return wn(B(t.server).server,Fe,t)}}),Qr=N({name:`traefik restart`,summary:`Restart traefik.`,options:{server:P},async run(e,t){let{server:n}=B(t.server);await Y(n,`${q}\ndocker restart ${Fe} >/dev/null\nrecord "restarted traefik"`),S(`traefik restarted`)}});function $r(e,t){let n=ke(process.cwd()),r=e??n?.name;return r||E(`which site? pass a name or run inside a folder with .landfall.json`),{site:r,...B(t??n?.server)}}const ei=e=>qt(e)?b.green(e):Jt(e)?b.dim(e):b.red(e),ti=N({name:`sites list`,summary:`List deployed sites on a server.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`},others:{type:`boolean`,desc:`with --json: {sites, others}, others = containers Traefik routes that landfall did not deploy`}},async run(e,t){let{name:n,server:r}=B(t.server??ke(process.cwd())?.server),{sites:i,others:a}=await On(r);if(t.json)return console.log(JSON.stringify(t.others?{sites:i,others:a}:i,null,2));if(a.length&&(console.log(b.dim("not deployed by landfall, routed by Traefik (`lf adopt <name>` manages a Compose project):")),j([[b.dim(`NAME`),b.dim(`DOMAINS`),b.dim(`FOLDER`),b.dim(`STATUS`)],...a.map(e=>[e.name,e.domains.join(`, `),e.dir??b.dim(e.containers.join(`, `)),ei(e.status)])]),i.length&&console.log()),!i.length)return console.log(`no landfall sites on ${n} yet — ${b.bold(`lf deploy --domain example.com`)}`);j([[b.dim(`NAME`),b.dim(`KIND`),b.dim(`DOMAINS`),b.dim(`SERVICES`),b.dim(`RELEASE`),b.dim(`UPDATED`),b.dim(`STATUS`)],...i.map(({meta:e,status:t,services:n,kind:r,domains:i})=>[b.bold(e.name),r,i.join(`, `),n.map(e=>e.service).join(`, `)||b.dim(`-`),e.current||b.dim(`-`),M(e.updatedAt),ei(t)])])}}),ni=N({name:`sites info`,args:`[name]`,summary:`Show a site's services, domains and release history.`,options:{server:P},async run([e],t){let{site:n,server:r}=$r(e,t.server),i=(await On(r)).sites.find(e=>e.meta.name===n);i||E(`no site "${n}" on ${r.host}. See \`lf sites list\``);let a=i.meta;console.log(`${b.bold(a.name)} ${i.kind} ${ei(i.status)}`);for(let e of i.domains)console.log(` ${b.cyan(`https://`+e)}`);if(console.log(b.dim(` ${a.adopted??$(n)+`/current`} (docker compose works there)`)),i.services.length&&(console.log(`\n${b.bold(`Services`)}`),j(i.services.map(e=>[` ${e.service}`,b.dim(e.container),ei(e.status)]))),a.releases.length&&i.state!==`old`){console.log(`\n${b.bold(`Releases`)}`);for(let e of Cn(a)){let t=e.id===a.current?b.green(`● `):` `,n=e.services.filter(e=>e.port||e.own).map(e=>`${e.name}${e.port?`:`+e.port:``}`);console.log(`${t}${e.id} ${b.dim(M(e.at))} ${b.dim(e.services.map(e=>e.name).join(`, `))}${n.length?b.dim(` → ${n.join(`, `)}`):``}`)}}}}),ri=N({name:`logs`,args:`[name] [service]`,summary:`Show a site's logs: all its services, or one.`,options:{server:P,...Kr},async run([e,t],n){let{site:r,server:i}=$r(e,n.server);return Vn(i,r,{...n,service:t})}}),ii=(e,t,n,r,i)=>N({name:e,args:`[name] [service]`,summary:`${e[0].toUpperCase()+e.slice(1)} a site's services, or one of them.`,options:{server:P},async run([a,o],s){let{site:c,server:l}=$r(a,s.server);await Sn(l,c);let u=o?` ${J(o)}`:``,d=`record ${J(`${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} ${J(`cd -P ${$(c)}/current && dc ${r}${u}`)}; else ${d}; fi`:d;await Bn(l,c,t+u,p),S(`${o?`${c} ${o}`:c} ${n}`)}}),ai=ii(`restart`,`restart`,`restarted`),oi=ii(`stop`,`stop`,`stopped`,`start`,!0),si=ii(`start`,`up -d --wait`,`started`,`stop`,!1),ci=N({name:`rollback`,args:`[name]`,summary:`Switch a site back to the previous release (or --to <release>).`,options:{server:P,to:{type:`string`,desc:"release id (see `lf sites info`)"}},async run([e],t){let{site:n,server:r}=$r(e,t.server),{meta:i,proxy:a}=await Sn(r,n,{deployed:`roll back`}),o=Cn(i),s=o.findIndex(e=>e.id===i.current);s<0&&!t.to&&E(`current release ${i.current} is not in the release list; pick one with --to (see \`lf sites info\`)`);let c=t.to?o.find(e=>e.id===t.to):o[s+1];c||E(t.to?`no release ${t.to}`:`no release older than ${i.current}`),await Rn(r,i,a,`rollback ${n} from ${i.current} to ${c.id}`,e=>void(e.current=c.id)),S(`${n} rolled back to ${b.bold(c.id)}`)}}),li=N({name:`adopt`,args:`<project>`,summary:"Make a Compose project landfall did not deploy a site: `lf sites`, logs, restart,\nstop and start then work for it. It keeps running from its folder, with its own files and\nTraefik labels; landfall does not deploy, roll back or change its domains. `lf sites rm`\nstops it and forgets it again. The projects you can adopt are listed under `lf sites`.",options:{server:P,name:{type:`string`,desc:`site name (default: the project name)`}},async run([e],t){e||E("usage: lf adopt <project> (see the projects not deployed by landfall in `lf sites`)");let{server:n,name:r}=B(t.server),i=await On(n),a=i.others.find(t=>t.name===e);if(!a){let t=i.sites.find(t=>Rt(t.meta)===e);E(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||E(`${e} is a single container, not a Compose project: only Compose projects can be adopted`);let s=yn(t.name??fn(e));i.sites.some(e=>e.meta.name===s)&&E(`there is a site "${s}" already: pick another name with --name`);let c=new Set([...Zt,...Qt].map(e=>`${o.dir}/${e}`)),l=(o.files??``).split(`,`).filter(e=>e&&!c.has(e));l.length&&E(`${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 An(n,{v:2,name:s,kind:`compose`,domains:o.domains,current:``,releases:[],adopted:o.dir,project:e,createdAt:u,updatedAt:u}),S(`adopted ${e} as ${s} (${o.domains.join(`, `)})`),console.log(b.dim(`it runs from ${o.dir} as before; lf logs ${s}, lf restart ${s} and lf sites rm ${s} now work`))}}),ui=N({name:`sites rm`,args:`<name>`,summary:`Stop a site and delete its files and releases from the server.
|
|
609
609
|
|
|
610
610
|
Named volumes (databases, uploads) stay, so deploying the site again finds its data;
|
|
611
|
-
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:
|
|
612
|
-
`));let s=
|
|
613
|
-
record ${
|
|
614
|
-
${Z(`.env`,s,`600`)}`),
|
|
611
|
+
--volumes deletes them too. An adopted site is stopped and forgotten; its folder stays.`,options:{server:P,volumes:{type:`boolean`,desc:`also delete the site's named volumes (its data)`}},async run([e],t){e||E(`usage: lf sites rm <name>`);let{site:n,server:r,name:i}=$r(e,t.server),{meta:a}=await xn(r,n);a||E(`no site "${n}" on ${i}. See \`lf sites list\``);let o=a,s=Yt(o).join(`, `)||`no domains`,c=t.volumes?` and its volumes (all its data)`:``;await k(o.adopted?`Stop ${n} (${s}) on ${i} and forget it${c}? ${o.adopted} stays.`:`Delete ${n} (${s})${c} from ${i}? This cannot be undone.`,n),await Un(r,o,{volumes:t.volumes}),S(`removed ${n}${t.volumes?` and its volumes`:b.dim(` (its volumes stay; --volumes deletes them)`)}`)}}),di=N({name:`env`,args:`[name] [KEY=VALUE...]`,summary:`Show or change a site's .env; services whose environment changed are recreated.`,options:{server:P,unset:{type:`string`,short:`u`,multiple:!0,desc:`remove KEY`}},async run(e,t){let n=e.filter(e=>e.includes(`=`)),[r]=e.filter(e=>!e.includes(`=`)),{site:i,server:a}=$r(r,t.server),{env:o}=await Sn(a,i,{deployed:`manage the .env of`});if(!n.length&&!t.unset?.length)return void process.stdout.write(o||b.dim(`(empty)
|
|
612
|
+
`));let s=Wn(o,Gn(n),t.unset??[]),c=[...n.map(e=>e.slice(0,e.indexOf(`=`))),...(t.unset??[]).map(e=>`-${e}`)];await Bn(a,i,`up -d --wait --quiet-pull`,`u=$(saved .env)
|
|
613
|
+
record ${J(`env ${i} ${c.join(` `)}`)} "cd ${$(i)} && $u && cd -P current && dc up -d"
|
|
614
|
+
${Z(`.env`,s,`600`)}`),S(`updated env of ${i}`)}});async function fi(e){try{return{status:(await fetch(`https://${e}`,{method:`HEAD`,signal:AbortSignal.timeout(8e3),redirect:`manual`})).status}}catch(e){return{error:e?.cause?.code??e?.code??e?.message??String(e)}}}async function pi(e,t,n,r,i,a){let o=await On(e);for(let e of o.others){let t=e.domains.filter(e=>r.includes(e));t.length&&w(`${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&&Yt(e).some(e=>r.includes(e)));for(let e of s){let t=Yt(e).filter(e=>r.includes(e)),i=Ut(e)?t.filter(t=>!e.domains.includes(t)):t;if(i.length){let t=Lt(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)`;E(`${i.join(`, `)} ${i.length>1?`serve`:`serves`} ${e.name} now and cannot move from there: ${t}`)}a&&E(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now; ${a}`);let o=t.length===e.domains.length?` ${e.name} keeps running with no domain.`:``;await k(`${t.join(`, `)} ${t.length>1?`serve`:`serves`} ${e.name} now. Move ${t.length>1?`them`:`it`} to ${n}?${o}`)}return async()=>{for(let a of s)await Rn(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)))),vn(t,a.name,a.domains),C(b.dim(a.domains.length?`${a.name} keeps ${a.domains.join(`, `)}`:`${a.name} serves no domain now`))}}const mi=N({name:`domains`,args:`[site]`,summary:`Show a site's domains with their DNS and HTTPS state, or change them.
|
|
615
615
|
|
|
616
616
|
Without flags it lists and checks; --add/--rm/--set change the site (undoable)
|
|
617
|
-
and update .landfall.json of remembered projects.`,options:{server:
|
|
617
|
+
and update .landfall.json of remembered projects.`,options:{server:P,add:{type:`string`,short:`a`,multiple:!0,desc:"add a domain (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},rm:{type:`string`,multiple:!0,desc:`remove a domain`},set:{type:`string`,multiple:!0,desc:`replace all domains`},json:{type:`boolean`,desc:`machine-readable output (listing)`}},async run([e],t){let{site:n,name:r,server:i}=$r(e,t.server),a=!!(t.add||t.rm||t.set),{meta:o,proxy:s}=await Sn(i,n,a?{deployed:`change the domains of`}:{});if(!a){let e=Yt(o),r=await jt(i),[a,s]=await Promise.all([Pt(e,r),Promise.all(e.map(fi))]),c=a.map((e,t)=>({...e,https:s[t]}));if(t.json)return void console.log(JSON.stringify({site:n,serverIps:r,domains:c},null,2));for(let e of c){let t=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`),n=e.https.status?(e.https.status<400?b.green:e.https.status===401?b.cyan:b.yellow)(`https ${e.https.status}`):b.red(`https ${e.https.error}`);console.log(`${b.cyan(`https://${e.domain}`)} ${t} ${n}`)}c.some(e=>!e.ok)&&console.log(b.dim(`\npoint an A record at ${r.join(` or `)} for each domain`));return}Ut(o)||E(`no service of ${n} serves the site's domains: its compose.yaml sets them (x-landfall.domains)`);let c=e=>(e??[]).map(e=>Ce(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=>!V.test(e));u.length&&E(`not a domain: ${u.join(`, `)}`);let d=c(t.rm).filter(e=>!o.domains.includes(e));if(d.length&&w(`${d.join(`, `)} not on ${n}`),l.join()===o.domains.join())return S(`nothing to change`);let f=o.domains.filter(e=>!l.includes(e));if(f.length){let e=l.length?``:` ${n} keeps running, unreachable until it gets a domain again.`;await k(`Remove ${f.join(`, `)} from ${n}? ${f.length>1?`They stop`:`It stops`} serving it.${e}`)}let p=l.filter(e=>!o.domains.includes(e));await(await pi(i,r,n,p,s))(),await Rn(i,o,s,`domains of ${n}: ${l.join(`, `)}`,e=>void(e.domains=l)),vn(r,n,l);let m=ke(process.cwd());m?.name===n&&Ae(process.cwd(),{...m,domains:l}),l.length?S(`${n} serves ${l.map(e=>b.cyan(`https://${e}`)).join(`, `)}`):S(`${n} serves no domain now ${b.dim(`(lf domains ${n} --add <domain> routes it again)`)}`);let ee=await jt(i);for(let e of await Pt(p,ee))e.ok||w(`${e.domain} ${e.ips.length?`points at ${e.ips.join(`, `)}`:`has no DNS record`}, not ${ee.join(` or `)}: HTTPS waits for it`)}}),hi=N({name:`auth`,args:`[site]`,summary:`Protect a site with a password (HTTP basic auth in Traefik).
|
|
618
618
|
|
|
619
619
|
lf auth my-site --user anna add or change a login (asks the password)
|
|
620
620
|
lf auth my-site --rm-user anna remove a login
|
|
621
621
|
lf auth my-site --off remove all logins: the site is public again
|
|
622
|
-
lf auth my-site show who can log in`,options:{server:
|
|
622
|
+
lf auth my-site show who can log in`,options:{server:P,user:{type:`string`,short:`u`,desc:`login to add or change`},password:{type:`string`,short:`p`,desc:`its password (asked when missing)`},"rm-user":{type:`string`,multiple:!0,desc:`remove a login`},off:{type:`boolean`,desc:`remove every login`}},async run([e],t){let{site:n,server:r}=$r(e,t.server),{meta:i,proxy:a}=await Sn(r,n,{deployed:`set logins for`}),o=(i.auth??[]).map(Rr);if(!t.user&&!t[`rm-user`]&&!t.off){if(!o.length)return void console.log(`${n} is public ${b.dim(`(add a login with --user)`)}`);console.log(`${n} asks for a login: ${o.map(e=>b.bold(e)).join(`, `)}`);return}let s=t.off?[]:[...i.auth??[]],c=new Set(t[`rm-user`]??[]);for(let e of c)o.includes(e)||w(`no login ${e} on ${n}`);if(t.user&&c.add(t.user),s=s.filter(e=>!c.has(Rr(e))),t.user&&s.push(await zr(t.user,t.password)),s.join(`
|
|
623
623
|
`)===(i.auth??[]).join(`
|
|
624
|
-
`))return
|
|
624
|
+
`))return S(`nothing to change`);let l=s.map(Rr),u=o.filter(e=>!l.includes(e));l.length?u.length&&await k(`Remove the login${u.length>1?`s`:``} ${u.join(`, `)} from ${n}?`):await k(`Remove every login from ${n}? Anyone can open it then.`),await Rn(r,i,a,l.length?`login for ${n}: ${l.join(`, `)}`:`removed the login of ${n}`,e=>void(e.auth=s.length?s:void 0)),S(l.length?`${n} asks for a login (${l.join(`, `)})`:`${n} is public again`)}}),gi=N({name:`open`,args:`[site]`,summary:`Open a site in the browser.`,options:{server:P},async run([e],t){let{site:n,server:r}=$r(e,t.server),{meta:i}=await Sn(r,n),a=Yt(i)[0];a||E(`${n} serves no domain`);let o=`https://${a}`;console.log(b.cyan(o)),se(o)}}),_i=async e=>{let t=``;for await(let n of e)t+=n;return t};let vi;function yi(){if(vi)return vi;let e=o.mkdtempSync(p(l(),`landfall-`));return vi=p(e,`askpass`),o.writeFileSync(vi,`#!/bin/sh
|
|
625
625
|
printf '%s\\n' "$LANDFALL_PASSWORD"
|
|
626
|
-
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),
|
|
627
|
-
`)),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!==
|
|
626
|
+
`,{mode:448}),process.on(`exit`,()=>o.rmSync(e,{recursive:!0,force:!0})),vi}const bi=e=>({SSH_ASKPASS:yi(),SSH_ASKPASS_REQUIRE:`force`,LANDFALL_PASSWORD:e});async function xi(e){if(e){let e=(await _i(process.stdin)).replace(/\r?\n$/,``);return e||E(`--password-stdin: nothing on stdin`),e}return F(`PASSWORD`)||void 0}const Si=e=>p(s(),`.ssh`,`landfall_${e}`);function Ci(e){let t=Si(e);return o.existsSync(t)?{path:t,created:!1}:(o.mkdirSync(p(s(),`.ssh`),{recursive:!0,mode:448}),r(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-f`,t,`-N`,``,`-C`,`landfall-${e}`],{stdio:`inherit`}).status!==0&&E(`ssh-keygen failed`),{path:t,created:!0})}async function wi(e,t,n){let i=Q(t),a=t.key?L(t.key):void 0;if(a&&!o.existsSync(a)&&E(`key not found: ${a}`),a&&!o.existsSync(`${a}.pub`)){let e=r(`ssh-keygen`,[`-y`,`-f`,a],{encoding:`utf8`});e.status!==0&&E(`cannot read ${a} (passphrase-protected keys need ssh-agent and a .pub next to it)`),o.writeFileSync(`${a}.pub`,e.stdout,{mode:420})}let s=await X(t);if(!s&&!t.key&&o.existsSync(Si(e))&&await X({...t,key:Si(e)})&&(t.key=a=Si(e),s=!0),s)S(`key login works`),a??=dt(t);else if(n.check){w("no key login yet — `lf setup` would install a key first");return}let c=!1;if(!a&&!n.check){let t=Ci(e);a=t.path,c=t.created,c&&S(`generated key ${R(a)}`)}let l=t.key&&L(t.key);if(t.key=a,!n.check&&!(s&&l===a)&&(c||!await X(t))){x(`installing key on ${i.hostname} (${n.password?`with the given password`:`enter the ${i.user} password once`})`);let e=n.password?{...process.env,...bi(n.password)}:process.env;r(`ssh-copy-id`,[`-f`,...Ye(t),Je(t)],{stdio:n.password?[`ignore`,`inherit`,`inherit`]:`inherit`,env:e}).status===0&&await X(t)||E(`could not log in with the key — check host, user and password`),S(`key installed`)}return a}const Ti=e=>{for(let t of e)console.log(b.dim(` `)+t)};function Ei(e,t,n,r){let i=Q(t),a={...r.existing,host:t.host};if(r.sshConfig&&!r.ownAlias){let r=vt({alias:e,hostname:i.hostname,user:t.user??i.user,port:i.port,key:n});S(`${r.created?`created`:`updated`} ${R(lt)} with ${b.bold(`Host ${e}`)} — try \`ssh ${e}\``),Ti(r.block.split(`
|
|
627
|
+
`)),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!==dt({host:t.host}),s=o&&r.ownAlias&&r.sshConfig?bt(e,n):void 0;s?(S(`added the key to your ${b.bold(`Host ${e}`)} in ${R(s.file)} ${b.dim(`(backup: ${R(s.backup)})`)}`),Ti([...s.removed.map(e=>b.red(`- ${e}`)),b.green(`+ ${s.added}`)]),delete a.key):o&&(a.key=R(n)),r.ownAlias&&C(b.dim(`kept your own ~/.ssh/config entry for ${e}; lf logs in as ${a.user??i.user}`))}return a}const Di=N({name:`keygen`,args:`<name>`,summary:`Create an ed25519 key pair at ~/.ssh/landfall_<name> (skips if it exists) and print the public key.`,async run([e]){e||E(`usage: lf keygen <name>`);let{path:t,created:n}=Ci(e);S(`${n?`created`:`key exists:`} ${R(t)}`),console.log(o.readFileSync(`${t}.pub`,`utf8`).trim())}});async function Oi(e,t){let n=/* @__PURE__ */ new Set;for(let r of e)r.status!==`off`&&(r.status===`conflict`?r.part===`traefik`?w(`skipping traefik: ${r.detail} (free it, then \`lf traefik setup\`)`):t||await ae(`${r.label}: ${r.detail}. Apply anyway?`)?n.add(r.part):w(`skipping ${r.label}: ${r.detail} (--force to apply)`):n.add(r.part));return n}async function ki(e,t){x(`traefik settings (enter keeps the default)`),t.image??=e.traefikImage??await ie(`traefik image`,`traefik:v3`),t.hostNetwork===void 0&&e.hostNetwork===void 0&&(C(b.dim(`host network: traefik sees real client IPs (also IPv6) and ufw guards 80/443; default publishes the ports via Docker`)),t.hostNetwork=await A(`run traefik on the host network?`,!1)),t.email===void 0&&e.email===void 0&&(t.email=await ie(`Let's Encrypt e-mail (optional)`,``)||void 0);let n=Se(e);t.dashboard??=await ie(`dashboard domain, behind basic auth (optional${n?", `off` for none":``})`,n?`traefik.${n}`:``)||void 0,t.dashboard===`off`&&(t.dashboard=void 0)}function Ai(e,t){let n=Object.fromEntries(Object.entries(ji.options).flatMap(([e,t])=>`default`in t?[[e,t.default]]:[]));return ji.run([e],{...n,...t},[])}const ji=N({name:`setup`,args:`<name>`,summary:`Prepare a server: SSH key, Docker, firewall, deploy user, Traefik.
|
|
628
628
|
|
|
629
629
|
Turns a fresh box into a deploy target (safe to re-run):
|
|
630
630
|
1. create a dedicated SSH key (~/.ssh/landfall_<name>) and install it (asks for the password once)
|
|
@@ -644,7 +644,7 @@ missing. Steps that would break services already running there are skipped:
|
|
|
644
644
|
a Traefik already running there is used instead (lf traefik use)
|
|
645
645
|
Use --check to only see that report, --force to apply firewall/hardening anyway.
|
|
646
646
|
In a terminal, a first Traefik install asks for image, host network, e-mail and
|
|
647
|
-
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: ${
|
|
647
|
+
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: ${Me})`},"host-network":{type:`boolean`,desc:`run traefik on the host network: real client IPs, ufw applies; remembered`},check:{type:`boolean`,desc:`only report what is set up and what setup would change`},force:{type:`boolean`,desc:`apply firewall/ssh hardening despite conflicts`},"key-only":{type:`boolean`,desc:`only install the ssh key, change nothing else`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin instead of asking (also: $LANDFALL_PASSWORD)`}},async run([e],t){e||E(`usage: lf setup <name> [--host <ip>]`);let n=z(),r=n.servers[e],i=ft().includes(e)&&!St(e),a={host:t.host??r?.host??e,user:t.user??(t.host?`root`:r?.user),port:t.port?Number(t.port):r?.port,key:t.key??r?.key};!t.user&&t[`deploy-user`]&&Q(a).user===t[`deploy-user`]&&await X({...a,user:`root`})&&(a.user=`root`);let s=Q(a);x(`setting up ${b.bold(e)} ${b.dim(`(${s.user}@${s.hostname}:${s.port})`)}`);let c=0,l=e=>x(`[${++c}/5] ${e}`);l(`ssh key`);let u=await wi(e,a,{check:t.check,password:await xi(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([vr(a,p),cr(),lr()]);Object.assign(m.values,{docker_latest:ee??``,compose_latest:te??``});let h=Number(m.values.ssh_port)||s.port,g=Mr(m,{sshPort:h,loginUser:s.user,deployUser:f,firewall:t.firewall,harden:t.harden,traefik:t.traefik}),_=g.find(e=>e.part===`traefik`),v={email:t.email,staging:t.staging,image:t.image,hostNetwork:t[`host-network`],dashboard:t.dashboard,dashboardUser:`admin`},ne=_&&!_.proxy&&_.status!==`conflict`&&_.status!==`off`;ne&&(_.detail+=b.dim(` · ${Vr(Br(r??a,v))}`));let y=Ot(m.values.os_id??``,m.values.os_version??``,m.values.pkg??`none`);if(C(`os: ${m.values.os??`unknown`}${m.values.pkg?b.dim(` · ${m.values.pkg}`):``}`),y.note&&(y.level===`ok`?C:w)(y.note),Ir(g),t.check)return;let T=await Oi(g,t.force),D=ne&&_.status===`todo`&&re(),O=t.domain?_e(t.domain):r&&Se(r);if(O===void 0&&D){let e=await ie(`main domain for sites, e.g. example.com (optional)`,``);O=e.trim()?_e(e):void 0}D&&(await ki({...r??a,domains:xe(r?.domains,O)},v),C(`traefik: ${Vr(Br(r??a,v))}${v.dashboard?`, dashboard ${v.dashboard}`:``}`));let ae=g.filter(e=>e.status!==`done`&&T.has(e.part)).length;if(ae&&!await A(`apply the ${ae} change${ae===1?``:`s`} above?`))return;let k=[`packages`,`docker`,`layout`,`user`,`firewall`,`harden`];g.some(e=>k.includes(e.part)&&e.status!==`done`&&T.has(e.part))?(l(`bootstrapping (docker, firewall, users, ssh hardening)`),await Y(a,q+Be,{env:{PUBKEY:d,DEPLOY_USER:f,SSH_PORT:h,HARDEN:T.has(`harden`),FIREWALL:T.has(`firewall`)}}),S(`server bootstrapped`)):(l(`bootstrap`),S(`already bootstrapped, nothing to change`));let j={...a};if(f){let e={...a,user:f};await X(e)?(S(`deploy user ${b.bold(f)} ready`),j=e):w(`cannot log in as ${f}; staying with ${s.user}`)}l(`ssh config + landfall config`);let M=Ei(e,j,u,{existing:r,sshConfig:t[`ssh-config`],ownAlias:i,port:t.port,keyGiven:!!t.key});O&&(M.domains=xe(M.domains,O)),n.servers[e]=M,n.default??=e,me(n),l(`traefik`),_?.proxy?T.has(`traefik`)&&_.status===`todo`?await Ur(M,_.proxy):S(_.detail):T.has(`traefik`)?await Hr(e,M,v):C(b.dim(`skipped`)),O?(console.log(`\n${b.green(`Done.`)} Point ${b.bold(O)} and ${b.bold(`*.${O}`)} (A records) at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain blog --server ${e} ${b.dim(`# https://blog.${O}`)}`),console.log(` lf server domain ${e} ${b.dim(`# check the DNS`)}\n`)):(console.log(`\n${b.green(`Done.`)} Point a domain's A record at ${b.bold(s.hostname)}, then:\n`),console.log(` lf deploy ./dist --domain example.com --server ${e}\n`))}}),Mi={compose:{files:``},dockerfile:{files:``,port:!0},static:{files:`/public`},image:{port:!0}};function Ni(e,t){if(!t)return e;let n=Rr(t);return[...(e??[]).filter(e=>Rr(e)!==n),t]}const Pi=(e=/* @__PURE__ */ new Date)=>e.toISOString().replace(/[-:T]/g,``).slice(0,14);function Fi(e,t=/* @__PURE__ */ new Date){let n=t.getTime();for(;e.includes(Pi(new Date(n)));)n+=1e3;return Pi(new Date(n))}const Ii=N({name:`deploy`,args:`[dir]`,summary:`Deploy a Compose project, static folder, Dockerfile or image with HTTPS.
|
|
648
648
|
|
|
649
649
|
Kinds:
|
|
650
650
|
compose the folder has a compose.yaml: its services run on the server as one
|
|
@@ -658,16 +658,16 @@ so \`docker compose\` works there without landfall. Settings are saved to
|
|
|
658
658
|
./.landfall.json, so later deploys are just \`lf deploy\`.
|
|
659
659
|
|
|
660
660
|
A server landfall has not set up yet is set up first (\`lf setup\`), asking
|
|
661
|
-
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:
|
|
662
|
-
`)}`),
|
|
663
|
-
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function
|
|
661
|
+
before and logging in with the password once: \`lf deploy -s vps --host 1.2.3.4\`.`,options:{server:P,name:{type:`string`,short:`n`,desc:`site name (default: folder name)`},domain:{type:`string`,short:`d`,multiple:!0,desc:"domain to serve (`blog` = blog.<main domain>, `@` = the main domain); another site's moves here"},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`},build:{type:`string`,short:`b`,desc:`run this command locally first (e.g. 'npm run build')`},keep:{type:`string`,desc:`releases to keep for rollback`,default:`5`},save:{type:`boolean`,desc:`write .landfall.json (--no-save to skip)`,default:!0},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)`},"auth-password":{type:`string`,desc:`with --auth-user: its password (asked when missing)`}},async run([e],t){let n=process.cwd(),i=ke(n)??{};i.name&&C(b.dim(`using ${De}`));let{name:a,server:s}=B(t.server??i.server);t.host&&!t.server&&!i.server&&!z().default&&E(`--host needs --server <name> for the new server`);let c=t.host?`bare`:await it(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&&!(re()&&await A(`${a} ${e}. Set it up now (lf setup)?`))&&E(`${a} ${e}: run \`lf setup ${a}\`${c===`no-login`?` (--host <ip> for a new server)`:``}, or deploy with --host`),await Ai(a,{host:t.host,user:t.user,port:t[`ssh-port`],"password-stdin":t[`password-stdin`]}),{server:s}=B(a),await it(s)!==`ready`&&E(`${a} is still not set up`)}let l=yn(t.name??i.name??fn(d(n))),u=be(t.keep,`--keep`,1),f=(t.domain?.length?t.domain:i.domains??[]).map(e=>Ce(e,s)).filter(e=>!V.test(e));f.length&&E(`not a domain: ${f.join(`, `)}`),t[`auth-password`]&&!t[`auth-user`]&&E(`--auth-password needs --auth-user`);let te=t[`auth-user`]?await zr(t[`auth-user`],t[`auth-password`],`--auth-user`):void 0,h=t.build??i.build;if(h){x(`building locally: ${h}`);let e=r(h,{shell:!0,stdio:`inherit`});e.status!==0&&E(`build failed (exit ${e.status})`)}let g=t.static??i.static,_=t.image??(g?void 0:i.image),v=_&&ye(_),ne=ee(n,e??i.dir??`.`),y=v||g?void 0:t.file??pn(ne,i);y&&!o.existsSync(p(ne,y))&&E(`no ${y} in ${m(n,ne)||`.`}`);let T=y||v?ne:ee(n,e??i.dir??un(n)),D=y?`compose`:v?`image`:!g&&o.existsSync(p(T,`Dockerfile`))?`dockerfile`:`static`;D===`static`&&!o.existsSync(p(T,`index.html`))&&w(`no index.html in ${m(n,T)||`.`}`);let O=Mi[D].port?be(t.port??i.port??(D===`dockerfile`?dn(p(T,`Dockerfile`)):void 0)??3e3,`--port`,1,65535):void 0,ae=D===`static`&&!!(t.spa??i.spa);x(`deploying ${b.bold(l)} (${D}) to ${b.bold(a)}`);let k=await xn(s,l),{env:j,proxy:M,exists:oe}=k,se=k.meta&&Lt(k.meta)?k.meta:void 0,N=se?void 0:k.meta;N?.adopted&&E(`${l} is adopted (${N.adopted}): deploy it there with docker compose, or pick another --name`),se?w(`${l} was deployed by an older landfall: this moves it to a Compose project (its old releases go, so it cannot be undone)`):N&&N.kind!==D&&C(b.dim(`site was ${N.kind}, now ${D}`));let P;(t.env?.length||t[`env-file`])&&(P=j,t[`env-file`]&&(P=Wn(P,Gn(Li(o.readFileSync(t[`env-file`],`utf8`))))),t.env?.length&&(P=Wn(P,Gn(t.env))));let ce=Fi((N?.releases??[]).map(e=>e.id)),le=`${$(l)}/releases/${ce}`,ue=D===`compose`?{}:{"compose.yaml":sn(D,{port:O,image:v}),...D===`static`&&{"nginx.conf":cn(ae)}},de,F,fe;try{let e=Mi[D].files;e!==void 0&&(x(`uploading ${m(n,T)||`.`}`),await ct(s,T,le+e));let r=await kn(s,l,ce,{file:y,generated:ue,env:P}),o=tn(r.config,r.dir,e=>Ce(e,s));for(let e of o.warnings)w(e);o.errors.length&&E(`${y??`compose.yaml`} cannot be deployed:\n${o.errors.map(e=>` - ${e}`).join(`
|
|
662
|
+
`)}`),de={id:ce,at:(/* @__PURE__ */ new Date()).toISOString(),services:o.services};let c=o.services.flatMap(e=>e.domains??[]);F=[];let u=!1;if(Ht(de)){u=!t.domain?.length&&!!i.domains?.length,F=[t.domain,i.domains,N?.domains,se?.domains].find(e=>e?.length)??[];let e=!F.length&&!!N&&!!Ut(N)&&!N.domains.length;if(!F.length&&!e){let e=Se(s),t=e?void 0:Mt(await jt(s));F=(await ie(`Domain(s) to serve (comma separated):`,e?`${l}.${e}`:t?Nt(l,t):void 0)).split(/[\s,]+/).filter(Boolean)}F=[...new Set(F.map(e=>Ce(e,s)))];let n=F.filter(e=>!V.test(e)),r=F.filter(e=>c.includes(e));!F.length&&!e&&E(`at least one --domain is required`),e&&C(b.dim(`${l} serves no domain (lf domains ${l} --add <domain>, or deploy with -d)`)),n.length&&E(`not a domain: ${n.join(`, `)}`),r.length&&E(`${r.join(`, `)} is already routed by x-landfall.domains`)}else t.domain?.length&&w(`-d ignored: every routed service of ${l} names its own x-landfall.domains`);fe=await pi(s,a,l,[...F,...c],M,u?`${De} lists it; deploy with -d to move it`:void 0)}catch(e){throw await jn(s,l,ce).catch(()=>{}),e}let pe=(/* @__PURE__ */ new Date()).toISOString(),I={v:2,name:l,kind:D,domains:F,auth:Ni(N?.auth??se?.auth,te),current:ce,releases:[...N?.releases??[],de],createdAt:N?.createdAt??se?.createdAt??pe,updatedAt:pe};await fe(),await zn(s,I,M,{env:P,build:!0,pull:!0,keep:u,prev:N,takeover:se,existed:!N&&!se&&oe,what:`deploy ${l} release ${ce}${N?``:` (new site, ${Yt(I).join(`, `)})`}`});let L=Yt(I);if(t.save){let e={name:l,server:a,domains:F};if(D===`image`)Object.assign(e,{image:v,port:O});else{let r=m(n,T)||`.`;D===`compose`?(r!==`.`&&(e.dir=r),(t.file||i.compose)&&(e.compose=y)):r!==un(n)&&(e.dir=r),D===`dockerfile`&&(e.port=O),ae&&(e.spa=!0),g&&D===`static`&&(e.static=!0)}h&&(e.build=h),Ae(n,e),_n({path:n,name:l,server:a,domains:L,kind:D})}S(`release ${b.bold(ce)} live`);for(let e of L)console.log(` ${b.cyan(`https://${e}`)}`);L[0]&&await Ri(L[0])}});function Li(e){return e.split(`
|
|
663
|
+
`).map(e=>e.trim().replace(/^export\s+/,``)).filter(e=>e&&!e.startsWith(`#`)&&e.includes(`=`))}async function Ri(e){try{let t=await fetch(`https://${e}`,{signal:AbortSignal.timeout(15e3),redirect:`manual`});t.status<400?S(`https://${e} responded ${t.status}`):w(`https://${e} responded ${t.status}`)}catch(t){let n=t?.cause?.code??t?.code??t?.message??String(t);w(`could not reach https://${e} yet (${n}). DNS or the first certificate may still be pending.`)}}const zi=N({name:`init`,args:`[dir]`,summary:`Write a compose.yaml for this project, to run it locally and deploy it.
|
|
664
664
|
|
|
665
665
|
compose.yaml what runs on the server; x-landfall routes a service through Traefik
|
|
666
666
|
compose.override.yaml local only (docker compose merges it, lf deploy leaves it out):
|
|
667
667
|
publishes the port on localhost
|
|
668
668
|
|
|
669
669
|
\`docker compose up\` then runs the site locally, \`lf deploy\` on a server. Existing
|
|
670
|
-
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=
|
|
670
|
+
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=ee(process.cwd(),e??`.`);o.statSync(n,{throwIfNoEntry:!1})?.isDirectory()||E(`${e} is not a folder`);let r=mn(n);r.kind===`compose`&&E(`${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(p(n,e)));a.length&&E(`${a.join(` and `)} exist already`);let s=r.kind===`static`?80:be(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||C(b.dim(`no built site found: serving ./${e}, change it in compose.yaml if your build writes elsewhere`)),u=` web:
|
|
671
671
|
image: nginx:alpine
|
|
672
672
|
restart: unless-stopped
|
|
673
673
|
volumes:
|
|
@@ -712,8 +712,8 @@ services:
|
|
|
712
712
|
${d}:
|
|
713
713
|
ports:
|
|
714
714
|
- "127.0.0.1:${c}:${s}"
|
|
715
|
-
`;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;
|
|
716
|
-
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function
|
|
715
|
+
`;for(let[e,t]of Object.entries(i))o.writeFileSync(p(n,e),t);let f=m(process.cwd(),n)||`.`;S(`wrote compose.yaml and compose.override.yaml in ${f}`),l&&!o.existsSync(p(n,`.env`))&&C(`locally: put POSTGRES_PASSWORD=… in ${p(f,`.env`)} (keep it out of git)`),C(`run it: docker compose up${r.kind===`static`?``:` --build`} → http://localhost:${c}`),C(`deploy it: lf deploy${l?` -e POSTGRES_PASSWORD=<secret> (the first time)`:``}`)}}),Bi=e=>e.steps.filter(e=>e.undo&&!e.undone);function Vi(e){let t=/* @__PURE__ */ new Map,n=/* @__PURE__ */ new Map;return e.split(`
|
|
716
|
+
`).forEach((e,r)=>{let[i,a,o,s,c,l,u,d]=e.split(` `);if(d===void 0||l!==`do`&&l!==`undo`)return;let f=t.get(a);f||t.set(a,f={n:t.size+1,id:a,time:i,actor:o,cmd:c,steps:[]});let p={line:r+1,time:i,user:s,what:u,undone:!1};if(l===`undo`){let e=n.get(Number(u));e&&(e.undone=!0),p.reverts=Number(u),p.what=`undid: ${e?.what??`line ${u}`}`}else d&&(p.undo=Buffer.from(d,`base64`).toString());n.set(p.line,p),f.steps.push(p)}),[...t.values()]}async function Hi(e){let{stdout:t}=await Y(e,`cat ${K} 2>/dev/null || sudo -n cat ${K} 2>/dev/null || true`,{capture:!0});return Vi(t)}function Ui(e,t){let n=t.match(/^#?(\d+)(?:\.(\d+))?$/),r=n?e[Number(n[1])-1]:e.find(e=>e.id===t);if(r||E(`no run ${t} in the journal (\`lf log\` lists them)`),!n?.[2])return{run:r,steps:r.steps};let i=r.steps[Number(n[2])-1];return i||E(`run #${r.n} has ${r.steps.length} steps, not ${n[2]}`),{run:r,steps:[i]}}async function Wi(e,t){let n=Q(e).user;if(t===n||n===`root`)return{login:e};if(t!==`root`)return{login:{...e,user:t}};let{stdout:r}=await Y(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 X(i)?{login:i}:E(`undoing this needs root: ${n} has no passwordless sudo and root's key login does not work`)}const Gi=String.raw`
|
|
717
717
|
undo_step() { # undo_step <line> <undo shell, base64>
|
|
718
718
|
set +e
|
|
719
719
|
( set -e; eval "$(printf %s "$2" | base64 -d)" )
|
|
@@ -721,16 +721,16 @@ undo_step() { # undo_step <line> <undo shell, base64>
|
|
|
721
721
|
set -e
|
|
722
722
|
[ "$rc" = 0 ] || { echo "could not undo journal line $1 (exit $rc); later steps are undone, the rest is kept" >&2; exit 1; }
|
|
723
723
|
_journal undo "$1" ""
|
|
724
|
-
}`;async function
|
|
725
|
-
`),{sudo:c}),r.push(s),t=i}return n.length}async function
|
|
726
|
-
record ${
|
|
724
|
+
}`;async function Ki(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} ${J(Buffer.from(e.undo).toString(`base64`))}`),{login:s,sudo:c}=await Wi(e,n[t].user);a.some(e=>e.undo.includes(`userdel`))&&await Promise.all(r.map($e)),await Y(s,[q,Gi,...o].join(`
|
|
725
|
+
`),{sudo:c}),r.push(s),t=i}return n.length}async function qi(e){let t=e,n=await vr(t);if(n.values.sudo!==`1`){let r={...e,user:`root`};Q(e).user!==`root`&&await X(r)||E(`the firewall needs root or passwordless sudo; ${Q(e).user} has no sudo and root's key login does not work`),t=r,n=await vr(t)}let r=n.values,i=Number(r.ssh_port)||Q(e).port;return{login:t,ufw:r.ufw===`active`?`active`:r.ufw===`missing`?`missing`:`inactive`,policy:r.ufw_in||`DROP`,sshPort:i,rules:n.rules.map(Cr),ports:kr(n),blocked:Or(n,[i]).blocked,probe:n}}const Ji=/^\d+(:\d+)?(,\d+(:\d+)?)*(\/(tcp|udp))?$/,Yi=/^[0-9a-fA-F.:]+(\/\d+)?$/;function Xi(e){Ji.test(e.port)||E(`not a port: ${e.port} (e.g. 5432, 51820/udp, 6000:6007/tcp)`);let[t,n]=e.port.split(`/`);t.split(/[,:]/).some(e=>Number(e)<1||Number(e)>65535)&&E(`ports go from 1 to 65535: ${t}`),/[,:]/.test(t)&&!n&&E(`ufw needs a protocol for port lists and ranges: ${t}/tcp or ${t}/udp`),e.from&&!Yi.test(e.from)&&E(`not an address or network: ${e.from} (e.g. 203.0.113.7 or 10.0.0.0/8)`);let r=e.from?[e.action,`from`,e.from,`to`,`any`,`port`,t,...n?[`proto`,n]:[]]:[e.action,e.port];return e.comment?[...r,`comment`,e.comment]:r}const Zi=e=>`ufw_add ${e.map(J).join(` `)}`,Qi=e=>e[0]===`deny`?`${Zi([`prepend`,...e])} 2>/dev/null || ${Zi(e)}`:Zi(e),$i=e=>`$SUDO ufw --force delete ${e.spec} >/dev/null
|
|
726
|
+
record ${J(`ufw delete ${e.spec}`)} ${J(`$SUDO ufw ${e.action===`allow`||e.action===`limit`?``:`prepend `}${e.spec} >/dev/null 2>&1 || $SUDO ufw ${e.spec} >/dev/null`)}`,ea=e=>Cr(e.map((t,n)=>e[n-1]===`comment`?`'${t}'`:t).join(` `)),ta=`command -v ufw >/dev/null || { echo "ufw is not installed (lf firewall enable installs it)" >&2; exit 1; }`;async function na(e,t,n){if(e.ufw!==`active`)return;let r=wr(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 ae(`${i}. Do it anyway?`)||E(`${i} (--force to do it anyway)`)}function ra(e,t){let n=/^\d+$/.test(t)?Number(t):e.rules.findIndex(e=>e.spec===t)+1;return e.rules[n-1]||E(`no rule ${t}; \`lf firewall\` lists them (${e.rules.length} rules)`),{rule:e.rules[n-1],n}}const ia={server:P,from:{type:`string`,desc:`only from this address or network (e.g. 10.0.0.0/8)`},comment:{type:`string`,desc:`note stored with the rule`}};function aa(e){return N({name:`firewall ${e}`,args:`<port[/proto]>`,summary:e===`allow`?"Open a port in ufw, e.g. `lf firewall allow 5432 --from 10.0.0.0/8` or `lf firewall allow 51820/udp`.":"Block a port in ufw, e.g. `lf firewall deny 5432`. Goes before the other rules, so it wins over an allow.",options:{...ia,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([t],n){t||E(`usage: lf firewall ${e} <port[/proto]> [--from ip]`);let r=Xi({action:e,port:t,from:n.from,comment:n.comment}),i=await qi(B(n.server).server);e===`deny`&&await na(i,[ea(r),...i.rules],n.force),await Y(i.login,`${q}\n${ta}\n${Qi(r)}`),S(`${r.join(` `)}${i.ufw===`active`?``:b.dim(` (takes effect once the firewall is on: lf firewall enable)`)}`)}})}const oa=aa(`allow`),sa=aa(`deny`),ca=N({name:`firewall status`,summary:`Show the firewall (ufw): on or off, its rules, and who can reach each listening port.`,options:{server:P,json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await qi(r);if(t.json){let{ufw:e,policy:t,sshPort:n,rules:r,ports:a,blocked:o}=i;console.log(JSON.stringify({ufw:e,policy:t,sshPort:n,rules:r.map((e,t)=>({n:t+1,...e})),ports:a,blocked:o},null,2));return}let a=i.ufw===`active`?b.green(`on`):i.ufw===`missing`?b.yellow(`not installed`):b.yellow(`off`);x(`firewall on ${n}: ${a}${i.ufw===`active`?b.dim(` · incoming default ${i.policy.toLowerCase()}`):``}`),console.log(`\n${b.bold(`Rules`)}`),i.rules.length||C(b.dim(`none`)),j(i.rules.map((e,t)=>[` ${t+1}`,e.action===`allow`||e.action===`limit`?b.green(e.action):b.red(e.action),e.spec.replace(/^\S+\s+/,``)])),console.log(`\n${b.bold(`Listening ports`)}`);let o={open:b.green,limited:b.yellow,blocked:b.red,local:b.dim};j(i.ports.map(e=>[` ${e.port}`,e.proc,o[e.state](e.why)])),i.ufw!==`active`&&(console.log(),i.blocked.length?w(`enabling would block ${i.blocked.join(`, `)}: allow them first`):C(b.dim(`lf firewall enable turns it on (ssh port ${i.sshPort} stays open)`)))}}),la=N({name:`firewall enable`,summary:`Turn ufw on (installs it if missing). Always allows the ssh port first; refuses if it would block a port in use.`,options:{server:P,force:{type:`boolean`,desc:`turn it on even if it blocks ports in use`}},async run(e,t){let{name:n,server:r}=B(t.server),i=await qi(r);if(i.ufw===`active`)return S(`firewall on ${n} is already on`);if(i.blocked.length){let e=`turning the firewall on would block ${i.blocked.join(`, `)}`;t.force||await ae(`${e}. Turn it on anyway?`)||E(`${e}. Allow them first (lf firewall allow <port>), or --force`)}await na({...i,ufw:`active`},[...i.rules,ea([`allow`,`${i.sshPort}/tcp`])],t.force),await Y(i.login,`${q}
|
|
727
727
|
command -v ufw >/dev/null || { log "installing ufw"; pkg_install ufw; record "installed ufw"; }
|
|
728
|
-
${
|
|
729
|
-
ufw_enable`),
|
|
730
|
-
${
|
|
731
|
-
${
|
|
732
|
-
${
|
|
733
|
-
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=
|
|
728
|
+
${Zi([`allow`,`${i.sshPort}/tcp`])}
|
|
729
|
+
ufw_enable`),S(`firewall on ${n} is on; ssh port ${i.sshPort} allowed`)}}),ua=N({name:`firewall disable`,summary:"Turn ufw off. Rules are kept and apply again after `lf firewall enable`.",options:{server:P},async run(e,t){let{name:n,server:r}=B(t.server),i=await qi(r);if(i.ufw!==`active`)return S(`firewall on ${n} is already off`);await A(`Turn the firewall on ${n} off? Every listening port becomes reachable.`,!1)||E(`cancelled`),await Y(i.login,`${q}\n$SUDO ufw disable\nrecord "disabled ufw" '$SUDO ufw --force enable >/dev/null'`),S(`firewall on ${n} is off (${i.rules.length} rules kept)`)}}),da=N({name:`firewall rm`,args:`<number|rule>`,summary:"Remove a rule by its number in `lf firewall`, or by its text (e.g. 'allow 5432').",options:{server:P,force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall rm <number|rule>`);let{name:n,server:r}=B(t.server),i=await qi(r),{rule:a,n:o}=ra(i,e);await na(i,i.rules.filter(e=>e!==a),t.force),await k(`Remove rule ${o} (${a.spec}) on ${n}?`),await Y(i.login,`${q}\n${ta}\n${$i(a)}`),S(`removed rule ${o}: ${a.spec}`)}}),fa=N({name:`firewall update`,args:`<number|rule>`,summary:`Change a rule: what you pass replaces that part, the rest stays. If ufw rejects the new rule, the old one is restored.`,options:{...ia,port:{type:`string`,desc:`port[/proto], e.g. 5432 or 51820/udp`},from:{type:`string`,desc:`only from this address or network; 'any' for everyone`},action:{type:`string`,desc:`allow, deny or limit`},force:{type:`boolean`,desc:`even if it blocks ssh`}},async run([e],t){e||E(`usage: lf firewall update <number|rule> [--port p] [--from ip|any] [--action allow|deny|limit]`);let n=await qi(B(t.server).server),{rule:r,n:i}=ra(n,e),a=t.action??r.action;[`allow`,`deny`,`limit`].includes(a)||E(`--action must be allow, deny or limit, not ${a}`);let o=t.port??(r.port&&(r.proto?`${r.port}/${r.proto}`:r.port));o||E(`rule ${i} (${r.spec}) names no port; pass --port`);let s=Xi({action:a,port:o,from:t.from===`any`?void 0:t.from??r.from,comment:t.comment??r.comment}),c=n.rules.filter(e=>e!==r);await na(n,a===`deny`?[ea(s),...c]:[...c,ea(s)],t.force),await Y(n.login,`${q}
|
|
730
|
+
${ta}
|
|
731
|
+
${$i(r)}
|
|
732
|
+
${Qi(s)} || { $SUDO ufw ${r.spec} >/dev/null; record "restored ufw ${r.spec}"; echo "kept rule ${i}" >&2; exit 1; }`),S(`rule ${i}: ${r.spec} → ${s.join(` `)}`)}});function pa(e,t){let n=b.dim(`${t+1}.`.padStart(4));return e.reverts?`${n} ${b.dim(`↩ `+e.what)}`:e.undone?`${n} ${b.dim(`✓ ${e.what} (undone)`)}`:e.undo?`${n} ${b.cyan(`↺`)} ${e.what}`:`${n} ${b.dim(`· `+e.what+` (not reversible)`)}`}const ma=e=>e.trim().replace(/\\(.)/g,`$1`),ha=(e,t)=>e.action===t.action&&e.port===t.port&&e.proto===t.proto&&e.from===t.from;async function ga(e,t,n){if(!t.some(e=>e.undo.includes(`ufw`)))return;let r=await qi(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(`
|
|
733
|
+
`)){let e=t.match(/\$SUDO ufw --force delete (.+?) >\/dev\/null/),n=t.match(/\$SUDO ufw (prepend )?((?:allow|deny|reject|limit) .+?) >\/dev\/null/);if(e){let t=Cr(ma(e[1])),n=i.findIndex(e=>ha(e,t));n>=0&&i.splice(n,1)}else n?i=n[1]?[Cr(ma(n[2])),...i]:[...i,Cr(ma(n[2]))]:/\$SUDO ufw disable/.test(t)?a=`inactive`:/\$SUDO ufw --force enable/.test(t)&&(a=`active`)}await na({...r,ufw:a},i,n)}const _a=e=>`${b.bold(`#${e.n}`.padEnd(5))} ${b.dim(M(e.time).padEnd(9))} ${b.bold(e.cmd||`?`)} ${b.dim(`${e.actor} · ${e.id}`)}`,va=N({name:`history`,summary:"What landfall changed on a server, newest first. ↺ marks steps `lf undo` can revert.",options:{server:P,n:{type:`string`,short:`n`,desc:`runs to show`,default:`20`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{name:n,server:r}=B(t.server),i=(await Hi(r)).reverse().slice(0,Number(t.n)||20);if(t.json)return console.log(JSON.stringify(i,null,2));if(!i.length)return C(`nothing in ${K} on ${n} yet`);for(let e of i)console.log(_a(e)),e.steps.forEach((e,t)=>console.log(pa(e,t)));let a=i.find(e=>Bi(e).length);a&&console.log(b.dim(`\nlf undo reverts #${a.n}; lf undo <n> picks a run, <n>.<step> one step`))}}),ya=N({name:`undo`,args:`[run|run.step]`,summary:"Revert what landfall did: the latest run, run <n> from `lf history`, one step <n>.<m>, or --all.",options:{server:P,all:{type:`boolean`,desc:`everything landfall did that can be reverted`},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){let{name:n,server:r}=B(t.server),i=await Hi(r),a,o;if(t.all)a=i.flatMap(e=>e.steps),o=`everything landfall did on ${n}`;else{let t=e?Ui(i,e):[...i].reverse().map(e=>({run:e,steps:e.steps})).find(e=>Bi(e.run).length);if(!t)return S(`nothing to undo on ${n}`);({steps:a}=t),o=`#${t.run.n} ${t.run.cmd} (${M(t.run.time)})`;let r=i.filter(e=>e.n>t.run.n&&Bi(e).length);r.length&&w(`later runs build on it and stay: ${r.map(e=>`#${e.n} ${e.cmd}`).join(`, `)}`)}let s=a.filter(e=>e.undo&&!e.undone);if(!s.length)return S(`nothing left to undo in ${o}`);x(`undo ${o}`);for(let e of[...s].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);let c=a.filter(e=>!e.undo&&!e.reverts);for(let e of c)console.error(` ${b.dim(`· ${e.what} stays (not reversible)`)}`);await ga(r,s,t.force);let l=`Revert ${s.length} change${s.length>1?`s`:``} on ${n}?`;t.all?await k(l,n):await A(l,!1)||E(`cancelled`),await Ki(r,s),S(`reverted ${s.length} change${s.length>1?`s`:``} on ${n}`),s.some(e=>e.what.startsWith(`created user`))&&C(b.dim(`lf still logs in to ${n} as that user: lf server rm ${n}, or lf setup again`))}}),ba=N({name:`server add`,args:`<name>`,summary:`Register a server without setting it up (fresh or not).
|
|
734
734
|
|
|
735
735
|
Without --host, <name> is used as an ssh alias from ~/.ssh/config,
|
|
736
736
|
so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
@@ -738,19 +738,19 @@ so \`lf server add vps\` works whenever \`ssh vps\` works.
|
|
|
738
738
|
With --host lf logs in with --key, or installs its own key
|
|
739
739
|
(~/.ssh/landfall_<name>) using the password once, and writes \`Host <name>\`
|
|
740
740
|
to ~/.ssh/config (created if missing), so \`ssh <name>\` works afterwards.
|
|
741
|
-
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||
|
|
742
|
-
Any ~/.ssh/config alias works too (--all to list them).`)))}}),
|
|
741
|
+
The password comes from the prompt, --password-stdin or $LANDFALL_PASSWORD.`,options:{host:{type:`string`,desc:`hostname or IP (default: <name> as ssh alias)`},user:{type:`string`,short:`u`,desc:`login user (default: root for --host, else ssh config)`},port:{type:`string`,short:`p`,desc:`ssh port (default: from ssh config)`},key:{type:`string`,short:`i`,desc:`private key file to log in with`},"password-stdin":{type:`boolean`,desc:`read the login password from stdin (installs landfall's key)`},"ssh-config":{type:`boolean`,desc:`write Host <name> to ~/.ssh/config (--no-ssh-config)`,default:!0},email:{type:`string`,desc:`contact e-mail for the Let's Encrypt account (optional)`},domain:{type:`string`,desc:`main domain: sites default to <site>.<domain>`},default:{type:`boolean`,desc:`make it the default server`}},async run([e],t){e||E(`usage: lf server add <name> [--host ...]`);let n=t.domain&&_e(t.domain),r=z(),i=r.servers[e],a=ft().includes(e)&&!St(e);t.host&&a&&E(`${e} is already a Host in your ssh config; pick another name or drop --host`);let o={host:t.host??i?.host??e,user:t.user??(t.host?`root`:i?.user),port:t.port?Number(t.port):i?.port,key:t.key??i?.key},s=await xi(t[`password-stdin`]),c=await wi(e,o,{check:!s&&!re(),password:s}),l=c?Ei(e,o,c,{existing:i,sshConfig:t[`ssh-config`],ownAlias:a,port:t.port,keyGiven:!!t.key}):{...i,...o};l.key||delete l.key,l.user||delete l.user,l.port||delete l.port,t.email&&(l.email=t.email),n&&(l.domains=xe(l.domains,n)),r.servers[e]=l,(t.default||!r.default)&&(r.default=e),me(r),S(`added ${b.bold(e)}${r.default===e?b.dim(` (default)`):``}`),C(c?b.dim(`next: lf setup ${e} --check shows what setup would install`):`then: ${b.bold(`lf setup ${e}`)} (asks for the password and installs a key)`)}}),xa=N({name:`server list`,summary:`List servers registered with landfall and aliases found in ~/.ssh/config.`,options:{all:{type:`boolean`,short:`a`,desc:`also list every ssh config alias`}},async run(e,t){let n=z(),r=[[b.dim(`NAME`),b.dim(`TARGET`),b.dim(`SOURCE`)]];for(let[e,t]of Object.entries(n.servers)){let i=`${t.user?t.user+`@`:``}${t.host}${t.port?`:`+t.port:``}`;r.push([e+(n.default===e?b.green(` *`):``),i,`landfall`])}if(t.all)for(let e of ft())n.servers[e]||r.push([e,e,St(e)?`ssh (landfall)`:`ssh`]);r.length===1?console.log(`no servers yet — ${b.bold(`lf server add <ssh-alias>`)} or ${b.bold(`lf setup <name> --host <ip>`)}`):(j(r),t.all||console.log(b.dim(`
|
|
742
|
+
Any ~/.ssh/config alias works too (--all to list them).`)))}}),Sa=N({name:`server rm`,args:`<name>`,summary:`Forget a server (nothing is changed on the server itself).`,options:{"ssh-config":{type:`boolean`,desc:`also remove the landfall-managed Host block from ~/.ssh/config`}},async run([e],t){e||E(`usage: lf server rm <name>`),!z().servers[e]&&!St(e)&&E(`unknown server "${e}"`),await k(`Forget server ${e}? Nothing on the server changes.`),he(e),t[`ssh-config`]&&xt(e)&&S(`removed Host ${e} from ~/.ssh/config`),S(`removed ${e}`)}}),Ca=/^(setup|traefik setup|traefik use)( |$)/,wa=N({name:`server uninstall`,args:`<name>`,summary:`Remove landfall from a server: its sites, Traefik, ${H} and what setup changed.
|
|
743
743
|
|
|
744
744
|
Deletes every site landfall deployed (named volumes stay unless --volumes) and forgets
|
|
745
745
|
adopted ones, which keep running. Stops landfall's Traefik (a Traefik of your own is left
|
|
746
746
|
alone), reverts what \`lf setup\` changed (deploy user, ufw, ssh hardening, docker network)
|
|
747
|
-
and deletes ${
|
|
748
|
-
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 `+
|
|
749
|
-
`)),l.length&&(
|
|
750
|
-
`)}:null}}}const
|
|
747
|
+
and deletes ${H}, its journal included. Then forgets the server locally.
|
|
748
|
+
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 `+H},force:{type:`boolean`,desc:`even if it leaves ufw on without an ssh rule`}},async run([e],t){e||E(`usage: lf server uninstall <name>`);let{name:n,server:r}=B(e),i=await Wi(r,`root`),[a,s,c]=await Promise.all([On(r),Wr(r),Hi(r)]);s.compose===`foreign`&&E(`${W}/docker-compose.yml was not written by landfall: move it out of ${H} first`);let l=t[`keep-setup`]?[]:c.filter(e=>Ca.test(e.cmd)).flatMap(Bi).filter(e=>!e.undo.includes(H));x(`uninstall landfall from ${b.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(` ${b.red(`-`)} ${n}${t.volumes&&!e.meta.adopted?` and its volumes`:``}`)}s.compose&&console.error(` ${b.red(`-`)} stop and remove traefik and its certificates`),s.proxy&&console.error(` ${b.dim(`· your traefik ${s.proxy} stays`)}`);for(let e of[...l].reverse())console.error(` ${b.cyan(`↺`)} ${e.what}`);console.error(` ${b.red(`-`)} delete ${H}, its journal included`),console.error(` ${b.red(`-`)} forget ${n} locally`),console.error(` ${b.dim(`· docker, packages, landfall's key for root${t[`keep-setup`]?`, deploy user, ufw and ssh hardening`:``} stay`)}`),await ga(r,l,t.force),await k(`Uninstall landfall from ${n}? This cannot be undone.`,n);let u=[q];for(let{meta:e}of a.sites){let n=e.adopted?[`rmf ${J($(e.name))}`]:Hn(e,{volumes:t.volumes});u.push(`log ${J(`removing ${e.name}`)}`,`(`,...n,`)`,`record ${J(`removed site ${e.name}`)}`)}s.compose&&u.push(`log "stopping traefik"`,`(cd ${W} && dc down --remove-orphans)`,`record "removed traefik"`),u.length>1&&await Y(r,u.join(`
|
|
749
|
+
`)),l.length&&(x(`reverting setup (${l.length} change${l.length>1?`s`:``})`),await Ki(r,l)),await Y(i.login,`rm -rf ${H}`,{sudo:i.sudo}),S(`landfall removed from ${n}`),he(n),xt(n)&&S(`removed Host ${n} from ~/.ssh/config`),S(`forgot ${n}`),o.existsSync(Si(n))&&C(b.dim(`${R(Si(n))} stays: root on ${n} still accepts it`))}}),Ta=N({name:`server use`,args:`<name>`,summary:`Set the default server used when --server is omitted.`,async run([e]){e||E(`usage: lf server use <name>`),ge(e),S(`default server: ${b.bold(e)}`)}}),Ea=N({name:`ssh`,args:`[name] [-- command...]`,summary:`Open a shell on a server, or run a command there.`,passthrough:!0,async run([e],t,n){let{server:r}=B(e);return tt(r,n)}}),Da=N({name:`server info`,args:`[name]`,summary:`Show OS, hardware, load, memory, disk, addresses and containers of a server.`,options:{json:{type:`boolean`,desc:`machine-readable output`}},async run([e],t){let{name:n,server:r}=B(e),i=await Et(r);if(t.json)return void console.log(JSON.stringify({name:n,...i,ips:await jt(r,i)},null,2));let a=(e,t)=>t?` (${Math.round(e/t*100)}%)`:``,o=i.mem.total-i.mem.available;console.log(b.bold(n)+b.dim(` (${r.host})`));let s=[[`os`,`${i.os}${i.pkg===`none`?``:b.dim(` · ${i.pkg}`)}`],[`kernel`,`${i.kernel} ${i.arch}`],[`uptime`,`${It(i.uptime)}${i.reboot?b.yellow(` · reboot required`):``}`],[`cpu`,`${i.cpus} cores, load ${i.load}`],[`memory`,`${Ft(o)} / ${Ft(i.mem.total)}${a(o,i.mem.total)}`],[`disk`,`${Ft(i.disk.used)} / ${Ft(i.disk.total)}${a(i.disk.used,i.disk.total)}`],[`address`,[i.ip4,i.ip6].filter(Boolean).join(`, `)||`unknown`],[`docker`,i.docker||`not installed or no access (run lf setup)`]];for(let[e,t]of s)console.log(`${b.dim(e.padEnd(8))} ${t}`);i.support.note&&(i.support.level===`ok`?console.log:w)(i.support.note),i.docker&&(console.log(),await Y(r,`docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' 2>/dev/null || true`))}});function Oa(e){let t=z(),n=t.servers[e],r=n??{host:e},i=Q(r),a=yt(r.host),s=Br(r,{});return{name:e,isDefault:t.default===e,configFile:R(I),saved:n?{...n,dashboard:n.dashboard&&{host:n.dashboard.host,user:n.dashboard.user}}:null,domains:r.domains??[],traefik:{...s,summary:Vr(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:R(e),exists:o.existsSync(L(e))})),block:a?{file:R(a.file),line:a.start+1,managed:a.managed,text:a.lines.join(`
|
|
750
|
+
`)}:null}}}const ka=N({name:`server config`,args:`[name]`,summary:`Show every setting landfall uses for a server: its entry in landfall's config,
|
|
751
751
|
the main domain, Traefik settings, what ssh resolves (host, user, port, keys)
|
|
752
|
-
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}=
|
|
753
|
-
`))console.log(` ${e}`)}else console.log(`\n${
|
|
752
|
+
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}=B(e),r=Oa(n);if(t.json)return void console.log(JSON.stringify(r,null,2));let i=(e,t)=>console.log(` ${b.dim(e.padEnd(10))} ${t}`);if(console.log(`${b.bold(n)}${r.isDefault?b.dim(` (default)`):``}`),console.log(`\n${b.bold(`landfall`)} ${b.dim(r.configFile)}`),!r.saved)i(`entry`,b.dim(`none — an ssh alias landfall was not told about (lf server add)`));else for(let[e,t]of Object.entries(r.saved))t!==void 0&&e!==`dashboard`&&e!==`domains`&&i(e,String(t));r.domains.length||i(`domain`,b.dim(`none (lf server domain ${n} example.com)`)),r.domains.forEach((e,t)=>i(t?``:r.domains.length>1?`domains`:`domain`,t?e:`${e} ${b.dim(`(primary: new sites get <site>.${e})`)}`)),i(`traefik`,`${r.traefik.summary}${r.traefik.email?`, ${r.traefik.email}`:``} ${b.dim(`(when landfall installs it)`)}`),i(`dashboard`,r.traefik.dashboard?`https://${r.traefik.dashboard}`:b.dim(`off`)),console.log(`\n${b.bold(`ssh`)} ${b.dim(`ssh -G ${r.saved?.host??n}`)}`),i(`login`,`${r.ssh.user}@${r.ssh.hostname}:${r.ssh.port}`);for(let e of r.ssh.keys)i(`key`,e.exists?e.file:`${e.file} ${b.dim(`(missing)`)}`);if(r.ssh.block){console.log(`\n${b.bold(`ssh config`)} ${b.dim(`${r.ssh.block.file}:${r.ssh.block.line}${r.ssh.block.managed?`, written by landfall`:`, your own`}`)}`);for(let e of r.ssh.block.text.split(`
|
|
753
|
+
`))console.log(` ${e}`)}else console.log(`\n${b.bold(`ssh config`)} ${b.dim(`no Host block; ssh uses its defaults`)}`)}});async function Aa(e,t){let n=await jt(e),[r,i]=await Pt([t,`landfall-${_(4).toString(`hex`)}.${t}`],n);return{ips:n,apex:r,wildcard:{...i,domain:`*.${t}`}}}async function ja(e){let{stdout:t}=await Y(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 Ma=async e=>Ee(await ja(e).catch(()=>[])).filter(t=>!e.domains?.includes(t.domain)),Na=N({name:`server domain`,args:`[name] [domain...]`,summary:`Show or set a server's main domains.
|
|
754
754
|
|
|
755
755
|
The first main domain is the primary: \`lf deploy\` serves a new site at
|
|
756
756
|
<site>.<primary>, \`--domain blog\` means blog.<primary>, and
|
|
@@ -761,14 +761,14 @@ More main domains are listed and DNS-checked; use them with full names
|
|
|
761
761
|
|
|
762
762
|
Domains given as arguments replace the list (the first becomes primary).
|
|
763
763
|
Without any, it lists them and, if there are none, suggests the base domains
|
|
764
|
-
the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=
|
|
764
|
+
the server's sites already use.`,options:{add:{type:`string`,short:`a`,multiple:!0,desc:`add a main domain (after the primary)`},primary:{type:`string`,desc:`make this main domain the primary (added if new)`},rm:{type:`string`,multiple:!0,desc:`remove a main domain`},clear:{type:`boolean`,desc:`forget all main domains`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let n=z(),r=e.length>0&&!(e=>!!n.servers[e]||ft().includes(e))(e[0])&&V.test(e[0].replace(/^\*\./,``)),i=r?void 0:e[0],a=r?e:e.slice(1),{name:o,server:s}=B(i);if(a.length||t.add||t.rm||t.primary||t.clear){let e=n.servers[o]??={host:o},r=(t.rm??[]).map(_e),i=t.clear?[]:a.length?a.map(_e):[...e.domains??[]];i=[.../* @__PURE__ */ new Set([...i,...(t.add??[]).map(_e)])].filter(e=>!r.includes(e)),i=xe(i,t.primary&&_e(t.primary));let c=r.filter(t=>!e.domains?.includes(t));c.length&&w(`${c.join(`, `)} is not a main domain of ${o}`);let l=(e.domains??[]).filter(e=>!i.includes(e));if(l.length&&await k(`Remove the main domain${l.length>1?`s`:``} ${l.join(`, `)} of ${o}? Sites keep their domains.`),i.length?e.domains=i:delete e.domains,me(n),s.domains=e.domains,S(i.length?`main domains of ${b.bold(o)}: ${i.map((e,t)=>t?e:b.bold(e)).join(`, `)}${i.length>1?b.dim(` (first = primary)`):``}`:`${o} has no main domain`),!i.length)return}let c=s.domains??[];if(!c.length){let e=await Ma(s);if(t.json)return void console.log(JSON.stringify({name:o,domains:c,suggest:e}));C(`${o} has no main domain yet`);for(let t of e)C(`${t.hosts} routed hosts use ${b.bold(t.domain)} — ${b.bold(`lf server domain ${o} --add ${t.domain}`)}`);e.length||C(`set one: ${b.bold(`lf server domain ${o} example.com`)}`);return}let l=await Promise.all(c.map(e=>Aa(s,e)));if(t.json)return void console.log(JSON.stringify({name:o,domains:c.map((e,t)=>({domain:e,primary:t===0,...l[t]}))},null,2));let u=Math.max(...c.map(e=>e.length))+2,d=[];c.forEach((e,t)=>{let n=l[t];for(let e of[n.apex,n.wildcard]){let r=e.ok?b.green(`dns ok`):e.ips.length?b.yellow(`dns → ${e.ips.join(`, `)}`):b.red(`no dns`);console.log(`${e.domain.padEnd(u)} ${r}${e===n.apex&&t===0&&c.length>1?b.dim(` primary`):``}`),e.ok||d.push(` ${e.domain} A ${n.ips[0]??`<server ip>`}`)}}),d.length&&console.log(b.dim(`
|
|
765
765
|
add these DNS records at your DNS provider:
|
|
766
766
|
`)+d.join(`
|
|
767
|
-
`));let f=
|
|
768
|
-
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on`,default:`4777`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-
|
|
767
|
+
`));let f=Se(s);C(b.dim(`sites: lf deploy -d blog → blog.${f} · dashboard: lf traefik setup --dashboard traefik`))}}),Pa=N({name:`ui`,summary:`Web dashboard in your browser: servers, sites, firewall, history, every action.
|
|
768
|
+
Runs on 127.0.0.1 only; the printed link logs the browser in.`,options:{port:{type:`string`,short:`p`,desc:`port to listen on`,default:`4777`},open:{type:`boolean`,desc:`open the browser (--no-open to only print the link)`,default:!0}},async run(e,t){let{webUi:n}=await import(`./server-BqVugweK.mjs`);return n({port:Number(t.port)||4777,open:t.open})}}),Fa={current:b.green,update:b.yellow,unknown:b.dim,pinned:b.dim,rebuild:b.dim,foreign:b.dim,missing:b.red};function Ia(e,t){let n=e.updates.filter(e=>e.security).length;if(e.mgr===`none`)return C(`packages: no supported package manager`);let r=e.updates.length?`${b.bold(String(e.updates.length))} package updates${n?`, ${b.yellow(`${n} security`)}`:``}`:b.green(`packages up to date`);console.log(`${r}${e.reboot?b.yellow(` · reboot required`):``}${e.refreshed?``:b.dim(` (package lists not refreshed)`)}`);let i=t?e.updates:e.updates.slice(0,15);j(i.map(e=>[` `+e.name,b.dim(e.from??``),`→ `+e.to,e.security?b.yellow(`security`):``])),i.length<e.updates.length&&console.log(b.dim(` … ${e.updates.length-i.length} more (--all)`))}function La(e){e.length&&(console.log(`\n${b.bold(`Images`)}`),j(e.map(e=>[` `+e.name,b.dim(e.ref||`-`),Fa[e.state](e.state===`update`?`newer image`:e.state),b.dim(e.note??``)])))}function Ra(e){let t=dr(e);console.log(`\n${b.bold(`Docker`)} ${e.docker?`${e.docker} · compose ${e.compose||b.red(`missing`)}`:b.red(`not installed`)}`);for(let e of[t.dockerNote,t.composeNote])e&&console.log(` `+b.yellow(e));!t.dockerNote&&!t.composeNote&&e.docker&&console.log(b.dim(` newest releases`))}const za=N({name:`updates`,summary:`Show pending OS package updates (security, reboot needed) and newer images for traefik and sites. Changes nothing.`,options:{server:P,refresh:{type:`boolean`,desc:`refresh package lists first (apt update; --no-refresh to skip)`,default:!0},all:{type:`boolean`,desc:`list every package`},json:{type:`boolean`,desc:`machine-readable output`}},async run(e,t){let{server:n}=B(t.server),r=await Yn(n,t.refresh),i=await mr(n),a=await ur(n);if(t.json)return void console.log(JSON.stringify({packages:r,images:i,engine:{...a,...dr(a)}},null,2));Ia(r,t.all),Ra(a),La(i);let o=t.server?` -s ${t.server}`:``,s=i.filter(e=>e.state===`update`).length;(r.updates.length||s)&&console.log(b.dim(`\napply with: lf update${o}`)),dr(a).composeUpdate&&console.log(b.dim(`newest compose: lf update --compose${o}`))}}),Ba=N({name:`update`,args:`[site...]`,summary:`Apply updates: OS packages and/or newer images (traefik, image and static sites).
|
|
769
769
|
|
|
770
770
|
Without --packages/--images both are updated. Package upgrades cannot be undone;
|
|
771
771
|
image updates can (\`lf undo\` puts the old image back). --compose replaces the
|
|
772
|
-
compose plugin setup installed with the newest release (undoable too).`,options:{server:
|
|
773
|
-
`)[0]}`)}}t.push(``,
|
|
774
|
-
`))}async function
|
|
772
|
+
compose plugin setup installed with the newest release (undoable too).`,options:{server:P,packages:{type:`boolean`,desc:`upgrade OS packages`},security:{type:`boolean`,desc:`only security updates (apt, dnf)`},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}=B(t.server);if(t.compose){(e.length||t.packages||t.images)&&E(`--compose goes alone`);let i=await Kn(r,`updating docker compose`),a=await ur(i),o=dr(a);if(!o.composeUpdate)return void(o.composeNote?C(o.composeNote):S(`compose ${a.compose} is the newest`));await A(`Update compose ${a.compose} to ${a.latestCompose} on ${n}?`)&&(x(`downloading docker compose`),await fr(i),S(`compose updated (lf undo puts the old one back)`));return}let i=!t.packages&&!t.images;e.length&&t.packages&&E(`sites only apply to --images`);let a=!e.length&&(t.packages||i||t.security),o=t.images||i||e.length>0;if(a){let e=await Kn(r,`upgrading packages`),i=await Yn(e),a=t.security?i.updates.filter(e=>e.security):i.updates;i.mgr===`none`?w(`no supported package manager, skipping packages`):a.length?(Ia({...i,updates:a},!1),t.security&&i.mgr===`apk`&&E(`apk has no security-only upgrades; drop --security`),await A(`Upgrade ${a.length} package${a.length===1?``:`s`} on ${n}?`)&&(x(`upgrading packages on ${n}`),await Zn(e,{only:t.security&&i.mgr===`apt-get`?a.map(e=>e.name):void 0,what:t.security?`${a.length} security packages`:`${a.length} packages`,reboot:t.reboot}),S(`packages upgraded`),t.reboot&&C(`rebooting if required; give it a minute`))):S(t.security?`no security updates`:`packages up to date`)}if(o){let t=await mr(r);if(e.length){let r=e.filter(e=>!t.some(t=>t.name===e));r.length&&E(`no site or service ${r.join(`, `)} on ${n}`)}let i=e.length?t.filter(t=>e.includes(t.name)):t;for(let e of i.filter(e=>e.state!==`update`&&e.state!==`current`&&e.state!==`unknown`))C(b.dim(`${e.name}: ${e.note??e.state}`));let a=i.filter(t=>t.state===`update`||e.includes(t.name)&&t.state===`unknown`);a.length?(La(a),await A(`Pull and recreate ${a.map(e=>e.name).join(`, `)}?`)&&(x(`updating images`),await hr(r,a),S(`images updated (lf undo puts the old ones back)`))):S(`images up to date`)}}});var Va=`0.4.2`;const Ha=[ji,zi,Ii,ti,ni,ri,di,ci,ai,oi,si,ui,li,mi,hi,gi,za,Ba,ba,xa,Da,ka,Na,Ta,Sa,wa,Ea,qr,Jr,Xr,Zr,Qr,Yr,ca,la,ua,oa,sa,fa,da,va,ya,Di,Pa],Ua={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`,upgrade:`update`},Wa=new Map(Ha.map(e=>[e.name,e]));function Ga(e){for(let t of[2,1]){let n=e.slice(0,t).join(` `),r=Wa.get(Ua[n]??n);if(r&&e.length>=t)return{cmd:r,rest:e.slice(t)}}return{rest:e}}function Ka(){let e=[[`Servers`,[`setup`,`server add`,`server list`,`server info`,`server config`,`server domain`,`server use`,`server rm`,`server uninstall`,`ssh`,`keygen`]],[`Traefik`,[`traefik setup`,`traefik status`,`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`,`logs`,`env`,`rollback`,`restart`,`stop`,`start`,`sites rm`]],[`Updates`,[`updates`,`update`]],[`History`,[`history`,`undo`]],[`Dashboard`,[`ui`]]],t=[`${b.bold(`landfall`)} ${b.dim(Va)} — set up servers and deploy sites behind Traefik + HTTPS`,``,`${b.bold(`Usage:`)} lf <command> [options] ${b.dim(`lf <command> --help for details`)}`,` lf ui ${b.dim(`web dashboard in your browser`)}`];for(let[n,r]of e){t.push(``,b.bold(n));for(let e of r){let n=Wa.get(e);t.push(` ${(e+(n.args?` `+n.args:``)).padEnd(32)} ${n.summary.split(`
|
|
773
|
+
`)[0]}`)}}t.push(``,b.bold(`Quick start`),` lf setup vps --check ${b.dim(`# what is on vps, what setup would change`)}`,` lf setup vps ${b.dim("# docker + traefik on a server you reach with `ssh vps`")}`,` lf setup box --host 203.0.113.7 ${b.dim("# fresh box (root password once) → `ssh box` works")}`,` lf deploy ./dist -d example.com -s vps ${b.dim(`# publish a static site with HTTPS`)}`,` lf deploy ${b.dim(`# redeploy using ./.landfall.json`)}`,` lf deploy ./dist -y ${b.dim(`# no domain yet: serve at <name>.<ip>.sslip.io`)}`,` lf auth my-site --user anna ${b.dim(`# put a password in front of a site`)}`,` lf updates -s vps && lf update -s vps ${b.dim(`# pending OS/image updates, then apply them`)}`,` lf ls ${b.dim(`# what runs where`)}`,` lf history -s vps && lf undo -s vps ${b.dim(`# what landfall changed, and revert the last of it`)}`,``,b.dim(`Servers are ssh targets: any ~/.ssh/config alias works as --server.`)),console.log(t.join(`
|
|
774
|
+
`))}async function qa(e){if(!e.length||e[0]===`help`||e[0]===`-h`||e[0]===`--help`)return Ka(),0;if(e[0]===`-v`||e[0]===`--version`||e[0]===`version`)return console.log(Va),0;let{cmd:t,rest:n}=Ga(e);if(!t)return console.error(`${b.red(`error:`)} unknown command "${e.join(` `)}"\n`),Ka(),2;let{values:r,positionals:i,rest:a}=le(t,n);if(r.help)return console.log(ue(t)),0;O(!!r.yes),rt.cmd=de(t,i,r);let o=await t.run(i,r,a);return typeof o==`number`?o:0}async function Ja(e){try{return await qa(e)}catch(e){return e instanceof T?console.error(`${b.red(`error:`)} ${e.message}`):console.error(e),1}}Ja(process.argv.slice(2)).then(e=>process.stdout.write(``,()=>process.exit(e)));export{W as A,se as B,nt as C,G as D,K as E,z as F,ke as I,B as L,V as M,pe as N,H as O,L as P,ge as R,ft as S,Me as T,Pt as _,qi as a,jt as b,bi as c,ur as d,mr as f,gn as g,mn as h,Ma as i,I as j,U as k,fi as l,On as m,Aa as n,Bi as o,Yn as p,Oa as r,Hi as s,Va as t,dr as u,Et as v,Ke as w,Q as x,Mt as y,b as z};
|