Repository navigation
Expand file tree
/
Copy pathdeploy.sh
More file actions
executable file
·433 lines (367 loc) · 12.6 KB
/
Copy pathdeploy.sh
File metadata and controls
executable file
·433 lines (367 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
#!/bin/bash
set -e
VERSION="1.0.0"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Utilities
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $1"; }
info() { echo -e "${CYAN}ℹ${NC} $1"; }
ok() { echo -e "${GREEN}✓${NC} $1"; }
warn() { echo -e "${YELLOW}⚠${NC} $1"; }
error() { echo -e "${RED}✗${NC} $1" >&2; exit 1; }
step() {
CURRENT_STEP=$((CURRENT_STEP + 1))
echo -ne "${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} $1..."
}
step_done() { echo -e " ${GREEN}✓${NC}"; }
# Header
header() {
echo ""
echo -e "${BOLD}⚡ stk deploy v${VERSION}${NC} - Production Install"
echo "────────────────────────────────────────"
echo ""
}
# Interactive setup
interactive_setup() {
header
read -r -p "? Domain name: " DOMAIN
[ -z "$DOMAIN" ] && error "Domain is required"
read -r -p "? Git repository [level09/stk]: " input
REPO="${input:-level09/stk}"
read -r -p "? Branch [master]: " input
BRANCH="${input:-master}"
read -r -p "? Database (sqlite/postgres) [sqlite]: " input
DB="${input:-sqlite}"
echo ""
echo "────────────────────────────────────────"
echo ""
}
# Validation
validate() {
[ "$EUID" -eq 0 ] || error "Must run as root (use sudo)"
[ -z "$DOMAIN" ] && error "DOMAIN is required"
[ "$DB" = "sqlite" ] || [ "$DB" = "postgres" ] || error "DB must be sqlite or postgres"
# Derive defaults
APP_USER="${APP_USER:-${DOMAIN%%.*}}"
# useradd rejects names starting with a digit (e.g. DOMAIN is an IP)
[[ "$APP_USER" =~ ^[a-z_] ]] || APP_USER="stk"
# admin@<IP> is invalid email syntax and the login form would reject it
if [[ "$DOMAIN" =~ ^[0-9.]+$ ]]; then
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
else
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@${DOMAIN}}"
fi
ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(openssl rand -base64 16)}"
PYTHON_PORT="${PYTHON_PORT:-5000}"
APP_DIR="/home/${APP_USER}/${DOMAIN}"
GIT_URL="https://github.com/${REPO}.git"
TOTAL_STEPS=14
if [ "$DB" = "postgres" ]; then TOTAL_STEPS=15; fi
}
# Install system packages
install_packages() {
step "Installing system packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null
apt-get install -y -qq git curl wget >/dev/null 2>&1
step_done
}
# Install Caddy
install_caddy() {
step "Installing Caddy"
if ! command -v caddy &>/dev/null; then
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https >/dev/null 2>&1
curl -sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null
curl -sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq >/dev/null && apt-get install -y -qq caddy >/dev/null 2>&1
fi
step_done
}
# Install PostgreSQL + Redis (full mode only)
install_postgres() {
if [ "$DB" = "postgres" ]; then
step "Installing PostgreSQL + Redis"
apt-get install -y -qq postgresql postgresql-contrib redis-server >/dev/null 2>&1
systemctl enable --now postgresql >/dev/null 2>&1
systemctl enable --now redis-server >/dev/null 2>&1
DB_PASSWORD=$(openssl rand -hex 16)
sudo -u postgres createuser "$APP_USER" 2>/dev/null || true
sudo -u postgres createdb "$APP_USER" -O "$APP_USER" 2>/dev/null || true
sudo -u postgres psql -qc "ALTER USER \"${APP_USER}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null
step_done
fi
}
# Create application user
create_user() {
step "Creating user '${APP_USER}'"
if ! id "$APP_USER" &>/dev/null; then
getent group "$APP_USER" >/dev/null || groupadd "$APP_USER"
useradd -m -s /bin/bash -g "$APP_USER" "$APP_USER"
fi
# Add caddy to app user's group so it can serve static files
usermod -aG "$APP_USER" caddy 2>/dev/null || true
step_done
}
# Setup SSH access for app user
setup_ssh() {
step "Setting up SSH access"
local user_ssh="/home/${APP_USER}/.ssh"
mkdir -p "$user_ssh"
# Copy root's authorized keys if they exist
if [ -f /root/.ssh/authorized_keys ]; then
cp /root/.ssh/authorized_keys "$user_ssh/"
fi
chown -R "${APP_USER}:${APP_USER}" "$user_ssh"
chmod 700 "$user_ssh"
chmod 600 "$user_ssh/authorized_keys" 2>/dev/null || true
# Sudoers: only allow managing the app service
echo "${APP_USER} ALL=(ALL) NOPASSWD: /bin/systemctl start ${DOMAIN}.service, /bin/systemctl stop ${DOMAIN}.service, /bin/systemctl restart ${DOMAIN}.service, /bin/systemctl status ${DOMAIN}.service" > "/etc/sudoers.d/${APP_USER}"
chmod 440 "/etc/sudoers.d/${APP_USER}"
# Allow app user to read logs without sudo
usermod -aG systemd-journal "$APP_USER" 2>/dev/null || true
step_done
}
# Clone repository
clone_repo() {
step "Cloning repository"
if [ -d "$APP_DIR" ]; then
rm -rf "$APP_DIR"
fi
sudo -u "$APP_USER" git clone -q --branch "$BRANCH" "$GIT_URL" "$APP_DIR"
step_done
}
# Install uv + Python 3.13 + dependencies (uv downloads its own CPython)
setup_python() {
step "Setting up Python 3.13 + uv"
if [ ! -f /usr/local/bin/uv ]; then
curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1
cp ~/.local/bin/uv /usr/local/bin/ 2>/dev/null || cp ~/.cargo/bin/uv /usr/local/bin/ 2>/dev/null || true
chmod 755 /usr/local/bin/uv
fi
local extras=""
[ "$DB" = "postgres" ] && extras="--extra full"
# shellcheck disable=SC2086
sudo -u "$APP_USER" bash -c "cd $APP_DIR && /usr/local/bin/uv sync --frozen --no-dev --python 3.13 $extras" >/dev/null 2>&1
step_done
}
# Generate .env file
generate_env() {
step "Generating .env file"
local cookie_secure="True" scheme="https" environment="production"
if [ "$SKIP_SSL" = "true" ] || [ "$DOMAIN" = "localhost" ]; then
cookie_secure="False"
scheme="http"
environment="development"
fi
cat > "${APP_DIR}/.env" << EOF
QUART_APP=run.py
QUART_DEBUG=0
STK_ENV=${environment}
STK_PUBLIC_URL=${scheme}://${DOMAIN}
SECURITY_WAN_RP_ID=${DOMAIN}
SECURITY_WAN_EXPECTED_ORIGIN=${scheme}://${DOMAIN}
SECURITY_REGISTERABLE=False
SECRET_KEY=$(openssl rand -hex 32)
SECURITY_PASSWORD_SALT=$(openssl rand -hex 32)
SESSION_COOKIE_SECURE=${cookie_secure}
EOF
if [ "$DB" = "postgres" ]; then
cat >> "${APP_DIR}/.env" << EOF
SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://${APP_USER}:${DB_PASSWORD}@127.0.0.1:5432/${APP_USER}
REDIS_URL=redis://localhost:6379/1
EOF
fi
chown "$APP_USER:$APP_USER" "${APP_DIR}/.env"
chmod 600 "${APP_DIR}/.env"
step_done
}
# Initialize database (Alembic migrations to head)
init_database() {
step "Initializing database"
sudo -u "$APP_USER" bash -c "cd $APP_DIR && /usr/local/bin/uv run --no-sync stk create-db" >/dev/null 2>&1
step_done
}
# Create admin user
create_admin() {
step "Creating admin user"
sudo -u "$APP_USER" bash -c "cd $APP_DIR && /usr/local/bin/uv run --no-sync stk install -e '${ADMIN_EMAIL}' -p '${ADMIN_PASSWORD}'" >/dev/null 2>&1
# Save credentials
cat > "/home/${APP_USER}/.credentials" << EOF
stk Deployment Credentials
─────────────────────────────
Domain: ${DOMAIN}
Admin Email: ${ADMIN_EMAIL}
Admin Password: ${ADMIN_PASSWORD}
─────────────────────────────
EOF
chown "$APP_USER:$APP_USER" "/home/${APP_USER}/.credentials"
chmod 600 "/home/${APP_USER}/.credentials"
step_done
}
# Create systemd service
create_systemd_service() {
step "Creating systemd service"
local after="network.target"
[ "$DB" = "postgres" ] && after="network.target postgresql.service redis-server.service"
cat > "/etc/systemd/system/${DOMAIN}.service" << EOF
[Unit]
Description=stk App - ${DOMAIN}
After=${after}
[Service]
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${APP_DIR}/.env
ExecStart=${APP_DIR}/.venv/bin/uvicorn run:app --host 127.0.0.1 --port ${PYTHON_PORT} --ws-max-size 65536 --ws-max-queue 16 --proxy-headers --forwarded-allow-ips=127.0.0.1
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
step_done
}
# Configure Caddy
configure_caddy() {
step "Configuring Caddy"
local site="${DOMAIN}"
local hsts='Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"'
if [ "$SKIP_SSL" = "true" ] || [ "$DOMAIN" = "localhost" ]; then
site=":80"
hsts=""
fi
cat > /etc/caddy/Caddyfile << EOF
${site} {
# Compression
encode zstd gzip
# Security headers
header {
${hsts}
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
# Static files with caching
handle_path /static/* {
root * ${APP_DIR}/stk/static
file_server
header Cache-Control "public, max-age=31536000, immutable"
}
# App
reverse_proxy 127.0.0.1:${PYTHON_PORT} {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Logging
log {
output file /var/log/caddy/${DOMAIN}.log {
roll_size 10mb
roll_keep 5
}
format json
}
}
EOF
# Create log directory
mkdir -p /var/log/caddy
chown caddy:caddy /var/log/caddy
step_done
}
# Harden SSH
harden_ssh() {
step "Hardening SSH"
# Key-only authentication
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#\?ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh >/dev/null 2>&1
# Brute-force protection
apt-get install -y -qq fail2ban >/dev/null 2>&1
systemctl enable --now fail2ban >/dev/null 2>&1
step_done
}
# Setup firewall
setup_firewall() {
step "Configuring firewall"
if command -v ufw &>/dev/null; then
ufw --force enable >/dev/null 2>&1
ufw allow 22/tcp >/dev/null 2>&1
ufw allow 80/tcp >/dev/null 2>&1
ufw allow 443/tcp >/dev/null 2>&1
fi
step_done
}
# Start all services
start_services() {
step "Starting services"
systemctl enable --now "${DOMAIN}.service" >/dev/null 2>&1
systemctl enable --now caddy >/dev/null 2>&1
systemctl restart caddy >/dev/null 2>&1
step_done
}
# Success message
success_message() {
local url
if [ "$SKIP_SSL" = "true" ] || [ "$DOMAIN" = "localhost" ]; then
url="http://${DOMAIN}"
else
url="https://${DOMAIN}"
fi
echo ""
echo "────────────────────────────────────────"
echo -e "${GREEN}${BOLD}🚀 Deployed!${NC} ${url}"
echo ""
echo -e " ${BOLD}SSH:${NC} ssh ${APP_USER}@${DOMAIN}"
echo -e " ${BOLD}Email:${NC} ${ADMIN_EMAIL}"
echo -e " ${BOLD}Password:${NC} (saved to /home/${APP_USER}/.credentials)"
echo "────────────────────────────────────────"
echo ""
}
# Main
main() {
CURRENT_STEP=0
REPO="${REPO:-level09/stk}"
BRANCH="${BRANCH:-master}"
DB="${DB:-sqlite}"
SKIP_SSL="${SKIP_SSL:-false}"
# Check if interactive mode (no DOMAIN set)
if [ -z "$DOMAIN" ]; then
# Can't do interactive if stdin is not a terminal (e.g., curl | bash)
if [ ! -t 0 ]; then
echo -e "${RED}Error:${NC} DOMAIN is required when running non-interactively"
echo ""
echo "Usage: wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh && sudo DOMAIN=example.com bash /tmp/deploy.sh"
exit 1
fi
interactive_setup
else
header
fi
validate
install_packages
install_caddy
install_postgres
create_user
setup_ssh
clone_repo
setup_python
generate_env
init_database
create_admin
create_systemd_service
configure_caddy
harden_ssh
setup_firewall
start_services
success_message
}
main "$@"