diff --git a/.github/workflows/release-dashboard.yml b/.github/workflows/release-dashboard.yml
index 3b42eee..ff1628a 100644
--- a/.github/workflows/release-dashboard.yml
+++ b/.github/workflows/release-dashboard.yml
@@ -253,6 +253,24 @@ jobs:
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
+ - name: Patch server.js for portable __dirname
+ working-directory: lynx/dashboard/ui
+ run: |
+ # bun --compile embeds the compile-time __dirname (CI path) into the binary.
+ # At runtime on the deployment server that path does not exist, causing chdir to fail.
+ # Fix: shadow __dirname with a version that falls back to the binary's own directory
+ # when the compile-time path is absent from disk.
+ node -e "
+ const fs = require('fs');
+ let content = fs.readFileSync('.next/standalone/server.js', 'utf8');
+ content = content.replace(
+ \"const path = require('path')\",
+ \"const path = require('path')\\nconst __dirname = require('fs').existsSync(__dirname) ? __dirname : path.dirname(process.execPath)\"
+ );
+ fs.writeFileSync('.next/standalone/server.js', content);
+ process.stdout.write('server.js patched: __dirname resolves to binary directory on deployment\\n');
+ "
+
- name: Compile standalone binary
working-directory: lynx/dashboard/ui
run: |
diff --git a/lynx/dashboard/docker-compose.yml b/lynx/dashboard/docker-compose.yml
index a0cc365..5650015 100644
--- a/lynx/dashboard/docker-compose.yml
+++ b/lynx/dashboard/docker-compose.yml
@@ -1,11 +1,31 @@
services:
+ nginx:
+ container_name: lynx-dashboard-nginx
+ image: docker.io/library/nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
+ ports:
+ - "19443:19443"
+ volumes:
+ - /etc/lynx/tls:/etc/lynx/tls:ro
+ - /etc/lynx/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
+ - /etc/lynx/nginx/updating.html:/etc/lynx/nginx/updating.html:ro
+ depends_on:
+ frontend:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "pgrep nginx > /dev/null"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+ restart: unless-stopped
+ networks:
+ - lynx-dashboard-app
+
frontend:
container_name: lynx-dashboard-frontend
image: docker.io/library/alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
working_dir: /etc/lynx/frontend
- command: ["/etc/lynx/frontend/lynx-dashboard-frontend"]
- ports:
- - "19443:3000"
+ command: ["/bin/sh", "-c", "apk add --no-cache libgcc libstdc++ && exec /etc/lynx/frontend/lynx-dashboard-frontend"]
environment:
- NODE_ENV=production
- PORT=3000
@@ -90,7 +110,7 @@ services:
- lynx-dashboard-pg-root
- lynx-dashboard-pg-pass
volumes:
- - postgres_data:/var/lib/postgresql/data
+ - postgres_data:/var/lib/postgresql
- ./server/db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d lynx_dashboard"]
diff --git a/lynx/dashboard/nginx/default.conf b/lynx/dashboard/nginx/default.conf
new file mode 100644
index 0000000..d86461c
--- /dev/null
+++ b/lynx/dashboard/nginx/default.conf
@@ -0,0 +1,30 @@
+server {
+ listen 19443 ssl;
+
+ ssl_certificate /etc/lynx/tls/dashboard.crt;
+ ssl_certificate_key /etc/lynx/tls/dashboard.key;
+ ssl_protocols TLSv1.3;
+ ssl_prefer_server_ciphers off;
+
+ add_header X-Frame-Options DENY always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy no-referrer always;
+
+ location / {
+ proxy_pass http://lynx-dashboard-frontend:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ 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 https;
+ proxy_read_timeout 3600s;
+ }
+
+ error_page 502 503 /updating.html;
+ location = /updating.html {
+ root /etc/lynx/nginx;
+ internal;
+ }
+}
diff --git a/lynx/dashboard/nginx/updating.html b/lynx/dashboard/nginx/updating.html
new file mode 100644
index 0000000..cd12551
--- /dev/null
+++ b/lynx/dashboard/nginx/updating.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ Lynx — Updating
+
+
+
+
+
+
Updating Lynx Dashboard
+
The system is applying an update. This page will reload automatically.
+
+
+
diff --git a/lynx/dashboard/server/db/init/01-init.sql b/lynx/dashboard/server/db/init/01-init.sql
index 64db0e7..ebe88ec 100644
--- a/lynx/dashboard/server/db/init/01-init.sql
+++ b/lynx/dashboard/server/db/init/01-init.sql
@@ -10,7 +10,7 @@ GRANT CONNECT ON DATABASE lynx_dashboard TO lynx_dashboard_app;
\connect lynx_dashboard
-GRANT USAGE ON SCHEMA public TO lynx_dashboard_app;
+GRANT USAGE, CREATE ON SCHEMA public TO lynx_dashboard_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO lynx_dashboard_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
diff --git a/lynx/dashboard/server/src/scheduler.rs b/lynx/dashboard/server/src/scheduler.rs
index 9ef133d..f5b2b51 100644
--- a/lynx/dashboard/server/src/scheduler.rs
+++ b/lynx/dashboard/server/src/scheduler.rs
@@ -85,7 +85,9 @@ async fn check_releases(state: &AppState) {
}
if let Some(ref ver) = latest_dashboard {
- let current = env!("CARGO_PKG_VERSION");
+ let current = std::fs::read_to_string(crate::update::VERSION_FILE)
+ .map(|s| s.trim().to_string())
+ .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string());
if ver.as_str() != current {
tracing::info!(latest = %ver, current, "scheduler: dashboard update available");
trigger_dashboard_update(state, ver).await;
diff --git a/lynx/dashboard/server/src/update.rs b/lynx/dashboard/server/src/update.rs
index ec4911d..27f80f3 100644
--- a/lynx/dashboard/server/src/update.rs
+++ b/lynx/dashboard/server/src/update.rs
@@ -9,6 +9,7 @@ const FRONTEND_BINARY: &str = "/etc/lynx/frontend/lynx-dashboard-frontend";
const FRONTEND_DIR: &str = "/etc/lynx/frontend";
const FRONTEND_CONTAINER: &str = "lynx-dashboard-frontend";
const PODMAN_SOCKET: &str = "/run/podman/podman.sock";
+pub const VERSION_FILE: &str = "/etc/lynx/bin/dashboard-version";
const MAX_DOWNLOAD_BYTES: usize = 200 * 1024 * 1024;
pub struct DashboardUpdateParams {
@@ -52,6 +53,9 @@ pub async fn perform_dashboard_update(p: DashboardUpdateParams) {
.await;
if result.is_ok() {
+ if let Err(e) = write_version_file(&p.version) {
+ tracing::warn!("could not write version file: {e}");
+ }
tracing::info!(
version = p.version,
"dashboard update complete — exiting for Podman restart"
@@ -166,6 +170,12 @@ fn swap_backend_binary(binary: &[u8]) -> Result<()> {
Ok(())
}
+/// Write `version` to the on-disk version file so the scheduler can read it
+/// on the next startup and avoid re-triggering the same update.
+pub(crate) fn write_version_file(version: &str) -> Result<()> {
+ std::fs::write(VERSION_FILE, version).context("write version file")
+}
+
fn extract_assets(data: &[u8], dest: &str) -> Result<()> {
use std::io::Write;
use std::process::{Command, Stdio};
diff --git a/scripts/dashboard/install-dashboard.sh b/scripts/dashboard/install-dashboard.sh
index 4eef81b..5a781d3 100644
--- a/scripts/dashboard/install-dashboard.sh
+++ b/scripts/dashboard/install-dashboard.sh
@@ -2,30 +2,394 @@
# =============================================================================
# install-dashboard.sh
# =============================================================================
-# Description: Installs the Lynx Dashboard on this VPS.
-# Handles fresh installation and version updates.
-# Installs to /opt/lynx/dashboard.
+# Description: Installs the Lynx Dashboard (backend + frontend + local agent)
+# on this VPS. Downloads binaries from the latest dashboard@*
+# GitHub release and verifies Ed25519 signatures before installing.
#
# Dependencies:
-# - detect-os.sh must be sourced first (provides PKG_MANAGER, PKG_INSTALL, etc.)
-# - install-podman.sh must run first (Podman required)
-# - install-nftables.sh must run first (nftables required)
+# - detect-os.sh must be sourced first
+# - install-podman.sh must run first
+# - install-nftables.sh must run first
# - Colors must be exported from install.sh
# =============================================================================
set -euo pipefail
-DASHBOARD_DIR="/opt/lynx/dashboard"
+# --- Constants ----------------------------------------------------------------
+
+readonly RELEASE_VERIFY_KEY="OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q="
+readonly GITHUB_REPO="Jaro-c/Lynx"
+readonly BIN_DIR="/etc/lynx/bin"
+readonly FRONTEND_DIR="/etc/lynx/frontend"
+readonly SECRETS_DIR="/etc/lynx/secrets"
+readonly TLS_DIR="/etc/lynx/tls"
+readonly NGINX_DIR="/etc/lynx/nginx"
+readonly DEPLOY_DIR="/opt/lynx/dashboard"
+
+# --- Helpers ------------------------------------------------------------------
+
+_compose() {
+ if podman compose version &>/dev/null 2>&1; then
+ podman compose "$@"
+ elif command -v podman-compose &>/dev/null; then
+ podman-compose "$@"
+ else
+ echo -e "${RED}Error: podman compose not available. Install podman-compose.${RESET}" >&2
+ exit 1
+ fi
+}
+
+_wait_healthy() {
+ local container="$1"
+ local max_secs="${2:-90}"
+ local elapsed=0
+ echo -e "${CYAN}Waiting for ${container} to be healthy...${RESET}"
+ while [[ $elapsed -lt $max_secs ]]; do
+ local status
+ status=$(podman inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || true)
+ if [[ "$status" == "healthy" ]]; then
+ echo -e "${GREEN}${container} is healthy.${RESET}"
+ return 0
+ fi
+ sleep 3
+ elapsed=$((elapsed + 3))
+ done
+ echo -e "${RED}Timeout waiting for ${container} (last status: ${status:-unknown})${RESET}" >&2
+ exit 1
+}
+
+_create_secret() {
+ local name="$1"
+ local value="$2"
+ podman secret rm "$name" &>/dev/null || true
+ printf '%s' "$value" | podman secret create "$name" -
+}
+
+_ensure_python_crypto() {
+ if python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey" 2>/dev/null; then
+ return 0
+ fi
+ echo -e "${CYAN}Installing Python cryptography...${RESET}"
+ case "$PKG_MANAGER" in
+ apt-get) apt-get install -y python3-cryptography ;;
+ dnf) dnf install -y python3-cryptography ;;
+ pacman) pacman -S --noconfirm python-cryptography ;;
+ esac
+}
+
+_ensure_podman_compose() {
+ if podman compose version &>/dev/null 2>&1 || command -v podman-compose &>/dev/null; then
+ return 0
+ fi
+ echo -e "${CYAN}Installing podman-compose...${RESET}"
+ case "$PKG_MANAGER" in
+ apt-get) apt-get install -y podman-compose ;;
+ dnf) dnf install -y podman-compose ;;
+ pacman) pacman -S --noconfirm python-podman-compose ;;
+ esac
+}
+
+_ensure_uuid_gen() {
+ command -v uuidgen &>/dev/null && return 0
+ case "$PKG_MANAGER" in
+ apt-get) apt-get install -y uuid-runtime ;;
+ dnf) dnf install -y util-linux ;;
+ pacman) pacman -S --noconfirm util-linux ;;
+ esac
+}
+
+# --- TLS certificate ----------------------------------------------------------
+
+_gen_self_signed_cert() {
+ local ip="$1"
+ mkdir -p "$TLS_DIR"
+ chmod 700 "$TLS_DIR"
+
+ openssl req -x509 -nodes \
+ -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
+ -days 90 \
+ -subj "/CN=${ip}" \
+ -addext "subjectAltName=IP:${ip},IP:127.0.0.1,DNS:localhost" \
+ -keyout "${TLS_DIR}/dashboard.key" \
+ -out "${TLS_DIR}/dashboard.crt" \
+ 2>/dev/null
+
+ chmod 600 "${TLS_DIR}/dashboard.key"
+ chmod 644 "${TLS_DIR}/dashboard.crt"
+ echo -e "${GREEN}Self-signed TLS certificate generated (90 days, SAN IP:${ip}).${RESET}"
+}
+
+# --- Signature verification ---------------------------------------------------
+
+_verify_sig() {
+ local file="$1"
+ local sig_file="$2"
+ python3 - "$file" "$sig_file" "$RELEASE_VERIFY_KEY" <<'PYEOF'
+import base64, sys
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from cryptography.exceptions import InvalidSignature
+
+VERIFY_KEY_B64 = sys.argv[3]
+pub_bytes = base64.b64decode(VERIFY_KEY_B64)
+pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
+
+with open(sys.argv[1], 'rb') as f:
+ data = f.read()
+with open(sys.argv[2], 'rb') as f:
+ sig = f.read()
+
+try:
+ pub_key.verify(sig, data)
+except InvalidSignature:
+ print(f"FAIL: invalid signature for {sys.argv[1]}", file=sys.stderr)
+ sys.exit(1)
+PYEOF
+}
+
+# --- Download + verify --------------------------------------------------------
+
+_download_verify() {
+ local base_url="$1"
+ local artifact="$2"
+ local dest_dir="$3"
+
+ echo -e "${CYAN} Downloading ${artifact}...${RESET}"
+ curl -fsSL --max-time 300 \
+ -o "${dest_dir}/${artifact}" \
+ "${base_url}/${artifact}"
+ curl -fsSL --max-time 30 \
+ -o "${dest_dir}/${artifact}.sig" \
+ "${base_url}/${artifact}.sig"
+
+ echo -e "${CYAN} Verifying ${artifact}...${RESET}"
+ _verify_sig "${dest_dir}/${artifact}" "${dest_dir}/${artifact}.sig"
+ echo -e "${GREEN} ✔ ${artifact}${RESET}"
+}
+
+# --- Secret generation --------------------------------------------------------
+
+_gen_secrets() {
+ local pg_root pg_pass redis_pass api_token setup_token pepper kek
+
+ pg_root=$(openssl rand -hex 32)
+ pg_pass=$(openssl rand -hex 32)
+ redis_pass=$(openssl rand -hex 32)
+ api_token=$(openssl rand -hex 32)
+ setup_token=$(openssl rand -hex 32)
+ pepper=$(openssl rand -base64 32 | tr -d '\n')
+ kek=$(openssl rand -base64 32 | tr -d '\n')
+
+ # Ed25519 + X25519 key pairs via Python
+ local jwt_sign_priv jwt_sign_pub jwt_enc_priv jwt_enc_pub ca_priv ca_pub
+ read -r jwt_sign_priv jwt_sign_pub < <(python3 - <<'PYEOF'
+import base64, secrets
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+seed = secrets.token_bytes(32)
+priv = Ed25519PrivateKey.from_private_bytes(seed)
+pub_bytes = priv.public_key().public_bytes_raw()
+print(base64.b64encode(seed).decode(), base64.b64encode(pub_bytes).decode())
+PYEOF
+)
+
+ read -r jwt_enc_priv jwt_enc_pub < <(python3 - <<'PYEOF'
+import base64
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+priv = X25519PrivateKey.generate()
+priv_bytes = priv.private_bytes_raw()
+pub_bytes = priv.public_key().public_bytes_raw()
+print(base64.b64encode(priv_bytes).decode(), base64.b64encode(pub_bytes).decode())
+PYEOF
+)
+
+ read -r ca_priv ca_pub < <(python3 - <<'PYEOF'
+import base64, secrets
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+seed = secrets.token_bytes(32)
+priv = Ed25519PrivateKey.from_private_bytes(seed)
+pub_bytes = priv.public_key().public_bytes_raw()
+print(base64.b64encode(seed).decode(), base64.b64encode(pub_bytes).decode())
+PYEOF
+)
+
+ local database_url="postgresql://lynx_dashboard_app:${pg_pass}@lynx-dashboard-postgres:5432/lynx_dashboard"
+ local redis_url="redis://:${redis_pass}@lynx-dashboard-redis:6379"
+
+ # Persist secrets to Podman
+ _create_secret lynx-dashboard-pg-root "$pg_root"
+ _create_secret lynx-dashboard-pg-pass "$pg_pass"
+ _create_secret lynx-dashboard-redis-pass "$redis_pass"
+ _create_secret lynx-dashboard-api-token "$api_token"
+ _create_secret lynx-dashboard-setup-token "$setup_token"
+ _create_secret lynx-dashboard-pepper "$pepper"
+ _create_secret lynx-dashboard-kek "$kek"
+ _create_secret lynx-dashboard-jwt-sign-private "$jwt_sign_priv"
+ _create_secret lynx-dashboard-jwt-sign-public "$jwt_sign_pub"
+ _create_secret lynx-dashboard-jwt-enc-private "$jwt_enc_priv"
+ _create_secret lynx-dashboard-jwt-enc-public "$jwt_enc_pub"
+ _create_secret lynx-dashboard-ca-private "$ca_priv"
+ _create_secret lynx-dashboard-ca-public "$ca_pub"
+ _create_secret lynx-dashboard-database-url "$database_url"
+ _create_secret lynx-dashboard-redis-url "$redis_url"
+
+ # Save critical secrets to /etc/lynx/secrets (owner root, mode 600)
+ # These MUST be backed up — without them, data is irrecoverable.
+ mkdir -p "$SECRETS_DIR"
+ chmod 700 "$SECRETS_DIR"
+ printf '%s' "$kek" > "${SECRETS_DIR}/lynx-dashboard-kek"
+ printf '%s' "$pg_root" > "${SECRETS_DIR}/lynx-dashboard-pg-root"
+ printf '%s' "$pg_pass" > "${SECRETS_DIR}/lynx-dashboard-pg-pass"
+ printf '%s' "$ca_priv" > "${SECRETS_DIR}/lynx-dashboard-ca-private"
+ printf '%s' "$ca_pub" > "${SECRETS_DIR}/lynx-dashboard-ca-public"
+ printf '%s' "$jwt_sign_priv" > "${SECRETS_DIR}/lynx-dashboard-jwt-sign-private"
+ printf '%s' "$jwt_sign_pub" > "${SECRETS_DIR}/lynx-dashboard-jwt-sign-public"
+ printf '%s' "$setup_token" > "${SECRETS_DIR}/lynx-dashboard-setup-token"
+ chmod 600 "${SECRETS_DIR}"/*
+
+ # Return setup token for display
+ SETUP_TOKEN="$setup_token"
+}
+
+# --- Main install function ----------------------------------------------------
install_dashboard() {
echo -e "${CYAN}Installing Lynx Dashboard...${RESET}"
- if [[ -d "$DASHBOARD_DIR" ]]; then
- echo -e "${YELLOW}Existing installation detected at ${BOLD}${DASHBOARD_DIR}${RESET}"
- echo -e "${CYAN}Checking version...${RESET}"
- # TODO: version check and update logic
- else
- echo -e "${CYAN}No existing installation found. Proceeding with fresh install...${RESET}"
- mkdir -p "$DASHBOARD_DIR"
- # TODO: fresh install logic
+ # Detect arch
+ case "$(uname -m)" in
+ x86_64) ARCH="x86_64" ;;
+ aarch64) ARCH="arm64" ;;
+ *)
+ echo -e "${RED}Unsupported architecture: $(uname -m)${RESET}" >&2
+ exit 1
+ ;;
+ esac
+
+ # Ensure dependencies
+ _ensure_python_crypto
+ _ensure_podman_compose
+ _ensure_uuid_gen
+
+ # Check existing installation
+ if [[ -d "$DEPLOY_DIR" ]] || podman container exists lynx-dashboard-backend 2>/dev/null; then
+ echo -e "${YELLOW}Existing installation detected.${RESET}"
+ echo -e " ${BOLD}1)${RESET} Abort (default)"
+ echo -e " ${BOLD}2)${RESET} Update → use auto-update instead"
+ echo -e " ${BOLD}3)${RESET} Reinstall clean"
+ read -rp "Option [1/2/3]: " OPT
+ case "${OPT:-1}" in
+ 3)
+ echo -e "${YELLOW}Stopping existing containers...${RESET}"
+ (cd "$DEPLOY_DIR" && _compose -f compose.yml down --volumes 2>/dev/null) || true
+ rm -rf "$DEPLOY_DIR"
+ ;;
+ *)
+ echo -e "${CYAN}Aborting. Use the dashboard auto-update for upgrades.${RESET}"
+ exit 0
+ ;;
+ esac
fi
+
+ # Fetch latest release tag
+ echo -e "${CYAN}Fetching latest dashboard release...${RESET}"
+ LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases" \
+ | python3 -c "
+import sys, json
+releases = json.load(sys.stdin)
+for r in releases:
+ if r['tag_name'].startswith('dashboard@') and not r.get('prerelease', False):
+ print(r['tag_name']); break
+")
+ if [[ -z "${LATEST_TAG:-}" ]]; then
+ echo -e "${RED}Failed to find a dashboard release.${RESET}" >&2
+ exit 1
+ fi
+ VERSION="${LATEST_TAG#dashboard@}"
+ echo -e "${GREEN}Latest: ${BOLD}${LATEST_TAG}${RESET}"
+
+ # Download and verify binaries
+ echo -e "${CYAN}Downloading binaries...${RESET}"
+ TMPDIR_DL=$(mktemp -d)
+ trap 'rm -rf "$TMPDIR_DL"' EXIT
+
+ RELEASE_BASE="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}"
+ _download_verify "$RELEASE_BASE" "lynx-dashboard-backend-linux-${ARCH}" "$TMPDIR_DL"
+ _download_verify "$RELEASE_BASE" "lynx-dashboard-frontend-linux-${ARCH}" "$TMPDIR_DL"
+ _download_verify "$RELEASE_BASE" "lynx-dashboard-frontend-assets-linux-${ARCH}.tar.gz" "$TMPDIR_DL"
+
+ # Install binaries
+ echo -e "${CYAN}Installing binaries...${RESET}"
+ mkdir -p "$BIN_DIR" "$FRONTEND_DIR" "$DEPLOY_DIR"
+
+ install -m 755 \
+ "${TMPDIR_DL}/lynx-dashboard-backend-linux-${ARCH}" \
+ "${BIN_DIR}/lynx-dashboard-backend"
+ install -m 755 \
+ "${TMPDIR_DL}/lynx-dashboard-frontend-linux-${ARCH}" \
+ "${FRONTEND_DIR}/lynx-dashboard-frontend"
+ tar -xzf "${TMPDIR_DL}/lynx-dashboard-frontend-assets-linux-${ARCH}.tar.gz" \
+ -C "$FRONTEND_DIR"
+
+ # Write version file so the backend scheduler knows the current version on startup.
+ printf '%s' "$VERSION" > "${BIN_DIR}/dashboard-version"
+ echo -e "${GREEN}Binaries installed.${RESET}"
+
+ # Generate TLS certificate and deploy nginx config
+ echo -e "${CYAN}Generating TLS certificate...${RESET}"
+ CERT_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
+ [[ -z "$CERT_IP" ]] && CERT_IP=$(curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null || echo "127.0.0.1")
+ _gen_self_signed_cert "$CERT_IP"
+ mkdir -p "$NGINX_DIR"
+ cp "${SCRIPT_DIR}/lynx/dashboard/nginx/default.conf" "${NGINX_DIR}/default.conf"
+ cp "${SCRIPT_DIR}/lynx/dashboard/nginx/updating.html" "${NGINX_DIR}/updating.html"
+ echo -e "${GREEN}nginx config deployed.${RESET}"
+
+ # Generate and create all secrets
+ echo -e "${CYAN}Generating secrets...${RESET}"
+ _gen_secrets
+ echo -e "${GREEN}Secrets created.${RESET}"
+
+ # Podman networks
+ echo -e "${CYAN}Creating Podman networks...${RESET}"
+ for net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do
+ podman network exists "$net" 2>/dev/null || podman network create "$net"
+ done
+
+ # Deploy compose file and init SQL
+ cp "${SCRIPT_DIR}/lynx/dashboard/docker-compose.yml" "${DEPLOY_DIR}/compose.yml"
+ mkdir -p "${DEPLOY_DIR}/server/db/init"
+ cp -r "${SCRIPT_DIR}/lynx/dashboard/server/db/init/." "${DEPLOY_DIR}/server/db/init/"
+
+ # Fix relative path in compose to absolute
+ sed -i "s|./server/db/init|${DEPLOY_DIR}/server/db/init|g" "${DEPLOY_DIR}/compose.yml"
+
+ # Start containers
+ echo -e "${CYAN}Starting containers...${RESET}"
+ cd "$DEPLOY_DIR"
+ _compose -f compose.yml up -d
+
+ # Wait for services
+ _wait_healthy lynx-dashboard-postgres 60
+ _wait_healthy lynx-dashboard-redis 30
+ _wait_healthy lynx-dashboard-backend 90
+ _wait_healthy lynx-dashboard-frontend 60
+ _wait_healthy lynx-dashboard-nginx 30
+
+ # Use the same IP that's in the TLS cert SAN
+ VPS_IP="$CERT_IP"
+
+ # Done
+ echo
+ echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+ echo -e "${GREEN}${BOLD} Lynx Dashboard ${VERSION} installed successfully!${RESET}"
+ echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
+ echo
+ echo -e "${BOLD}Create your admin account:${RESET}"
+ echo -e " ${CYAN}https://${VPS_IP}:19443/register?setup_token=${SETUP_TOKEN}${RESET}"
+ echo
+ echo -e "${YELLOW}${BOLD}IMPORTANT — Back up these files now:${RESET}"
+ echo -e " ${YELLOW}${SECRETS_DIR}/${RESET}"
+ echo -e " Without KEK and pg-root, data is irrecoverable."
+ echo
+ echo -e "${BOLD}If something fails:${RESET}"
+ echo -e " ${CYAN}lynx-dashboard-backend logs --errors${RESET}"
+ echo
}
diff --git a/scripts/install-nftables.sh b/scripts/install-nftables.sh
index c53f441..75a1ccc 100644
--- a/scripts/install-nftables.sh
+++ b/scripts/install-nftables.sh
@@ -67,9 +67,14 @@ table inet filter {
ct state established,related accept
iif lo accept
tcp dport $SSH_PORT accept
+ # Allow DNS from Podman bridge subnets (aardvark-dns)
+ ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } udp dport 53 accept
+ ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } tcp dport 53 accept
+ ip6 saddr fc00::/7 udp dport 53 accept
+ ip6 saddr fc00::/7 tcp dport 53 accept
}
chain forward {
- type filter hook forward priority 0; policy drop;
+ type filter hook forward priority 0; policy accept;
}
chain output {
type filter hook output priority 0; policy accept;