@atriumchat/install 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.sh ADDED
@@ -0,0 +1,1510 @@
1
+ #!/bin/sh
2
+ # Atrium installer, for a fresh VPS (Ubuntu or Debian), as root:
3
+ # curl -fsSL https://github.com/atriumchat/atrium/releases/latest/download/install.sh | sh # the wizard
4
+ # curl -fsSL …/install.sh | sh -s update # update, no questions
5
+ # curl -fsSL …/install.sh | sh -s uninstall # take Atrium off the server again
6
+ # curl -fsSL …/install.sh | sh -s restore # put data back from a nightly backup
7
+ # curl -fsSL …/install.sh | sh -s link # the setup link again, while setup is not done
8
+ # curl -fsSL …/install.sh | DOMAIN=chat.example.com ATRIUM_YES=1 sh # no questions at all
9
+ # Each installer takes its own release's tarball from that GitHub Release, checked against the SHA-256 pinned
10
+ # below. The npm package @atriumchat/install mirrors both files, so the same works without GitHub:
11
+ # curl -fsSL https://cdn.jsdelivr.net/npm/@atriumchat/install/install.sh | sh
12
+ # Where Node is already installed, the same package runs with npx (its bin is atrium-install):
13
+ # npx @atriumchat/install # update: npx @atriumchat/install update
14
+ # Re-running is an update: .env (all the secrets) and the data stay.
15
+ # link prints the link with the setup code (https://app.DOMAIN/?setup=CODE) again, or says that setup is already done.
16
+ # restore lists the nightly backups in /root/atrium-backups/daily with their date and size and asks which one to put
17
+ # back (ATRIUM_YES=1 ATRIUM_RESTORE=<folder name, e.g. 2026-10-18> asks nothing). It first saves the current data as one
18
+ # more backup, pre-restore-<time>, in the same folder, then stops the app, puts the chosen database and files back,
19
+ # starts the app and checks that it answers. Works on the same server and on a new one with the backups copied to
20
+ # /root/atrium-backups/daily after a normal install.
21
+ # uninstall asks first (ATRIUM_YES=1 skips the question), then removes: the containers and their images, ATRIUM_DIR
22
+ # (/opt/atrium: .env, the data, the files), /root/atrium-backups, the nightly backup service and timer, this installer's
23
+ # copy (/root/atrium-install.sh) and log (/var/log/atrium-install.log), and what it changed for SSH: its key-only
24
+ # drop-in (password login is back as it was) and the desktop app's key (the line with the comment atrium-app).
25
+ # Docker and ufw stay. Nothing of it can be brought back, backups included.
26
+ # More knobs, all optional: SERVER_NAME, TURN_HOST, ATRIUM_DIR (/opt/atrium), ATRIUM_NO_GUM=1 (plain prompts),
27
+ # ATRIUM_FIREWALL=0 and ATRIUM_SSH=0 (leave ufw / sshd alone), ATRIUM_TARBALL + ATRIUM_TARBALL_SHA256 (a local or
28
+ # mirrored release), ATRIUM_REGISTRY + ATRIUM_APP_DIGEST + ATRIUM_CADDY_DIGEST (another image registry).
29
+ # For the desktop app, which installs over SSH: ATRIUM_AUTHORIZED_KEY (its public key, added for root, so password
30
+ # login can go) and ATRIUM_PROGRESS=1 (machine-readable "::atrium …" lines next to the usual output).
31
+ #
32
+ # This POSIX part only gets the program below onto bash with the terminal on stdin. Under `curl | sh` stdin is the
33
+ # script itself, so no prompt could ever read an answer: the bash program is saved to a temporary file and run
34
+ # from there. (It sits in a heredoc so sh never parses it; `deploy/release.sh lint` checks it with shellcheck.)
35
+ set -eu
36
+ umask 077
37
+ ATRIUM_TMP=$(mktemp -d "${TMPDIR:-/tmp}/atrium.XXXXXX")
38
+ export ATRIUM_TMP
39
+ trap 'rm -rf "$ATRIUM_TMP"' EXIT
40
+ # Ctrl+C reaches the bash program too; wait for it to clean up and only then remove the folder.
41
+ trap ':' INT TERM HUP
42
+ cat > "$ATRIUM_TMP/install.bash" <<'ATRIUM_INSTALLER'
43
+ #!/usr/bin/env bash
44
+ # The Atrium installer itself; the POSIX wrapper above starts it. Screens: welcome → server check → domain →
45
+ # summary → install → link (the server's name is given in the browser setup). Pretty prompts with gum
46
+ # (charmbracelet), fetched for this run only and checked against a pinned SHA-256; without gum or without a
47
+ # terminal the same steps run on plain `read` prompts.
48
+ set -Eeuo pipefail
49
+ shopt -s inherit_errexit 2>/dev/null || true
50
+ # "&" in a ${s//x/y} replacement means the match in bash 5.2; the templates must never do that.
51
+ shopt -u patsub_replacement 2>/dev/null || true
52
+ umask 022
53
+
54
+ # == The release this installer puts on the server. deploy/release.sh rewrites these lines.
55
+ RELEASE_VERSION=0.7.0
56
+ RELEASE_SHA256=bdcf551ad4a4bef983bc8892a5016469514a9d31c5e9b5d69d7e4faf811384b2
57
+ RELEASE_REPO=atriumchat/atrium
58
+ # The npm package that mirrors each release's install.sh and tarball (deploy/release.sh publishes it).
59
+ RELEASE_NPM=@atriumchat/install
60
+ RELEASE_REGISTRY=ghcr.io/atriumchat
61
+ RELEASE_APP_DIGEST=sha256:1d4e6b5773fdbe7594487528c73b12feb8919caf86265bdfa8f330b55beeae0f
62
+ RELEASE_CADDY_DIGEST=sha256:cd2cf5a7985bb21afa94786e9779dcf972b89e7e281a0a9b616acc659ce30bbf
63
+
64
+ # == gum, for this run only.
65
+ GUM_VERSION=0.17.0
66
+ GUM_SHA256_x86_64=69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb
67
+ GUM_SHA256_arm64=b0b9ed95cbf7c8b7073f17b9591811f5c001e33c7cfd066ca83ce8a07c576f9c
68
+
69
+ ACCENT_HEX='#ffffff'
70
+ # Neutral names for the TURN relay's subdomain: "turn." is an easy thing to find and block, and a random pick
71
+ # keeps installs from sharing that fingerprint.
72
+ TURN_NAMES=(media static cdn files assets img edge content res storage dl s3)
73
+
74
+ ATRIUM_TMP=${ATRIUM_TMP:-$(mktemp -d "${TMPDIR:-/tmp}/atrium.XXXXXX")}
75
+ LOG=/var/log/atrium-install.log
76
+ LOCK=/run/atrium-install.lock
77
+ GUM=
78
+ BG_PID=
79
+
80
+ # ---------------------------------------------------------------------------------------------- terminal
81
+
82
+ TTY=0
83
+ [[ -t 0 && -t 1 ]] && TTY=1
84
+ C_ACCENT='' C_BOLD='' C_DIM='' C_GREEN='' C_YELLOW='' C_RED='' C_RESET=''
85
+ if (( TTY )) && [[ -z ${NO_COLOR:-} && ${TERM:-dumb} != dumb ]]; then
86
+ if [[ ${COLORTERM:-} == *truecolor* || ${COLORTERM:-} == *24bit* ]]; then
87
+ C_ACCENT=$'\e[38;2;255;255;255m'
88
+ else
89
+ C_ACCENT=$'\e[97m'
90
+ fi
91
+ C_BOLD=$'\e[1m' C_DIM=$'\e[2m' C_GREEN=$'\e[32m' C_YELLOW=$'\e[33m' C_RED=$'\e[31m' C_RESET=$'\e[0m'
92
+ fi
93
+ # gum follows NO_COLOR by itself (monochrome); a dumb terminal gets plain prompts.
94
+
95
+ # Column maths on Cyrillic needs a UTF-8 locale (a fresh VPS or container may have none set).
96
+ if [[ ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} != *[Uu][Tt][Ff]* ]]; then
97
+ for l in C.UTF-8 C.utf8 en_US.UTF-8; do
98
+ if locale -a 2>/dev/null | grep -qix "${l/UTF-8/utf8}\|$l"; then export LC_ALL=$l; break; fi
99
+ done
100
+ fi
101
+
102
+ COLS=80
103
+ if (( TTY )); then
104
+ COLS=$(stty size 2>/dev/null | awk '{print $2}') || true
105
+ # A terminal that reports no size (0) gets the full 80 too.
106
+ [[ $COLS =~ ^[0-9]+$ ]] && (( COLS >= 40 )) || COLS=80
107
+ (( COLS > 80 )) && COLS=80
108
+ fi
109
+
110
+ say() { printf '%s\n' "$*"; }
111
+ # pad TEXT WIDTH: bash's printf pads by bytes, which skews every Cyrillic column.
112
+ pad() { printf '%s%*s' "$1" $(($2 > ${#1} ? $2 - ${#1} : 0)) ''; }
113
+ line() { printf ' %s\n' "$*"; }
114
+ ok() { line "${C_GREEN}✓${C_RESET} $*"; }
115
+ warn() { line "${C_YELLOW}!${C_RESET} $*"; }
116
+ bad() { line "${C_RED}✗${C_RESET} $*"; }
117
+ # Plain, not the accent: next to a red ✗ a red bullet would read as one more error.
118
+ bullet() { line "• $*"; }
119
+ dim() { line "${C_DIM}$*${C_RESET}"; }
120
+ log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG" 2>/dev/null || true; }
121
+ # For the desktop app (ATRIUM_PROGRESS=1): "::atrium run|done|fail STEP [seconds|why]", "::atrium sub TEXT" (what the
122
+ # running step is doing now), "::atrium ready URL".
123
+ progress() { if [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then printf '::atrium %s\n' "$*"; fi; }
124
+ # What a step prints, into the log and on to the desktop app a line at a time (indented, so it is plain output next to
125
+ # the "::atrium" lines, which go through as they are): the app shows the last one when a step takes long.
126
+ relay_output() {
127
+ local text
128
+ while IFS= read -r text || [[ -n $text ]]; do
129
+ if [[ $text == '::atrium '* ]]; then
130
+ printf '%s\n' "$text"
131
+ else
132
+ printf '%s\n' "$text" >>"$LOG"
133
+ printf ' %s\n' "$text"
134
+ fi
135
+ done
136
+ }
137
+ DIED=0
138
+ die() {
139
+ DIED=1
140
+ say
141
+ bad "$*"
142
+ [[ -s $LOG ]] && dim "Лог: $LOG"
143
+ exit 1
144
+ }
145
+ clear_screen() { if (( TTY )); then printf '\e[H\e[2J\e[3J'; fi; }
146
+
147
+ # ---------------------------------------------------------------------------------------------- cleanup
148
+
149
+ kill_tree() {
150
+ local pid=$1 child
151
+ for child in $(pgrep -P "$pid" 2>/dev/null || true); do kill_tree "$child"; done
152
+ kill -TERM "$pid" 2>/dev/null || true
153
+ }
154
+ cleanup() {
155
+ local rc=$?
156
+ # This function's own non-zero return isn't an error to log.
157
+ trap - ERR
158
+ [[ -n $BG_PID ]] && kill_tree "$BG_PID"
159
+ (( TTY )) && printf '\e[?25h'
160
+ # Anything that failed without saying so (set -e): at least say where to look.
161
+ if (( rc != 0 && rc != 130 && ! DIED )); then
162
+ say
163
+ bad "Установка прервалась на неожиданной ошибке."
164
+ dim "Лог: $LOG"
165
+ fi
166
+ [[ $ATRIUM_TMP == */atrium.* ]] && rm -rf -- "$ATRIUM_TMP"
167
+ return "$rc"
168
+ }
169
+ interrupted() {
170
+ trap - INT TERM HUP
171
+ say
172
+ dim "Прервано."
173
+ exit 130
174
+ }
175
+ trap cleanup EXIT
176
+ trap interrupted INT TERM HUP
177
+ trap 'log "error: line $LINENO: $BASH_COMMAND"' ERR
178
+
179
+ # ---------------------------------------------------------------------------------------------- helpers
180
+
181
+ # fetch URL FILE [max seconds]: HTTPS only.
182
+ fetch() {
183
+ if command -v curl >/dev/null; then
184
+ curl --proto '=https' --tlsv1.2 -fsSL --retry 2 --connect-timeout 10 --max-time "${3:-300}" -o "$2" "$1"
185
+ elif command -v wget >/dev/null; then
186
+ wget -q --https-only -T "${3:-300}" -O "$2" "$1"
187
+ else
188
+ return 1
189
+ fi
190
+ }
191
+ sha256_ok() { [[ $(sha256sum "$1" | awk '{print $1}') == "$2" ]]; }
192
+ rand() {
193
+ local s
194
+ s=$(head -c 96 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')
195
+ printf '%s' "${s:0:$1}"
196
+ }
197
+ apt_ready=0
198
+ apt_install() {
199
+ command -v apt-get >/dev/null || return 1
200
+ if (( ! apt_ready )); then
201
+ DEBIAN_FRONTEND=noninteractive apt-get update -q || return
202
+ apt_ready=1
203
+ fi
204
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends "$@"
205
+ }
206
+
207
+ # .env: KEY=value lines, read by docker compose. Never sourced here.
208
+ env_get() {
209
+ [[ -f $1 ]] || return 0
210
+ sed -n "s/^$2=//p" "$1" | tail -n1 | sed -e "s/^'\(.*\)'\$/\1/" -e 's/^"\(.*\)"$/\1/'
211
+ }
212
+ env_set() {
213
+ local file=$1 tmp
214
+ tmp=$(mktemp "$file.XXXXXX") || return
215
+ K=$2 V=$3 awk 'index($0, ENVIRON["K"] "=") == 1 { if (!done) print ENVIRON["K"] "=" ENVIRON["V"]; done = 1; next }
216
+ { print } END { if (!done) print ENVIRON["K"] "=" ENVIRON["V"] }' "$file" >"$tmp" || { rm -f "$tmp"; return 1; }
217
+ chmod 600 "$tmp" || return
218
+ mv -f "$tmp" "$file"
219
+ }
220
+
221
+ valid_domain() {
222
+ [[ ${#1} -le 200 && $1 =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ ]]
223
+ }
224
+ normalize_domain() {
225
+ local d=${1,,}
226
+ d=${d//[[:space:]]/}
227
+ d=${d#*://}
228
+ d=${d%%/*}
229
+ d=${d%.}
230
+ printf '%s' "$d"
231
+ }
232
+ valid_name() {
233
+ [[ -n $1 && ${#1} -le 40 && $1 != *[\'\"\\\$\`]* && $1 != *[[:cntrl:]]* ]]
234
+ }
235
+
236
+ # ---------------------------------------------------------------------------------------------- the server
237
+
238
+ detect_install() {
239
+ ATRIUM_DIR=${ATRIUM_DIR:-/opt/atrium}
240
+ ENV_FILE=$ATRIUM_DIR/deploy/.env
241
+ EXISTING=0
242
+ if [[ -f $ENV_FILE ]]; then
243
+ EXISTING=1
244
+ OLD_DOMAIN=$(env_get "$ENV_FILE" DOMAIN)
245
+ OLD_VERSION=$(env_get "$ENV_FILE" ATRIUM_VERSION)
246
+ DOMAIN=${DOMAIN:-$OLD_DOMAIN}
247
+ SERVER_NAME=${SERVER_NAME:-$(env_get "$ENV_FILE" SERVER_NAME)}
248
+ TURN_HOST=${TURN_HOST:-$(env_get "$ENV_FILE" TURN_HOST)}
249
+ # Installs from before TURN_HOST existed keep their turn.DOMAIN: its DNS record is already there.
250
+ TURN_HOST=${TURN_HOST:-turn}
251
+ fi
252
+ SERVER_NAME=${SERVER_NAME:-Atrium}
253
+ TURN_HOST=${TURN_HOST:-${TURN_NAMES[RANDOM % ${#TURN_NAMES[@]}]}}
254
+ }
255
+
256
+ # Listening sockets on a port, from /proc (no ss/netstat needed): prints the process name when ss can tell.
257
+ port_busy() {
258
+ local proto=$1 port=$2 hex state files
259
+ printf -v hex '%04X' "$port"
260
+ if [[ $proto == tcp ]]; then state=0A files=(/proc/net/tcp /proc/net/tcp6); else state=07 files=(/proc/net/udp /proc/net/udp6); fi
261
+ awk -v p=":$hex" -v s="$state" '$2 ~ p"$" && $4 == s { found = 1 } END { exit !found }' "${files[@]}" 2>/dev/null || return 1
262
+ if command -v ss >/dev/null; then
263
+ ss -Hlnp -"${proto:0:1}" "sport = :$port" 2>/dev/null | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | head -n1 || true
264
+ fi
265
+ return 0
266
+ }
267
+
268
+ # The server's public IPv4: asked from outside (a VPS behind NAT doesn't know it), else the outgoing interface's.
269
+ detect_ip() {
270
+ local ip url
271
+ for url in https://api.ipify.org https://ifconfig.me/ip https://icanhazip.com; do
272
+ ip=$(curl -4 -fsS --max-time 4 "$url" 2>/dev/null | tr -d '[:space:]') || true
273
+ [[ $ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { printf '%s' "$ip"; return; }
274
+ done
275
+ ip=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p') || true
276
+ printf '%s' "$ip"
277
+ }
278
+ local_ips() {
279
+ { hostname -I 2>/dev/null || true; ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 || true; } |
280
+ tr ' ' '\n' | { grep -E '^[0-9.]+$' || true; } | sort -u | tr '\n' ' '
281
+ }
282
+
283
+ # A records as the world sees them: DNS over HTTPS skips this box's resolver cache, which may still hold
284
+ # "no such name" from before the record was added.
285
+ resolve4() {
286
+ local out url
287
+ for url in "https://cloudflare-dns.com/dns-query?name=$1&type=A" "https://dns.google/resolve?name=$1&type=A"; do
288
+ if out=$(curl -fsS --max-time 5 -H 'accept: application/dns-json' "$url" 2>/dev/null); then
289
+ { grep -oE '"data": ?"[0-9]+(\.[0-9]+){3}"' <<<"$out" || true; } | grep -oE '[0-9]+(\.[0-9]+){3}' | sort -u | tr '\n' ' ' || true
290
+ return
291
+ fi
292
+ done
293
+ getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ' || true
294
+ }
295
+ # Cloudflare's proxy ("orange cloud") ranges: calls can't go through it.
296
+ cloudflare_ip() {
297
+ [[ $1 =~ ^(104\.(1[6-9]|2[0-9]|3[01])|172\.(6[4-9]|7[01])|188\.114\.(9[6-9]|10[0-9]|11[01])|162\.158|141\.101|108\.162|190\.93|198\.41|173\.245|103\.2[1-2]|103\.31)\. ]]
298
+ }
299
+
300
+ check_add() {
301
+ CHECK_LINES+=("$1|$2")
302
+ [[ $1 == bad ]] && CHECK_FATAL=1
303
+ # Each check as it's made, so the desktop app shows where the server check is.
304
+ progress sub "$2"
305
+ return 0
306
+ }
307
+
308
+ run_checks() {
309
+ CHECK_LINES=() CHECK_FATAL=0
310
+
311
+ if (( EUID == 0 )); then check_add ok "Права root"; else check_add bad "Нужен root: curl … | sudo sh"; fi
312
+
313
+ local os_name=Linux os_id='' arch
314
+ if [[ -r /etc/os-release ]]; then
315
+ # shellcheck source=/dev/null
316
+ os_name=$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-Linux}")
317
+ # shellcheck source=/dev/null
318
+ os_id=$(. /etc/os-release && printf '%s' "${ID:-} ${ID_LIKE:-}")
319
+ fi
320
+ arch=$(uname -m)
321
+ case $arch in
322
+ x86_64 | amd64 | aarch64 | arm64) ;;
323
+ *) check_add bad "Процессор $arch не поддерживается"; arch='' ;;
324
+ esac
325
+ if [[ $(uname -s) != Linux ]]; then
326
+ check_add bad "Нужен Linux"
327
+ elif [[ -n $arch ]]; then
328
+ if [[ $os_id == *ubuntu* || $os_id == *debian* ]]; then check_add ok "$os_name · $arch"; else check_add warn "$os_name · $arch: не проверялось"; fi
329
+ fi
330
+
331
+ local who p busy=()
332
+ for p in tcp/80 tcp/443 udp/443; do
333
+ if who=$(port_busy "${p%/*}" "${p#*/}"); then busy+=("${p#*/}/${p%/*}${who:+ ($who)}"); fi
334
+ done
335
+ if (( ${#busy[@]} == 0 )); then
336
+ check_add ok "Порты 80 и 443 свободны"
337
+ elif (( EXISTING )); then
338
+ check_add ok "Порты 80 и 443: уже Atrium"
339
+ else
340
+ check_add bad "Порты заняты: ${busy[*]}"
341
+ fi
342
+
343
+ DOCKER_OK=0
344
+ if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
345
+ DOCKER_OK=1
346
+ check_add ok "Docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || docker --version | awk '{print $3}' | tr -d ,)"
347
+ else
348
+ check_add info "Docker: поставим"
349
+ fi
350
+
351
+ local mem_mb disk_mb
352
+ mem_mb=$(awk '/^MemTotal:/ { print int($2 / 1024) }' /proc/meminfo 2>/dev/null || echo 0)
353
+ disk_mb=$(df -Pm /var/lib 2>/dev/null | awk 'NR == 2 { print $4 }' || echo 0)
354
+ local mem disk
355
+ mem=$(awk -v m="$mem_mb" 'BEGIN { printf "%.1f", m / 1024 }')
356
+ disk=$(awk -v m="$disk_mb" 'BEGIN { printf "%.0f", m / 1024 }')
357
+ if (( disk_mb < 2500 )); then
358
+ check_add bad "Диск: свободно $disk ГБ, нужно от 3 ГБ"
359
+ elif (( mem_mb < 900 )); then
360
+ check_add warn "Память $mem ГБ: мало, лучше от 1 ГБ"
361
+ else
362
+ check_add ok "Память $mem ГБ · диск $disk ГБ свободно"
363
+ fi
364
+
365
+ SSH_PLAN=keys
366
+ if [[ ${ATRIUM_SSH:-1} == 0 ]]; then
367
+ SSH_PLAN=off
368
+ elif ! command -v sshd >/dev/null; then
369
+ SSH_PLAN=none
370
+ elif ! has_ssh_key; then
371
+ SSH_PLAN=nokey
372
+ check_add warn "SSH-ключа нет: вход по паролю оставим"
373
+ elif [[ -d /etc/ssh/sshd_config.d ]] && grep -Eqi '^\s*Include\s+/etc/ssh/sshd_config\.d' /etc/ssh/sshd_config 2>/dev/null; then
374
+ check_add ok "SSH-ключ есть: вход по паролю выключим"
375
+ else
376
+ SSH_PLAN=noinclude
377
+ fi
378
+
379
+ if [[ ! -d /run/systemd/system ]]; then check_add warn "Нет systemd: без ночных бэкапов"; fi
380
+ if [[ -n ${SERVER_IP:-} ]]; then check_add ok "IP $SERVER_IP"; else check_add warn "Внешний IP не определился"; fi
381
+ }
382
+
383
+ has_ssh_key() {
384
+ local f home
385
+ for f in /root/.ssh/authorized_keys /root/.ssh/authorized_keys2; do
386
+ grep -Eqs '^[^#]*(ssh-(ed25519|rsa|dss)|ecdsa-sha2-|sk-(ssh|ecdsa))' "$f" && return 0
387
+ done
388
+ if [[ -n ${SUDO_USER:-} ]]; then
389
+ home=$(getent passwd "$SUDO_USER" | cut -d: -f6) || true
390
+ grep -Eqs '^[^#]*(ssh-(ed25519|rsa|dss)|ecdsa-sha2-|sk-(ssh|ecdsa))' "$home/.ssh/authorized_keys" && return 0
391
+ fi
392
+ return 1
393
+ }
394
+
395
+ # The first fatal check, for the desktop app to show.
396
+ first_bad_check() {
397
+ local item
398
+ for item in "${CHECK_LINES[@]}"; do
399
+ [[ ${item%%|*} == bad ]] && { printf '%s' "${item#*|}"; return; }
400
+ done
401
+ }
402
+
403
+ # ATRIUM_AUTHORIZED_KEY: the desktop app's own SSH key, for root. With a key in place the install switches password
404
+ # login off (step_ssh), and the app keeps coming in with its key for updates.
405
+ add_app_key() {
406
+ local key=${ATRIUM_AUTHORIZED_KEY:-}
407
+ [[ -n $key ]] || return 0
408
+ [[ $key =~ ^ssh-ed25519\ [A-Za-z0-9+/]+=*(\ [A-Za-z0-9@._-]+)?$ ]] || die "ATRIUM_AUTHORIZED_KEY: это не ключ ssh-ed25519"
409
+ progress sub "Ключ приложения для входа"
410
+ mkdir -p /root/.ssh
411
+ chmod 700 /root/.ssh
412
+ touch /root/.ssh/authorized_keys
413
+ chmod 600 /root/.ssh/authorized_keys
414
+ grep -qxF "$key" /root/.ssh/authorized_keys || printf '%s\n' "$key" >>/root/.ssh/authorized_keys
415
+ log "desktop app key added for root"
416
+ }
417
+
418
+ # The desktop app sets the address up just before it installs (a free name on dedyn.io, or the person's own
419
+ # records), so the installer waits for the names to answer here: the certificates can then come at once.
420
+ wait_dns() {
421
+ [[ ${ATRIUM_PROGRESS:-} == 1 ]] || return 0
422
+ local i
423
+ progress run address
424
+ for i in $(seq 1 60); do
425
+ check_dns
426
+ progress sub "Записей на месте: $DNS_HITS из 3"
427
+ (( DNS_OK )) && break
428
+ sleep 3
429
+ done
430
+ progress "done" address
431
+ }
432
+
433
+ print_checks() {
434
+ local item
435
+ for item in "${CHECK_LINES[@]}"; do
436
+ case ${item%%|*} in
437
+ ok) ok "${item#*|}" ;;
438
+ warn) warn "${item#*|}" ;;
439
+ bad) bad "${item#*|}" ;;
440
+ *) bullet "${item#*|}" ;;
441
+ esac
442
+ done
443
+ }
444
+
445
+ # DNS_ROWS: "host|now" per name that must point here; DNS_OK=1 when all do (DNS_HITS of them so far).
446
+ check_dns() {
447
+ local host got status want_ips
448
+ want_ips="${SERVER_IP:-} $(local_ips)"
449
+ DNS_ROWS=() DNS_OK=1 DNS_HITS=0
450
+ for host in "$DOMAIN" "app.$DOMAIN" "$TURN_HOST.$DOMAIN"; do
451
+ got=$(resolve4 "$host")
452
+ got=${got% }
453
+ if [[ -z $got ]]; then
454
+ status="${C_RED}✗${C_RESET} нет записи"
455
+ DNS_OK=0
456
+ elif [[ -n ${SERVER_IP:-} && " $want_ips " == *" ${got%% *} "* && $got != *" "* ]]; then
457
+ status="${C_GREEN}✓${C_RESET}"
458
+ DNS_HITS=$((DNS_HITS + 1))
459
+ elif cloudflare_ip "${got%% *}"; then
460
+ status="${C_RED}✗${C_RESET} прокси Cloudflare"
461
+ DNS_OK=0
462
+ elif [[ -z ${SERVER_IP:-} ]]; then
463
+ status="${C_YELLOW}?${C_RESET} $got"
464
+ DNS_OK=0
465
+ else
466
+ status="${C_RED}✗${C_RESET} $got"
467
+ DNS_OK=0
468
+ fi
469
+ DNS_ROWS+=("$host|$status")
470
+ done
471
+ }
472
+
473
+ print_dns() {
474
+ local row host w=0
475
+ for row in "${DNS_ROWS[@]}"; do host=${row%%|*}; (( ${#host} > w )) && w=${#host}; done
476
+ (( w += 2 ))
477
+ line "${C_DIM}$(pad Имя "$w")$(pad Тип 5)$(pad Значение 17)Сейчас${C_RESET}"
478
+ for row in "${DNS_ROWS[@]}"; do
479
+ line "$(pad "${row%%|*}" "$w")$(pad A 5)$(pad "${SERVER_IP:-IP сервера}" 17)${row#*|}"
480
+ done
481
+ }
482
+
483
+ # ---------------------------------------------------------------------------------------------- prompts
484
+
485
+ get_gum() {
486
+ (( TTY )) && [[ ${ATRIUM_NO_GUM:-} != 1 && ${TERM:-dumb} != dumb ]] || return 1
487
+ local arch sum name
488
+ case $(uname -m) in
489
+ x86_64 | amd64) arch=x86_64 sum=$GUM_SHA256_x86_64 ;;
490
+ aarch64 | arm64) arch=arm64 sum=$GUM_SHA256_arm64 ;;
491
+ *) return 1 ;;
492
+ esac
493
+ name=gum_${GUM_VERSION}_Linux_$arch
494
+ fetch "https://github.com/charmbracelet/gum/releases/download/v$GUM_VERSION/$name.tar.gz" "$ATRIUM_TMP/gum.tgz" 30 || return 1
495
+ sha256_ok "$ATRIUM_TMP/gum.tgz" "$sum" || { log "gum: SHA-256 mismatch"; return 1; }
496
+ tar -xzf "$ATRIUM_TMP/gum.tgz" -C "$ATRIUM_TMP" "$name/gum" 2>/dev/null || return 1
497
+ "$ATRIUM_TMP/$name/gum" --version >/dev/null 2>&1 || return 1
498
+ GUM=$ATRIUM_TMP/$name/gum
499
+ log "gum $GUM_VERSION ($arch) ready"
500
+ }
501
+
502
+ # choose OPTION... → CHOICE (0-based). Returns 1 for "back" (Esc, or "<" in plain mode); Ctrl+C quits.
503
+ # ESC_MEANS says what Esc does on this screen.
504
+ ESC_MEANS=назад
505
+ choose() {
506
+ local rc=0 out i options=("$@")
507
+ if [[ -n $GUM ]]; then
508
+ dim "↑↓ выбор · Enter · Esc — $ESC_MEANS"
509
+ out=$("$GUM" choose --cursor "› " --cursor.foreground "$ACCENT_HEX" --header "" --no-show-help \
510
+ --padding "0 0 0 2" "$@") || rc=$?
511
+ (( rc == 130 )) && interrupted
512
+ (( rc != 0 )) && return 1
513
+ CHOICE=0
514
+ for i in "${!options[@]}"; do
515
+ [[ ${options[i]} == "$out" ]] && CHOICE=$i
516
+ done
517
+ return 0
518
+ fi
519
+ local o
520
+ i=1
521
+ for o in "$@"; do line "${C_ACCENT}$i${C_RESET}) $o"; i=$((i + 1)); done
522
+ while :; do
523
+ read -r -p " › [1] " out || interrupted
524
+ [[ -z $out ]] && out=1
525
+ [[ $out == "<" || $out == *$'\e'* ]] && return 1
526
+ if [[ $out =~ ^[0-9]+$ ]] && (( out >= 1 && out <= $# )); then CHOICE=$((out - 1)); return 0; fi
527
+ done
528
+ }
529
+
530
+ # ask LABEL DEFAULT PLACEHOLDER → ANSWER. Returns 1 for "back".
531
+ ask() {
532
+ local rc=0
533
+ if [[ -n $GUM ]]; then
534
+ dim "Enter · Esc — $ESC_MEANS"
535
+ ANSWER=$("$GUM" input --prompt "› " --prompt.foreground "$ACCENT_HEX" --cursor.foreground "$ACCENT_HEX" \
536
+ --placeholder "$3" --value "$2" --width 60 --char-limit 200 --no-show-help --padding "0 0 0 2") || rc=$?
537
+ (( rc == 130 )) && interrupted
538
+ return "$rc"
539
+ fi
540
+ # Plain read, not readline: readline would swallow Esc as a Meta prefix.
541
+ dim "Enter · «<» или Esc, Enter — $ESC_MEANS"
542
+ read -r -p " ›${2:+ [$2]} " ANSWER || interrupted
543
+ [[ $ANSWER == "<" || $ANSWER == *$'\e'* ]] && return 1
544
+ [[ -n $ANSWER ]] || ANSWER=$2
545
+ return 0
546
+ }
547
+
548
+ # A spinner while CMD runs in the background (its output goes to the log). Ctrl+C stops it and quits.
549
+ spin() {
550
+ local title=$1 rc=0 g=0 frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' i=0
551
+ shift
552
+ "$@" >>"$LOG" 2>&1 &
553
+ BG_PID=$!
554
+ if [[ -n $GUM ]]; then
555
+ "$GUM" spin --spinner dot --spinner.foreground "$ACCENT_HEX" --title "$title" --padding "0 0 0 2" \
556
+ -- tail --pid="$BG_PID" -f /dev/null || g=$?
557
+ (( g == 130 )) && interrupted
558
+ elif (( TTY )); then
559
+ printf '\e[?25l'
560
+ while kill -0 "$BG_PID" 2>/dev/null; do
561
+ printf '\r %s %s' "${C_ACCENT}${frames:i++%10:1}${C_RESET}" "$title"
562
+ sleep 0.1
563
+ done
564
+ printf '\r\e[2K\e[?25h'
565
+ fi
566
+ wait "$BG_PID" || rc=$?
567
+ BG_PID=
568
+ return "$rc"
569
+ }
570
+
571
+ # ---------------------------------------------------------------------------------------------- screens
572
+
573
+ # The logo, 70 columns of half blocks traced from the mark and wordmark (logo-atrium/atrium-logo-short.svg): white
574
+ # on the terminal's own background, black and white like the brand.
575
+ LOGO=$(cat <<'EOF'
576
+ ████▄ ▄▄ ██
577
+ ██████▄ ██▄
578
+ ████████ ▄██████ █████ ██████ ██ ██ ██ ████████████
579
+ ███ ▀██ ▀▀ ███ ██ ███▀ ██ ██ ██ ██ ██▀ ███
580
+ ▄██ ███ ▄███▀███ ██ ███ ██ ██ ██ ██ ██ ███
581
+ ▄███ ████ ██ ▄██ ██▄ ███ ██ ██▄ ▄██ ██ ██ ███
582
+ ████ █████ ▀████▀██ ▀██ ███ ██ ▀▀████▀ ██ ██ ███
583
+ EOF
584
+ )
585
+
586
+ print_logo() {
587
+ say
588
+ if (( COLS >= 72 )); then
589
+ while IFS= read -r l; do say " ${C_ACCENT}${l}${C_RESET}"; done <<<"$LOGO"
590
+ else
591
+ line "${C_ACCENT}${C_BOLD}A T R I U M${C_RESET}"
592
+ fi
593
+ say
594
+ }
595
+
596
+ STEPS=(Проверка Домен Итог Установка)
597
+ # header N: the brand line and where we are, N = 1..4.
598
+ header() {
599
+ local i out=''
600
+ clear_screen
601
+ say
602
+ line "${C_ACCENT}${C_BOLD}Atrium${C_RESET} ${C_DIM}$RELEASE_VERSION${C_RESET}"
603
+ for i in "${!STEPS[@]}"; do
604
+ (( i > 0 )) && out+="${C_DIM} › ${C_RESET}"
605
+ if (( i + 1 == $1 )); then
606
+ out+="${C_ACCENT}${C_BOLD}${STEPS[i]}${C_RESET}"
607
+ elif (( i + 1 < $1 )); then
608
+ out+="${STEPS[i]}"
609
+ else
610
+ out+="${C_DIM}${STEPS[i]}${C_RESET}"
611
+ fi
612
+ done
613
+ line "$out"
614
+ local rule
615
+ printf -v rule '%*s' $((COLS - 4)) ''
616
+ dim "${rule// /─}"
617
+ say
618
+ }
619
+
620
+ title() { line "${C_BOLD}$*${C_RESET}"; say; }
621
+
622
+ screen_welcome() {
623
+ clear_screen
624
+ print_logo
625
+ line "${C_BOLD}Чат и звонки для своих${C_RESET}"
626
+ dim "Atrium $RELEASE_VERSION · установка на этот сервер"
627
+ say
628
+ if (( EXISTING )); then
629
+ line "Уже стоит: ${C_BOLD}${OLD_DOMAIN:-?}${C_RESET} ${C_DIM}${OLD_VERSION:+· $OLD_VERSION}${C_RESET}"
630
+ say
631
+ ESC_MEANS=выход
632
+ choose "Обновить до $RELEASE_VERSION" "Настроить заново" "Выйти" || exit 0
633
+ ESC_MEANS=назад
634
+ case $CHOICE in 0) return 2 ;; 1) return 0 ;; *) exit 0 ;; esac
635
+ fi
636
+ dim "Понадобятся домен и пять минут."
637
+ say
638
+ ESC_MEANS=выход
639
+ choose "Начать" "Выйти" || exit 0
640
+ ESC_MEANS=назад
641
+ (( CHOICE == 0 )) || exit 0
642
+ }
643
+
644
+ screen_checks() {
645
+ header 1
646
+ title "Проверка сервера"
647
+ if [[ -z ${SERVER_IP:-} ]]; then
648
+ spin "Проверяем сервер" detect_ip_to_file || true
649
+ SERVER_IP=$(cat "$ATRIUM_TMP/ip" 2>/dev/null || true)
650
+ fi
651
+ run_checks
652
+ print_checks
653
+ say
654
+ if (( CHECK_FATAL )); then
655
+ choose "Проверить снова" "Выйти" || return 1
656
+ (( CHOICE == 0 )) && return 3
657
+ DIED=1
658
+ exit 1
659
+ fi
660
+ choose "Дальше" "Проверить снова" || return 1
661
+ (( CHOICE == 0 )) || return 3
662
+ }
663
+ detect_ip_to_file() { detect_ip >"$ATRIUM_TMP/ip"; }
664
+
665
+ screen_domain() {
666
+ local err=''
667
+ while :; do
668
+ header 2
669
+ title "Домен"
670
+ dim "Адрес, по которому друзья откроют Atrium. Например: chat.example.com"
671
+ say
672
+ [[ -n $err ]] && { bad "$err"; say; }
673
+ ask "Домен" "${DOMAIN:-}" "chat.example.com" || return 1
674
+ local d
675
+ d=$(normalize_domain "$ANSWER")
676
+ if ! valid_domain "$d"; then
677
+ err="Не похоже на домен: ${ANSWER:-пусто}"
678
+ continue
679
+ fi
680
+ DOMAIN=$d err=''
681
+ screen_dns && return 0
682
+ done
683
+ }
684
+
685
+ # Returns 0 to go on, 1 to retype the domain.
686
+ screen_dns() {
687
+ while :; do
688
+ header 2
689
+ title "DNS · $DOMAIN"
690
+ spin "Проверяем DNS" check_dns_to_file || true
691
+ # shellcheck source=/dev/null
692
+ . "$ATRIUM_TMP/dns"
693
+ if (( DNS_OK )); then
694
+ ok "Все три имени ведут на этот сервер"
695
+ sleep 1
696
+ return 0
697
+ fi
698
+ line "Добавь A-записи у регистратора домена:"
699
+ say
700
+ print_dns
701
+ say
702
+ dim "Cloudflare: серое облако, только DNS."
703
+ say
704
+ choose "Проверить снова" "Продолжить без DNS" "Другой домен" || return 1
705
+ case $CHOICE in 0) continue ;; 1) return 0 ;; *) return 1 ;; esac
706
+ done
707
+ }
708
+ # Sourced inside a function: -g, or the results would be that function's locals.
709
+ check_dns_to_file() { check_dns; declare -p DNS_ROWS DNS_OK | sed 's/^declare /declare -g /' >"$ATRIUM_TMP/dns"; }
710
+
711
+ plan_lines() {
712
+ if (( DOCKER_OK )); then ok "Docker уже есть"; else bullet "Docker"; fi
713
+ if [[ ${ATRIUM_FIREWALL:-1} != 0 ]]; then bullet "Файрвол: 22, 80, 443, 7881, 50000–60000"; fi
714
+ case $SSH_PLAN in
715
+ keys) bullet "SSH только по ключу" ;;
716
+ nokey) warn "SSH: пароль остаётся, ключа нет" ;;
717
+ esac
718
+ [[ -d /run/systemd/system ]] && bullet "Ночные бэкапы"
719
+ bullet "Atrium $RELEASE_VERSION в $ATRIUM_DIR"
720
+ }
721
+
722
+ screen_summary() {
723
+ header 3
724
+ title "Всё готово к установке"
725
+ local k
726
+ for k in "Домен|$DOMAIN" "Приложение|app.$DOMAIN" "Звонки|$TURN_HOST.$DOMAIN"; do
727
+ line "${C_DIM}$(pad "${k%%|*}" 13)${C_RESET}${k#*|}"
728
+ done
729
+ (( DNS_OK )) || line "${C_DIM}$(pad DNS 13)${C_RESET}${C_YELLOW}ещё не готов${C_RESET}"
730
+ say
731
+ plan_lines
732
+ say
733
+ choose "Установить" "Назад" || return 1
734
+ (( CHOICE == 0 )) || return 1
735
+ }
736
+
737
+ # ---------------------------------------------------------------------------------------------- install steps
738
+ # run/spin capture exit codes in conditionals, which disables errexit inside the steps and their helpers.
739
+ # Required commands must propagate failures explicitly, even with set -e enabled.
740
+
741
+ step_docker() {
742
+ command -v curl >/dev/null || apt_install curl ca-certificates || return
743
+ if ! command -v docker >/dev/null; then
744
+ progress sub "Скачиваем установщик Docker"
745
+ fetch https://get.docker.com "$ATRIUM_TMP/get-docker.sh" 60 || return
746
+ progress sub "Ставим пакеты Docker"
747
+ # Without systemd it starts dockerd itself, which must not inherit the lock (main) and hold it for good.
748
+ sh "$ATRIUM_TMP/get-docker.sh" 9>&- || return
749
+ fi
750
+ progress sub "Запускаем Docker"
751
+ systemctl enable --now docker 2>/dev/null || true
752
+ docker compose version >/dev/null 2>&1 || apt_install docker-compose-plugin || apt_install docker-compose-v2
753
+ docker compose version
754
+ }
755
+
756
+ # Web, TURN over TLS and UDP on 443, LiveKit's TCP fallback and media ports;
757
+ # SSH rate-limited ("limit": the 6th new connection within 30 s is refused) on whatever port sshd listens on.
758
+ step_firewall() {
759
+ command -v ufw >/dev/null || { progress sub "Ставим ufw"; apt_install ufw || return; }
760
+ ufw default deny incoming || return
761
+ ufw default allow outgoing || return
762
+ local rule port ports
763
+ progress sub "Открываем порты 80, 443 и для звонков"
764
+ for rule in 80/tcp 443/tcp 443/udp 7881/tcp 50000:60000/udp; do ufw allow "$rule" || return; done
765
+ mkdir -p /run/sshd || return
766
+ ports=$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2 }' || true)
767
+ progress sub "Ограничиваем частоту входов по SSH"
768
+ for port in ${ports:-22}; do
769
+ ufw limit "$port/tcp" || return
770
+ ufw delete allow "$port/tcp" >/dev/null 2>&1 || true
771
+ ufw delete allow "$port" >/dev/null 2>&1 || true
772
+ done
773
+ ufw delete allow OpenSSH >/dev/null 2>&1 || true
774
+ progress sub "Включаем файрвол"
775
+ ufw --force enable
776
+ }
777
+
778
+ # Key-only login. Only reached when authorized_keys holds a key (run_checks), so nobody gets locked out.
779
+ # Its own file in sshd_config.d: no other file's settings get overwritten.
780
+ step_ssh() {
781
+ local conf=/etc/ssh/sshd_config.d/00-atrium-key-only.conf
782
+ progress sub "Выключаем вход по паролю"
783
+ mkdir -p /run/sshd || return
784
+ printf '%s\n' '# Written by the Atrium installer: log in with an SSH key only.' \
785
+ 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' 'PermitRootLogin prohibit-password' >"$conf" || return
786
+ progress sub "Проверяем настройки sshd"
787
+ if ! sshd -t; then
788
+ rm -f "$conf"
789
+ return 1
790
+ fi
791
+ # No systemd (or another unit name): the file is valid and applies at sshd's next start, which isn't a failure.
792
+ systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
793
+ }
794
+
795
+ step_files() {
796
+ local tgz=$ATRIUM_TMP/atrium.tar.gz stage=$ATRIUM_TMP/release f src got=0
797
+ if [[ -f $TARBALL_SRC ]]; then
798
+ cp "$TARBALL_SRC" "$tgz" || return
799
+ sha256_ok "$tgz" "$TARBALL_SHA256" && got=1
800
+ else
801
+ # The first source that gives the pinned bytes; a mirror can't slip anything else in.
802
+ for src in "$TARBALL_SRC" ${TARBALL_MIRRORS[@]+"${TARBALL_MIRRORS[@]}"}; do
803
+ progress sub "Скачиваем Atrium $RELEASE_VERSION"
804
+ echo "Tarball from $src"
805
+ if fetch "$src" "$tgz" 300 && sha256_ok "$tgz" "$TARBALL_SHA256"; then got=1; break; fi
806
+ echo "Not usable: $src"
807
+ done
808
+ fi
809
+ (( got )) || { echo "No source gave the release's tarball with SHA-256 $TARBALL_SHA256"; return 1; }
810
+ progress sub "Раскладываем файлы"
811
+ mkdir -p "$stage" || return
812
+ tar -xzf "$tgz" -C "$stage" --strip-components=1 --no-same-owner || return
813
+ for f in deploy/docker-compose.yml deploy/Caddyfile deploy/backup.sh deploy/livekit.yaml.tmpl; do
814
+ [[ -f $stage/$f ]] || { echo "Release has no $f"; return 1; }
815
+ done
816
+ mkdir -p "$ATRIUM_DIR/deploy" "$ATRIUM_DIR/landing" || return
817
+ # Caddy reads the Caddyfile once: an update that changes it needs Caddy recreated (step_up).
818
+ if [[ -f $ATRIUM_DIR/deploy/Caddyfile ]] && ! cmp -s "$stage/deploy/Caddyfile" "$ATRIUM_DIR/deploy/Caddyfile"; then
819
+ touch "$ATRIUM_TMP/caddyfile-changed" || return
820
+ fi
821
+ install -m 644 "$stage/deploy/docker-compose.yml" "$stage/deploy/Caddyfile" "$stage/deploy/livekit.yaml.tmpl" "$ATRIUM_DIR/deploy/" || return
822
+ install -m 755 "$stage/deploy/backup.sh" "$ATRIUM_DIR/deploy/" || return
823
+ return 0
824
+ }
825
+
826
+ # replace_dir SRC DIR: DIR ends up with exactly SRC's files, so none a release drops linger. DIR itself stays: Caddy's
827
+ # bind mount holds on to that folder, and a new one renamed in at the same path would stay invisible to it.
828
+ replace_dir() {
829
+ mkdir -p "$2" || return
830
+ find "$2" -mindepth 1 -delete || return
831
+ cp -R "$1/." "$2/" || return
832
+ chmod -R a+rX "$2"
833
+ }
834
+
835
+ # A random CSS class name.
836
+ css_name() {
837
+ local letters=abcdefghijklmnopqrstuvwxyz r
838
+ r=$(rand 6)
839
+ printf '%s%s' "${letters:RANDOM % 26:1}" "${r,,}"
840
+ }
841
+
842
+ # landing_colors HUE DARK: "bg fg line accent ink" (#rrggbb each) for a dark (1) or light (0) page in that hue.
843
+ # ink is black or white, whichever reads better on the accent.
844
+ landing_colors() {
845
+ awk -v h="$1" -v dark="$2" '
846
+ function mn(a, b) { return a < b ? a : b }
847
+ function mx(a, b) { return a > b ? a : b }
848
+ function ch(n, s, l, k) { k = (n + h / 30) % 12; return l - s * mn(l, 1 - l) * mx(-1, mn(mn(k - 3, 9 - k), 1)) }
849
+ function hex(s, l) {
850
+ R = ch(0, s / 100, l / 100); G = ch(8, s / 100, l / 100); B = ch(4, s / 100, l / 100)
851
+ return sprintf("#%02x%02x%02x", R * 255 + .5, G * 255 + .5, B * 255 + .5)
852
+ }
853
+ function lin(c) { return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ^ 2.4 }
854
+ BEGIN {
855
+ if (dark) { bg = hex(18, 7); fg = hex(20, 94); line = hex(14, 20); accent = hex(70, 62) }
856
+ else { bg = hex(35, 96); fg = hex(30, 12); line = hex(20, 84); accent = hex(75, 40) }
857
+ y = .2126 * lin(R) + .7152 * lin(G) + .0722 * lin(B)
858
+ print bg, fg, line, accent, ((y + .05) / .05 > 1.05 / (y + .05) ? "#000000" : "#ffffff")
859
+ }'
860
+ }
861
+
862
+ # The server's name as the app has it: given in the browser setup (SERVER_NAME from .env until then).
863
+ app_server_name() {
864
+ local json name re='"serverName": ?"(([^"\\]|\\.)*)"'
865
+ json=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null) || return 0
866
+ [[ $json =~ $re ]] || return 0
867
+ # Undo JSON's \" and \\, in that order.
868
+ name=${BASH_REMATCH[1]}
869
+ name=${name//\\\"/\"}
870
+ name=${name//\\\\/\\}
871
+ printf '%s' "$name"
872
+ }
873
+
874
+ # write_landing SEED: a seed install's cover page, the server's name and a way into the app. It shares nothing with
875
+ # other installs (no Atrium brand, no common files, no scripts): one HTML file whose colours, layout, type, wording
876
+ # and icon come from SEED (LANDING_SEED in .env, so an update keeps the look) and class names that are random.
877
+ write_landing() {
878
+ local n=$1 hue dark layout font weight track radius label shape name
879
+ local bg fg line accent ink icon w t b layout_css dir=$ATRIUM_TMP/landing
880
+ hue=$((n % 360)) n=$((n / 360))
881
+ dark=$((n % 2)) n=$((n / 2))
882
+ layout=$((n % 3)) n=$((n / 3))
883
+ font=$((n % 4)) n=$((n / 4))
884
+ radius=$((n % 3)) n=$((n / 3))
885
+ label=$((n % 3)) n=$((n / 3))
886
+ shape=$((n % 3))
887
+ read -r bg fg line accent ink < <(landing_colors "$hue" "$dark")
888
+ [[ $ink == '#'* ]] || return 1
889
+
890
+ case $font in
891
+ 0) font='system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' weight=700 track=-0.03em ;;
892
+ 1) font='Georgia, "Times New Roman", serif' weight=400 track=-0.01em ;;
893
+ 2) font='"Helvetica Neue", Helvetica, Arial, sans-serif' weight=800 track=-0.04em ;;
894
+ *) font='ui-monospace, "SF Mono", Menlo, Consolas, monospace' weight=500 track=0 ;;
895
+ esac
896
+ case $radius in 0) radius=999px ;; 1) radius=12px ;; *) radius=3px ;; esac
897
+ case $label in 0) label=Войти ;; 1) label=Открыть ;; *) label=Вход ;; esac
898
+ icon="fill='%23${accent#\#}'"
899
+ case $shape in
900
+ 0) icon="%3Ccircle cx='8' cy='8' r='8' $icon/%3E" ;;
901
+ 1) icon="%3Crect width='16' height='16' rx='4' $icon/%3E" ;;
902
+ *) icon="%3Cpath d='M8 0 16 8 8 16 0 8z' $icon/%3E" ;;
903
+ esac
904
+
905
+ # The name people gave the server in the browser setup; the domain while it's still the default one.
906
+ name=$(app_server_name)
907
+ [[ -n $name && ${name,,} != atrium ]] || name=$DOMAIN
908
+ name=${name//&/&amp;} name=${name//</&lt;} name=${name//>/&gt;} name=${name//\"/&quot;} name=${name//\'/&#39;}
909
+
910
+ w=$(css_name) t=$(css_name) b=$(css_name)
911
+ case $layout in
912
+ 0) layout_css="body { align-items: center; justify-content: center; padding: 24px; }
913
+ .$w { text-align: center; }
914
+ .$t { font-size: clamp(40px, 10vw, 104px); }
915
+ .$b { margin-top: 36px; }" ;;
916
+ 1) layout_css="body { align-items: flex-end; padding: max(24px, 6vw); }
917
+ .$w { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 24px 40px; }
918
+ .$t { font-size: clamp(44px, 12vw, 144px); }" ;;
919
+ *) layout_css="body { align-items: center; justify-content: center; padding: 16px; }
920
+ .$w { width: 100%; max-width: 400px; padding: 40px 32px; border: 1px solid $line; border-radius: 20px; }
921
+ .$t { font-size: 32px; }
922
+ .$b { display: block; margin-top: 28px; text-align: center; }" ;;
923
+ esac
924
+
925
+ rm -rf "$dir" || return
926
+ mkdir -p "$dir" || return
927
+ cat >"$dir/index.html" <<EOF || return
928
+ <!doctype html>
929
+ <html lang="ru">
930
+ <head>
931
+ <meta charset="utf-8">
932
+ <meta name="viewport" content="width=device-width, initial-scale=1">
933
+ <meta name="robots" content="noindex, nofollow">
934
+ <meta name="theme-color" content="$bg">
935
+ <title>$name</title>
936
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E$icon%3C/svg%3E">
937
+ <style>
938
+ * { box-sizing: border-box; }
939
+ html, body { margin: 0; }
940
+ body { display: flex; min-height: 100vh; min-height: 100dvh; background: $bg; color: $fg; font-family: $font; -webkit-font-smoothing: antialiased; }
941
+ .$t { margin: 0; font-weight: $weight; letter-spacing: $track; line-height: 1.05; overflow-wrap: anywhere; }
942
+ .$b { display: inline-block; padding: 14px 28px; border-radius: $radius; background: $accent; color: $ink; font: 600 17px/1.2 system-ui, sans-serif; text-decoration: none; }
943
+ .$b:hover { opacity: 0.88; }
944
+ .$b:focus-visible { outline: 2px solid $fg; outline-offset: 3px; }
945
+ $layout_css
946
+ </style>
947
+ </head>
948
+ <body>
949
+ <main class="$w">
950
+ <h1 class="$t">$name</h1>
951
+ <a class="$b" href="https://app.$DOMAIN">$label</a>
952
+ </main>
953
+ </body>
954
+ </html>
955
+ EOF
956
+ replace_dir "$dir" "$ATRIUM_DIR/landing/site"
957
+ }
958
+
959
+ step_config() {
960
+ cd "$ATRIUM_DIR/deploy" || return
961
+ progress sub "Секреты и настройки"
962
+ if [[ ! -f .env ]]; then
963
+ (umask 077 && cat >.env <<EOF
964
+ DOMAIN=$DOMAIN
965
+ SERVER_NAME='$SERVER_NAME'
966
+ INVITE_CODE=$(rand 10)
967
+ # The TURN relay's subdomain. A plain-sounding name: a "turn." host is an easy thing to find and block.
968
+ TURN_HOST=$TURN_HOST
969
+ APP_SECRET=$(rand 48)
970
+ LIVEKIT_API_KEY=API$(rand 12)
971
+ LIVEKIT_API_SECRET=$(rand 40)
972
+ EOF
973
+ ) || return
974
+ fi
975
+ chmod 600 .env || return
976
+ env_set .env DOMAIN "$DOMAIN" || return
977
+ env_set .env TURN_HOST "$TURN_HOST" || return
978
+ if [[ ${NAME_SET:-0} == 1 ]]; then env_set .env SERVER_NAME "'$SERVER_NAME'" || return; fi
979
+ # Which images run: this release's, pinned by digest.
980
+ env_set .env ATRIUM_REGISTRY "$REGISTRY" || return
981
+ env_set .env ATRIUM_VERSION "$RELEASE_VERSION" || return
982
+ env_set .env ATRIUM_APP_DIGEST "$APP_DIGEST" || return
983
+ env_set .env ATRIUM_CADDY_DIGEST "$CADDY_DIGEST" || return
984
+
985
+ # LiveKit's config comes from .env, so its keys always match the app's.
986
+ local s key
987
+ progress sub "Настройки звонков"
988
+ s=$(<livekit.yaml.tmpl) || return
989
+ for key in DOMAIN TURN_HOST LIVEKIT_API_KEY LIVEKIT_API_SECRET; do
990
+ s=${s//"{{$key}}"/$(env_get .env "$key")}
991
+ done
992
+ [[ $s != *'{{'* ]] || { echo "livekit.yaml.tmpl: unknown placeholder"; return 1; }
993
+ if [[ -f livekit.yaml ]] && [[ $(<livekit.yaml) != "$s" ]]; then touch "$ATRIUM_TMP/livekit-changed" || return; fi
994
+ (umask 077 && printf '%s\n' "$s" >livekit.yaml) || return
995
+
996
+ mkdir -p data caddy-data caddy-config sites routes/app || return
997
+ # The app runs as uid 10001 in its container; only it may touch the data.
998
+ chown -R 10001:10001 data || return
999
+ chmod 700 data || return
1000
+
1001
+ # The cover page's look; step_up writes the page.
1002
+ if [[ ! $(env_get .env LANDING_SEED) =~ ^[0-9]{1,10}$ ]]; then
1003
+ env_set .env LANDING_SEED "$(((RANDOM << 15) | RANDOM))" || return
1004
+ fi
1005
+ }
1006
+
1007
+ # Nightly backup at 04:30 server time, a systemd timer (a fresh server may have no cron). Log: journalctl -u atrium-backup
1008
+ step_backups() {
1009
+ command -v rsync >/dev/null || apt_install rsync || return
1010
+ command -v flock >/dev/null || apt_install util-linux || return
1011
+ progress sub "Служба и таймер на 04:30"
1012
+ cat >/etc/systemd/system/atrium-backup.service <<EOF || return
1013
+ [Unit]
1014
+ Description=Atrium nightly backup
1015
+
1016
+ [Service]
1017
+ Type=oneshot
1018
+ ExecStart=$ATRIUM_DIR/deploy/backup.sh
1019
+ EOF
1020
+ cat >/etc/systemd/system/atrium-backup.timer <<'EOF' || return
1021
+ [Unit]
1022
+ Description=Atrium nightly backup at 04:30
1023
+
1024
+ [Timer]
1025
+ OnCalendar=*-*-* 04:30:00
1026
+ Persistent=true
1027
+
1028
+ [Install]
1029
+ WantedBy=timers.target
1030
+ EOF
1031
+ systemctl daemon-reload || return
1032
+ systemctl enable --now atrium-backup.timer
1033
+ }
1034
+
1035
+ step_pull() {
1036
+ cd "$ATRIUM_DIR/deploy" || return
1037
+ progress sub "Образы приложения, звонков и веб-сервера"
1038
+ # The desktop app shows the last line of this; the plain installer keeps it quiet.
1039
+ if [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then docker compose pull; else docker compose pull --quiet; fi
1040
+ }
1041
+
1042
+ step_up() {
1043
+ cd "$ATRIUM_DIR/deploy" || return
1044
+ progress sub "Запускаем контейнеры"
1045
+ docker compose up -d --remove-orphans || return
1046
+ # Bind-mounted single files: an update swaps in new files the running containers never see.
1047
+ if [[ -f $ATRIUM_TMP/caddyfile-changed ]]; then docker compose up -d --force-recreate --no-deps caddy || return; fi
1048
+ # Restarting LiveKit drops calls in progress, so only when its config changed.
1049
+ if [[ -f $ATRIUM_TMP/livekit-changed ]]; then docker compose up -d --force-recreate --no-deps livekit || return; fi
1050
+ local i
1051
+ progress sub "Ждём ответ приложения"
1052
+ for i in $(seq 1 90); do
1053
+ curl -fsS --max-time 3 -o /dev/null http://127.0.0.1:8000/api/setup && break
1054
+ (( i == 90 )) && { docker compose ps; docker compose logs --tail 30 app; return 1; }
1055
+ sleep 1
1056
+ done
1057
+ # Once the app answers: the cover page carries the name it has. A site of one's own in landing/site, marked by a
1058
+ # file named .custom there, stays as it is.
1059
+ progress sub "Пишем обложку сайта"
1060
+ [[ -e $ATRIUM_DIR/landing/site/.custom ]] || write_landing "$(env_get .env LANDING_SEED)" || return
1061
+ # Images of earlier releases.
1062
+ docker image prune -f
1063
+ }
1064
+
1065
+ step_https() {
1066
+ local i
1067
+ progress sub "Ждём сертификат от Let's Encrypt"
1068
+ for i in $(seq 1 30); do
1069
+ curl -fsS --max-time 5 -o /dev/null --resolve "app.$DOMAIN:443:127.0.0.1" "https://app.$DOMAIN/api/setup" && return 0
1070
+ sleep 3
1071
+ done
1072
+ return 1
1073
+ }
1074
+
1075
+ # run TITLE FUNCTION [soft]: one line per step, ✓ with the time it took or ✗ with the log's tail.
1076
+ run() {
1077
+ local title=$1 fn=$2 soft=${3:-} start rc=0 took=''
1078
+ log "== $title"
1079
+ progress run "${fn#step_}"
1080
+ start=$SECONDS
1081
+ if (( TTY )); then
1082
+ spin "$title" "$fn" || rc=$?
1083
+ elif [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then
1084
+ # The log as always, and the output on to the desktop app, which shows its last line when a step takes long.
1085
+ line "… $title"
1086
+ "$fn" 2>&1 | relay_output || rc=$?
1087
+ else
1088
+ line "… $title"
1089
+ "$fn" >>"$LOG" 2>&1 || rc=$?
1090
+ fi
1091
+ (( SECONDS - start >= 3 )) && took=" ${C_DIM}$((SECONDS - start)) с${C_RESET}"
1092
+ if (( rc == 0 )); then
1093
+ ok "$title$took"
1094
+ progress "done" "${fn#step_}" "$((SECONDS - start))"
1095
+ return 0
1096
+ fi
1097
+ if [[ -n $soft ]]; then
1098
+ warn "$soft"
1099
+ progress "done" "${fn#step_}" "$((SECONDS - start))"
1100
+ return 1
1101
+ fi
1102
+ bad "$title"
1103
+ progress fail "${fn#step_}" "$title"
1104
+ say
1105
+ tail -n 12 "$LOG" | sed "s/^/ ${C_DIM}/; s/\$/${C_RESET}/"
1106
+ die "Не получилось. Исправь и запусти установку снова — данные не пропадут."
1107
+ }
1108
+
1109
+ install_all() {
1110
+ [[ -n ${WIZARD:-} ]] || say
1111
+ if (( DOCKER_OK )); then ok "Docker уже есть"; progress "done" docker 0; else run "Docker" step_docker; fi
1112
+ if [[ ${ATRIUM_FIREWALL:-1} != 0 ]]; then run "Файрвол" step_firewall; fi
1113
+ case $SSH_PLAN in
1114
+ keys) run "SSH только по ключу" step_ssh ;;
1115
+ nokey) warn "SSH: пароль остаётся, ключа нет"; progress "done" ssh 0 ;;
1116
+ noinclude) warn "SSH: не трогаем, старый sshd без sshd_config.d"; progress "done" ssh 0 ;;
1117
+ *) progress "done" ssh 0 ;;
1118
+ esac
1119
+ run "Файлы Atrium $RELEASE_VERSION" step_files
1120
+ run "Настройки и ключи" step_config
1121
+ if [[ -d /run/systemd/system ]]; then run "Ночные бэкапы" step_backups; fi
1122
+ run "Скачиваем образы" step_pull
1123
+ run "Запуск" step_up
1124
+ HTTPS_OK=0
1125
+ if (( DNS_OK )); then
1126
+ run "HTTPS-сертификаты" step_https "HTTPS ещё настраивается" && HTTPS_OK=1
1127
+ fi
1128
+ return 0
1129
+ }
1130
+
1131
+ screen_done() {
1132
+ local code needed url next
1133
+ code=$(env_get "$ENV_FILE" INVITE_CODE)
1134
+ needed=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null || true)
1135
+ if [[ $needed == *'"needed":true'* || $needed == *'"needed": true'* ]]; then
1136
+ url="https://app.$DOMAIN/?setup=$code"
1137
+ next="Открой ссылку: создашь админа и позовёшь друзей."
1138
+ else
1139
+ url="https://app.$DOMAIN"
1140
+ next="Atrium $RELEASE_VERSION работает."
1141
+ fi
1142
+ say
1143
+ if [[ -n $GUM ]]; then
1144
+ "$GUM" style --border rounded --border-foreground "$ACCENT_HEX" --bold --padding "1 3" --margin "0 2" "$url"
1145
+ else
1146
+ local bar
1147
+ bar=$(printf '%*s' $((${#url} + 6)) '' | sed 's/ /─/g')
1148
+ line "${C_ACCENT}╭${bar}╮${C_RESET}"
1149
+ line "${C_ACCENT}│${C_RESET} ${C_BOLD}${url}${C_RESET} ${C_ACCENT}│${C_RESET}"
1150
+ line "${C_ACCENT}╰${bar}╯${C_RESET}"
1151
+ fi
1152
+ say
1153
+ line "$next"
1154
+ progress ready "$url"
1155
+ if (( ! DNS_OK )); then
1156
+ dim "Ссылка заработает, когда DNS обновится: сертификаты придут сами."
1157
+ elif (( ! ${HTTPS_OK:-0} )); then
1158
+ dim "Сертификат ещё получается: подожди пару минут."
1159
+ fi
1160
+ say
1161
+ }
1162
+
1163
+ # ---------------------------------------------------------------------------------------------- link
1164
+
1165
+ # `link`: the setup link again, for an owner who lost it or opened the address without it.
1166
+ show_link() {
1167
+ (( EXISTING )) || die "Atrium здесь ещё не стоит"
1168
+ [[ -n $DOMAIN ]] || die "В $ENV_FILE нет DOMAIN"
1169
+ local needed code
1170
+ needed=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null) || die "Сервер не отвечает"
1171
+ if [[ $needed != *'"needed":true'* && $needed != *'"needed": true'* ]]; then
1172
+ line "Настройка уже закончена: https://app.$DOMAIN"
1173
+ return
1174
+ fi
1175
+ code=$(env_get "$ENV_FILE" INVITE_CODE)
1176
+ [[ -n $code ]] || die "В $ENV_FILE нет INVITE_CODE"
1177
+ printf '%s\n' "https://app.$DOMAIN/?setup=$code"
1178
+ }
1179
+
1180
+ # ---------------------------------------------------------------------------------------------- restore
1181
+
1182
+ BACKUPS=/root/atrium-backups/daily
1183
+
1184
+ # `restore`: the data of a nightly backup back in place (a channel's history lost, a server that broke). What was there
1185
+ # is kept first as pre-restore-<time>, so a wrong choice can be undone with the same command.
1186
+ restore_all() {
1187
+ (( EUID == 0 )) || die "Нужен root: sudo sh install.sh restore"
1188
+ (( EXISTING )) || die "Atrium здесь ещё не стоит: сначала установи, потом restore"
1189
+ local data=$ATRIUM_DIR/deploy/data names=() name pick="${ATRIUM_RESTORE:-}" size
1190
+ [[ -d $BACKUPS ]] || die "Бэкапов нет: $BACKUPS"
1191
+ # Newest first; only folders that hold a database count.
1192
+ while IFS= read -r name; do
1193
+ [[ $name != .* && -f $BACKUPS/$name/hangout.db ]] && names+=("$name")
1194
+ done < <(find "$BACKUPS" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r)
1195
+ (( ${#names[@]} )) || die "В $BACKUPS нет ни одной копии"
1196
+
1197
+ if [[ ${ATRIUM_YES:-} == 1 ]]; then
1198
+ [[ -n $pick ]] || die "Без вопросов: ATRIUM_YES=1 ATRIUM_RESTORE=<копия> sh install.sh restore. Копии: ${names[*]}"
1199
+ else
1200
+ (( TTY )) || die "Нет терминала. Без вопросов: ATRIUM_YES=1 ATRIUM_RESTORE=<копия> sh install.sh restore"
1201
+ fi
1202
+ if [[ -z $pick ]]; then
1203
+ say
1204
+ title "Какую копию вернуть?"
1205
+ local labels=()
1206
+ for name in "${names[@]}"; do
1207
+ size=$(du -sh "$BACKUPS/$name" 2>/dev/null | cut -f1)
1208
+ labels+=("$(pad "$name" 30)$size")
1209
+ done
1210
+ ESC_MEANS=выход
1211
+ choose "${labels[@]}" || exit 0
1212
+ pick=${names[CHOICE]}
1213
+ say
1214
+ dim "Нынешние данные сохранятся копией pre-restore-… в той же папке."
1215
+ say
1216
+ choose "Отмена" "Вернуть $pick" || exit 0
1217
+ if (( CHOICE != 1 )); then dim "Ничего не изменено."; exit 0; fi
1218
+ say
1219
+ fi
1220
+ # A name from the list only: no paths.
1221
+ [[ $pick != */* && $pick != .* && -f $BACKUPS/$pick/hangout.db ]] || die "Нет такой копии: $pick"
1222
+
1223
+ cd "$ATRIUM_DIR/deploy"
1224
+ local saved
1225
+ saved=pre-restore-$(date +%F_%H%M%S)
1226
+ docker compose stop app >>"$LOG" 2>&1 || die "Не остановить приложение. Смотри лог."
1227
+ ok "Приложение остановлено"
1228
+
1229
+ # With the app stopped, the files are whole: the current data are copied as they are (pictures hard-linked).
1230
+ mkdir -p "$BACKUPS/$saved/media"
1231
+ cp -a "$data"/hangout.db* "$BACKUPS/$saved/" 2>>"$LOG" || true
1232
+ local dir
1233
+ for dir in avatars uploads; do
1234
+ [[ -d $data/$dir ]] && { cp -al "$data/$dir" "$BACKUPS/$saved/media/" 2>>"$LOG" || cp -a "$data/$dir" "$BACKUPS/$saved/media/"; }
1235
+ done
1236
+ chmod -R go-rwx "$BACKUPS/$saved"
1237
+ ok "Нынешние данные: $saved"
1238
+
1239
+ rm -f "$data"/hangout.db "$data"/hangout.db-wal "$data"/hangout.db-shm
1240
+ cp -a "$BACKUPS/$pick/hangout.db" "$data/hangout.db"
1241
+ for dir in avatars uploads; do
1242
+ if [[ -d $BACKUPS/$pick/media/$dir ]]; then
1243
+ mkdir -p "$data/$dir"
1244
+ rsync -a --delete "$BACKUPS/$pick/media/$dir/" "$data/$dir/"
1245
+ else
1246
+ rm -rf "${data:?}/$dir"
1247
+ fi
1248
+ done
1249
+ chown -R 10001:10001 "$data"
1250
+ chmod 700 "$data"
1251
+ ok "Данные из $pick"
1252
+
1253
+ docker compose up -d app >>"$LOG" 2>&1 || die "Не запустить приложение. Данные до возврата: $BACKUPS/$saved"
1254
+ local i
1255
+ for i in $(seq 1 90); do
1256
+ curl -fs --max-time 3 -o /dev/null http://127.0.0.1:8000/api/setup && break
1257
+ (( i == 90 )) && die "Приложение не отвечает. Данные до возврата: $BACKUPS/$saved (sh install.sh restore)"
1258
+ sleep 1
1259
+ done
1260
+ ok "Приложение отвечает"
1261
+ say
1262
+ line "Готово: данные из $pick."
1263
+ say
1264
+ }
1265
+
1266
+ # ---------------------------------------------------------------------------------------------- uninstall
1267
+
1268
+ # `uninstall`: Atrium off this server, back to what it was before the installer (Docker and ufw stay).
1269
+ uninstall_all() {
1270
+ (( EUID == 0 )) || die "Нужен root: sudo sh install.sh uninstall"
1271
+ [[ $ATRIUM_DIR == /?* && $ATRIUM_DIR != *..* ]] || die "ATRIUM_DIR: нужен полный путь, например /opt/atrium"
1272
+ case ${ATRIUM_DIR%/} in
1273
+ /bin | /boot | /dev | /etc | /home | /lib | /lib64 | /mnt | /opt | /proc | /root | /run | /sbin | /srv | /sys | /tmp | /usr | /var)
1274
+ die "ATRIUM_DIR: $ATRIUM_DIR — системная папка, не Atrium" ;;
1275
+ esac
1276
+ # The folder is deleted whole, so it must be Atrium's: it has the compose file the installer puts there.
1277
+ [[ ! -e $ATRIUM_DIR || -f $ATRIUM_DIR/deploy/docker-compose.yml ]] || die "В $ATRIUM_DIR нет Atrium: ничего не трогаю"
1278
+
1279
+ [[ ${ATRIUM_YES:-} == 1 ]] || (( TTY )) || die "Нет терминала. Без вопросов: ATRIUM_YES=1 sh install.sh uninstall"
1280
+
1281
+ say
1282
+ title "Удалить Atrium с этого сервера?"
1283
+ bullet "Контейнеры и образы Atrium"
1284
+ bullet "Папка $ATRIUM_DIR: настройки, переписка, файлы"
1285
+ bullet "Бэкапы в /root/atrium-backups и ночной таймер"
1286
+ bullet "Установщик в /root и его лог"
1287
+ bullet "Вход по паролю: как было до установки"
1288
+ say
1289
+ dim "Docker и файрвол останутся. Вернуть удалённое нельзя, бэкапы тоже."
1290
+ say
1291
+ if [[ ${ATRIUM_YES:-} != 1 ]]; then
1292
+ ESC_MEANS=выход
1293
+ choose "Отмена" "Удалить Atrium" || exit 0
1294
+ if (( CHOICE != 1 )); then dim "Ничего не удалено."; exit 0; fi
1295
+ say
1296
+ fi
1297
+
1298
+ # The stack first, while its folder and .env are still there.
1299
+ if [[ -f $ATRIUM_DIR/deploy/docker-compose.yml ]] && command -v docker >/dev/null; then
1300
+ (cd "$ATRIUM_DIR/deploy" && docker compose down --rmi all --volumes --remove-orphans) >>"$LOG" 2>&1 ||
1301
+ die "Не остановить контейнеры. Смотри лог и запусти снова."
1302
+ ok "Контейнеры и образы"
1303
+ fi
1304
+ if [[ -d /run/systemd/system ]]; then
1305
+ systemctl disable --now atrium-backup.timer >/dev/null 2>&1 || true
1306
+ systemctl stop atrium-backup.service >/dev/null 2>&1 || true
1307
+ fi
1308
+ rm -f /etc/systemd/system/atrium-backup.service /etc/systemd/system/atrium-backup.timer
1309
+ if [[ -d /run/systemd/system ]]; then
1310
+ systemctl daemon-reload >/dev/null 2>&1 || true
1311
+ systemctl reset-failed atrium-backup.service >/dev/null 2>&1 || true
1312
+ fi
1313
+ ok "Ночной бэкап"
1314
+
1315
+ # The log goes with the rest: from here nothing may write it again.
1316
+ local log_file=$LOG
1317
+ LOG=/dev/null
1318
+ rm -rf -- "$ATRIUM_DIR" /root/atrium-backups
1319
+ rm -f -- /root/atrium-install.sh "$log_file"
1320
+ ok "Файлы и бэкапы"
1321
+
1322
+ # SSH as before: our key-only file goes, and the desktop app's key with it.
1323
+ rm -f /etc/ssh/sshd_config.d/00-atrium-key-only.conf
1324
+ if [[ -f /root/.ssh/authorized_keys ]]; then sed -i '/[[:space:]]atrium-app[[:space:]]*$/d' /root/.ssh/authorized_keys; fi
1325
+ if command -v sshd >/dev/null && sshd -t 2>/dev/null; then
1326
+ systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
1327
+ fi
1328
+ ok "Вход по SSH"
1329
+ say
1330
+ line "Atrium удалён."
1331
+ say
1332
+ }
1333
+
1334
+ # ---------------------------------------------------------------------------------------------- main
1335
+
1336
+ usage() {
1337
+ cat <<EOF
1338
+ Atrium $RELEASE_VERSION — установка на этот сервер.
1339
+ sh install.sh мастер установки (повторный запуск — обновление)
1340
+ sh install.sh update обновить без вопросов
1341
+ sh install.sh link ссылка для первой настройки ещё раз (если настройка не закончена)
1342
+ sh install.sh restore вернуть данные из ночной копии (ATRIUM_YES=1 ATRIUM_RESTORE=<копия> — без вопросов)
1343
+ sh install.sh uninstall снять Atrium с сервера: контейнеры, данные, бэкапы (ATRIUM_YES=1 — без вопроса)
1344
+ DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh без вопросов
1345
+ EOF
1346
+ }
1347
+
1348
+ # The release to install: this installer's own, or a local/mirrored one given with its checksum.
1349
+ pick_release() {
1350
+ REGISTRY=${ATRIUM_REGISTRY:-$RELEASE_REGISTRY}
1351
+ APP_DIGEST=${ATRIUM_APP_DIGEST-$RELEASE_APP_DIGEST}
1352
+ CADDY_DIGEST=${ATRIUM_CADDY_DIGEST-$RELEASE_CADDY_DIGEST}
1353
+ TARBALL_MIRRORS=()
1354
+ if [[ -n ${ATRIUM_TARBALL:-} ]]; then
1355
+ [[ -n ${ATRIUM_TARBALL_SHA256:-} ]] || die "ATRIUM_TARBALL без ATRIUM_TARBALL_SHA256"
1356
+ TARBALL_SRC=$ATRIUM_TARBALL TARBALL_SHA256=$ATRIUM_TARBALL_SHA256
1357
+ RELEASE_VERSION=${ATRIUM_VERSION:-$RELEASE_VERSION}
1358
+ else
1359
+ [[ -n $RELEASE_SHA256 ]] ||
1360
+ die "Это не релизная сборка установщика: возьми его с https://github.com/$RELEASE_REPO/releases/latest/download/install.sh"
1361
+ TARBALL_SRC=https://github.com/$RELEASE_REPO/releases/download/v$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz
1362
+ TARBALL_SHA256=$RELEASE_SHA256
1363
+ # When GitHub can't be reached: the same file from the npm mirror, through two CDNs.
1364
+ TARBALL_MIRRORS=(
1365
+ "https://cdn.jsdelivr.net/npm/$RELEASE_NPM@$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz"
1366
+ "https://unpkg.com/$RELEASE_NPM@$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz"
1367
+ )
1368
+ fi
1369
+ }
1370
+
1371
+ # No questions: ATRIUM_YES=1, or `update`.
1372
+ run_quiet() {
1373
+ if (( TTY )); then print_logo; else say "Atrium $RELEASE_VERSION"; fi
1374
+ [[ -n ${DOMAIN:-} ]] || die "Нужен домен: DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh"
1375
+ DOMAIN=$(normalize_domain "$DOMAIN")
1376
+ valid_domain "$DOMAIN" || die "Не похоже на домен: $DOMAIN"
1377
+ valid_name "$SERVER_NAME" || die "Имя сервера: до 40 символов, без кавычек, \\, \$ и \`"
1378
+ [[ -n ${ATRIUM_SERVER_NAME_SET:-} ]] && NAME_SET=1
1379
+ progress run check
1380
+ progress sub "Определяем внешний IP"
1381
+ SERVER_IP=$(detect_ip)
1382
+ add_app_key
1383
+ run_checks
1384
+ print_checks
1385
+ if (( CHECK_FATAL )); then
1386
+ progress fail check "$(first_bad_check)"
1387
+ die "Сервер не готов."
1388
+ fi
1389
+ progress "done" check
1390
+ wait_dns
1391
+ check_dns
1392
+ if (( ! DNS_OK )); then
1393
+ say
1394
+ warn "DNS ещё не готов:"
1395
+ print_dns
1396
+ fi
1397
+ install_all
1398
+ screen_done
1399
+ }
1400
+
1401
+ wizard() {
1402
+ WIZARD=1
1403
+ local step=welcome rc
1404
+ while :; do
1405
+ rc=0
1406
+ case $step in
1407
+ welcome)
1408
+ screen_welcome || rc=$?
1409
+ if (( rc == 2 )); then
1410
+ # Update: the wizard's answers are already in .env.
1411
+ header 1
1412
+ title "Проверка сервера"
1413
+ spin "Проверяем сервер" detect_ip_to_file || true
1414
+ SERVER_IP=$(cat "$ATRIUM_TMP/ip" 2>/dev/null || true)
1415
+ run_checks
1416
+ (( CHECK_FATAL )) && { print_checks; die "Сервер не готов."; }
1417
+ spin "Проверяем DNS" check_dns_to_file || true
1418
+ # shellcheck source=/dev/null
1419
+ . "$ATRIUM_TMP/dns"
1420
+ step=install
1421
+ else
1422
+ step=checks
1423
+ fi
1424
+ ;;
1425
+ checks)
1426
+ screen_checks || rc=$?
1427
+ case $rc in 0) step=domain ;; 3) SERVER_IP='' ;; *) step=welcome ;; esac
1428
+ ;;
1429
+ domain) if screen_domain; then step=summary; else step=checks; fi ;;
1430
+ summary) if screen_summary; then step=install; else step=domain; fi ;;
1431
+ install)
1432
+ header 4
1433
+ title "Установка"
1434
+ install_all
1435
+ screen_done
1436
+ return
1437
+ ;;
1438
+ esac
1439
+ done
1440
+ }
1441
+
1442
+ main() {
1443
+ case ${1:-} in
1444
+ '' | install) MODE=install ;;
1445
+ update) MODE=update ;;
1446
+ uninstall) MODE=uninstall ;;
1447
+ link) MODE="link" ;;
1448
+ restore) MODE=restore ;;
1449
+ -h | --help | help) usage; exit 0 ;;
1450
+ *) usage >&2; exit 2 ;;
1451
+ esac
1452
+ [[ -n ${SERVER_NAME:-} ]] && ATRIUM_SERVER_NAME_SET=1
1453
+ # One installer at a time: two would share the log and both run docker compose. The lock goes with this process
1454
+ # (fd 9). No /run to write to means no root, which the server check reports anyway.
1455
+ if command -v flock >/dev/null && { exec 9>>"$LOCK"; } 2>/dev/null && ! flock -n 9; then
1456
+ # The log is the other run's.
1457
+ DIED=1 LOG=/dev/null
1458
+ bad "Установщик уже запущен на этом сервере"
1459
+ exit 1
1460
+ fi
1461
+ if ! { : >>"$LOG"; } 2>/dev/null; then LOG=$ATRIUM_TMP/install.log; fi
1462
+ chmod 600 "$LOG" 2>/dev/null || true
1463
+ log "Atrium installer $RELEASE_VERSION, mode $MODE"
1464
+ if [[ $MODE == link ]]; then
1465
+ detect_install
1466
+ show_link
1467
+ return
1468
+ fi
1469
+ if [[ $MODE == restore ]]; then
1470
+ detect_install
1471
+ restore_all
1472
+ return
1473
+ fi
1474
+ if [[ $MODE == uninstall ]]; then
1475
+ detect_install
1476
+ uninstall_all
1477
+ return
1478
+ fi
1479
+ pick_release
1480
+ detect_install
1481
+ DNS_OK=0 DOCKER_OK=0 SSH_PLAN=none NAME_SET=0
1482
+
1483
+ if [[ $MODE == update ]]; then
1484
+ (( EXISTING )) || die "Atrium здесь ещё не стоит: запусти без update"
1485
+ DNS_OK=1
1486
+ run_quiet
1487
+ elif [[ ${ATRIUM_YES:-} == 1 ]]; then
1488
+ run_quiet
1489
+ elif (( ! TTY )); then
1490
+ die "Нет терминала. Без вопросов: DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh"
1491
+ else
1492
+ (( TTY )) && printf ' %sГотовим установщик…%s' "$C_DIM" "$C_RESET"
1493
+ get_gum || GUM=''
1494
+ printf '\r\e[2K'
1495
+ wizard
1496
+ fi
1497
+ }
1498
+
1499
+ main "$@"
1500
+ ATRIUM_INSTALLER
1501
+
1502
+ command -v bash >/dev/null 2>&1 || { echo "Atrium: нужен bash" >&2; exit 1; }
1503
+ # The terminal, if there is one: under `curl | sh` our stdin is the script, not the person.
1504
+ status=0
1505
+ if (: </dev/tty) 2>/dev/null; then
1506
+ bash "$ATRIUM_TMP/install.bash" "$@" </dev/tty || status=$?
1507
+ else
1508
+ bash "$ATRIUM_TMP/install.bash" "$@" </dev/null || status=$?
1509
+ fi
1510
+ exit "$status"