-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.sh
More file actions
552 lines (469 loc) · 21.8 KB
/
Copy pathsetup.sh
File metadata and controls
552 lines (469 loc) · 21.8 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
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
#!/usr/bin/env bash
# =============================================================================
# MyNet — Production Setup Script
# Supports: Ubuntu 22.04+, Raspberry Pi OS Bookworm/Bullseye (headless)
# Architectures: x86_64, aarch64 (Pi 4/5, 64-bit Pi 3), armv7l (32-bit Pi 3)
#
# Usage (run from the repository root):
# sudo bash setup.sh
# =============================================================================
set -euo pipefail
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
step() { echo -e "\n${BOLD}━━━ $* ━━━${RESET}"; }
# ── Spinner ───────────────────────────────────────────────────────────────────
# Usage: spin_run "Descriptive message" command [args...]
# Runs command in background, shows animated spinner, dumps output on failure.
_SPIN_LOG=$(mktemp /tmp/mynet-XXXXXX.log)
trap 'rm -f "$_SPIN_LOG"' EXIT
spin_run() {
local msg="$1"; shift
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠣⠏'
local i=0
"$@" >"$_SPIN_LOG" 2>&1 &
local pid=$!
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${CYAN}${frames:$((i % 10)):1}${RESET} %s" "$msg" >&2
sleep 0.12
i=$(( i + 1 ))
done
printf "\r\033[K" >&2
if ! wait "$pid"; then
echo -e "${RED}[ERROR]${RESET} Failed: $*" >&2
echo "──── Output ────────────────────────────────────────────────" >&2
cat "$_SPIN_LOG" >&2
echo "────────────────────────────────────────────────────────────" >&2
exit 1
fi
}
# ── Paths ─────────────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/mynet"
DATA_DIR="$INSTALL_DIR/data"
VENV_DIR="$INSTALL_DIR/venv"
STATIC_DIR="$INSTALL_DIR/static"
ENV_FILE="$INSTALL_DIR/.env"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_SRC="$REPO_ROOT/site/backend"
FRONTEND_SRC="$REPO_ROOT/site/frontend"
# ── Root check ────────────────────────────────────────────────────────────────
step "Preflight checks"
if [ "$EUID" -ne 0 ]; then
die "This script must be run as root. Try: sudo bash setup.sh"
fi
# ── Repo root check ───────────────────────────────────────────────────────────
if [ ! -f "$BACKEND_SRC/main.py" ] || [ ! -f "$FRONTEND_SRC/package.json" ]; then
die "Run this script from the MyNet repository root (the folder containing setup.sh)"
fi
success "Repository root confirmed: $REPO_ROOT"
# ── OS detection ──────────────────────────────────────────────────────────────
if [ -f /etc/os-release ]; then
source /etc/os-release
OS_NAME="${NAME:-unknown}"
OS_ID="${ID:-unknown}"
OS_VER="${VERSION_ID:-unknown}"
else
die "Cannot detect OS — /etc/os-release not found"
fi
ARCH="$(uname -m)"
info "OS: $OS_NAME $OS_VER | Arch: $ARCH"
case "$OS_ID" in
ubuntu|debian|raspbian) ;;
*) warn "Untested OS '$OS_ID'. Continuing but results may vary." ;;
esac
case "$ARCH" in
x86_64|aarch64|armv7l) ;;
*) warn "Untested architecture '$ARCH'. Continuing." ;;
esac
if [ "$ARCH" = "armv7l" ]; then
warn "32-bit ARM detected (Pi 3 in 32-bit mode). Consider using a 64-bit OS"
warn "for better Node.js and Python performance."
fi
# ── RAM and swap ──────────────────────────────────────────────────────────────
step "Memory check"
TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_RAM_MB=$((TOTAL_RAM_KB / 1024))
info "Total RAM: ${TOTAL_RAM_MB}MB"
SWAP_TOTAL_KB=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
SWAP_TOTAL_MB=$((SWAP_TOTAL_KB / 1024))
if [ "$TOTAL_RAM_MB" -lt 1800 ] && [ "$SWAP_TOTAL_MB" -lt 1024 ]; then
warn "Low RAM (${TOTAL_RAM_MB}MB) with insufficient swap (${SWAP_TOTAL_MB}MB)."
warn "The frontend build requires ~800MB. Creating a 2GB swapfile..."
if [ ! -f /swapfile ]; then
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# Persist across reboots
if ! grep -q '/swapfile' /etc/fstab; then
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
success "2GB swapfile created and activated"
else
swapon /swapfile 2>/dev/null || true
success "Existing swapfile activated"
fi
# Reduce swappiness — we only want swap for build-time peaks, not normal operation
sysctl -w vm.swappiness=10 > /dev/null
echo 'vm.swappiness=10' > /etc/sysctl.d/99-mynet-swap.conf
elif [ "$SWAP_TOTAL_MB" -ge 1024 ]; then
success "Swap OK (${SWAP_TOTAL_MB}MB)"
else
success "RAM OK (${TOTAL_RAM_MB}MB)"
fi
# ── System packages ───────────────────────────────────────────────────────────
step "Installing system packages"
spin_run "Updating package lists..." apt-get update -qq
# Python: use already-installed python3 if it's 3.10+, then try versioned
# packages, then fall back to the distro default python3.
# Note: deadsnakes PPA is Ubuntu-only and is not used here.
PYTHON_BIN=""
# Check if python3 is already present and meets the minimum version
if command -v python3 &>/dev/null; then
_maj=$(python3 -c 'import sys; print(sys.version_info.major)' 2>/dev/null || echo 0)
_min=$(python3 -c 'import sys; print(sys.version_info.minor)' 2>/dev/null || echo 0)
if [ "$_maj" -ge 3 ] && [ "$_min" -ge 10 ]; then
PYTHON_BIN="python3"
info "Using already-installed $(python3 --version)"
fi
fi
# Try versioned packages from the distro repos
if [ -z "$PYTHON_BIN" ]; then
for ver in 3.12 3.11 3.10; do
if apt-cache show "python$ver" &>/dev/null 2>&1; then
PYTHON_BIN="python$ver"
break
fi
done
fi
# Last resort: install distro python3 (Debian 12+ ships 3.11+, Debian 13 ships 3.12)
if [ -z "$PYTHON_BIN" ]; then
info "No versioned Python 3.10+ package found — installing distro python3..."
apt-get install -y -qq python3 python3-venv python3-dev
PYTHON_BIN="python3"
fi
info "Using $PYTHON_BIN"
# Build package list — python3 uses generic names; versioned bins use suffixed names
if [ "$PYTHON_BIN" = "python3" ]; then
PKGS=(
"python3"
"python3-venv"
"python3-dev"
"libffi-dev" # required by cryptography package
"fonts-dejavu-core" # required by Pillow for QR label generation
"nginx"
"curl"
"git"
"ca-certificates"
"jq" # required by the USB Storage helper (scripts/mynet-storage)
"util-linux" # lsblk, blkid, findmnt, systemd-escape — helper dependencies
"e2fsprogs" # mkfs.ext4 — used by the helper's "init" subcommand
)
else
PKGS=(
"$PYTHON_BIN"
"${PYTHON_BIN}-venv"
"${PYTHON_BIN}-dev"
"libffi-dev"
"fonts-dejavu-core"
"nginx"
"curl"
"git"
"ca-certificates"
"jq"
"util-linux"
"e2fsprogs"
)
fi
spin_run "Installing system packages..." apt-get install -y -qq "${PKGS[@]}"
success "System packages installed"
# ── Node.js 20 ────────────────────────────────────────────────────────────────
step "Installing Node.js 20"
if command -v node &>/dev/null && node --version | grep -q '^v2[0-9]' && command -v npm &>/dev/null; then
success "Node.js $(node --version) already installed"
else
info "Adding NodeSource repository..."
spin_run "Configuring NodeSource repository..." bash -c 'curl -fsSL https://deb.nodesource.com/setup_20.x | bash -'
spin_run "Installing Node.js..." apt-get install -y -qq nodejs
# Ubuntu's nodejs package doesn't always bundle npm — install it explicitly
if ! command -v npm &>/dev/null; then
spin_run "Installing npm..." apt-get install -y -qq npm
fi
# Refresh shell's command cache so npm/node are found immediately
hash -r 2>/dev/null || true
success "Node.js $(node --version) installed"
fi
# ── Directory structure ───────────────────────────────────────────────────────
step "Creating directory structure"
# Run the service as the user who invoked sudo (the repo owner)
SERVICE_USER="${SUDO_USER:-$(whoami)}"
SERVICE_GROUP="$(id -gn "$SERVICE_USER")"
info "Service will run as $SERVICE_USER"
mkdir -p "$INSTALL_DIR" "$DATA_DIR" "$STATIC_DIR"
success "Directories created under $INSTALL_DIR"
# ── Frontend build ────────────────────────────────────────────────────────────
step "Building frontend"
cd "$FRONTEND_SRC"
# Limit Node.js heap — important on Pi 3B+ (1GB RAM)
export NODE_OPTIONS="--max-old-space-size=700"
spin_run "Installing npm dependencies (this may take a while on Pi)..." npm ci --prefer-offline
spin_run "Building frontend (this may take a while on Pi)..." npm run build
success "Frontend built successfully"
# Copy built assets to install location
rm -rf "$STATIC_DIR"
cp -r "$FRONTEND_SRC/dist" "$STATIC_DIR"
success "Static files copied to $STATIC_DIR"
unset NODE_OPTIONS
cd "$REPO_ROOT"
# ── Python virtual environment ────────────────────────────────────────────────
step "Setting up Python environment"
if [ ! -d "$VENV_DIR" ]; then
"$PYTHON_BIN" -m venv "$VENV_DIR"
success "Virtual environment created at $VENV_DIR"
else
success "Virtual environment already exists — updating"
fi
spin_run "Upgrading pip..." "$VENV_DIR/bin/pip" install --quiet --upgrade pip
spin_run "Installing Python dependencies..." "$VENV_DIR/bin/pip" install --quiet -r "$BACKEND_SRC/requirements.txt"
success "Python dependencies installed"
# ── Environment configuration ─────────────────────────────────────────────────
step "Configuring environment"
if [ -f "$ENV_FILE" ]; then
warn ".env already exists at $ENV_FILE — skipping generation"
warn "Edit it manually if you need to change settings, then restart: systemctl restart mynet"
else
JWT_SECRET=$(openssl rand -hex 32)
cat > "$ENV_FILE" << EOF
# MyNet production environment
# Generated by setup.sh on $(date)
# REQUIRED — do not share this key
JWT_SECRET_KEY=$JWT_SECRET
# SQLite database location
DB_PATH=$DATA_DIR/mynet.db
# Port nginx listens on (default 80)
APP_PORT=80
EOF
chmod 600 "$ENV_FILE"
success ".env written to $ENV_FILE"
fi
# Read port from .env for nginx config
APP_PORT=$(grep '^APP_PORT=' "$ENV_FILE" | cut -d= -f2 | tr -d '[:space:]')
APP_PORT="${APP_PORT:-80}"
APP_URL="http://$(hostname -I | awk '{print $1}' | tr -d '[:space:]'):${APP_PORT}"
# ── nginx configuration ───────────────────────────────────────────────────────
step "Configuring nginx"
NGINX_CONF="/etc/nginx/sites-available/mynet"
cat > "$NGINX_CONF" << NGINX
upstream mynet_backend {
server 127.0.0.1:8000;
keepalive 8;
}
server {
listen ${APP_PORT};
server_name _;
root $STATIC_DIR;
index index.html;
# Limit request body size — backup files are JSON, 50MB is generous
client_max_body_size 50M;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; frame-ancestors 'self';" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript
application/json application/xml image/svg+xml;
# USB Storage endpoints — mkfs, migration, and snapshot restore can
# legitimately take minutes on a Pi. Must come BEFORE the generic /api/
# block so nginx matches the more-specific prefix first.
location /api/storage/ {
proxy_pass http://mynet_backend;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# API proxy → FastAPI backend
location /api/ {
proxy_pass http://mynet_backend;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 30s;
}
# WebSocket proxy → monitoring real-time updates
location /ws {
proxy_pass http://mynet_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Long-lived cache for hashed asset bundles
location ~* \.(js|css|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Short cache for images
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
}
# SPA fallback — all unmatched routes serve index.html
location / {
try_files \$uri \$uri/ /index.html;
}
}
NGINX
# Enable the site
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/mynet
# Remove default nginx site from both possible locations
rm -f /etc/nginx/sites-enabled/default
rm -f /etc/nginx/conf.d/default.conf
nginx -t
systemctl enable nginx
systemctl restart nginx
success "nginx configured on port $APP_PORT"
# ── systemd service ───────────────────────────────────────────────────────────
step "Creating systemd service"
# Source .env to pass variables to the service
DB_PATH=$(grep '^DB_PATH=' "$ENV_FILE" | cut -d= -f2 | tr -d '[:space:]')
DB_PATH="${DB_PATH:-$DATA_DIR/mynet.db}"
cat > /etc/systemd/system/mynet.service << SERVICE
[Unit]
Description=MyNet — home network manager (FastAPI/uvicorn)
Documentation=file://$REPO_ROOT
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_GROUP
WorkingDirectory=$BACKEND_SRC
EnvironmentFile=$ENV_FILE
ExecStart=$VENV_DIR/bin/uvicorn main:app \\
--host 127.0.0.1 \\
--port 8000 \\
--workers 1 \\
--log-level info
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mynet
# Grant ICMP capability for device monitoring (ping) — no root required
AmbientCapabilities=CAP_NET_RAW
# NOTE: CapabilityBoundingSet is intentionally NOT set. The USB Storage feature
# needs the backend to sudo into /usr/local/bin/mynet-storage via a narrow
# sudoers drop-in, and sudo's setuid-root escalation needs CAP_SETUID /
# CAP_SETGID / CAP_AUDIT_WRITE — all bounded away if we cap to CAP_NET_RAW.
# AmbientCapabilities above still limits what the Python service itself starts
# with; sudoers still gates which command can be run with elevated rights.
# Harden the service.
# NOTE: NoNewPrivileges=yes is intentionally NOT set here. The USB Storage
# feature (docs/features/storage.md) requires the backend to sudo into the
# helper via a narrow sudoers drop-in. Setting this flag would block that.
# ProtectSystem=yes (not "full") — the helper writes systemd unit files under
# /etc/systemd/system when activating USB mode; `full` makes /etc read-only
# even for privileged children via the inherited mount namespace.
PrivateTmp=yes
ProtectSystem=yes
[Install]
WantedBy=multi-user.target
SERVICE
# Ownership
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
chmod 750 "$DATA_DIR"
# ── USB Storage feature — install helper + sudoers + snapshots dir ───────────
# See USB_STORAGE_DESIGN.md § 15.1. Feature is opt-in (default SD mode), but
# the supporting files are installed on every fresh install so the Settings
# page can activate USB mode without re-running setup.
step "Installing USB Storage helper"
mkdir -p "$DATA_DIR/snapshots"
chown "$SERVICE_USER:$SERVICE_GROUP" "$DATA_DIR/snapshots"
chmod 750 "$DATA_DIR/snapshots"
success "Created $DATA_DIR/snapshots"
if [ -f "$REPO_ROOT/scripts/mynet-storage" ]; then
install -m 755 -o root -g root "$REPO_ROOT/scripts/mynet-storage" /usr/local/bin/mynet-storage
success "Installed /usr/local/bin/mynet-storage"
else
warn "scripts/mynet-storage not found in repo — USB Storage feature will be disabled"
fi
# Sudoers drop-in — validated with visudo before committing, so a bad edit
# never leaves the system in a state where sudo refuses all drop-ins.
SUDOERS_TMP="$(mktemp)"
cat > "$SUDOERS_TMP" <<SUDOERS
# Installed by MyNet setup.sh for the USB Storage feature.
# Grants the mynet service user permission to run the storage helper only.
# See USB_STORAGE_DESIGN.md § 3.2.
$SERVICE_USER ALL=(root) NOPASSWD: /usr/local/bin/mynet-storage
SUDOERS
chmod 0440 "$SUDOERS_TMP"
if visudo -c -f "$SUDOERS_TMP" >/dev/null; then
install -m 0440 -o root -g root "$SUDOERS_TMP" /etc/sudoers.d/mynet-storage
success "Installed /etc/sudoers.d/mynet-storage"
else
warn "Sudoers validation failed — USB Storage feature will be disabled"
fi
rm -f "$SUDOERS_TMP"
systemctl daemon-reload
systemctl enable mynet
systemctl restart mynet
success "mynet service enabled and started"
# ── Firewall ──────────────────────────────────────────────────────────────────
step "Firewall (UFW)"
if command -v ufw &>/dev/null; then
ufw allow ssh > /dev/null
ufw allow "$APP_PORT"/tcp > /dev/null
# Close direct backend port — only nginx should be reachable
ufw deny 8000/tcp > /dev/null 2>&1 || true
if ! ufw status | grep -q "Status: active"; then
ufw --force enable > /dev/null
fi
success "UFW: SSH + port $APP_PORT allowed, port 8000 blocked"
else
warn "UFW not installed — skipping firewall setup"
fi
# ── Health check ──────────────────────────────────────────────────────────────
step "Verifying installation"
info "Waiting for backend to start..."
for i in $(seq 1 20); do
if curl -sf http://127.0.0.1:8000/api/auth/setup-required > /dev/null 2>&1; then
success "Backend responding on port 8000"
break
fi
if [ "$i" -eq 20 ]; then
warn "Backend did not respond after 20 seconds."
warn "Check logs with: journalctl -u mynet -n 50"
fi
sleep 1
done
if curl -sf "http://127.0.0.1:${APP_PORT}/" > /dev/null 2>&1; then
success "nginx responding on port $APP_PORT"
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}════════════════════════════════════════${RESET}"
echo -e "${GREEN}${BOLD} MyNet is installed and running!${RESET}"
echo -e "${GREEN}${BOLD}════════════════════════════════════════${RESET}"
echo ""
echo -e " ${BOLD}App:${RESET} ${APP_URL}"
echo ""
echo -e " ${BOLD}Logs:${RESET} journalctl -u mynet -f"
echo -e " ${BOLD}Restart:${RESET} systemctl restart mynet"
echo -e " ${BOLD}Config:${RESET} $ENV_FILE"
echo ""
echo -e " Open ${BOLD}${APP_URL}${RESET} in your browser to complete setup."
echo ""