@atriumchat/install 0.0.0-stage → 0.7.1

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,1530 @@
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.1
56
+ RELEASE_SHA256=a731cb23ffd5a273796c3ffffde28ea9da6b62a5abeb80e3ca9b571fbc16b90b
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:d4f63d2b370a9fe686e2f01c92092fa7730b2baef045c42b1f3d4cbce0463f24
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
+ # Already there (a run again from the app), maybe already pinned to the agent: once is enough.
415
+ grep -qF "${key#ssh-ed25519 }" /root/.ssh/authorized_keys || printf '%s\n' "$key" >>/root/.ssh/authorized_keys
416
+ log "desktop app key added for root"
417
+ }
418
+
419
+ # Once Atrium runs, the desktop app's key (the line with the comment atrium-app) stops being root: it is pinned to
420
+ # deploy/atrium-agent, which only understands status, update, backup and logs, and loses forwarding and a terminal.
421
+ # The install itself still had the full key (it may need running again); an update pins servers installed before.
422
+ lock_app_key() {
423
+ local f=/root/.ssh/authorized_keys agent=$ATRIUM_DIR/deploy/atrium-agent
424
+ [[ -f $f && -x $agent ]] || return 0
425
+ grep -Eq '^ssh-ed25519 [^ ]+ atrium-app[[:space:]]*$' "$f" || return 0
426
+ sed -i -E "s|^(ssh-ed25519 [^ ]+ atrium-app)[[:space:]]*\$|restrict,command=\"$agent\" \\1|" "$f"
427
+ log "desktop app key pinned to $agent"
428
+ }
429
+
430
+ # The desktop app sets the address up just before it installs (a free name on dedyn.io, or the person's own
431
+ # records), and a new name can take minutes to reach every resolver. Only the start and the certificates need it, so
432
+ # the installer waits here after everything else is done (install_all): the wait mostly hides behind Docker and the
433
+ # images instead of holding the install up from its second step.
434
+ wait_dns() {
435
+ [[ ${ATRIUM_PROGRESS:-} == 1 ]] || return 0
436
+ local i
437
+ progress run address
438
+ for i in $(seq 1 60); do
439
+ check_dns
440
+ progress sub "Записей на месте: $DNS_HITS из 3"
441
+ (( DNS_OK )) && break
442
+ sleep 3
443
+ done
444
+ progress "done" address
445
+ }
446
+
447
+ print_checks() {
448
+ local item
449
+ for item in "${CHECK_LINES[@]}"; do
450
+ case ${item%%|*} in
451
+ ok) ok "${item#*|}" ;;
452
+ warn) warn "${item#*|}" ;;
453
+ bad) bad "${item#*|}" ;;
454
+ *) bullet "${item#*|}" ;;
455
+ esac
456
+ done
457
+ }
458
+
459
+ # DNS_ROWS: "host|now" per name that must point here; DNS_OK=1 when all do (DNS_HITS of them so far).
460
+ check_dns() {
461
+ local host got status want_ips
462
+ want_ips="${SERVER_IP:-} $(local_ips)"
463
+ DNS_ROWS=() DNS_OK=1 DNS_HITS=0
464
+ for host in "$DOMAIN" "app.$DOMAIN" "$TURN_HOST.$DOMAIN"; do
465
+ got=$(resolve4 "$host")
466
+ got=${got% }
467
+ if [[ -z $got ]]; then
468
+ status="${C_RED}✗${C_RESET} нет записи"
469
+ DNS_OK=0
470
+ elif [[ -n ${SERVER_IP:-} && " $want_ips " == *" ${got%% *} "* && $got != *" "* ]]; then
471
+ status="${C_GREEN}✓${C_RESET}"
472
+ DNS_HITS=$((DNS_HITS + 1))
473
+ elif cloudflare_ip "${got%% *}"; then
474
+ status="${C_RED}✗${C_RESET} прокси Cloudflare"
475
+ DNS_OK=0
476
+ elif [[ -z ${SERVER_IP:-} ]]; then
477
+ status="${C_YELLOW}?${C_RESET} $got"
478
+ DNS_OK=0
479
+ else
480
+ status="${C_RED}✗${C_RESET} $got"
481
+ DNS_OK=0
482
+ fi
483
+ DNS_ROWS+=("$host|$status")
484
+ done
485
+ }
486
+
487
+ print_dns() {
488
+ local row host w=0
489
+ for row in "${DNS_ROWS[@]}"; do host=${row%%|*}; (( ${#host} > w )) && w=${#host}; done
490
+ (( w += 2 ))
491
+ line "${C_DIM}$(pad Имя "$w")$(pad Тип 5)$(pad Значение 17)Сейчас${C_RESET}"
492
+ for row in "${DNS_ROWS[@]}"; do
493
+ line "$(pad "${row%%|*}" "$w")$(pad A 5)$(pad "${SERVER_IP:-IP сервера}" 17)${row#*|}"
494
+ done
495
+ }
496
+
497
+ # ---------------------------------------------------------------------------------------------- prompts
498
+
499
+ get_gum() {
500
+ (( TTY )) && [[ ${ATRIUM_NO_GUM:-} != 1 && ${TERM:-dumb} != dumb ]] || return 1
501
+ local arch sum name
502
+ case $(uname -m) in
503
+ x86_64 | amd64) arch=x86_64 sum=$GUM_SHA256_x86_64 ;;
504
+ aarch64 | arm64) arch=arm64 sum=$GUM_SHA256_arm64 ;;
505
+ *) return 1 ;;
506
+ esac
507
+ name=gum_${GUM_VERSION}_Linux_$arch
508
+ fetch "https://github.com/charmbracelet/gum/releases/download/v$GUM_VERSION/$name.tar.gz" "$ATRIUM_TMP/gum.tgz" 30 || return 1
509
+ sha256_ok "$ATRIUM_TMP/gum.tgz" "$sum" || { log "gum: SHA-256 mismatch"; return 1; }
510
+ tar -xzf "$ATRIUM_TMP/gum.tgz" -C "$ATRIUM_TMP" "$name/gum" 2>/dev/null || return 1
511
+ "$ATRIUM_TMP/$name/gum" --version >/dev/null 2>&1 || return 1
512
+ GUM=$ATRIUM_TMP/$name/gum
513
+ log "gum $GUM_VERSION ($arch) ready"
514
+ }
515
+
516
+ # choose OPTION... → CHOICE (0-based). Returns 1 for "back" (Esc, or "<" in plain mode); Ctrl+C quits.
517
+ # ESC_MEANS says what Esc does on this screen.
518
+ ESC_MEANS=назад
519
+ choose() {
520
+ local rc=0 out i options=("$@")
521
+ if [[ -n $GUM ]]; then
522
+ dim "↑↓ выбор · Enter · Esc — $ESC_MEANS"
523
+ out=$("$GUM" choose --cursor "› " --cursor.foreground "$ACCENT_HEX" --header "" --no-show-help \
524
+ --padding "0 0 0 2" "$@") || rc=$?
525
+ (( rc == 130 )) && interrupted
526
+ (( rc != 0 )) && return 1
527
+ CHOICE=0
528
+ for i in "${!options[@]}"; do
529
+ [[ ${options[i]} == "$out" ]] && CHOICE=$i
530
+ done
531
+ return 0
532
+ fi
533
+ local o
534
+ i=1
535
+ for o in "$@"; do line "${C_ACCENT}$i${C_RESET}) $o"; i=$((i + 1)); done
536
+ while :; do
537
+ read -r -p " › [1] " out || interrupted
538
+ [[ -z $out ]] && out=1
539
+ [[ $out == "<" || $out == *$'\e'* ]] && return 1
540
+ if [[ $out =~ ^[0-9]+$ ]] && (( out >= 1 && out <= $# )); then CHOICE=$((out - 1)); return 0; fi
541
+ done
542
+ }
543
+
544
+ # ask LABEL DEFAULT PLACEHOLDER → ANSWER. Returns 1 for "back".
545
+ ask() {
546
+ local rc=0
547
+ if [[ -n $GUM ]]; then
548
+ dim "Enter · Esc — $ESC_MEANS"
549
+ ANSWER=$("$GUM" input --prompt "› " --prompt.foreground "$ACCENT_HEX" --cursor.foreground "$ACCENT_HEX" \
550
+ --placeholder "$3" --value "$2" --width 60 --char-limit 200 --no-show-help --padding "0 0 0 2") || rc=$?
551
+ (( rc == 130 )) && interrupted
552
+ return "$rc"
553
+ fi
554
+ # Plain read, not readline: readline would swallow Esc as a Meta prefix.
555
+ dim "Enter · «<» или Esc, Enter — $ESC_MEANS"
556
+ read -r -p " ›${2:+ [$2]} " ANSWER || interrupted
557
+ [[ $ANSWER == "<" || $ANSWER == *$'\e'* ]] && return 1
558
+ [[ -n $ANSWER ]] || ANSWER=$2
559
+ return 0
560
+ }
561
+
562
+ # A spinner while CMD runs in the background (its output goes to the log). Ctrl+C stops it and quits.
563
+ spin() {
564
+ local title=$1 rc=0 g=0 frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' i=0
565
+ shift
566
+ "$@" >>"$LOG" 2>&1 &
567
+ BG_PID=$!
568
+ if [[ -n $GUM ]]; then
569
+ "$GUM" spin --spinner dot --spinner.foreground "$ACCENT_HEX" --title "$title" --padding "0 0 0 2" \
570
+ -- tail --pid="$BG_PID" -f /dev/null || g=$?
571
+ (( g == 130 )) && interrupted
572
+ elif (( TTY )); then
573
+ printf '\e[?25l'
574
+ while kill -0 "$BG_PID" 2>/dev/null; do
575
+ printf '\r %s %s' "${C_ACCENT}${frames:i++%10:1}${C_RESET}" "$title"
576
+ sleep 0.1
577
+ done
578
+ printf '\r\e[2K\e[?25h'
579
+ fi
580
+ wait "$BG_PID" || rc=$?
581
+ BG_PID=
582
+ return "$rc"
583
+ }
584
+
585
+ # ---------------------------------------------------------------------------------------------- screens
586
+
587
+ # The logo, 70 columns of half blocks traced from the mark and wordmark (logo-atrium/atrium-logo-short.svg): white
588
+ # on the terminal's own background, black and white like the brand.
589
+ LOGO=$(cat <<'EOF'
590
+ ████▄ ▄▄ ██
591
+ ██████▄ ██▄
592
+ ████████ ▄██████ █████ ██████ ██ ██ ██ ████████████
593
+ ███ ▀██ ▀▀ ███ ██ ███▀ ██ ██ ██ ██ ██▀ ███
594
+ ▄██ ███ ▄███▀███ ██ ███ ██ ██ ██ ██ ██ ███
595
+ ▄███ ████ ██ ▄██ ██▄ ███ ██ ██▄ ▄██ ██ ██ ███
596
+ ████ █████ ▀████▀██ ▀██ ███ ██ ▀▀████▀ ██ ██ ███
597
+ EOF
598
+ )
599
+
600
+ print_logo() {
601
+ say
602
+ if (( COLS >= 72 )); then
603
+ while IFS= read -r l; do say " ${C_ACCENT}${l}${C_RESET}"; done <<<"$LOGO"
604
+ else
605
+ line "${C_ACCENT}${C_BOLD}A T R I U M${C_RESET}"
606
+ fi
607
+ say
608
+ }
609
+
610
+ STEPS=(Проверка Домен Итог Установка)
611
+ # header N: the brand line and where we are, N = 1..4.
612
+ header() {
613
+ local i out=''
614
+ clear_screen
615
+ say
616
+ line "${C_ACCENT}${C_BOLD}Atrium${C_RESET} ${C_DIM}$RELEASE_VERSION${C_RESET}"
617
+ for i in "${!STEPS[@]}"; do
618
+ (( i > 0 )) && out+="${C_DIM} › ${C_RESET}"
619
+ if (( i + 1 == $1 )); then
620
+ out+="${C_ACCENT}${C_BOLD}${STEPS[i]}${C_RESET}"
621
+ elif (( i + 1 < $1 )); then
622
+ out+="${STEPS[i]}"
623
+ else
624
+ out+="${C_DIM}${STEPS[i]}${C_RESET}"
625
+ fi
626
+ done
627
+ line "$out"
628
+ local rule
629
+ printf -v rule '%*s' $((COLS - 4)) ''
630
+ dim "${rule// /─}"
631
+ say
632
+ }
633
+
634
+ title() { line "${C_BOLD}$*${C_RESET}"; say; }
635
+
636
+ screen_welcome() {
637
+ clear_screen
638
+ print_logo
639
+ line "${C_BOLD}Чат и звонки для своих${C_RESET}"
640
+ dim "Atrium $RELEASE_VERSION · установка на этот сервер"
641
+ say
642
+ if (( EXISTING )); then
643
+ line "Уже стоит: ${C_BOLD}${OLD_DOMAIN:-?}${C_RESET} ${C_DIM}${OLD_VERSION:+· $OLD_VERSION}${C_RESET}"
644
+ say
645
+ ESC_MEANS=выход
646
+ choose "Обновить до $RELEASE_VERSION" "Настроить заново" "Выйти" || exit 0
647
+ ESC_MEANS=назад
648
+ case $CHOICE in 0) return 2 ;; 1) return 0 ;; *) exit 0 ;; esac
649
+ fi
650
+ dim "Понадобятся домен и пять минут."
651
+ say
652
+ ESC_MEANS=выход
653
+ choose "Начать" "Выйти" || exit 0
654
+ ESC_MEANS=назад
655
+ (( CHOICE == 0 )) || exit 0
656
+ }
657
+
658
+ screen_checks() {
659
+ header 1
660
+ title "Проверка сервера"
661
+ if [[ -z ${SERVER_IP:-} ]]; then
662
+ spin "Проверяем сервер" detect_ip_to_file || true
663
+ SERVER_IP=$(cat "$ATRIUM_TMP/ip" 2>/dev/null || true)
664
+ fi
665
+ run_checks
666
+ print_checks
667
+ say
668
+ if (( CHECK_FATAL )); then
669
+ choose "Проверить снова" "Выйти" || return 1
670
+ (( CHOICE == 0 )) && return 3
671
+ DIED=1
672
+ exit 1
673
+ fi
674
+ choose "Дальше" "Проверить снова" || return 1
675
+ (( CHOICE == 0 )) || return 3
676
+ }
677
+ detect_ip_to_file() { detect_ip >"$ATRIUM_TMP/ip"; }
678
+
679
+ screen_domain() {
680
+ local err=''
681
+ while :; do
682
+ header 2
683
+ title "Домен"
684
+ dim "Адрес, по которому друзья откроют Atrium. Например: chat.example.com"
685
+ say
686
+ [[ -n $err ]] && { bad "$err"; say; }
687
+ ask "Домен" "${DOMAIN:-}" "chat.example.com" || return 1
688
+ local d
689
+ d=$(normalize_domain "$ANSWER")
690
+ if ! valid_domain "$d"; then
691
+ err="Не похоже на домен: ${ANSWER:-пусто}"
692
+ continue
693
+ fi
694
+ DOMAIN=$d err=''
695
+ screen_dns && return 0
696
+ done
697
+ }
698
+
699
+ # Returns 0 to go on, 1 to retype the domain.
700
+ screen_dns() {
701
+ while :; do
702
+ header 2
703
+ title "DNS · $DOMAIN"
704
+ spin "Проверяем DNS" check_dns_to_file || true
705
+ # shellcheck source=/dev/null
706
+ . "$ATRIUM_TMP/dns"
707
+ if (( DNS_OK )); then
708
+ ok "Все три имени ведут на этот сервер"
709
+ sleep 1
710
+ return 0
711
+ fi
712
+ line "Добавь A-записи у регистратора домена:"
713
+ say
714
+ print_dns
715
+ say
716
+ dim "Cloudflare: серое облако, только DNS."
717
+ say
718
+ choose "Проверить снова" "Продолжить без DNS" "Другой домен" || return 1
719
+ case $CHOICE in 0) continue ;; 1) return 0 ;; *) return 1 ;; esac
720
+ done
721
+ }
722
+ # Sourced inside a function: -g, or the results would be that function's locals.
723
+ check_dns_to_file() { check_dns; declare -p DNS_ROWS DNS_OK | sed 's/^declare /declare -g /' >"$ATRIUM_TMP/dns"; }
724
+
725
+ plan_lines() {
726
+ if (( DOCKER_OK )); then ok "Docker уже есть"; else bullet "Docker"; fi
727
+ if [[ ${ATRIUM_FIREWALL:-1} != 0 ]]; then bullet "Файрвол: 22, 80, 443, 7881, 50000–60000"; fi
728
+ case $SSH_PLAN in
729
+ keys) bullet "SSH только по ключу" ;;
730
+ nokey) warn "SSH: пароль остаётся, ключа нет" ;;
731
+ esac
732
+ [[ -d /run/systemd/system ]] && bullet "Ночные бэкапы"
733
+ bullet "Atrium $RELEASE_VERSION в $ATRIUM_DIR"
734
+ }
735
+
736
+ screen_summary() {
737
+ header 3
738
+ title "Всё готово к установке"
739
+ local k
740
+ for k in "Домен|$DOMAIN" "Приложение|app.$DOMAIN" "Звонки|$TURN_HOST.$DOMAIN"; do
741
+ line "${C_DIM}$(pad "${k%%|*}" 13)${C_RESET}${k#*|}"
742
+ done
743
+ (( DNS_OK )) || line "${C_DIM}$(pad DNS 13)${C_RESET}${C_YELLOW}ещё не готов${C_RESET}"
744
+ say
745
+ plan_lines
746
+ say
747
+ choose "Установить" "Назад" || return 1
748
+ (( CHOICE == 0 )) || return 1
749
+ }
750
+
751
+ # ---------------------------------------------------------------------------------------------- install steps
752
+ # run/spin capture exit codes in conditionals, which disables errexit inside the steps and their helpers.
753
+ # Required commands must propagate failures explicitly, even with set -e enabled.
754
+
755
+ step_docker() {
756
+ command -v curl >/dev/null || apt_install curl ca-certificates || return
757
+ if ! command -v docker >/dev/null; then
758
+ progress sub "Скачиваем установщик Docker"
759
+ fetch https://get.docker.com "$ATRIUM_TMP/get-docker.sh" 60 || return
760
+ progress sub "Ставим пакеты Docker"
761
+ # Without systemd it starts dockerd itself, which must not inherit the lock (main) and hold it for good.
762
+ sh "$ATRIUM_TMP/get-docker.sh" 9>&- || return
763
+ fi
764
+ progress sub "Запускаем Docker"
765
+ systemctl enable --now docker 2>/dev/null || true
766
+ docker compose version >/dev/null 2>&1 || apt_install docker-compose-plugin || apt_install docker-compose-v2
767
+ docker compose version
768
+ }
769
+
770
+ # Web, TURN over TLS and UDP on 443, LiveKit's TCP fallback and media ports;
771
+ # SSH rate-limited ("limit": the 6th new connection within 30 s is refused) on whatever port sshd listens on.
772
+ step_firewall() {
773
+ command -v ufw >/dev/null || { progress sub "Ставим ufw"; apt_install ufw || return; }
774
+ ufw default deny incoming || return
775
+ ufw default allow outgoing || return
776
+ local rule port ports
777
+ progress sub "Открываем порты 80, 443 и для звонков"
778
+ for rule in 80/tcp 443/tcp 443/udp 7881/tcp 50000:60000/udp; do ufw allow "$rule" || return; done
779
+ mkdir -p /run/sshd || return
780
+ ports=$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2 }' || true)
781
+ progress sub "Ограничиваем частоту входов по SSH"
782
+ for port in ${ports:-22}; do
783
+ ufw limit "$port/tcp" || return
784
+ ufw delete allow "$port/tcp" >/dev/null 2>&1 || true
785
+ ufw delete allow "$port" >/dev/null 2>&1 || true
786
+ done
787
+ ufw delete allow OpenSSH >/dev/null 2>&1 || true
788
+ progress sub "Включаем файрвол"
789
+ ufw --force enable
790
+ }
791
+
792
+ # Key-only login. Only reached when authorized_keys holds a key (run_checks), so nobody gets locked out.
793
+ # Its own file in sshd_config.d: no other file's settings get overwritten.
794
+ step_ssh() {
795
+ local conf=/etc/ssh/sshd_config.d/00-atrium-key-only.conf
796
+ progress sub "Выключаем вход по паролю"
797
+ mkdir -p /run/sshd || return
798
+ printf '%s\n' '# Written by the Atrium installer: log in with an SSH key only.' \
799
+ 'PasswordAuthentication no' 'KbdInteractiveAuthentication no' 'PermitRootLogin prohibit-password' >"$conf" || return
800
+ progress sub "Проверяем настройки sshd"
801
+ if ! sshd -t; then
802
+ rm -f "$conf"
803
+ return 1
804
+ fi
805
+ # No systemd (or another unit name): the file is valid and applies at sshd's next start, which isn't a failure.
806
+ systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
807
+ }
808
+
809
+ step_files() {
810
+ local tgz=$ATRIUM_TMP/atrium.tar.gz stage=$ATRIUM_TMP/release f src got=0
811
+ if [[ -f $TARBALL_SRC ]]; then
812
+ cp "$TARBALL_SRC" "$tgz" || return
813
+ sha256_ok "$tgz" "$TARBALL_SHA256" && got=1
814
+ else
815
+ # The first source that gives the pinned bytes; a mirror can't slip anything else in.
816
+ for src in "$TARBALL_SRC" ${TARBALL_MIRRORS[@]+"${TARBALL_MIRRORS[@]}"}; do
817
+ progress sub "Скачиваем Atrium $RELEASE_VERSION"
818
+ echo "Tarball from $src"
819
+ if fetch "$src" "$tgz" 300 && sha256_ok "$tgz" "$TARBALL_SHA256"; then got=1; break; fi
820
+ echo "Not usable: $src"
821
+ done
822
+ fi
823
+ (( got )) || { echo "No source gave the release's tarball with SHA-256 $TARBALL_SHA256"; return 1; }
824
+ progress sub "Раскладываем файлы"
825
+ mkdir -p "$stage" || return
826
+ tar -xzf "$tgz" -C "$stage" --strip-components=1 --no-same-owner || return
827
+ for f in deploy/docker-compose.yml deploy/Caddyfile deploy/backup.sh deploy/atrium-agent deploy/livekit.yaml.tmpl; do
828
+ [[ -f $stage/$f ]] || { echo "Release has no $f"; return 1; }
829
+ done
830
+ mkdir -p "$ATRIUM_DIR/deploy" "$ATRIUM_DIR/landing" || return
831
+ # Caddy reads the Caddyfile once: an update that changes it needs Caddy recreated (step_up).
832
+ if [[ -f $ATRIUM_DIR/deploy/Caddyfile ]] && ! cmp -s "$stage/deploy/Caddyfile" "$ATRIUM_DIR/deploy/Caddyfile"; then
833
+ touch "$ATRIUM_TMP/caddyfile-changed" || return
834
+ fi
835
+ install -m 644 "$stage/deploy/docker-compose.yml" "$stage/deploy/Caddyfile" "$stage/deploy/livekit.yaml.tmpl" "$ATRIUM_DIR/deploy/" || return
836
+ install -m 755 "$stage/deploy/backup.sh" "$stage/deploy/atrium-agent" "$ATRIUM_DIR/deploy/" || return
837
+ return 0
838
+ }
839
+
840
+ # replace_dir SRC DIR: DIR ends up with exactly SRC's files, so none a release drops linger. DIR itself stays: Caddy's
841
+ # bind mount holds on to that folder, and a new one renamed in at the same path would stay invisible to it.
842
+ replace_dir() {
843
+ mkdir -p "$2" || return
844
+ find "$2" -mindepth 1 -delete || return
845
+ cp -R "$1/." "$2/" || return
846
+ chmod -R a+rX "$2"
847
+ }
848
+
849
+ # A random CSS class name.
850
+ css_name() {
851
+ local letters=abcdefghijklmnopqrstuvwxyz r
852
+ r=$(rand 6)
853
+ printf '%s%s' "${letters:RANDOM % 26:1}" "${r,,}"
854
+ }
855
+
856
+ # landing_colors HUE DARK: "bg fg line accent ink" (#rrggbb each) for a dark (1) or light (0) page in that hue.
857
+ # ink is black or white, whichever reads better on the accent.
858
+ landing_colors() {
859
+ awk -v h="$1" -v dark="$2" '
860
+ function mn(a, b) { return a < b ? a : b }
861
+ function mx(a, b) { return a > b ? a : b }
862
+ 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)) }
863
+ function hex(s, l) {
864
+ R = ch(0, s / 100, l / 100); G = ch(8, s / 100, l / 100); B = ch(4, s / 100, l / 100)
865
+ return sprintf("#%02x%02x%02x", R * 255 + .5, G * 255 + .5, B * 255 + .5)
866
+ }
867
+ function lin(c) { return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ^ 2.4 }
868
+ BEGIN {
869
+ if (dark) { bg = hex(18, 7); fg = hex(20, 94); line = hex(14, 20); accent = hex(70, 62) }
870
+ else { bg = hex(35, 96); fg = hex(30, 12); line = hex(20, 84); accent = hex(75, 40) }
871
+ y = .2126 * lin(R) + .7152 * lin(G) + .0722 * lin(B)
872
+ print bg, fg, line, accent, ((y + .05) / .05 > 1.05 / (y + .05) ? "#000000" : "#ffffff")
873
+ }'
874
+ }
875
+
876
+ # The server's name as the app has it: given in the browser setup (SERVER_NAME from .env until then).
877
+ app_server_name() {
878
+ local json name re='"serverName": ?"(([^"\\]|\\.)*)"'
879
+ json=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null) || return 0
880
+ [[ $json =~ $re ]] || return 0
881
+ # Undo JSON's \" and \\, in that order.
882
+ name=${BASH_REMATCH[1]}
883
+ name=${name//\\\"/\"}
884
+ name=${name//\\\\/\\}
885
+ printf '%s' "$name"
886
+ }
887
+
888
+ # write_landing SEED: a seed install's cover page, the server's name and a way into the app. It shares nothing with
889
+ # other installs (no Atrium brand, no common files, no scripts): one HTML file whose colours, layout, type, wording
890
+ # and icon come from SEED (LANDING_SEED in .env, so an update keeps the look) and class names that are random.
891
+ write_landing() {
892
+ local n=$1 hue dark layout font weight track radius label shape name
893
+ local bg fg line accent ink icon w t b layout_css dir=$ATRIUM_TMP/landing
894
+ hue=$((n % 360)) n=$((n / 360))
895
+ dark=$((n % 2)) n=$((n / 2))
896
+ layout=$((n % 3)) n=$((n / 3))
897
+ font=$((n % 4)) n=$((n / 4))
898
+ radius=$((n % 3)) n=$((n / 3))
899
+ label=$((n % 3)) n=$((n / 3))
900
+ shape=$((n % 3))
901
+ read -r bg fg line accent ink < <(landing_colors "$hue" "$dark")
902
+ [[ $ink == '#'* ]] || return 1
903
+
904
+ case $font in
905
+ 0) font='system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' weight=700 track=-0.03em ;;
906
+ 1) font='Georgia, "Times New Roman", serif' weight=400 track=-0.01em ;;
907
+ 2) font='"Helvetica Neue", Helvetica, Arial, sans-serif' weight=800 track=-0.04em ;;
908
+ *) font='ui-monospace, "SF Mono", Menlo, Consolas, monospace' weight=500 track=0 ;;
909
+ esac
910
+ case $radius in 0) radius=999px ;; 1) radius=12px ;; *) radius=3px ;; esac
911
+ case $label in 0) label=Войти ;; 1) label=Открыть ;; *) label=Вход ;; esac
912
+ icon="fill='%23${accent#\#}'"
913
+ case $shape in
914
+ 0) icon="%3Ccircle cx='8' cy='8' r='8' $icon/%3E" ;;
915
+ 1) icon="%3Crect width='16' height='16' rx='4' $icon/%3E" ;;
916
+ *) icon="%3Cpath d='M8 0 16 8 8 16 0 8z' $icon/%3E" ;;
917
+ esac
918
+
919
+ # The name people gave the server in the browser setup; the domain while it's still the default one.
920
+ name=$(app_server_name)
921
+ [[ -n $name && ${name,,} != atrium ]] || name=$DOMAIN
922
+ name=${name//&/&amp;} name=${name//</&lt;} name=${name//>/&gt;} name=${name//\"/&quot;} name=${name//\'/&#39;}
923
+
924
+ w=$(css_name) t=$(css_name) b=$(css_name)
925
+ case $layout in
926
+ 0) layout_css="body { align-items: center; justify-content: center; padding: 24px; }
927
+ .$w { text-align: center; }
928
+ .$t { font-size: clamp(40px, 10vw, 104px); }
929
+ .$b { margin-top: 36px; }" ;;
930
+ 1) layout_css="body { align-items: flex-end; padding: max(24px, 6vw); }
931
+ .$w { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 24px 40px; }
932
+ .$t { font-size: clamp(44px, 12vw, 144px); }" ;;
933
+ *) layout_css="body { align-items: center; justify-content: center; padding: 16px; }
934
+ .$w { width: 100%; max-width: 400px; padding: 40px 32px; border: 1px solid $line; border-radius: 20px; }
935
+ .$t { font-size: 32px; }
936
+ .$b { display: block; margin-top: 28px; text-align: center; }" ;;
937
+ esac
938
+
939
+ rm -rf "$dir" || return
940
+ mkdir -p "$dir" || return
941
+ cat >"$dir/index.html" <<EOF || return
942
+ <!doctype html>
943
+ <html lang="ru">
944
+ <head>
945
+ <meta charset="utf-8">
946
+ <meta name="viewport" content="width=device-width, initial-scale=1">
947
+ <meta name="robots" content="noindex, nofollow">
948
+ <meta name="theme-color" content="$bg">
949
+ <title>$name</title>
950
+ <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">
951
+ <style>
952
+ * { box-sizing: border-box; }
953
+ html, body { margin: 0; }
954
+ body { display: flex; min-height: 100vh; min-height: 100dvh; background: $bg; color: $fg; font-family: $font; -webkit-font-smoothing: antialiased; }
955
+ .$t { margin: 0; font-weight: $weight; letter-spacing: $track; line-height: 1.05; overflow-wrap: anywhere; }
956
+ .$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; }
957
+ .$b:hover { opacity: 0.88; }
958
+ .$b:focus-visible { outline: 2px solid $fg; outline-offset: 3px; }
959
+ $layout_css
960
+ </style>
961
+ </head>
962
+ <body>
963
+ <main class="$w">
964
+ <h1 class="$t">$name</h1>
965
+ <a class="$b" href="https://app.$DOMAIN">$label</a>
966
+ </main>
967
+ </body>
968
+ </html>
969
+ EOF
970
+ replace_dir "$dir" "$ATRIUM_DIR/landing/site"
971
+ }
972
+
973
+ step_config() {
974
+ cd "$ATRIUM_DIR/deploy" || return
975
+ progress sub "Секреты и настройки"
976
+ if [[ ! -f .env ]]; then
977
+ (umask 077 && cat >.env <<EOF
978
+ DOMAIN=$DOMAIN
979
+ SERVER_NAME='$SERVER_NAME'
980
+ INVITE_CODE=$(rand 10)
981
+ # The TURN relay's subdomain. A plain-sounding name: a "turn." host is an easy thing to find and block.
982
+ TURN_HOST=$TURN_HOST
983
+ APP_SECRET=$(rand 48)
984
+ LIVEKIT_API_KEY=API$(rand 12)
985
+ LIVEKIT_API_SECRET=$(rand 40)
986
+ EOF
987
+ ) || return
988
+ fi
989
+ chmod 600 .env || return
990
+ env_set .env DOMAIN "$DOMAIN" || return
991
+ env_set .env TURN_HOST "$TURN_HOST" || return
992
+ if [[ ${NAME_SET:-0} == 1 ]]; then env_set .env SERVER_NAME "'$SERVER_NAME'" || return; fi
993
+ # Which images run: this release's, pinned by digest.
994
+ env_set .env ATRIUM_REGISTRY "$REGISTRY" || return
995
+ env_set .env ATRIUM_VERSION "$RELEASE_VERSION" || return
996
+ env_set .env ATRIUM_APP_DIGEST "$APP_DIGEST" || return
997
+ env_set .env ATRIUM_CADDY_DIGEST "$CADDY_DIGEST" || return
998
+
999
+ # LiveKit's config comes from .env, so its keys always match the app's.
1000
+ local s key
1001
+ progress sub "Настройки звонков"
1002
+ s=$(<livekit.yaml.tmpl) || return
1003
+ for key in DOMAIN TURN_HOST LIVEKIT_API_KEY LIVEKIT_API_SECRET; do
1004
+ s=${s//"{{$key}}"/$(env_get .env "$key")}
1005
+ done
1006
+ [[ $s != *'{{'* ]] || { echo "livekit.yaml.tmpl: unknown placeholder"; return 1; }
1007
+ if [[ -f livekit.yaml ]] && [[ $(<livekit.yaml) != "$s" ]]; then touch "$ATRIUM_TMP/livekit-changed" || return; fi
1008
+ (umask 077 && printf '%s\n' "$s" >livekit.yaml) || return
1009
+
1010
+ mkdir -p data caddy-data caddy-config sites routes/app || return
1011
+ # The app runs as uid 10001 in its container; only it may touch the data.
1012
+ chown -R 10001:10001 data || return
1013
+ chmod 700 data || return
1014
+
1015
+ # The cover page's look; step_up writes the page.
1016
+ if [[ ! $(env_get .env LANDING_SEED) =~ ^[0-9]{1,10}$ ]]; then
1017
+ env_set .env LANDING_SEED "$(((RANDOM << 15) | RANDOM))" || return
1018
+ fi
1019
+ }
1020
+
1021
+ # Nightly backup at 04:30 server time, a systemd timer (a fresh server may have no cron). Log: journalctl -u atrium-backup
1022
+ step_backups() {
1023
+ command -v rsync >/dev/null || apt_install rsync || return
1024
+ command -v flock >/dev/null || apt_install util-linux || return
1025
+ progress sub "Служба и таймер на 04:30"
1026
+ cat >/etc/systemd/system/atrium-backup.service <<EOF || return
1027
+ [Unit]
1028
+ Description=Atrium nightly backup
1029
+
1030
+ [Service]
1031
+ Type=oneshot
1032
+ ExecStart=$ATRIUM_DIR/deploy/backup.sh
1033
+ EOF
1034
+ cat >/etc/systemd/system/atrium-backup.timer <<'EOF' || return
1035
+ [Unit]
1036
+ Description=Atrium nightly backup at 04:30
1037
+
1038
+ [Timer]
1039
+ OnCalendar=*-*-* 04:30:00
1040
+ Persistent=true
1041
+
1042
+ [Install]
1043
+ WantedBy=timers.target
1044
+ EOF
1045
+ systemctl daemon-reload || return
1046
+ systemctl enable --now atrium-backup.timer
1047
+ }
1048
+
1049
+ step_pull() {
1050
+ cd "$ATRIUM_DIR/deploy" || return
1051
+ progress sub "Образы приложения, звонков и веб-сервера"
1052
+ # The desktop app shows the last line of this; the plain installer keeps it quiet.
1053
+ if [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then docker compose pull; else docker compose pull --quiet; fi
1054
+ }
1055
+
1056
+ step_up() {
1057
+ cd "$ATRIUM_DIR/deploy" || return
1058
+ progress sub "Запускаем контейнеры"
1059
+ docker compose up -d --remove-orphans || return
1060
+ # Bind-mounted single files: an update swaps in new files the running containers never see.
1061
+ if [[ -f $ATRIUM_TMP/caddyfile-changed ]]; then docker compose up -d --force-recreate --no-deps caddy || return; fi
1062
+ # Restarting LiveKit drops calls in progress, so only when its config changed.
1063
+ if [[ -f $ATRIUM_TMP/livekit-changed ]]; then docker compose up -d --force-recreate --no-deps livekit || return; fi
1064
+ local i
1065
+ progress sub "Ждём ответ приложения"
1066
+ for i in $(seq 1 90); do
1067
+ curl -fsS --max-time 3 -o /dev/null http://127.0.0.1:8000/api/setup && break
1068
+ (( i == 90 )) && { docker compose ps; docker compose logs --tail 30 app; return 1; }
1069
+ sleep 1
1070
+ done
1071
+ # Once the app answers: the cover page carries the name it has. A site of one's own in landing/site, marked by a
1072
+ # file named .custom there, stays as it is.
1073
+ progress sub "Пишем обложку сайта"
1074
+ [[ -e $ATRIUM_DIR/landing/site/.custom ]] || write_landing "$(env_get .env LANDING_SEED)" || return
1075
+ # Images of earlier releases.
1076
+ docker image prune -f
1077
+ }
1078
+
1079
+ step_https() {
1080
+ local i
1081
+ progress sub "Ждём сертификат от Let's Encrypt"
1082
+ for i in $(seq 1 30); do
1083
+ curl -fsS --max-time 5 -o /dev/null --resolve "app.$DOMAIN:443:127.0.0.1" "https://app.$DOMAIN/api/setup" && return 0
1084
+ sleep 3
1085
+ done
1086
+ return 1
1087
+ }
1088
+
1089
+ # run TITLE FUNCTION [soft]: one line per step, ✓ with the time it took or ✗ with the log's tail.
1090
+ run() {
1091
+ local title=$1 fn=$2 soft=${3:-} start rc=0 took=''
1092
+ log "== $title"
1093
+ progress run "${fn#step_}"
1094
+ start=$SECONDS
1095
+ if (( TTY )); then
1096
+ spin "$title" "$fn" || rc=$?
1097
+ elif [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then
1098
+ # The log as always, and the output on to the desktop app, which shows its last line when a step takes long.
1099
+ line "… $title"
1100
+ "$fn" 2>&1 | relay_output || rc=$?
1101
+ else
1102
+ line "… $title"
1103
+ "$fn" >>"$LOG" 2>&1 || rc=$?
1104
+ fi
1105
+ (( SECONDS - start >= 3 )) && took=" ${C_DIM}$((SECONDS - start)) с${C_RESET}"
1106
+ if (( rc == 0 )); then
1107
+ ok "$title$took"
1108
+ progress "done" "${fn#step_}" "$((SECONDS - start))"
1109
+ return 0
1110
+ fi
1111
+ if [[ -n $soft ]]; then
1112
+ warn "$soft"
1113
+ progress "done" "${fn#step_}" "$((SECONDS - start))"
1114
+ return 1
1115
+ fi
1116
+ bad "$title"
1117
+ progress fail "${fn#step_}" "$title"
1118
+ say
1119
+ tail -n 12 "$LOG" | sed "s/^/ ${C_DIM}/; s/\$/${C_RESET}/"
1120
+ die "Не получилось. Исправь и запусти установку снова — данные не пропадут."
1121
+ }
1122
+
1123
+ install_all() {
1124
+ [[ -n ${WIZARD:-} ]] || say
1125
+ if (( DOCKER_OK )); then ok "Docker уже есть"; progress "done" docker 0; else run "Docker" step_docker; fi
1126
+ if [[ ${ATRIUM_FIREWALL:-1} != 0 ]]; then run "Файрвол" step_firewall; fi
1127
+ case $SSH_PLAN in
1128
+ keys) run "SSH только по ключу" step_ssh ;;
1129
+ nokey) warn "SSH: пароль остаётся, ключа нет"; progress "done" ssh 0 ;;
1130
+ noinclude) warn "SSH: не трогаем, старый sshd без sshd_config.d"; progress "done" ssh 0 ;;
1131
+ *) progress "done" ssh 0 ;;
1132
+ esac
1133
+ run "Файлы Atrium $RELEASE_VERSION" step_files
1134
+ run "Настройки и ключи" step_config
1135
+ if [[ -d /run/systemd/system ]]; then run "Ночные бэкапы" step_backups; fi
1136
+ run "Скачиваем образы" step_pull
1137
+ if [[ ${ATRIUM_PROGRESS:-} == 1 ]]; then
1138
+ wait_dns
1139
+ check_dns
1140
+ fi
1141
+ run "Запуск" step_up
1142
+ HTTPS_OK=0
1143
+ if (( DNS_OK )); then
1144
+ run "HTTPS-сертификаты" step_https "HTTPS ещё настраивается" && HTTPS_OK=1
1145
+ fi
1146
+ return 0
1147
+ }
1148
+
1149
+ screen_done() {
1150
+ local code needed url next
1151
+ code=$(env_get "$ENV_FILE" INVITE_CODE)
1152
+ needed=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null || true)
1153
+ if [[ $needed == *'"needed":true'* || $needed == *'"needed": true'* ]]; then
1154
+ url="https://app.$DOMAIN/?setup=$code"
1155
+ next="Открой ссылку: создашь админа и позовёшь друзей."
1156
+ else
1157
+ url="https://app.$DOMAIN"
1158
+ next="Atrium $RELEASE_VERSION работает."
1159
+ fi
1160
+ say
1161
+ if [[ -n $GUM ]]; then
1162
+ "$GUM" style --border rounded --border-foreground "$ACCENT_HEX" --bold --padding "1 3" --margin "0 2" "$url"
1163
+ else
1164
+ local bar
1165
+ bar=$(printf '%*s' $((${#url} + 6)) '' | sed 's/ /─/g')
1166
+ line "${C_ACCENT}╭${bar}╮${C_RESET}"
1167
+ line "${C_ACCENT}│${C_RESET} ${C_BOLD}${url}${C_RESET} ${C_ACCENT}│${C_RESET}"
1168
+ line "${C_ACCENT}╰${bar}╯${C_RESET}"
1169
+ fi
1170
+ say
1171
+ line "$next"
1172
+ progress ready "$url"
1173
+ if (( ! DNS_OK )); then
1174
+ dim "Ссылка заработает, когда DNS обновится: сертификаты придут сами."
1175
+ elif (( ! ${HTTPS_OK:-0} )); then
1176
+ dim "Сертификат ещё получается: подожди пару минут."
1177
+ fi
1178
+ say
1179
+ }
1180
+
1181
+ # ---------------------------------------------------------------------------------------------- link
1182
+
1183
+ # `link`: the setup link again, for an owner who lost it or opened the address without it.
1184
+ show_link() {
1185
+ (( EXISTING )) || die "Atrium здесь ещё не стоит"
1186
+ [[ -n $DOMAIN ]] || die "В $ENV_FILE нет DOMAIN"
1187
+ local needed code
1188
+ needed=$(curl -fsS --max-time 5 http://127.0.0.1:8000/api/setup 2>/dev/null) || die "Сервер не отвечает"
1189
+ if [[ $needed != *'"needed":true'* && $needed != *'"needed": true'* ]]; then
1190
+ line "Настройка уже закончена: https://app.$DOMAIN"
1191
+ return
1192
+ fi
1193
+ code=$(env_get "$ENV_FILE" INVITE_CODE)
1194
+ [[ -n $code ]] || die "В $ENV_FILE нет INVITE_CODE"
1195
+ printf '%s\n' "https://app.$DOMAIN/?setup=$code"
1196
+ }
1197
+
1198
+ # ---------------------------------------------------------------------------------------------- restore
1199
+
1200
+ BACKUPS=/root/atrium-backups/daily
1201
+
1202
+ # `restore`: the data of a nightly backup back in place (a channel's history lost, a server that broke). What was there
1203
+ # is kept first as pre-restore-<time>, so a wrong choice can be undone with the same command.
1204
+ restore_all() {
1205
+ (( EUID == 0 )) || die "Нужен root: sudo sh install.sh restore"
1206
+ (( EXISTING )) || die "Atrium здесь ещё не стоит: сначала установи, потом restore"
1207
+ local data=$ATRIUM_DIR/deploy/data names=() name pick="${ATRIUM_RESTORE:-}" size
1208
+ [[ -d $BACKUPS ]] || die "Бэкапов нет: $BACKUPS"
1209
+ # Newest first; only folders that hold a database count.
1210
+ while IFS= read -r name; do
1211
+ [[ $name != .* && -f $BACKUPS/$name/hangout.db ]] && names+=("$name")
1212
+ done < <(find "$BACKUPS" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r)
1213
+ (( ${#names[@]} )) || die "В $BACKUPS нет ни одной копии"
1214
+
1215
+ if [[ ${ATRIUM_YES:-} == 1 ]]; then
1216
+ [[ -n $pick ]] || die "Без вопросов: ATRIUM_YES=1 ATRIUM_RESTORE=<копия> sh install.sh restore. Копии: ${names[*]}"
1217
+ else
1218
+ (( TTY )) || die "Нет терминала. Без вопросов: ATRIUM_YES=1 ATRIUM_RESTORE=<копия> sh install.sh restore"
1219
+ fi
1220
+ if [[ -z $pick ]]; then
1221
+ say
1222
+ title "Какую копию вернуть?"
1223
+ local labels=()
1224
+ for name in "${names[@]}"; do
1225
+ size=$(du -sh "$BACKUPS/$name" 2>/dev/null | cut -f1)
1226
+ labels+=("$(pad "$name" 30)$size")
1227
+ done
1228
+ ESC_MEANS=выход
1229
+ choose "${labels[@]}" || exit 0
1230
+ pick=${names[CHOICE]}
1231
+ say
1232
+ dim "Нынешние данные сохранятся копией pre-restore-… в той же папке."
1233
+ say
1234
+ choose "Отмена" "Вернуть $pick" || exit 0
1235
+ if (( CHOICE != 1 )); then dim "Ничего не изменено."; exit 0; fi
1236
+ say
1237
+ fi
1238
+ # A name from the list only: no paths.
1239
+ [[ $pick != */* && $pick != .* && -f $BACKUPS/$pick/hangout.db ]] || die "Нет такой копии: $pick"
1240
+
1241
+ cd "$ATRIUM_DIR/deploy"
1242
+ local saved
1243
+ saved=pre-restore-$(date +%F_%H%M%S)
1244
+ docker compose stop app >>"$LOG" 2>&1 || die "Не остановить приложение. Смотри лог."
1245
+ ok "Приложение остановлено"
1246
+
1247
+ # With the app stopped, the files are whole: the current data are copied as they are (pictures hard-linked).
1248
+ mkdir -p "$BACKUPS/$saved/media"
1249
+ cp -a "$data"/hangout.db* "$BACKUPS/$saved/" 2>>"$LOG" || true
1250
+ local dir
1251
+ for dir in avatars uploads; do
1252
+ [[ -d $data/$dir ]] && { cp -al "$data/$dir" "$BACKUPS/$saved/media/" 2>>"$LOG" || cp -a "$data/$dir" "$BACKUPS/$saved/media/"; }
1253
+ done
1254
+ chmod -R go-rwx "$BACKUPS/$saved"
1255
+ ok "Нынешние данные: $saved"
1256
+
1257
+ rm -f "$data"/hangout.db "$data"/hangout.db-wal "$data"/hangout.db-shm
1258
+ cp -a "$BACKUPS/$pick/hangout.db" "$data/hangout.db"
1259
+ for dir in avatars uploads; do
1260
+ if [[ -d $BACKUPS/$pick/media/$dir ]]; then
1261
+ mkdir -p "$data/$dir"
1262
+ rsync -a --delete "$BACKUPS/$pick/media/$dir/" "$data/$dir/"
1263
+ else
1264
+ rm -rf "${data:?}/$dir"
1265
+ fi
1266
+ done
1267
+ chown -R 10001:10001 "$data"
1268
+ chmod 700 "$data"
1269
+ ok "Данные из $pick"
1270
+
1271
+ docker compose up -d app >>"$LOG" 2>&1 || die "Не запустить приложение. Данные до возврата: $BACKUPS/$saved"
1272
+ local i
1273
+ for i in $(seq 1 90); do
1274
+ curl -fs --max-time 3 -o /dev/null http://127.0.0.1:8000/api/setup && break
1275
+ (( i == 90 )) && die "Приложение не отвечает. Данные до возврата: $BACKUPS/$saved (sh install.sh restore)"
1276
+ sleep 1
1277
+ done
1278
+ ok "Приложение отвечает"
1279
+ say
1280
+ line "Готово: данные из $pick."
1281
+ say
1282
+ }
1283
+
1284
+ # ---------------------------------------------------------------------------------------------- uninstall
1285
+
1286
+ # `uninstall`: Atrium off this server, back to what it was before the installer (Docker and ufw stay).
1287
+ uninstall_all() {
1288
+ (( EUID == 0 )) || die "Нужен root: sudo sh install.sh uninstall"
1289
+ [[ $ATRIUM_DIR == /?* && $ATRIUM_DIR != *..* ]] || die "ATRIUM_DIR: нужен полный путь, например /opt/atrium"
1290
+ case ${ATRIUM_DIR%/} in
1291
+ /bin | /boot | /dev | /etc | /home | /lib | /lib64 | /mnt | /opt | /proc | /root | /run | /sbin | /srv | /sys | /tmp | /usr | /var)
1292
+ die "ATRIUM_DIR: $ATRIUM_DIR — системная папка, не Atrium" ;;
1293
+ esac
1294
+ # The folder is deleted whole, so it must be Atrium's: it has the compose file the installer puts there.
1295
+ [[ ! -e $ATRIUM_DIR || -f $ATRIUM_DIR/deploy/docker-compose.yml ]] || die "В $ATRIUM_DIR нет Atrium: ничего не трогаю"
1296
+
1297
+ [[ ${ATRIUM_YES:-} == 1 ]] || (( TTY )) || die "Нет терминала. Без вопросов: ATRIUM_YES=1 sh install.sh uninstall"
1298
+
1299
+ say
1300
+ title "Удалить Atrium с этого сервера?"
1301
+ bullet "Контейнеры и образы Atrium"
1302
+ bullet "Папка $ATRIUM_DIR: настройки, переписка, файлы"
1303
+ bullet "Бэкапы в /root/atrium-backups и ночной таймер"
1304
+ bullet "Установщик в /root и его лог"
1305
+ bullet "Вход по паролю: как было до установки"
1306
+ say
1307
+ dim "Docker и файрвол останутся. Вернуть удалённое нельзя, бэкапы тоже."
1308
+ say
1309
+ if [[ ${ATRIUM_YES:-} != 1 ]]; then
1310
+ ESC_MEANS=выход
1311
+ choose "Отмена" "Удалить Atrium" || exit 0
1312
+ if (( CHOICE != 1 )); then dim "Ничего не удалено."; exit 0; fi
1313
+ say
1314
+ fi
1315
+
1316
+ # The stack first, while its folder and .env are still there.
1317
+ if [[ -f $ATRIUM_DIR/deploy/docker-compose.yml ]] && command -v docker >/dev/null; then
1318
+ (cd "$ATRIUM_DIR/deploy" && docker compose down --rmi all --volumes --remove-orphans) >>"$LOG" 2>&1 ||
1319
+ die "Не остановить контейнеры. Смотри лог и запусти снова."
1320
+ ok "Контейнеры и образы"
1321
+ fi
1322
+ if [[ -d /run/systemd/system ]]; then
1323
+ systemctl disable --now atrium-backup.timer >/dev/null 2>&1 || true
1324
+ systemctl stop atrium-backup.service >/dev/null 2>&1 || true
1325
+ fi
1326
+ rm -f /etc/systemd/system/atrium-backup.service /etc/systemd/system/atrium-backup.timer
1327
+ if [[ -d /run/systemd/system ]]; then
1328
+ systemctl daemon-reload >/dev/null 2>&1 || true
1329
+ systemctl reset-failed atrium-backup.service >/dev/null 2>&1 || true
1330
+ fi
1331
+ ok "Ночной бэкап"
1332
+
1333
+ # The log goes with the rest: from here nothing may write it again.
1334
+ local log_file=$LOG
1335
+ LOG=/dev/null
1336
+ rm -rf -- "$ATRIUM_DIR" /root/atrium-backups
1337
+ rm -f -- /root/atrium-install.sh "$log_file"
1338
+ ok "Файлы и бэкапы"
1339
+
1340
+ # SSH as before: our key-only file goes, and the desktop app's key with it.
1341
+ rm -f /etc/ssh/sshd_config.d/00-atrium-key-only.conf
1342
+ if [[ -f /root/.ssh/authorized_keys ]]; then sed -i '/[[:space:]]atrium-app[[:space:]]*$/d' /root/.ssh/authorized_keys; fi
1343
+ if command -v sshd >/dev/null && sshd -t 2>/dev/null; then
1344
+ systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
1345
+ fi
1346
+ ok "Вход по SSH"
1347
+ say
1348
+ line "Atrium удалён."
1349
+ say
1350
+ }
1351
+
1352
+ # ---------------------------------------------------------------------------------------------- main
1353
+
1354
+ usage() {
1355
+ cat <<EOF
1356
+ Atrium $RELEASE_VERSION — установка на этот сервер.
1357
+ sh install.sh мастер установки (повторный запуск — обновление)
1358
+ sh install.sh update обновить без вопросов
1359
+ sh install.sh link ссылка для первой настройки ещё раз (если настройка не закончена)
1360
+ sh install.sh restore вернуть данные из ночной копии (ATRIUM_YES=1 ATRIUM_RESTORE=<копия> — без вопросов)
1361
+ sh install.sh uninstall снять Atrium с сервера: контейнеры, данные, бэкапы (ATRIUM_YES=1 — без вопроса)
1362
+ DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh без вопросов
1363
+ EOF
1364
+ }
1365
+
1366
+ # The release to install: this installer's own, or a local/mirrored one given with its checksum.
1367
+ pick_release() {
1368
+ REGISTRY=${ATRIUM_REGISTRY:-$RELEASE_REGISTRY}
1369
+ APP_DIGEST=${ATRIUM_APP_DIGEST-$RELEASE_APP_DIGEST}
1370
+ CADDY_DIGEST=${ATRIUM_CADDY_DIGEST-$RELEASE_CADDY_DIGEST}
1371
+ TARBALL_MIRRORS=()
1372
+ if [[ -n ${ATRIUM_TARBALL:-} ]]; then
1373
+ [[ -n ${ATRIUM_TARBALL_SHA256:-} ]] || die "ATRIUM_TARBALL без ATRIUM_TARBALL_SHA256"
1374
+ TARBALL_SRC=$ATRIUM_TARBALL TARBALL_SHA256=$ATRIUM_TARBALL_SHA256
1375
+ RELEASE_VERSION=${ATRIUM_VERSION:-$RELEASE_VERSION}
1376
+ else
1377
+ [[ -n $RELEASE_SHA256 ]] ||
1378
+ die "Это не релизная сборка установщика: возьми его с https://github.com/$RELEASE_REPO/releases/latest/download/install.sh"
1379
+ TARBALL_SRC=https://github.com/$RELEASE_REPO/releases/download/v$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz
1380
+ TARBALL_SHA256=$RELEASE_SHA256
1381
+ # When GitHub can't be reached: the same file from the npm mirror, through two CDNs.
1382
+ TARBALL_MIRRORS=(
1383
+ "https://cdn.jsdelivr.net/npm/$RELEASE_NPM@$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz"
1384
+ "https://unpkg.com/$RELEASE_NPM@$RELEASE_VERSION/atrium-$RELEASE_VERSION.tar.gz"
1385
+ )
1386
+ fi
1387
+ }
1388
+
1389
+ # No questions: ATRIUM_YES=1, or `update`.
1390
+ run_quiet() {
1391
+ if (( TTY )); then print_logo; else say "Atrium $RELEASE_VERSION"; fi
1392
+ [[ -n ${DOMAIN:-} ]] || die "Нужен домен: DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh"
1393
+ DOMAIN=$(normalize_domain "$DOMAIN")
1394
+ valid_domain "$DOMAIN" || die "Не похоже на домен: $DOMAIN"
1395
+ valid_name "$SERVER_NAME" || die "Имя сервера: до 40 символов, без кавычек, \\, \$ и \`"
1396
+ [[ -n ${ATRIUM_SERVER_NAME_SET:-} ]] && NAME_SET=1
1397
+ progress run check
1398
+ progress sub "Определяем внешний IP"
1399
+ SERVER_IP=$(detect_ip)
1400
+ add_app_key
1401
+ run_checks
1402
+ print_checks
1403
+ if (( CHECK_FATAL )); then
1404
+ progress fail check "$(first_bad_check)"
1405
+ die "Сервер не готов."
1406
+ fi
1407
+ progress "done" check
1408
+ # From the desktop app the address is waited for later, right before the start (install_all).
1409
+ check_dns
1410
+ if (( ! DNS_OK )) && [[ ${ATRIUM_PROGRESS:-} != 1 ]]; then
1411
+ say
1412
+ warn "DNS ещё не готов:"
1413
+ print_dns
1414
+ fi
1415
+ install_all
1416
+ lock_app_key
1417
+ screen_done
1418
+ }
1419
+
1420
+ wizard() {
1421
+ WIZARD=1
1422
+ local step=welcome rc
1423
+ while :; do
1424
+ rc=0
1425
+ case $step in
1426
+ welcome)
1427
+ screen_welcome || rc=$?
1428
+ if (( rc == 2 )); then
1429
+ # Update: the wizard's answers are already in .env.
1430
+ header 1
1431
+ title "Проверка сервера"
1432
+ spin "Проверяем сервер" detect_ip_to_file || true
1433
+ SERVER_IP=$(cat "$ATRIUM_TMP/ip" 2>/dev/null || true)
1434
+ run_checks
1435
+ (( CHECK_FATAL )) && { print_checks; die "Сервер не готов."; }
1436
+ spin "Проверяем DNS" check_dns_to_file || true
1437
+ # shellcheck source=/dev/null
1438
+ . "$ATRIUM_TMP/dns"
1439
+ step=install
1440
+ else
1441
+ step=checks
1442
+ fi
1443
+ ;;
1444
+ checks)
1445
+ screen_checks || rc=$?
1446
+ case $rc in 0) step=domain ;; 3) SERVER_IP='' ;; *) step=welcome ;; esac
1447
+ ;;
1448
+ domain) if screen_domain; then step=summary; else step=checks; fi ;;
1449
+ summary) if screen_summary; then step=install; else step=domain; fi ;;
1450
+ install)
1451
+ header 4
1452
+ title "Установка"
1453
+ install_all
1454
+ lock_app_key
1455
+ screen_done
1456
+ return
1457
+ ;;
1458
+ esac
1459
+ done
1460
+ }
1461
+
1462
+ main() {
1463
+ case ${1:-} in
1464
+ '' | install) MODE=install ;;
1465
+ update) MODE=update ;;
1466
+ uninstall) MODE=uninstall ;;
1467
+ link) MODE="link" ;;
1468
+ restore) MODE=restore ;;
1469
+ -h | --help | help) usage; exit 0 ;;
1470
+ *) usage >&2; exit 2 ;;
1471
+ esac
1472
+ [[ -n ${SERVER_NAME:-} ]] && ATRIUM_SERVER_NAME_SET=1
1473
+ # One installer at a time: two would share the log and both run docker compose. The lock goes with this process
1474
+ # (fd 9). No /run to write to means no root, which the server check reports anyway.
1475
+ if command -v flock >/dev/null && { exec 9>>"$LOCK"; } 2>/dev/null && ! flock -n 9; then
1476
+ # The log is the other run's.
1477
+ DIED=1 LOG=/dev/null
1478
+ bad "Установщик уже запущен на этом сервере"
1479
+ exit 1
1480
+ fi
1481
+ if ! { : >>"$LOG"; } 2>/dev/null; then LOG=$ATRIUM_TMP/install.log; fi
1482
+ chmod 600 "$LOG" 2>/dev/null || true
1483
+ log "Atrium installer $RELEASE_VERSION, mode $MODE"
1484
+ if [[ $MODE == link ]]; then
1485
+ detect_install
1486
+ show_link
1487
+ return
1488
+ fi
1489
+ if [[ $MODE == restore ]]; then
1490
+ detect_install
1491
+ restore_all
1492
+ return
1493
+ fi
1494
+ if [[ $MODE == uninstall ]]; then
1495
+ detect_install
1496
+ uninstall_all
1497
+ return
1498
+ fi
1499
+ pick_release
1500
+ detect_install
1501
+ DNS_OK=0 DOCKER_OK=0 SSH_PLAN=none NAME_SET=0
1502
+
1503
+ if [[ $MODE == update ]]; then
1504
+ (( EXISTING )) || die "Atrium здесь ещё не стоит: запусти без update"
1505
+ DNS_OK=1
1506
+ run_quiet
1507
+ elif [[ ${ATRIUM_YES:-} == 1 ]]; then
1508
+ run_quiet
1509
+ elif (( ! TTY )); then
1510
+ die "Нет терминала. Без вопросов: DOMAIN=chat.example.com ATRIUM_YES=1 sh install.sh"
1511
+ else
1512
+ (( TTY )) && printf ' %sГотовим установщик…%s' "$C_DIM" "$C_RESET"
1513
+ get_gum || GUM=''
1514
+ printf '\r\e[2K'
1515
+ wizard
1516
+ fi
1517
+ }
1518
+
1519
+ main "$@"
1520
+ ATRIUM_INSTALLER
1521
+
1522
+ command -v bash >/dev/null 2>&1 || { echo "Atrium: нужен bash" >&2; exit 1; }
1523
+ # The terminal, if there is one: under `curl | sh` our stdin is the script, not the person.
1524
+ status=0
1525
+ if (: </dev/tty) 2>/dev/null; then
1526
+ bash "$ATRIUM_TMP/install.bash" "$@" </dev/tty || status=$?
1527
+ else
1528
+ bash "$ATRIUM_TMP/install.bash" "$@" </dev/null || status=$?
1529
+ fi
1530
+ exit "$status"