@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/LICENSE +661 -0
- package/README.md +18 -2
- package/atrium-0.7.0.tar.gz +0 -0
- package/install.sh +1510 -0
- package/package.json +8 -4
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//&/&} name=${name//</<} name=${name//>/>} name=${name//\"/"} name=${name//\'/'}
|
|
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"
|