#!/usr/bin/env bash set -euo pipefail LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="${ROOT_DIR:-$(cd "$LIB_DIR/../../.." && pwd)}" CONFIG_DIR="$ROOT_DIR/config" STACK_ENV="$CONFIG_DIR/system-stack.env" STACK_ENV_EXAMPLE="$CONFIG_DIR/system-stack.env.example" SERVICE_CTL="$ROOT_DIR/scripts/linux/service_ctl.sh" WEBTTY_SHELL="$ROOT_DIR/scripts/linux/webtty_shell.sh" APT_STAMP="/tmp/live-stack-apt-updated.stamp" INSTALL_TAG="${INSTALL_TAG:-stack}" log() { printf '[%s] %s\n' "$INSTALL_TAG" "$*" } ensure_root() { if [ "${EUID:-$(id -u)}" -ne 0 ]; then exec sudo -E bash "$0" "$@" fi } apt_update_once() { local now age now="$(date +%s)" if [ -f "$APT_STAMP" ]; then age=$((now - $(stat -c %Y "$APT_STAMP"))) if [ "$age" -lt 21600 ]; then return 0 fi fi log "Updating apt metadata" apt-get update touch "$APT_STAMP" } apt_install() { apt_update_once apt-get install -y --no-install-recommends "$@" } have() { command -v "$1" >/dev/null 2>&1 } need_docker() { [ "${ENABLE_SRS:-1}" = "1" ] || [ "${ENABLE_HOMEPAGE:-1}" = "1" ] || [ "${ENABLE_FILEBROWSER:-1}" = "1" ] || [ "${ENABLE_NEKO:-0}" = "1" ] } compose_run() { local compose_file="$1" shift if docker compose version >/dev/null 2>&1; then docker compose -f "$compose_file" "$@" else docker-compose -f "$compose_file" "$@" fi } compose_in_service_dir() { local compose_file="$1" shift local d base d="$(cd "$(dirname "$compose_file")" && pwd)" base="$(basename "$compose_file")" ( cd "$d" || exit 1 if docker compose version >/dev/null 2>&1; then docker compose -f "$base" "$@" else docker-compose -f "$base" "$@" fi ) } as_live() { runuser -u live -- bash -lc "cd '$ROOT_DIR' && $*" } load_env() { mkdir -p "$CONFIG_DIR" if [ ! -f "$STACK_ENV" ] && [ -f "$STACK_ENV_EXAMPLE" ]; then cp "$STACK_ENV_EXAMPLE" "$STACK_ENV" fi if [ -f "$STACK_ENV" ]; then set -a . "$STACK_ENV" set +a fi HOSTNAME_ALIAS="${HOSTNAME_ALIAS:-live}" PORT="${PORT:-8001}" WEBTTY_PORT="${WEBTTY_PORT:-7681}" HOMEPAGE_PORT="${HOMEPAGE_PORT:-3080}" FILEBROWSER_PORT="${FILEBROWSER_PORT:-8082}" NETDATA_PORT="${NETDATA_PORT:-19999}" SRS_HTTP_PORT="${SRS_HTTP_PORT:-8080}" SRS_API_PORT="${SRS_API_PORT:-1985}" SRS_RTMP_PORT="${SRS_RTMP_PORT:-1935}" SRS_SRT_PORT="${SRS_SRT_PORT:-10080}" SRS_WEBRTC_PORT="${SRS_WEBRTC_PORT:-8088}" SHELLCRASH_DIR="${SHELLCRASH_DIR:-/etc/ShellCrash}" WEBTTY_USER="${WEBTTY_USER:-live}" WEBTTY_PASS="${WEBTTY_PASS:-12345678}" FILEBROWSER_USER="${FILEBROWSER_USER:-live}" FILEBROWSER_PASS="${FILEBROWSER_PASS:-12345678}" WIFI_CONNECTION_NAME="${WIFI_CONNECTION_NAME:-live-wifi}" WIFI_SSID="${WIFI_SSID:-live}" WIFI_PSK="${WIFI_PSK:-12345678}" BROWSER_PROFILE_DIR="${BROWSER_PROFILE_DIR:-/var/lib/live-browser/profile}" DISABLE_IPV6="${DISABLE_IPV6:-1}" ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}" ANDROID_PANEL_DIR="${ANDROID_PANEL_DIR:-/opt/live-modules/web-scrcpy}" ANDROID_PANEL_VIDEO_BIT_RATE="${ANDROID_PANEL_VIDEO_BIT_RATE:-1024000}" ENABLE_LIVE_EDGE_PROXY="${ENABLE_LIVE_EDGE_PROXY:-1}" } ensure_live_user() { if ! id -u live >/dev/null 2>&1; then log "Creating live user" useradd -m -s /bin/bash live fi echo "live:12345678" | chpasswd usermod -aG sudo,audio,video,plugdev,render,gpio,spidev,pwm,i2c,docker live 2>/dev/null || true } install_base_packages() { log "Installing base packages" export DEBIAN_FRONTEND=noninteractive apt_install \ git curl wget jq unzip xz-utils ca-certificates sudo \ python3 python3-venv python3-pip build-essential \ avahi-daemon avahi-utils libnss-mdns \ network-manager dnsmasq net-tools \ bluez systemctl enable --now NetworkManager 2>/dev/null || true } install_media_packages() { log "Installing media and Android tools" export DEBIAN_FRONTEND=noninteractive apt_install ffmpeg mpv adb xinit xserver-xorg openbox dbus-x11 apt_install scrcpy || true } install_nodejs() { if have node; then local major major="$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)" if [ "$major" -ge 20 ]; then return 0 fi fi log "Installing Node.js 20" curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt_install nodejs } install_docker_stack() { if ! need_docker; then return 0 fi if have docker && (docker compose version >/dev/null 2>&1 || have docker-compose); then return 0 fi log "Installing Docker" apt_install docker.io docker-compose systemctl enable --now docker usermod -aG docker live || true } prepare_python_runtime() { log "Preparing Python virtual environment" chown -R live:live "$ROOT_DIR" as_live "python3 -m venv .venv" as_live ". .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt" } prepare_web_console() { log "Building web console" as_live "cd web-console && if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm run build" } install_live_console_service() { log "Installing live console service" chmod +x "$ROOT_DIR/start.sh" "$ROOT_DIR/web.sh" "$ROOT_DIR/dev.sh" "$SERVICE_CTL" "$WEBTTY_SHELL" cat >/etc/systemd/system/live-console.service </etc/sudoers.d/live-control-plane </etc/sysctl.d/99-live-disable-ipv6.conf </dev/null 2>&1 || true fi systemctl enable --now avahi-daemon systemctl restart avahi-daemon } configure_wifi_profile() { if [ "${ENABLE_WIFI_PROFILE:-1}" != "1" ]; then return 0 fi if ! have nmcli || [ ! -d /sys/class/net/wlan0 ]; then log "Skipping Wi-Fi profile setup: wlan0 or nmcli is missing" return 0 fi log "Ensuring Wi-Fi profile ${WIFI_CONNECTION_NAME}" if ! nmcli connection show "$WIFI_CONNECTION_NAME" >/dev/null 2>&1; then nmcli connection add type wifi ifname wlan0 con-name "$WIFI_CONNECTION_NAME" ssid "$WIFI_SSID" \ wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$WIFI_PSK" ipv4.method auto connection.autoconnect yes fi } install_shellcrash() { if [ "${ENABLE_SHELLCRASH:-1}" != "1" ]; then return 0 fi if [ -x "$SHELLCRASH_DIR/start.sh" ]; then log "ShellCrash already installed" return 0 fi log "Installing ShellCrash" local url for url in \ 'https://testingcf.jsdelivr.net/gh/juewuy/ShellCrash@dev' \ 'https://raw.githubusercontent.com/juewuy/ShellCrash/dev' do if wget -q --no-check-certificate -O /tmp/install_shellcrash.sh "$url/install_en.sh" && [ -s /tmp/install_shellcrash.sh ]; then break fi done if [ ! -s /tmp/install_shellcrash.sh ]; then log "ShellCrash installer download failed" return 1 fi printf '1\n1\n1\n1\n' | bash /tmp/install_shellcrash.sh } install_browser_stack() { if [ "${ENABLE_BROWSER:-1}" != "1" ]; then return 0 fi log "Installing browser stack" export DEBIAN_FRONTEND=noninteractive apt_install chromium || apt_install chromium-browser || true mkdir -p /etc/chromium/policies/managed /etc/chromium-browser/policies/managed cat >/etc/chromium/policies/managed/live-browser.json </dev/null || true mkdir -p "$BROWSER_PROFILE_DIR" chown -R live:live "$(dirname "$BROWSER_PROFILE_DIR")" chmod +x "$ROOT_DIR/scripts/linux/launch_browser.sh" } install_neko_browser() { if [ "${ENABLE_NEKO:-0}" != "1" ]; then return 0 fi if ! have docker || ! (docker compose version >/dev/null 2>&1 || have docker-compose); then log "Neko skipped: docker compose unavailable" return 0 fi log "Installing Neko Chromium (Docker, m1k1o/neko)" set -a [ -f "$STACK_ENV" ] && . "$STACK_ENV" set +a local neko_dir="$ROOT_DIR/services/neko" local neko_prof="${NEKO_PROFILE_HOST_DIR:-$neko_dir/data/chromium-profile}" mkdir -p "$neko_prof" chmod 777 "$neko_prof" 2>/dev/null || true export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" export NEKO_USER_PASS="${NEKO_USER_PASS:-live}" export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-admin}" export NEKO_PROFILE_HOST_DIR="$neko_prof" export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1920x1080@30}" export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-52000-52100}" export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52100}" export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}" if [ -z "${NEKO_WEBRTC_NAT1TO1:-}" ]; then export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')" else export NEKO_WEBRTC_NAT1TO1 fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" compose_in_service_dir "$neko_dir/docker-compose.yml" up -d if [ "${ENABLE_NEKO_FF:-0}" = "1" ] && [ -f "$neko_dir/docker-compose.firefox.yml" ]; then log "Installing Neko Firefox (second desktop, m1k1o/neko)" local ff_prof="${NEKO_FF_PROFILE_HOST_DIR:-$neko_dir/data/firefox-profile}" mkdir -p "$ff_prof" chmod 777 "$ff_prof" 2>/dev/null || true export NEKO_FF_HTTP_PORT="${NEKO_FF_HTTP_PORT:-9201}" export NEKO_FF_DESKTOP_SCREEN="${NEKO_FF_DESKTOP_SCREEN:-1920x1080@30}" export NEKO_FF_WEBRTC_EPR="${NEKO_FF_WEBRTC_EPR:-52200-52300}" export NEKO_FF_WEBRTC_UDP_RANGE="${NEKO_FF_WEBRTC_UDP_RANGE:-52200-52300}" export NEKO_FF_PROFILE_HOST_DIR="$ff_prof" compose_in_service_dir "$neko_dir/docker-compose.firefox.yml" up -d fi } configure_adb_udev() { log "Configuring Android udev access" cat >/etc/udev/rules.d/51-live-android.rules </etc/systemd/system/live-android-panel.service </dev/null 2>&1; then log "ttyd package unavailable in apt repositories" fi if ! have ttyd; then log "WebTTY skipped because ttyd is unavailable" return 0 fi systemctl disable --now ttyd.service >/dev/null 2>&1 || true cat >/etc/systemd/system/live-webtty.service <"$target" <>"$target" <>"$target" <>"$target" <>"$target" <>"$target" <>"$target" <>"$target" </dev/null 2>&1 || true docker run --rm \ -v "$fb_db_dir:/database" \ -v "$fb_cfg_dir:/config" \ "$fb_image" \ config init --config /config/settings.json --database /database/filebrowser.db >/dev/null 2>&1 || true docker run --rm \ -v "$fb_db_dir:/database" \ -v "$fb_cfg_dir:/config" \ "$fb_image" \ config set \ --address 0.0.0.0 \ --port 80 \ --locale zh-cn \ --auth.method json \ --minimumPasswordLength 8 \ --hideLoginButton=false \ --branding.name "Live File Browser" \ --config /config/settings.json \ --database /database/filebrowser.db >/dev/null fb_hash="$(docker run --rm "$fb_image" hash "$FILEBROWSER_PASS" | tail -n 1)" fb_tmp_json="$fb_cfg_dir/filebrowser-users.import.json" FILEBROWSER_HASH="$fb_hash" FILEBROWSER_USER="$FILEBROWSER_USER" python3 - <<'PY' >"$fb_tmp_json" import json import os password_hash = os.environ["FILEBROWSER_HASH"].strip() username = os.environ["FILEBROWSER_USER"].strip() or "live" base_user = { "scope": ".", "locale": "zh-cn", "lockPassword": False, "viewMode": "mosaic", "singleClick": False, "redirectAfterCopyMove": True, "perm": { "admin": True, "execute": True, "create": True, "rename": True, "modify": True, "delete": True, "share": True, "download": True, }, "commands": [], "sorting": {"by": "name", "asc": False}, "rules": [], "hideDotfiles": False, "dateFormat": False, "aceEditorTheme": "", } print( json.dumps( [ {"id": 1, "username": "admin", "password": password_hash, **base_user}, {"id": 0, "username": username, "password": password_hash, **base_user}, ], indent=2, ) ) PY chmod 0666 "$fb_tmp_json" docker run --rm \ -w /config \ -v "$fb_db_dir:/database" \ -v "$fb_cfg_dir:/config" \ "$fb_image" \ users import $(basename "$fb_tmp_json") --overwrite --config /config/settings.json --database /database/filebrowser.db >/dev/null rm -f "$fb_cfg_dir/users.backup.json" rm -f "$fb_tmp_json" compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d } prepare_srs() { if [ "${ENABLE_SRS:-1}" != "1" ] || ! need_docker; then return 0 fi log "Starting SRS" mkdir -p "$ROOT_DIR/services/srs/data" compose_in_service_dir "$ROOT_DIR/services/srs/docker-compose.yml" up -d } run_hardware_probe() { log "Probing hardware profile" as_live ". .venv/bin/activate && python scripts/hardware_probe.py --write" } install_live_edge_proxy() { if [ "${ENABLE_LIVE_EDGE_PROXY:-1}" != "1" ]; then return 0 fi log "Installing edge proxy (Caddy :80 → control plane :${PORT})" mkdir -p /opt/live/generated local lp="$ROOT_DIR/live-platform" if [ -f "$lp/tools/render_caddyfile.py" ]; then python3 "$lp/tools/render_caddyfile.py" "$HOSTNAME_ALIAS" "$PORT" /opt/live/generated/Caddyfile else log "WARN: live-platform/tools/render_caddyfile.py missing, skip Caddyfile" return 0 fi # 若 Homepage 仍占 80,下线并改绑 3080 后重启 if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx live-homepage; then if docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -E 'live-homepage' | grep -qE '0\.0\.0\.0:80->|:::80->'; then log "Rebinding Homepage from host :80 → :3080 (free port for Caddy)" export HOMEPAGE_PORT=3080 if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true fi compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true render_homepage_services compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true fi fi if ! command -v caddy >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq caddy || true fi fi if ! command -v caddy >/dev/null 2>&1; then log "Installing Caddy from GitHub release (apt had no caddy)" local arch raw raw="$(uname -m)" case "$raw" in aarch64|arm64) arch="arm64" ;; x86_64|amd64) arch="amd64" ;; armv7l|armv6l) arch="armv7" ;; *) log "WARN: unknown arch $raw — install caddy manually" return 0 ;; esac local ver="2.8.4" local tmp="/tmp/caddy-${ver}-linux-${arch}.tar.gz" curl -fsSL "https://github.com/caddyserver/caddy/releases/download/v${ver}/caddy_${ver}_linux_${arch}.tar.gz" -o "$tmp" tar -xzf "$tmp" -C /tmp caddy install -m0755 /tmp/caddy /usr/local/bin/caddy rm -f "$tmp" /tmp/caddy fi systemctl stop caddy >/dev/null 2>&1 || true systemctl disable caddy >/dev/null 2>&1 || true local caddy_bin caddy_bin="$(command -v caddy || true)" [ -n "$caddy_bin" ] || caddy_bin="/usr/local/bin/caddy" cat >/etc/systemd/system/live-edge.service < :${PORT}) After=network-online.target live-console.service Wants=network-online.target Requires=live-console.service [Service] Type=simple ExecStart=$caddy_bin run --config /opt/live/generated/Caddyfile --adapter caddyfile Restart=on-failure RestartSec=4 [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload systemctl enable --now live-edge.service log "Edge proxy live-edge.service enabled (http://${HOSTNAME_ALIAS}.local)" } print_summary() { local profile="$1" cat <