diff --git a/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json b/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json new file mode 100644 index 0000000..2068527 --- /dev/null +++ b/lynx/.sqlx/query-bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO rotation_log (id, reason, scope) VALUES ($1, 'scheduled', 'all')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bdf70cd6a08e50102718e83221929b232a00327c7008dabd71021ce47d095471" +} diff --git a/lynx/agent/setup-agent.sh b/lynx/agent/setup-agent.sh index d28e913..fd4d2e0 100644 --- a/lynx/agent/setup-agent.sh +++ b/lynx/agent/setup-agent.sh @@ -55,7 +55,7 @@ AGENT_PORT=9090 LYNX_AGENT_USER="lynx-agent" PG_NETWORK="lynx-agent-db" PG_CONTAINER="lynx-agent-postgres" -PG_IMAGE="docker.io/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359" +PG_IMAGE="docker.io/percona/percona-distribution-postgresql@sha256:71cce6ed329d4108461eeaa40fb0c1517bee2e0f78051cee40a4b010eed448c3" PG_DB="lynx_agent" PG_SUBNET="172.20.100.0/24" PG_STATIC_IP="172.20.100.2" # Fixed IP — agent binary (root) connects directly, no host port mapping @@ -597,11 +597,12 @@ LATEST_AGENT_TAG=$(curl -fsSL \ | python3 -c " import sys, json releases = json.load(sys.stdin) -for r in releases: - tag = r.get('tag_name', '') - if tag.startswith('agent@') and not r.get('prerelease'): - print(tag) - break +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('agent@') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) + print(max(tags, key=ver)) " 2>/dev/null) if [[ -z "$LATEST_AGENT_TAG" ]]; then @@ -732,8 +733,26 @@ log_info "Internal bearer token..." INTERNAL_TOKEN=$("$BINARY_PATH" gen-rand 32) printf '%s' "$INTERNAL_TOKEN" | podman secret create lynx-agent-internal-token - >/dev/null +log_info "KEK (Key Encryption Key)..." +mkdir -p /etc/lynx/credentials +chmod 700 /etc/lynx/credentials +( + KEK=$("$BINARY_PATH" gen-rand 32) + printf '%s' "$KEK" > /etc/lynx/credentials/lynx-kek + chmod 600 /etc/lynx/credentials/lynx-kek + KEK="$("$BINARY_PATH" gen-rand 32)" +) + log_ok "Agent secrets generated" +log_info "Creating pg_tde keyring directory..." +# pg_tde manages its own keyring file — we just provide the directory. +# Owned by UID 26 (postgres user inside the Percona container) for rootful Podman. +mkdir -p /etc/lynx/pg-keyring +chown 26:26 /etc/lynx/pg-keyring +chmod 700 /etc/lynx/pg-keyring +log_ok "pg_tde keyring dir: /etc/lynx/pg-keyring" + # --- Podman network for agent DB ------------------------------------------- log_section "Creating Podman network: $PG_NETWORK" @@ -763,6 +782,15 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO lynx_agent_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO lynx_agent_app; + +-- Enable transparent storage encryption via pg_tde (AES-256). +-- The keyring file is created and managed by pg_tde at /var/pg-keyring/lynx.keyring. +-- All future tables in lynx_agent will be transparently encrypted (tde_heap). +CREATE EXTENSION IF NOT EXISTS pg_tde; +SELECT pg_tde_add_database_key_provider_file('lynx-keyring', '/var/pg-keyring/lynx.keyring'); +SELECT pg_tde_create_key_using_database_key_provider('lynx-agent-key', 'lynx-keyring'); +SELECT pg_tde_set_key_using_database_key_provider('lynx-agent-key', 'lynx-keyring'); +ALTER DATABASE lynx_agent SET default_table_access_method = tde_heap; PGSQL chmod 644 "$PG_INIT_DIR/01-init.sql" @@ -772,6 +800,15 @@ log_ok "Init script: $PG_INIT_DIR/01-init.sql" log_section "Starting PostgreSQL for agent" +# Remove any stale data volume from a partial previous install. +# The init SQL (docker-entrypoint-initdb.d) only runs on an empty data dir — +# a leftover volume causes init to be silently skipped, leaving the app user +# without the correct password and pg_tde without a keyring. +if podman volume exists lynx-agent-pg-data 2>/dev/null; then + log_info "Removing stale PostgreSQL data volume from previous install..." + podman volume rm --force lynx-agent-pg-data 2>/dev/null || true +fi + podman run -d \ --name "$PG_CONTAINER" \ --network "$PG_NETWORK" \ @@ -781,8 +818,10 @@ podman run -d \ -e POSTGRES_USER=postgres \ -e POSTGRES_DB="$PG_DB" \ -e POSTGRES_PASSWORD_FILE=/run/secrets/lynx-agent-pg-root \ - -v lynx-agent-pg-data:/var/lib/postgresql \ + -e 'POSTGRES_INITDB_ARGS=-c shared_preload_libraries=pg_tde' \ + -v lynx-agent-pg-data:/data/db \ -v "$PG_INIT_DIR:/docker-entrypoint-initdb.d:ro" \ + -v /etc/lynx/pg-keyring:/var/pg-keyring \ --restart unless-stopped \ "$PG_IMAGE" @@ -836,6 +875,7 @@ DATABASE_URL_FILE=/run/credentials/lynx-agent.service/database-url INTERNAL_TOKEN_FILE=/run/credentials/lynx-agent.service/internal-token DASHBOARD_VERIFY_KEY_FILE=/run/credentials/lynx-agent.service/lynx-dashboard-pubkey SYNC_TOKEN_FILE=/run/credentials/lynx-agent.service/sync-token +KEK_FILE=/run/credentials/lynx-agent.service/lynx-kek LISTEN_ADDR=127.0.0.1:${AGENT_PORT} DASHBOARD_URL=http://${DASHBOARD_WG_IP}:8080 RUST_LOG=info @@ -913,6 +953,8 @@ LoadCredential=database-url:/etc/lynx/credentials/database-url LoadCredential=internal-token:/etc/lynx/credentials/internal-token LoadCredential=lynx-dashboard-pubkey:/etc/lynx/credentials/lynx-dashboard-pubkey LoadCredential=sync-token:/etc/lynx/credentials/sync-token +LoadCredential=lynx-wg-psk:/etc/lynx/credentials/lynx-wg-psk +LoadCredential=lynx-kek:/etc/lynx/credentials/lynx-kek # Minimal hardening — agent is a privileged system daemon (package management, # nftables, system user creation, binary self-update all require root). @@ -1016,6 +1058,13 @@ if [[ -f "$_DASH_WG_CONF" ]]; then fi unset _DASH_WG_CONF +# Persist PSK as a separate credential for systemd LoadCredential tmpfs isolation. +# The wg.conf already contains it for tunnel setup; this credential lets the agent +# Rust code read the PSK from /run/credentials/lynx-agent.service/lynx-wg-psk +# without accessing the conf file directly. +printf '%s' "$PSK" > /etc/lynx/credentials/lynx-wg-psk +chmod 600 /etc/lynx/credentials/lynx-wg-psk + AGENT_PRIV="$("$BINARY_PATH" gen-rand 32)" # overwrite PSK="$("$BINARY_PATH" gen-rand 32)" unset AGENT_PRIV PSK diff --git a/lynx/agent/update-agent.sh b/lynx/agent/update-agent.sh index 907de18..c6a2a0e 100644 --- a/lynx/agent/update-agent.sh +++ b/lynx/agent/update-agent.sh @@ -96,11 +96,12 @@ LATEST_TAG=$(curl -fsSL \ | python3 -c " import sys, json releases = json.load(sys.stdin) -for r in releases: - tag = r.get('tag_name', '') - if tag.startswith('agent@') and not r.get('prerelease'): - print(tag) - break +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('agent@') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) + print(max(tags, key=ver)) " 2>/dev/null) if [[ -z "$LATEST_TAG" ]]; then diff --git a/lynx/dashboard/server/src/podman.rs b/lynx/dashboard/server/src/podman.rs index a503083..9be2839 100644 --- a/lynx/dashboard/server/src/podman.rs +++ b/lynx/dashboard/server/src/podman.rs @@ -95,3 +95,29 @@ pub async fn secret_read(name: &str) -> Option> { .and_then(|s| s.as_str()) .map(|s| Zeroizing::new(s.trim().to_string())) } + +/// Stop a container via the Podman libpod API. +/// Returns Ok on 204 (stopped) or 304 (already stopped). +pub async fn container_stop(name: &str) -> Result<()> { + let path = format!("/v4.0.0/libpod/containers/{name}/stop"); + let status = podman_http("POST", &path, &[]) + .await + .context("stop container")?; + if status == 204 || status == 304 { + return Ok(()); + } + anyhow::bail!("Podman container stop {name}: HTTP {status}"); +} + +/// Start a container via the Podman libpod API. +/// Returns Ok on 204 (started) or 304 (already running). +pub async fn container_start(name: &str) -> Result<()> { + let path = format!("/v4.0.0/libpod/containers/{name}/start"); + let status = podman_http("POST", &path, &[]) + .await + .context("start container")?; + if status == 204 || status == 304 { + return Ok(()); + } + anyhow::bail!("Podman container start {name}: HTTP {status}"); +} diff --git a/lynx/dashboard/server/src/scheduler.rs b/lynx/dashboard/server/src/scheduler.rs index d98f6b6..4395472 100644 --- a/lynx/dashboard/server/src/scheduler.rs +++ b/lynx/dashboard/server/src/scheduler.rs @@ -64,19 +64,25 @@ async fn check_releases(state: &AppState) { let latest_agent = releases .iter() + .filter(|r| r.get("prerelease").and_then(|v| v.as_bool()) != Some(true)) + .filter(|r| r.get("draft").and_then(|v| v.as_bool()) != Some(true)) .filter_map(|r| r["tag_name"].as_str()) .filter(|t| t.starts_with("agent@")) .map(|t| t.trim_start_matches("agent@")) - .next() - .map(|s| s.to_string()); + .filter_map(|s| parse_semver(s).map(|v| (s, v))) + .max_by_key(|(_, v)| *v) + .map(|(s, _)| s.to_string()); let latest_dashboard = releases .iter() + .filter(|r| r.get("prerelease").and_then(|v| v.as_bool()) != Some(true)) + .filter(|r| r.get("draft").and_then(|v| v.as_bool()) != Some(true)) .filter_map(|r| r["tag_name"].as_str()) .filter(|t| t.starts_with("dashboard@")) .map(|t| t.trim_start_matches("dashboard@")) - .next() - .map(|s| s.to_string()); + .filter_map(|s| parse_semver(s).map(|v| (s, v))) + .max_by_key(|(_, v)| *v) + .map(|(s, _)| s.to_string()); if let Some(ref ver) = latest_agent { tracing::info!(version = %ver, "scheduler: latest agent release detected"); @@ -289,7 +295,17 @@ pub(crate) async fn needs_scheduled_rotation(db: &sqlx::PgPool) -> bool { .unwrap_or(Some(chrono::Utc::now())); match last { - None => true, + // Fresh install: no rotation history. Seed the clock so the 90-day + // window starts from today rather than triggering an immediate rotation. + None => { + let _ = sqlx::query!( + "INSERT INTO rotation_log (id, reason, scope) VALUES ($1, 'scheduled', 'all')", + uuid::Uuid::now_v7(), + ) + .execute(db) + .await; + false + } Some(ts) => (chrono::Utc::now() - ts).num_days() >= ROTATION_INTERVAL_DAYS, } } diff --git a/lynx/dashboard/server/src/update.rs b/lynx/dashboard/server/src/update.rs index 082ab23..1537673 100644 --- a/lynx/dashboard/server/src/update.rs +++ b/lynx/dashboard/server/src/update.rs @@ -8,7 +8,6 @@ const BINARY_PATH: &str = "/etc/lynx/bin/lynx-dashboard-backend"; 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; @@ -113,7 +112,7 @@ async fn download_and_verify(url: &str, sig_url: &str, label: &str) -> Result Result<()> { // Stop container so the binary file is not in use during swap. - podman_request(&format!("/containers/{FRONTEND_CONTAINER}/stop")) + crate::podman::container_stop(FRONTEND_CONTAINER) .await .context("stop frontend container")?; @@ -142,7 +141,7 @@ async fn swap_frontend(binary: &[u8], assets: &[u8]) -> Result<()> { .context("spawn_blocking extract assets")??; // Start container with the new binary. - podman_request(&format!("/containers/{FRONTEND_CONTAINER}/start")) + crate::podman::container_start(FRONTEND_CONTAINER) .await .context("start frontend container")?; @@ -202,32 +201,6 @@ fn extract_assets(data: &[u8], dest: &str) -> Result<()> { Ok(()) } -async fn podman_request(path: &str) -> Result<()> { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::UnixStream; - - let mut stream = UnixStream::connect(PODMAN_SOCKET) - .await - .context("connect to Podman socket")?; - - let req = format!("POST {path} HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"); - stream.write_all(req.as_bytes()).await?; - stream.shutdown().await?; - - let mut buf = Vec::with_capacity(1024); - stream.read_to_end(&mut buf).await?; - - let resp = std::str::from_utf8(&buf).unwrap_or(""); - let status_line = resp.lines().next().unwrap_or(""); - - // 2xx = success; 304 = already in target state (container already stopped/started) - if !status_line.contains(" 2") && !status_line.contains(" 304") { - anyhow::bail!("Podman API {path}: {status_line}"); - } - - Ok(()) -} - fn validate_github_url(url: &str) -> Result<()> { let allowed = [ "https://github.com/", diff --git a/lynx/dashboard/setup-dashboard.sh b/lynx/dashboard/setup-dashboard.sh index d146506..30d6fe0 100644 --- a/lynx/dashboard/setup-dashboard.sh +++ b/lynx/dashboard/setup-dashboard.sh @@ -615,11 +615,12 @@ LATEST_TAG=$(curl -fsSL \ | python3 -c " import sys, json releases = json.load(sys.stdin) -for r in releases: - tag = r.get('tag_name', '') - if tag.startswith('dashboard@') and not r.get('prerelease'): - print(tag) - break +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('dashboard@') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) + print(max(tags, key=ver)) " 2>/dev/null) if [[ -z "$LATEST_TAG" ]]; then @@ -737,6 +738,13 @@ SECRETS_DIR="/etc/lynx/secrets" mkdir -p "$SECRETS_DIR" chmod 700 "$SECRETS_DIR" +# pg_tde keyring directory — pg_tde manages the keyring file here. +# Owned by UID 26 (postgres in the Percona container). Must be backed up: +# without /etc/lynx/pg-keyring/lynx.keyring, the encrypted DB is unrecoverable. +mkdir -p /etc/lynx/pg-keyring +chown 26:26 /etc/lynx/pg-keyring +chmod 700 /etc/lynx/pg-keyring + _write_secret() { local name="$1" value="$2" printf '%s' "$value" > "$SECRETS_DIR/$name" @@ -747,6 +755,10 @@ log_info "Generating PostgreSQL root password..." ( PG_ROOT=$("$LB" gen-rand 32) _write_secret lynx-dashboard-pg-root "$PG_ROOT" + # Percona PostgreSQL image runs as UID 26 (postgres) from the start — not root. + # The bind-mounted secret file must be world-readable; the parent dir (700 root:root) + # prevents host access from unprivileged users. + chmod 644 "$SECRETS_DIR/lynx-dashboard-pg-root" PG_ROOT="$("$LB" gen-rand 32)" ) @@ -754,6 +766,7 @@ log_info "Generating PostgreSQL app password and database URL..." ( PG_PASS=$("$LB" gen-rand 32) _write_secret lynx-dashboard-pg-pass "$PG_PASS" + chmod 644 "$SECRETS_DIR/lynx-dashboard-pg-pass" _write_secret lynx-dashboard-database-url \ "postgresql://lynx_dashboard_app:${PG_PASS}@lynx-dashboard-postgres:5432/lynx_dashboard" PG_PASS="$("$LB" gen-rand 32)" @@ -940,6 +953,7 @@ services: - /etc/lynx/bin:/etc/lynx/bin - /etc/lynx/frontend:/etc/lynx/frontend - /run/podman/podman.sock:/run/podman/podman.sock + - /etc/lynx/secrets:/etc/lynx/secrets:rw - /etc/lynx/secrets/lynx-dashboard-database-url:/run/secrets/lynx-dashboard-database-url:ro - /etc/lynx/secrets/lynx-dashboard-redis-url:/run/secrets/lynx-dashboard-redis-url:ro - /etc/lynx/secrets/lynx-dashboard-api-token:/run/secrets/lynx-dashboard-api-token:ro @@ -976,15 +990,17 @@ services: postgres: container_name: lynx-dashboard-postgres - image: docker.io/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359 + image: docker.io/percona/percona-distribution-postgresql@sha256:71cce6ed329d4108461eeaa40fb0c1517bee2e0f78051cee40a4b010eed448c3 environment: - POSTGRES_USER=postgres - POSTGRES_DB=lynx_dashboard - POSTGRES_PASSWORD_FILE=/run/secrets/lynx-dashboard-pg-root + - POSTGRES_INITDB_ARGS=-c shared_preload_libraries=pg_tde volumes: - - postgres_data:/var/lib/postgresql + - postgres_data:/data/db - /etc/lynx/secrets/lynx-dashboard-pg-root:/run/secrets/lynx-dashboard-pg-root:ro - /etc/lynx/secrets/lynx-dashboard-pg-pass:/run/secrets/lynx-dashboard-pg-pass:ro + - /etc/lynx/pg-keyring:/var/pg-keyring healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d lynx_dashboard"] interval: 5s @@ -1071,7 +1087,7 @@ done # Initialize app user and privileges. Runs every install — idempotent. # Direct psql avoids the docker-entrypoint-initdb.d mechanism which only runs # on a completely empty PGDATA directory and silently skips on reinstalls. -log_info "Initializing PostgreSQL app user..." +log_info "Initializing PostgreSQL app user and encryption..." _PG_PASS=$(cat "$SECRETS_DIR/lynx-dashboard-pg-pass") podman exec -i lynx-dashboard-postgres psql -U postgres -d lynx_dashboard << SQL DO \$\$ @@ -1086,9 +1102,20 @@ GRANT CONNECT ON DATABASE lynx_dashboard 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 GRANT USAGE, SELECT ON SEQUENCES TO lynx_dashboard_app; + +-- Enable transparent storage encryption via pg_tde (AES-256). +-- The keyring file is created and managed by pg_tde at /var/pg-keyring/lynx.keyring. +-- All future tables in lynx_dashboard will be transparently encrypted (tde_heap). +-- BACKUP REQUIREMENT: /etc/lynx/pg-keyring/lynx.keyring must be backed up -- +-- without it, the encrypted database is unrecoverable even with a valid pg_dump. +CREATE EXTENSION IF NOT EXISTS pg_tde; +SELECT pg_tde_add_database_key_provider_file('lynx-keyring', '/var/pg-keyring/lynx.keyring'); +SELECT pg_tde_create_key_using_database_key_provider('lynx-dashboard-key', 'lynx-keyring'); +SELECT pg_tde_set_key_using_database_key_provider('lynx-dashboard-key', 'lynx-keyring'); +ALTER DATABASE lynx_dashboard SET default_table_access_method = tde_heap; SQL unset _PG_PASS -log_ok "PostgreSQL app user initialized" +log_ok "PostgreSQL app user and encryption initialized" # 2. Valkey log_info "Starting Valkey..." @@ -1546,5 +1573,10 @@ AGENT_PSK="$("$BACKEND_FILE" gen-rand 32)" unset AGENT_PSK DASHBOARD_PUB DASHBOARD_SIGN_PUBKEY echo -e "${YELLOW}Next step:${RESET} Run the agent install script on this VPS to complete the local WireGuard tunnel." echo "" +echo -e "${BOLD}${RED}=== BACKUP REQUIRED ===${RESET}" +echo -e " Back up these files — loss means permanent data loss:" +echo -e " ${BOLD}/etc/lynx/pg-keyring/lynx.keyring${RESET} ← pg_tde encryption keyring" +echo -e " ${BOLD}/etc/lynx/secrets/lynx-dashboard-kek${RESET} ← application KEK" +echo "" echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" echo "" diff --git a/lynx/dashboard/update-dashboard.sh b/lynx/dashboard/update-dashboard.sh index eda5a71..3eecd87 100644 --- a/lynx/dashboard/update-dashboard.sh +++ b/lynx/dashboard/update-dashboard.sh @@ -97,11 +97,12 @@ LATEST_TAG=$(curl -fsSL \ | python3 -c " import sys, json releases = json.load(sys.stdin) -for r in releases: - tag = r.get('tag_name', '') - if tag.startswith('dashboard@') and not r.get('prerelease'): - print(tag) - break +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('dashboard@') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) + print(max(tags, key=ver)) " 2>/dev/null) if [[ -z "$LATEST_TAG" ]]; then @@ -297,9 +298,36 @@ log_info "Starting frontend container..." if ! podman start lynx-dashboard-frontend 2>/dev/null; then /etc/lynx/bin/lynx-compose -p lynx-dashboard -f "$COMPOSE_FILE" up -d frontend 2>/dev/null || { log_error "Failed to start frontend container" + if [[ -f "${FRONTEND_BIN_FILE}.prev" ]]; then + log_warn "Restoring previous frontend binary..." + mv "${FRONTEND_BIN_FILE}.prev" "$FRONTEND_BIN_FILE" + podman start lynx-dashboard-frontend 2>/dev/null || true + log_error "Previous frontend version restored — investigate before retrying" + fi exit 1 } fi + +log_info "Waiting for frontend to become healthy..." +for i in $(seq 1 30); do + if podman inspect lynx-dashboard-frontend --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + log_ok "Frontend healthy" + break + fi + if [[ $i -eq 30 ]]; then + log_error "Frontend did not become healthy after update" + if [[ -f "${FRONTEND_BIN_FILE}.prev" ]]; then + log_warn "Restoring previous frontend binary..." + podman stop lynx-dashboard-frontend 2>/dev/null || true + mv "${FRONTEND_BIN_FILE}.prev" "$FRONTEND_BIN_FILE" + podman start lynx-dashboard-frontend 2>/dev/null || true + log_error "Previous frontend version restored — investigate before retrying" + fi + podman logs lynx-dashboard-frontend --tail 30 2>/dev/null || true + exit 1 + fi + sleep 3 +done log_ok "Frontend deployed" # --- Write version file -----------------------------------------------------