Files
archy/scripts/self-update.sh
T
archipelagoandClaude Fable 5 abf0f56afc fix(security): close the five host-networked app UIs the audit could not see
Scanning archi-dev-box from OUTSIDE found five ports serving their screens
with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui
50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's
unprotected list. They are host-networked, so Podman publishes nothing to
pin and their manifests declared 'ports: []'; the gate builds its map from
declared ports, so it neither protected them nor reported them. An audit
that reports success while five screens are open is worse than no audit.

Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is
declared 'auth: gated' so the daemon owns the outside. 'bind:' on a
host-networked app is a statement of where the container listens, not a
publish instruction — quadlet already skips PublishPort in host mode.
Guardian 8175 is declared on the fedimint app because its companion has no
manifest, and the gate keys on port, not container.

Credential paths were NOT exposed and are verified so: /lnd-connect-info,
the /proxy/lnd/ passthrough, container logs and every RPC method through
these screens all return 401 unauthenticated. What leaked was the page
shell.

Also fixes the delivery gap that would have made this unshippable: only
bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to
/opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes
through no path at all. All five now sync; the two whose rebuilds the
daemon owns are synced without being handed to container-specs.

Every remaining undeclared port is now declared with a stated reason —
gated: botfights 9100, router 8084, pine 10380; exempt with rationale:
fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS +
own auth, and enrolled devices cannot hold a session), pine TLS 10381,
lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports
remain across all 56 manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 08:46:09 -04:00

513 lines
20 KiB
Bash
Executable File

#!/bin/bash
# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply
# Designed to run on installed Archipelago nodes (as archipelago user)
#
# Usage:
# ./self-update.sh # Check + apply if available
# ./self-update.sh --check # Check only, don't apply
# ./self-update.sh --force # Apply even if already up to date
#
# The script:
# 1. Pulls latest code from origin (146.59.87.168:3000)
# 2. Builds the Rust backend (release mode)
# 3. Builds the Vue frontend (production mode)
# 4. Installs the new binary and web UI
# 5. Restarts the archipelago service
# 6. Verifies health after restart
set -euo pipefail
REPO_DIR="$HOME/archy"
BACKEND_DIR="$REPO_DIR/core"
FRONTEND_DIR="$REPO_DIR/neode-ui"
INSTALL_BIN="/usr/local/bin/archipelago"
INSTALL_WEB="/opt/archipelago/web-ui"
STATE_FILE="/var/lib/archipelago/update_state.json"
LOG_FILE="/var/lib/archipelago/update.log"
LOCK_FILE="/tmp/archipelago-update.lock"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "$LOG_FILE"; }
ok() { echo -e "${GREEN}[$(date '+%H:%M:%S')] OK${NC} $*" | tee -a "$LOG_FILE"; }
err() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR${NC} $*" | tee -a "$LOG_FILE"; }
warn(){ echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARN${NC} $*" | tee -a "$LOG_FILE"; }
cleanup() {
rm -f "$LOCK_FILE"
}
trap cleanup EXIT
# Prevent concurrent updates
if [ -f "$LOCK_FILE" ]; then
pid=$(cat "$LOCK_FILE" 2>/dev/null)
if kill -0 "$pid" 2>/dev/null; then
err "Update already in progress (PID $pid)"
exit 1
fi
warn "Stale lock file found, removing"
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
# Parse args
CHECK_ONLY=false
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=true; shift ;;
--force) FORCE=true; shift ;;
*) shift ;;
esac
done
# Ensure repo exists
if [ ! -d "$REPO_DIR/.git" ]; then
err "Repo not found at $REPO_DIR"
err "Clone it first: git clone http://146.59.87.168:3000/lfg2025/archy ~/archy"
exit 1
fi
cd "$REPO_DIR"
if ! command -v nano >/dev/null 2>&1; then
log "Installing nano for Archipelago terminal..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq nano 2>>"$LOG_FILE"; then
ok "nano installed"
else
warn "Unable to install nano automatically; continuing update"
fi
fi
if ! command -v ping >/dev/null 2>&1; then
log "Installing iputils-ping..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
ok "ping installed"
else
warn "Unable to install ping automatically; continuing update"
fi
fi
if ! command -v esptool >/dev/null 2>&1; then
log "Installing esptool for LoRa radio firmware flashing..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
ok "esptool installed"
else
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
fi
fi
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
# buildable-from-source path Debian could verify). Without it, esptool's
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
# Fetching the exact same file from the matching upstream esptool release
# tag restores full (and correct) flashing behavior — it's the same
# open-source codebase, just the one blob Debian's packaging couldn't
# include.
if command -v esptool >/dev/null 2>&1; then
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
if [ ! -f "$STUB_FILE" ]; then
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
2>>"$LOG_FILE"; then
sudo chmod 644 "$STUB_FILE"
ok "esp32s3 stub flasher installed"
else
sudo rm -f "$STUB_FILE" 2>/dev/null
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
fi
fi
fi
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
# below (discovered the hard way: ensurepip needs python3-venv, and
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
# of these are pulled in by a bare `python3` package on Debian trixie).
for pkg in python3-venv binutils libpython3.13; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
log "Installing $pkg (reticulum-daemon build prerequisite)..."
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
fi
done
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
# Check if there are updates
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" = "$REMOTE" ] && [ "$FORCE" = "false" ]; then
ok "Already up to date ($LOCAL)"
if [ "$CHECK_ONLY" = "true" ]; then
echo '{"update_available": false, "current": "'"$LOCAL"'"}'
fi
exit 0
fi
# Calculate what changed
COMMITS_BEHIND=$(git rev-list HEAD..origin/main --count)
log "Update available: $COMMITS_BEHIND commits behind"
log " Local: $LOCAL"
log " Remote: $REMOTE"
if [ "$CHECK_ONLY" = "true" ]; then
CHANGELOG=$(git log HEAD..origin/main --oneline --no-merges | head -20)
echo '{"update_available": true, "current": "'"$LOCAL"'", "latest": "'"$REMOTE"'", "commits_behind": '"$COMMITS_BEHIND"'}'
echo ""
echo "Changes:"
echo "$CHANGELOG"
exit 0
fi
# Backup current binary
BACKUP_DIR="/var/lib/archipelago/update-backup"
mkdir -p "$BACKUP_DIR"
if [ -f "$INSTALL_BIN" ]; then
cp "$INSTALL_BIN" "$BACKUP_DIR/archipelago.bak"
log "Backed up current binary"
fi
# Pull latest code
log "Pulling latest code..."
git pull origin main --ff-only 2>>"$LOG_FILE" || {
err "Git pull failed — local changes? Run: git reset --hard origin/main"
exit 1
}
NEW_VERSION=$(git rev-parse --short HEAD)
log "Now at: $NEW_VERSION"
# Build backend
log "Building Rust backend (release)..."
cd "$BACKEND_DIR"
if cargo build --release --workspace 2>>"$LOG_FILE"; then
ok "Backend built successfully"
else
err "Backend build failed — rolling back"
cd "$REPO_DIR"
git reset --hard "$LOCAL" 2>>"$LOG_FILE"
exit 1
fi
# Install binary
BUILT_BIN="$BACKEND_DIR/target/release/archipelago"
if [ ! -f "$BUILT_BIN" ]; then
err "Built binary not found at $BUILT_BIN"
exit 1
fi
sudo cp "$BUILT_BIN" "$INSTALL_BIN"
sudo chmod +x "$INSTALL_BIN"
ok "Backend installed"
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
# Non-fatal: archipelago falls back to its dev venv path if the packaged
# binaries aren't present, so a missing/failed build here degrades mesh
# Reticulum support rather than breaking the update. This mirrors
# deploy-to-target.sh's existing manual-deploy step, which until now was the
# only path that ever installed these — a node that only ever received OTA
# self-updates had neither binary.
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
for tool in archy-reticulum-daemon archy-rnodeconf; do
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
sudo chmod +x "/usr/local/bin/$tool"
ok "$tool installed"
else
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
fi
done
else
warn "reticulum-daemon tools build failed — continuing without updating them"
fi
fi
# Build frontend
log "Building Vue frontend (production)..."
cd "$FRONTEND_DIR"
npm ci --silent 2>>"$LOG_FILE" || npm install --silent 2>>"$LOG_FILE"
if npm run build 2>>"$LOG_FILE"; then
ok "Frontend built successfully"
else
err "Frontend build failed — backend already updated, service may need manual fix"
exit 1
fi
# Install frontend (always ship fresh AIUI from demo/aiui; preserve claude-login.html)
BUILT_WEB="$REPO_DIR/web/dist/neode-ui"
if [ -d "$BUILT_WEB" ]; then
# Bake AIUI into the built tree so rsync --delete does not wipe it.
# demo/aiui is the canonical AIUI bundle checked into the repo; copying
# it here means every self-update ships a matching AIUI version instead
# of preserving whatever stale copy happened to be on disk (which is
# empty on nodes where an earlier ad-hoc deploy blew it away).
if [ -d "$REPO_DIR/demo/aiui" ] && [ -f "$REPO_DIR/demo/aiui/index.html" ]; then
log "Staging AIUI bundle from demo/aiui into frontend dist..."
rm -rf "$BUILT_WEB/aiui"
cp -r "$REPO_DIR/demo/aiui" "$BUILT_WEB/aiui"
else
warn "demo/aiui not found in repo; existing /opt/archipelago/web-ui/aiui will be wiped by rsync --delete"
fi
# Sync new files, preserving claude-login.html (per-node admin bookmark)
sudo rsync -a --delete \
--exclude 'claude-login.html' \
"$BUILT_WEB/" "$INSTALL_WEB/"
ok "Frontend installed"
else
warn "Frontend build output not found at $BUILT_WEB — skipping"
fi
# Update helper scripts in /opt/archipelago/scripts/
# These are canonical home; keep a copy at /opt/archipelago/image-versions.sh
# for backward compatibility with older binaries that still look there.
SCRIPTS_DEST="/opt/archipelago/scripts"
sudo mkdir -p "$SCRIPTS_DEST"
for script in image-versions.sh reconcile-containers.sh container-specs.sh container-doctor.sh sync-npm-public-hosts.sh app-surface-smoke-test.sh bitcoin-stack-lifecycle-test.sh; do
src="$REPO_DIR/scripts/$script"
if [ -f "$src" ]; then
sudo install -m 755 "$src" "$SCRIPTS_DEST/$script"
ok "Updated $script"
else
warn "Missing $src — skipping"
fi
done
# Legacy path for image-versions.sh (older binaries looked here first)
if [ -f "$REPO_DIR/scripts/image-versions.sh" ]; then
sudo cp "$REPO_DIR/scripts/image-versions.sh" /opt/archipelago/image-versions.sh
fi
# Sync app manifests and app-local build contexts into the canonical
# production manifest root. The backend orchestrator loads install specs from
# /opt/archipelago/apps; updating only the binary/frontend can leave a node
# with new installer logic but stale or missing app manifests.
APPS_DEST="/opt/archipelago/apps"
if [ -d "$REPO_DIR/apps" ]; then
sudo mkdir -p "$APPS_DEST"
sudo rsync -a --delete "$REPO_DIR/apps/" "$APPS_DEST/"
ok "App manifests synced"
else
warn "Apps directory not found at $REPO_DIR/apps — install manifests may be stale"
fi
# Update first-boot-containers.sh too (the canonical first-boot orchestrator).
# Nodes run it once on install, but keeping a fresh copy on disk means any
# future boot or reconciler invocation uses current port specs and caps.
if [ -f "$REPO_DIR/scripts/first-boot-containers.sh" ]; then
sudo install -m 755 "$REPO_DIR/scripts/first-boot-containers.sh" \
"$SCRIPTS_DEST/first-boot-containers.sh"
fi
# Sync UI container source trees (docker/bitcoin-ui, docker/lnd-ui,
# docker/electrs-ui) into /opt/archipelago/docker/<name>/. If any file in a
# UI tree changed since last update, rebuild that image and recreate its
# container using the spec from container-specs.sh. This is what prevented
# the lnd-ui port mismatch from reaching nodes through OTA: self-update used
# to update only the backend + frontend, never the UI container images.
UI_DOCKER_DEST="/opt/archipelago/docker"
sudo mkdir -p "$UI_DOCKER_DEST"
UI_REBUILD_LIST=""
# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below:
# container-specs.sh has no spec for either (and their container names break
# the archy-<ui> assumption — the FIPS one is plain `fips-ui`). Their rebuilds
# come from elsewhere — the daemon's companion installer for fedimint-ui, the
# orchestrator's build context for fips-ui — but BOTH read
# /opt/archipelago/docker/<ui>, and nothing was ever updating that directory.
# So source edits to those two trees reached nodes through no path at all:
# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS
# screens unauthenticated on every interface (found by scanning archi-dev-box
# from outside, 2026-08-05 — the in-node audit could not see them).
for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do
src="$REPO_DIR/docker/$ui"
dst="$UI_DOCKER_DEST/$ui"
[ -d "$src" ] || continue
# Hash source tree to decide if rebuild is needed. Any content change
# (Dockerfile, nginx.conf, index.html, assets) triggers a rebuild.
# Hash file contents only (not paths or metadata) so src and dst match
# when their contents are identical regardless of directory prefix.
src_hash=$( (cd "$src" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
dst_hash=""
if [ -d "$dst" ]; then
dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1)
fi
if [ "$src_hash" != "$dst_hash" ]; then
log "UI source changed for $ui; syncing"
sudo rsync -a --delete "$src/" "$dst/"
case "$ui" in
# Rebuilt below from container-specs.sh.
bitcoin-ui|lnd-ui|electrs-ui)
UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;;
# Synced only — rebuilt by the daemon (companion installer /
# orchestrator build context), which watches this directory.
# Adding them to the rebuild list would fail: no spec exists and
# the container names are not archy-<ui>.
*)
log " $ui synced; rebuild is owned by the daemon" ;;
esac
else
ok "UI source unchanged for $ui"
fi
done
# Rebuild changed UI images + recreate containers as the archipelago user
# (rootless podman storage lives under ~archipelago). Port mappings and caps
# come from scripts/container-specs.sh so spec drift can't sneak in.
if [ -n "$UI_REBUILD_LIST" ]; then
log "Rebuilding UI containers:$UI_REBUILD_LIST"
# shellcheck disable=SC1091
# container-specs.sh provides load_spec_archy-<ui> and mem_limit <name>.
SPECS="$SCRIPTS_DEST/container-specs.sh"
if [ ! -f "$SPECS" ]; then
warn "container-specs.sh missing at $SPECS; skipping UI rebuild"
else
for ui in $UI_REBUILD_LIST; do
cname="archy-$ui"
log " rebuilding $cname from $UI_DOCKER_DEST/$ui"
# Build image as archipelago user so it lands in the right store.
if ! sudo -u archipelago bash -c "
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
cd '$UI_DOCKER_DEST/$ui' &&
podman build --no-cache -t 'localhost/$ui:local' . >>'$LOG_FILE' 2>&1
"; then
err " build failed for $ui; keeping existing container"
continue
fi
# Recreate container using spec from container-specs.sh.
if ! sudo -u archipelago bash -c "
export XDG_RUNTIME_DIR=/run/user/\$(id -u archipelago)
source '$SPECS'
load_spec_$cname || { echo 'spec load failed for $cname'; exit 1; }
podman stop '$cname' 2>/dev/null || true
podman rm '$cname' 2>/dev/null || true
PORT_ARG=''
[ -n \"\$SPEC_PORTS\" ] && PORT_ARG=\"-p \$SPEC_PORTS\"
NET_ARG=''
[ \"\$SPEC_NETWORK\" = 'host' ] && NET_ARG='--network host'
CAP_ARGS='--cap-drop ALL'
for c in \$SPEC_CAPS; do CAP_ARGS=\"\$CAP_ARGS --cap-add \$c\"; done
podman run -d --name '$cname' \$PORT_ARG \$NET_ARG \\
--user 0:0 \$CAP_ARGS \\
--memory=\"\$SPEC_MEMORY\" \\
--restart unless-stopped \\
--security-opt \"\$SPEC_SECURITY\" \\
'localhost/$ui:local' >>'$LOG_FILE' 2>&1
"; then
err " recreate failed for $cname"
continue
fi
ok " $cname rebuilt and running"
done
fi
fi
# Update kiosk display helpers used by HDMI/TV installs.
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" ]; then
sudo install -m 755 "$REPO_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" \
/usr/local/bin/archipelago-kiosk-launcher
ok "Updated archipelago-kiosk-launcher"
fi
# Update systemd services if changed
SYSTEMD_UNITS_CHANGED=false
for unit in archipelago.service archipelago-fips.service archipelago-kiosk.service archipelago-kiosk-watchdog.service; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
SYSTEMD_UNITS_CHANGED=true
ok "Updated $unit"
fi
done
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
fi
# Keep the doctor timer/service current too. Container uptime fixes rely on
# these units as much as on the helper scripts themselves.
DOCTOR_UNITS_CHANGED=false
for unit in archipelago-doctor.service archipelago-doctor.timer; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
DOCTOR_UNITS_CHANGED=true
ok "Updated $unit"
fi
done
if [ "$DOCTOR_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
sudo systemctl enable --now archipelago-doctor.timer 2>>"$LOG_FILE" || \
warn "Failed to enable archipelago-doctor.timer"
fi
# Install/refresh tmpfiles.d rules. The logs rule creates
# /var/log/archipelago/ + container-installs.log with archipelago:archipelago
# ownership so the non-root backend can append install audit lines.
# Apply immediately so existing nodes don't need a reboot.
if [ -f "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" ]; then
sudo install -m 644 "$REPO_DIR/image-recipe/configs/archipelago-tmpfiles.conf" \
/usr/lib/tmpfiles.d/archipelago-logs.conf
sudo systemd-tmpfiles --create /usr/lib/tmpfiles.d/archipelago-logs.conf 2>/dev/null || true
ok "Log tmpfiles rule installed"
fi
# Restart service
log "Restarting archipelago service..."
sudo systemctl restart archipelago
# Wait for health
log "Waiting for backend health..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:5678/health > /dev/null 2>&1; then
ok "Backend healthy after ${i}s"
break
fi
if [ "$i" = "30" ]; then
err "Backend failed to start within 30s"
warn "Rolling back binary..."
if [ -f "$BACKUP_DIR/archipelago.bak" ]; then
sudo cp "$BACKUP_DIR/archipelago.bak" "$INSTALL_BIN"
sudo systemctl restart archipelago
err "Rolled back to previous binary"
fi
exit 1
fi
sleep 1
done
# Update state file for the UI
python3 -c "
import json, datetime
state = {
'current_version': '$NEW_VERSION',
'last_check': datetime.datetime.utcnow().isoformat() + 'Z',
'available_update': None,
'update_in_progress': False,
'rollback_available': True,
'schedule': 'daily_check'
}
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
" 2>/dev/null || true
echo ""
ok "Update complete: $LOCAL -> $NEW_VERSION"
log "Changelog:"
git log "$LOCAL".."$NEW_VERSION" --oneline --no-merges | head -10 | tee -a "$LOG_FILE"