#!/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" "$*" } # 网络/镜像波动时重试(不退出整段安装) retry_run() { local max="${1:-3}" local delay="${2:-3}" shift 2 local n=0 while [ "$n" -lt "$max" ]; do if "$@"; then return 0 fi n=$((n + 1)) log "retry ${n}/${max} failed command: $*" sleep "$delay" done return 1 } 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" if ! apt-get update; then log "WARN: apt metadata refresh failed; continuing with cached package lists" fi touch "$APT_STAMP" } apt_install() { apt_update_once if apt-get install -y --no-install-recommends "$@"; then return 0 fi log "apt install failed; trying dpkg --configure -a and apt -f install" export DEBIAN_FRONTEND=noninteractive dpkg --configure -a 2>/dev/null || true apt-get -f install -y || true apt-get install -y --no-install-recommends "$@" } allow_tcp_port_if_firewall_active() { local port="$1" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${port}/tcp" >/dev/null 2>&1 || true fi } allow_udp_port_if_firewall_active() { local port="$1" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${port}/udp" >/dev/null 2>&1 || true fi } allow_udp_range_if_firewall_active() { local port_range="$1" local ufw_range="${port_range//-/:}" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${ufw_range}/udp" >/dev/null 2>&1 || true fi } prune_neko_profile() { local prof="$1" [ -n "$prof" ] || return 0 mkdir -p "$prof" rm -rf \ "$prof/SingletonLock" \ "$prof/SingletonSocket" \ "$prof/SingletonCookie" \ "$prof/SingletonStartupLock" \ "$prof/lockfile" \ "$prof/DevToolsActivePort" \ "$prof/BrowserMetrics" \ "$prof/Crash Reports" \ "$prof/Crashpad" \ "$prof/ShaderCache" \ "$prof/GrShaderCache" \ "$prof/GraphiteDawnCache" \ "$prof/Default/Cache" \ "$prof/Default/Code Cache" \ "$prof/Default/GPUCache" \ "$prof/Default/DawnGraphiteCache" \ "$prof/Default/DawnWebGPUCache" \ 2>/dev/null || true chmod -R 0777 "$prof" 2>/dev/null || true find "$prof" -type d -exec chmod 0777 {} \; 2>/dev/null || true find "$prof" -type f -exec chmod 0666 {} \; 2>/dev/null || true } have() { command -v "$1" >/dev/null 2>&1 } need_docker() { [ "${ENABLE_SRS:-1}" = "1" ] || \ [ "${ENABLE_HOMEPAGE:-1}" = "1" ] || \ [ "${ENABLE_FILEBROWSER:-1}" = "1" ] || \ [ "${ENABLE_NEKO:-1}" = "1" ] || \ [ "${ENABLE_REDROID:-1}" = "1" ] || \ [ "${ENABLE_MITMPROXY:-1}" = "1" ] || \ [ "${ENABLE_VSCODE_SERVER:-1}" = "1" ] || \ [ "${ENABLE_UPTIME_KUMA:-1}" = "1" ] || \ [ "${ENABLE_OPENCLAW:-1}" = "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' && $*" } create_python_venv_shim() { local target="$1" local python_bin="${2:-python3}" local python_path pip_bin pip_path if [[ "$python_bin" = /* ]] && [ -x "$python_bin" ]; then python_path="$python_bin" else python_path="$(command -v "$python_bin" 2>/dev/null || true)" fi if [ -z "$python_path" ]; then python_path="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)" fi pip_bin="$(command -v pip3 2>/dev/null || command -v pip 2>/dev/null || true)" if [[ "$pip_bin" = /* ]] && [ -x "$pip_bin" ]; then pip_path="$pip_bin" elif [ -n "$pip_bin" ]; then pip_path="$(command -v "$pip_bin" 2>/dev/null || true)" else pip_path="" fi mkdir -p "$target/bin" cat >"$target/bin/activate" </dev/null || true 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}" LIVE_NOFILE_SOFT="${LIVE_NOFILE_SOFT:-524288}" ENABLE_NEKO="${ENABLE_NEKO:-1}" NEKO_START_URL="${NEKO_START_URL:-https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/}" NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" NEKO_HTTP_PORT_1="${NEKO_HTTP_PORT_1:-$NEKO_HTTP_PORT}" NEKO_HTTP_PORT_2="${NEKO_HTTP_PORT_2:-9201}" NEKO_HTTP_PORT_3="${NEKO_HTTP_PORT_3:-9202}" NEKO_INSTANCE_COUNT="${NEKO_INSTANCE_COUNT:-2}" NEKO_WEBRTC_TCPMUX="${NEKO_WEBRTC_TCPMUX:-52300}" NEKO_WEBRTC_UDPMUX="${NEKO_WEBRTC_UDPMUX:-52300}" NEKO_WEBRTC_TCPMUX_1="${NEKO_WEBRTC_TCPMUX_1:-$NEKO_WEBRTC_TCPMUX}" NEKO_WEBRTC_TCPMUX_2="${NEKO_WEBRTC_TCPMUX_2:-52301}" NEKO_WEBRTC_TCPMUX_3="${NEKO_WEBRTC_TCPMUX_3:-52302}" NEKO_WEBRTC_UDPMUX_1="${NEKO_WEBRTC_UDPMUX_1:-$NEKO_WEBRTC_UDPMUX}" NEKO_WEBRTC_UDPMUX_2="${NEKO_WEBRTC_UDPMUX_2:-52301}" NEKO_WEBRTC_UDPMUX_3="${NEKO_WEBRTC_UDPMUX_3:-52302}" NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52099}" NEKO_WEBRTC_UDP_RANGE_1="${NEKO_WEBRTC_UDP_RANGE_1:-$NEKO_WEBRTC_UDP_RANGE}" NEKO_WEBRTC_UDP_RANGE_2="${NEKO_WEBRTC_UDP_RANGE_2:-52101-52199}" NEKO_WEBRTC_UDP_RANGE_3="${NEKO_WEBRTC_UDP_RANGE_3:-52201-52299}" NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-$NEKO_WEBRTC_UDP_RANGE_1}" NEKO_WEBRTC_EPR_1="${NEKO_WEBRTC_EPR_1:-$NEKO_WEBRTC_EPR}" NEKO_WEBRTC_EPR_2="${NEKO_WEBRTC_EPR_2:-$NEKO_WEBRTC_UDP_RANGE_2}" NEKO_WEBRTC_EPR_3="${NEKO_WEBRTC_EPR_3:-$NEKO_WEBRTC_UDP_RANGE_3}" NEKO_IMAGE_X86="${NEKO_IMAGE_X86:-ghcr.io/m1k1o/neko/google-chrome:3.0.0}" NEKO_IMAGE_ARM64="${NEKO_IMAGE_ARM64:-ghcr.io/m1k1o/neko/chromium:3.0.0}" case "$NEKO_IMAGE_X86" in ghcr.io/m1k1o/neko/google-chrome:latest) NEKO_IMAGE_X86="ghcr.io/m1k1o/neko/google-chrome:3.0.0" ;; esac case "$NEKO_IMAGE_ARM64" in ghcr.io/m1k1o/neko/chromium:latest) NEKO_IMAGE_ARM64="ghcr.io/m1k1o/neko/chromium:3.0.0" ;; esac NEKO_SHM_SIZE="${NEKO_SHM_SIZE:-512mb}" NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1280x720@15}" NEKO_MAX_FPS="${NEKO_MAX_FPS:-15}" NEKO_VIDEO_BITRATE="${NEKO_VIDEO_BITRATE:-1200}" NEKO_BROWSER_RENDER_MODE="${NEKO_BROWSER_RENDER_MODE:-auto}" ENABLE_REDROID="${ENABLE_REDROID:-1}" REDROID_DEFAULT_PORT="${REDROID_DEFAULT_PORT:-5555}" REDROID_INSTANCE_PREFIX="${REDROID_INSTANCE_PREFIX:-redroid13}" REDROID_DATA_PREFIX="${REDROID_DATA_PREFIX:-/var/lib/live-redroid}" REDROID_GPU_MODE="${REDROID_GPU_MODE:-guest}" REDROID_DEVICE_PROFILE="${REDROID_DEVICE_PROFILE:-pixel-7-pro}" REDROID_ADB_INSECURE="${REDROID_ADB_INSECURE:-1}" REDROID_BOOT_ARGS="${REDROID_BOOT_ARGS:-androidboot.redroid_magisk=1}" REDROID_EXPECT_GMS="${REDROID_EXPECT_GMS:-1}" REDROID_EXPECT_MAGISK="${REDROID_EXPECT_MAGISK:-1}" REDROID_IMAGE_X86="${REDROID_IMAGE_X86:-fahaddz/redroid:13}" REDROID_IMAGE_X86_ALT="${REDROID_IMAGE_X86_ALT:-fahaddz/redroid:13}" REDROID_IMAGE_ARM64="${REDROID_IMAGE_ARM64:-fahaddz/redroid:13-arm-pi5}" REDROID_IMAGE_ARM64_ALT="${REDROID_IMAGE_ARM64_ALT:-fahaddz/redroid:13-arm-pi5}" if [ "$REDROID_IMAGE_ARM64" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE_ARM64="fahaddz/redroid:13-arm-pi5" fi if [ "$REDROID_IMAGE_ARM64_ALT" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE_ARM64_ALT="fahaddz/redroid:13-arm-pi5" fi if [ "${REDROID_IMAGE:-}" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE="fahaddz/redroid:13-arm-pi5" fi case " ${REDROID_BOOT_ARGS} " in *" androidboot.redroid_gpu_mode="*) ;; *) REDROID_BOOT_ARGS="${REDROID_BOOT_ARGS} androidboot.redroid_gpu_mode=${REDROID_GPU_MODE}" ;; esac ENABLE_MITMPROXY="${ENABLE_MITMPROXY:-1}" MITMPROXY_WEB_PORT="${MITMPROXY_WEB_PORT:-8086}" MITMPROXY_PROXY_PORT="${MITMPROXY_PROXY_PORT:-8087}" ENABLE_VSCODE_SERVER="${ENABLE_VSCODE_SERVER:-1}" VSCODE_SERVER_PORT="${VSCODE_SERVER_PORT:-8440}" VSCODE_SERVER_PASSWORD="${VSCODE_SERVER_PASSWORD:-12345678}" ENABLE_UPTIME_KUMA="${ENABLE_UPTIME_KUMA:-1}" UPTIME_KUMA_PORT="${UPTIME_KUMA_PORT:-3001}" ENABLE_PULSEAUDIO="${ENABLE_PULSEAUDIO:-1}" PULSEAUDIO_TCP_PORT="${PULSEAUDIO_TCP_PORT:-4713}" ENABLE_KMSCUBE="${ENABLE_KMSCUBE:-1}" KMSCUBE_AUTOSTART="${KMSCUBE_AUTOSTART:-0}" KMSCUBE_DRM_DEVICE="${KMSCUBE_DRM_DEVICE:-/dev/dri/card0}" ENABLE_OPENCLAW="${ENABLE_OPENCLAW:-1}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}" OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-12345678}" OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-1}" OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-/var/lib/live-openclaw/config}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-/var/lib/live-openclaw/workspace}" } resolve_neko_image() { case "$(uname -m)" in x86_64|amd64) printf '%s' "$NEKO_IMAGE_X86" ;; aarch64|arm64) printf '%s' "$NEKO_IMAGE_ARM64" ;; *) printf '%s' "$NEKO_IMAGE_ARM64" ;; esac } resolve_neko_supervisor_conf() { case "$(uname -m)" in x86_64|amd64) printf '%s' "google-chrome.conf" ;; aarch64|arm64) printf '%s' "chromium.conf" ;; *) printf '%s' "chromium.conf" ;; esac } resolve_neko_profile_dir() { local family family="$(resolve_neko_supervisor_conf)" family="${family%.conf}" printf '%s' "$ROOT_DIR/services/neko/data/${family}-profile-v2" } resolve_neko_profile_dir_for_instance() { local idx="${1:-1}" local base base="${NEKO_PROFILE_HOST_DIR:-$(resolve_neko_profile_dir)}" case "$idx" in 1) printf '%s' "$base" ;; *) printf '%s-%s' "$base" "$idx" ;; esac } redroid_binderfs_ready() { [ -e /dev/binderfs/binder-control ] } redroid_legacy_binder_devices_ready() { [ -e /dev/binder ] && [ -e /dev/hwbinder ] && [ -e /dev/vndbinder ] } redroid_binder_mode() { if redroid_binderfs_ready; then printf '%s\n' binderfs return 0 fi if redroid_legacy_binder_devices_ready; then printf '%s\n' legacy return 0 fi return 1 } redroid_binder_is_provisionable() { redroid_binder_mode >/dev/null 2>&1 && return 0 grep -qw binder /proc/filesystems 2>/dev/null } redroid_support_detail() { if redroid_binderfs_ready; then printf '%s' "binderfs mounted" return 0 fi if redroid_legacy_binder_devices_ready; then printf '%s' "legacy binder devices available" return 0 fi if grep -qw binder /proc/filesystems 2>/dev/null; then printf '%s' "binder filesystem supported but not mounted" return 0 fi printf '%s' "binder kernel support unavailable" } ensure_redroid_host_support() { if redroid_binder_mode >/dev/null 2>&1; then return 0 fi if have modprobe; then modprobe binder_linux devices=binder,hwbinder,vndbinder >/dev/null 2>&1 || \ modprobe binder_linux >/dev/null 2>&1 || true fi if redroid_binder_mode >/dev/null 2>&1; then return 0 fi if grep -qw binder /proc/filesystems 2>/dev/null; then mkdir -p /dev/binderfs if have mountpoint; then if ! mountpoint -q /dev/binderfs 2>/dev/null; then mount -t binder binder /dev/binderfs >/dev/null 2>&1 || \ mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1 || true fi elif ! grep -qE '[[:space:]]/dev/binderfs[[:space:]]' /proc/mounts 2>/dev/null; then mount -t binder binder /dev/binderfs >/dev/null 2>&1 || \ mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1 || true fi fi redroid_binder_mode >/dev/null 2>&1 } install_redroid_host_service() { mkdir -p /etc/modules-load.d /etc/modprobe.d cat >/etc/modules-load.d/live-redroid.conf </etc/modprobe.d/live-redroid.conf </etc/systemd/system/live-redroid-host.service <<'UNIT' [Unit] Description=Live Redroid binder host preparation After=systemd-modules-load.service local-fs.target Wants=systemd-modules-load.service Before=docker.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/bin/bash -lc 'modprobe binder_linux devices=binder,hwbinder,vndbinder >/dev/null 2>&1 || modprobe binder_linux >/dev/null 2>&1 || true; mkdir -p /dev/binderfs; if command -v mountpoint >/dev/null 2>&1; then mountpoint -q /dev/binderfs || mount -t binder binder /dev/binderfs >/dev/null 2>&1 || mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1; else grep -qE "[[:space:]]/dev/binderfs[[:space:]]" /proc/mounts || mount -t binder binder /dev/binderfs >/dev/null 2>&1 || mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1; fi; test -e /dev/binderfs/binder-control || { test -e /dev/binder && test -e /dev/hwbinder && test -e /dev/vndbinder; }' [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload if systemctl enable --now live-redroid-host.service >/dev/null 2>&1; then return 0 fi log "WARN: live-redroid-host.service failed; Redroid will require preconfigured binder devices" return 1 } redroid_container_matches_host_support() { local name="$1" case "$(redroid_binder_mode 2>/dev/null || true)" in binderfs) docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/binderfs' ;; legacy) docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/binder' && \ docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/hwbinder' && \ docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/vndbinder' ;; *) return 1 ;; esac } resolve_neko_instance_count() { local requested="${NEKO_INSTANCE_COUNT:-2}" local cpu count cpu="$(nproc 2>/dev/null || echo 4)" case "$requested" in 1|2|3) count="$requested" ;; auto|"") if [ "$cpu" -le 4 ]; then count=2 else count=3 fi ;; *) count=2 ;; esac if [ "$count" -lt 1 ]; then count=1 fi if [ "$count" -gt 3 ]; then count=3 fi printf '%s' "$count" } resolve_neko_service_names() { local count name count="$(resolve_neko_instance_count)" for name in 1 2 3; do [ "$name" -le "$count" ] || break printf 'neko%s\n' "$name" done } resolve_neko_instances_csv() { resolve_neko_service_names | paste -sd, - } resolve_neko_http_port_for_instance() { case "${1:-1}" in 1) printf '%s' "${NEKO_HTTP_PORT_1:-${NEKO_HTTP_PORT:-9200}}" ;; 2) printf '%s' "${NEKO_HTTP_PORT_2:-9201}" ;; 3) printf '%s' "${NEKO_HTTP_PORT_3:-9202}" ;; *) printf '%s' "${NEKO_HTTP_PORT_1:-${NEKO_HTTP_PORT:-9200}}" ;; esac } remove_legacy_neko_container() { if have docker && docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx 'live-neko-chromium'; then docker stop live-neko-chromium >/dev/null 2>&1 || true docker rm live-neko-chromium >/dev/null 2>&1 || true fi } 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 live 2>/dev/null || true if getent group docker >/dev/null 2>&1; then usermod -aG docker live 2>/dev/null || true fi } normalize_repo_root() { local parent safe_root parent="$(dirname "$ROOT_DIR")" safe_root="${LIVE_STACK_REPO_ROOT:-/opt/live/d2ypp}" if runuser -u live -- test -x "$parent" 2>/dev/null && runuser -u live -- test -r "$ROOT_DIR" 2>/dev/null; then return 0 fi if [ "$ROOT_DIR" = "$safe_root" ]; then return 0 fi log "Relocating repository to $safe_root for live-user access" mkdir -p "$(dirname "$safe_root")" rm -rf "$safe_root" cp -a "$ROOT_DIR" "$safe_root" chown -R live:live "$safe_root" ROOT_DIR="$safe_root" 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" } ensure_runtime_dirs_owned_by_live() { local rel path for rel in \ config \ logs \ downloads \ backup \ backup_config \ .pm2 \ .process_runner \ services/neko/data do path="$ROOT_DIR/$rel" mkdir -p "$path" chown -R live:live "$path" 2>/dev/null || true done } 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 usbutils v4l-utils 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 (prefer 20.x from NodeSource)" if retry_run 3 5 bash -c 'curl -fsSL https://deb.nodesource.com/setup_20.x | bash -'; then apt_install nodejs || true else log "NodeSource unavailable; using distribution nodejs" apt_install nodejs npm || apt_install nodejs || true fi if have node; then major="$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)" case "$major" in ''|*[!0-9]*) log "WARN: could not parse Node major version" ;; *) [ "$major" -lt 20 ] && log "WARN: Node major=$major (web-console expects >=20); upgrade manually if build fails" ;; esac return 0 fi log "WARN: Node.js not installed; web-console build may fail on this distro" return 0 } install_docker_compose_plugin_binary() { local raw arch version url target raw="$(uname -m)" case "$raw" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; armv7l|armv6l) arch="armv7" ;; *) log "WARN: unsupported arch for docker compose plugin binary: $raw" return 1 ;; esac version="${DOCKER_COMPOSE_PLUGIN_VERSION:-2.29.7}" target="/usr/local/lib/docker/cli-plugins/docker-compose" url="https://github.com/docker/compose/releases/download/v${version}/docker-compose-linux-${arch}" mkdir -p "$(dirname "$target")" retry_run 3 5 curl -fsSL "$url" -o "$target" || return 1 chmod +x "$target" return 0 } install_docker_stack() { if ! need_docker; then return 0 fi if have docker; then if docker compose version >/dev/null 2>&1; then return 0 fi log "Docker detected without Compose v2; installing docker-compose-plugin" apt_install docker-compose-plugin || true if ! docker compose version >/dev/null 2>&1; then log "docker-compose-plugin package unavailable; installing Compose v2 binary" install_docker_compose_plugin_binary || true fi if docker compose version >/dev/null 2>&1; then return 0 fi if have docker-compose; then log "WARN: falling back to docker-compose v1; some stacks may need docker-compose-plugin" return 0 fi fi log "Installing Docker" if ! apt_install docker.io docker-compose docker-compose-plugin; then log "docker-compose-plugin package unavailable; retrying without it" apt_install docker.io docker-compose || apt_install docker.io fi systemctl enable --now docker if ! docker compose version >/dev/null 2>&1; then log "Docker installed without Compose v2 package; trying Compose v2 binary" install_docker_compose_plugin_binary || true fi if ! docker compose version >/dev/null 2>&1 && have docker-compose; then log "WARN: falling back to docker-compose v1; some stacks may need docker-compose-plugin" fi usermod -aG docker live || true } prepare_python_runtime() { log "Preparing Python virtual environment" chown -R live:live "$ROOT_DIR" local req_hash req_stamp req_stamp="$ROOT_DIR/.venv/.requirements.sha256" req_hash="$(sha256sum "$ROOT_DIR/requirements.txt" "$ROOT_DIR/pyproject.toml" 2>/dev/null | sha256sum | awk '{print $1}')" if ! as_live "test -x .venv/bin/python && .venv/bin/python -V >/dev/null 2>&1"; then rm -rf "$ROOT_DIR/.venv" as_live "python3 -m venv .venv" || { log "venv create failed; retry once with --clear" rm -rf "$ROOT_DIR/.venv" if ! as_live "python3 -m venv .venv"; then log "WARN: venv still failed; using a shim over the system Python" rm -rf "$ROOT_DIR/.venv" create_python_venv_shim "$ROOT_DIR/.venv" fi } else log "Reusing existing Python virtual environment" fi if [ -f "$req_stamp" ] && [ "$(cat "$req_stamp" 2>/dev/null)" = "$req_hash" ]; then log "Python requirements unchanged; skipping pip install" return 0 fi if ! as_live ". .venv/bin/activate && python -m pip install --upgrade pip && python -m pip install -r requirements.txt"; then log "pip install failed; retry with --no-cache-dir" if ! as_live ". .venv/bin/activate && python -m pip install --upgrade pip && python -m pip install --no-cache-dir -r requirements.txt"; then log "WARN: Python deps incomplete; control plane may fail until fixed manually" return 0 fi fi printf '%s\n' "$req_hash" >"$req_stamp" chown live:live "$req_stamp" 2>/dev/null || true } prepare_web_console() { log "Building web console" if as_live ". .venv/bin/activate && python scripts/launch.py --build-only --no-env-check"; then return 0 fi log "web console build failed; cleaning artifacts and retrying" as_live "cd web-console && rm -rf node_modules .next out" || true if ! as_live ". .venv/bin/activate && python scripts/launch.py --build-only --no-env-check"; then log "WARN: web console build still failed — service may serve stale assets until rebuilt" fi } 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 </dev/null 2>&1 || true rm -f /etc/systemd/system/live-neko-ip-watch.service systemctl daemon-reload return 0 fi log "Installing Neko LAN IP watcher" chmod +x "$ROOT_DIR/scripts/linux/neko_ip_watch.sh" cat >/etc/systemd/system/live-neko-ip-watch.service </etc/sudoers.d/live-control-plane </dev/null | awk '$1=="inet" {sub(/\/.*/, "", $2); print $2; exit}')" if [ -n "$src" ]; then printf '%s\n' "$src" return 0 fi fi for path in /sys/class/net/*; do [ -e "$path" ] || continue name="${path##*/}" case "$name" in lo|docker*|br-*|veth*|virbr*|tun*|tap*|zt*|tailscale*|wg*|sit*|dummy*|p2p-dev-*|mon.*) continue ;; esac candidate="$(ip -4 addr show dev "$name" 2>/dev/null | awk '$1=="inet" {sub(/\/.*/, "", $2); print $2; exit}')" if [ -n "$candidate" ]; then printf '%s\n' "$candidate" return 0 fi done hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -Ev '^(127\.|169\.254\.)' | head -n 1 } neko_nat1to1_is_usable() { local candidate="$1" local iface [ -n "$candidate" ] || return 1 iface="$(ip -4 addr show 2>/dev/null | awk -v want="$candidate" ' /^[0-9]+: / { iface=$2 sub(/:$/, "", iface) } $1=="inet" { ip=$2 sub(/\/.*/, "", ip) if (ip==want) { print iface exit } } ')" case "$iface" in ""|lo|docker*|br-*|veth*|virbr*|tun*|tap*|zt*|tailscale*|wg*|sit*|dummy*) return 1 ;; *) return 0 ;; esac } configure_mdns() { if [ "${ENABLE_MDNS:-1}" != "1" ]; then return 0 fi log "Configuring hostname and mDNS" hostnamectl set-hostname "$HOSTNAME_ALIAS" local mdns_interfaces preferred_lan_ip mdns_interfaces="$(detect_mdns_interfaces)" preferred_lan_ip="$(detect_preferred_lan_ip || true)" python3 - </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 ok=0 rm -f /tmp/install_shellcrash.sh for url in \ 'https://testingcf.jsdelivr.net/gh/juewuy/ShellCrash@dev' \ 'https://raw.githubusercontent.com/juewuy/ShellCrash/dev' do if retry_run 2 4 wget -q --no-check-certificate -O /tmp/install_shellcrash.sh "$url/install_en.sh" && [ -s /tmp/install_shellcrash.sh ]; then ok=1 break fi done if [ "$ok" != "1" ] || [ ! -s /tmp/install_shellcrash.sh ]; then log "ShellCrash installer download failed — continuing hub install (proxy later or set ENABLE_SHELLCRASH=0)" return 0 fi if ! printf '1\n1\n1\n1\n' | bash /tmp/install_shellcrash.sh; then log "ShellCrash install script returned error — continuing; configure manually under $SHELLCRASH_DIR if needed" return 0 fi } install_browser_stack() { if [ "${ENABLE_BROWSER:-1}" != "1" ]; then return 0 fi log "Installing browser stack" export DEBIAN_FRONTEND=noninteractive case "$(uname -m)" in x86_64|amd64) if ! have google-chrome && ! have google-chrome-stable; then local chrome_deb="/tmp/google-chrome-stable_current_amd64.deb" if retry_run 3 5 wget -q -O "$chrome_deb" https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb; then apt_install "$chrome_deb" || dpkg -i "$chrome_deb" || apt-get -f install -y || true fi fi if have google-chrome || have google-chrome-stable; then log "Google Chrome is available; skipping Chromium install on x86" else apt_install chromium || apt_install chromium-browser || true fi ;; *) apt_install chromium || apt_install chromium-browser || true ;; esac mkdir -p /etc/chromium/policies/managed /etc/chromium-browser/policies/managed /etc/opt/chrome/policies/managed cat >/etc/chromium/policies/managed/live-browser.json </dev/null || true cp /etc/chromium/policies/managed/live-browser.json /etc/opt/chrome/policies/managed/live-browser.json 2>/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:-1}" != "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 browser desktops (Docker, 3 instances)" set -a [ -f "$STACK_ENV" ] && . "$STACK_ENV" set +a local neko_dir="$ROOT_DIR/services/neko" local idx neko_prof for idx in 1 2 3; do neko_prof="$(resolve_neko_profile_dir_for_instance "$idx")" prune_neko_profile "$neko_prof" done export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" export NEKO_HTTP_PORT_1="${NEKO_HTTP_PORT_1:-$NEKO_HTTP_PORT}" export NEKO_HTTP_PORT_2="${NEKO_HTTP_PORT_2:-9201}" export NEKO_HTTP_PORT_3="${NEKO_HTTP_PORT_3:-9202}" export NEKO_USER_PASS="${NEKO_USER_PASS:-12345678}" export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-12345678}" export NEKO_PROFILE_HOST_DIR="${NEKO_PROFILE_HOST_DIR:-$(resolve_neko_profile_dir)}" export NEKO_PROFILE_HOST_DIR_1="${NEKO_PROFILE_HOST_DIR_1:-$(resolve_neko_profile_dir_for_instance 1)}" export NEKO_PROFILE_HOST_DIR_2="${NEKO_PROFILE_HOST_DIR_2:-$(resolve_neko_profile_dir_for_instance 2)}" export NEKO_PROFILE_HOST_DIR_3="${NEKO_PROFILE_HOST_DIR_3:-$(resolve_neko_profile_dir_for_instance 3)}" export NEKO_START_URL="${NEKO_START_URL:-https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/}" export NEKO_IMAGE="$(resolve_neko_image)" export NEKO_BROWSER_SUPERVISOR_CONF="$(resolve_neko_supervisor_conf)" export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1920x1080@30}" export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52099}" export NEKO_WEBRTC_UDP_RANGE_1="${NEKO_WEBRTC_UDP_RANGE_1:-$NEKO_WEBRTC_UDP_RANGE}" export NEKO_WEBRTC_UDP_RANGE_2="${NEKO_WEBRTC_UDP_RANGE_2:-52101-52199}" export NEKO_WEBRTC_UDP_RANGE_3="${NEKO_WEBRTC_UDP_RANGE_3:-52201-52299}" export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-$NEKO_WEBRTC_UDP_RANGE_1}" export NEKO_WEBRTC_EPR_1="${NEKO_WEBRTC_EPR_1:-$NEKO_WEBRTC_EPR}" export NEKO_WEBRTC_EPR_2="${NEKO_WEBRTC_EPR_2:-$NEKO_WEBRTC_UDP_RANGE_2}" export NEKO_WEBRTC_EPR_3="${NEKO_WEBRTC_EPR_3:-$NEKO_WEBRTC_UDP_RANGE_3}" # Upstream n.eko images include STUN servers by default, and ICE Lite with ICE servers is unstable. # Keep full ICE on by default even if an older system-stack.env still contains NEKO_WEBRTC_ICELITE=1. if [ "${NEKO_FORCE_ICELITE:-0}" = "1" ]; then export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}" else export NEKO_WEBRTC_ICELITE=0 fi export NEKO_WEBRTC_TCPMUX="${NEKO_WEBRTC_TCPMUX:-52300}" export NEKO_WEBRTC_UDPMUX="${NEKO_WEBRTC_UDPMUX:-52300}" export NEKO_WEBRTC_TCPMUX_1="${NEKO_WEBRTC_TCPMUX_1:-$NEKO_WEBRTC_TCPMUX}" export NEKO_WEBRTC_TCPMUX_2="${NEKO_WEBRTC_TCPMUX_2:-52301}" export NEKO_WEBRTC_TCPMUX_3="${NEKO_WEBRTC_TCPMUX_3:-52302}" export NEKO_WEBRTC_UDPMUX_1="${NEKO_WEBRTC_UDPMUX_1:-$NEKO_WEBRTC_UDPMUX}" export NEKO_WEBRTC_UDPMUX_2="${NEKO_WEBRTC_UDPMUX_2:-52301}" export NEKO_WEBRTC_UDPMUX_3="${NEKO_WEBRTC_UDPMUX_3:-52302}" if ! neko_nat1to1_is_usable "${NEKO_WEBRTC_NAT1TO1:-}"; then export NEKO_WEBRTC_NAT1TO1="$(detect_preferred_lan_ip || true)" else export NEKO_WEBRTC_NAT1TO1 fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" for port in "$NEKO_HTTP_PORT_1" "$NEKO_HTTP_PORT_2" "$NEKO_HTTP_PORT_3" "$NEKO_WEBRTC_TCPMUX_1" "$NEKO_WEBRTC_TCPMUX_2" "$NEKO_WEBRTC_TCPMUX_3"; do allow_tcp_port_if_firewall_active "$port" done for port in "$NEKO_WEBRTC_UDPMUX_1" "$NEKO_WEBRTC_UDPMUX_2" "$NEKO_WEBRTC_UDPMUX_3"; do allow_udp_port_if_firewall_active "$port" done for port_range in "$NEKO_WEBRTC_UDP_RANGE_1" "$NEKO_WEBRTC_UDP_RANGE_2" "$NEKO_WEBRTC_UDP_RANGE_3"; do allow_udp_range_if_firewall_active "$port_range" done remove_legacy_neko_container compose_in_service_dir "$neko_dir/docker-compose.yml" up -d } install_redroid() { if [ "${ENABLE_REDROID:-1}" != "1" ]; then return 0 fi if ! have docker; then log "Redroid skipped: docker unavailable" return 0 fi local image name data_dir case "$(uname -m)" in x86_64|amd64) image="${REDROID_IMAGE:-${REDROID_IMAGE_X86_ALT:-$REDROID_IMAGE_X86}}" ;; aarch64|arm64) image="${REDROID_IMAGE:-${REDROID_IMAGE_ARM64_ALT:-$REDROID_IMAGE_ARM64}}" ;; *) image="${REDROID_IMAGE:-$REDROID_IMAGE_X86}" ;; esac name="${REDROID_INSTANCE_PREFIX}-1" data_dir="${REDROID_DATA_PREFIX}-${name}" install_redroid_host_service || true if ! ensure_redroid_host_support; then log "Redroid skipped: $(redroid_support_detail)" if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then log "Removing incompatible Redroid container (${name})" docker rm -f "$name" >/dev/null 2>&1 || true fi return 0 fi mkdir -p "$data_dir" allow_tcp_port_if_firewall_active "$REDROID_DEFAULT_PORT" if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then if redroid_container_matches_host_support "$name"; then log "Redroid already running" return 0 fi log "Recreating running Redroid container to apply binder mounts" docker rm -f "$name" >/dev/null 2>&1 || true fi if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then if ! redroid_container_matches_host_support "$name"; then log "Recreating existing Redroid container to apply binder mounts" docker rm -f "$name" >/dev/null 2>&1 || true else log "Starting existing Redroid container" docker start "$name" >/dev/null 2>&1 || true return 0 fi fi log "Installing Redroid container (${image})" docker run -d \ --name "$name" \ --privileged \ --pull always \ --restart unless-stopped \ -p "${REDROID_DEFAULT_PORT}:5555" \ -v "${data_dir}:/data" \ -e "REDROID_GPU_MODE=${REDROID_GPU_MODE}" \ -e "REDROID_DEVICE_PROFILE=${REDROID_DEVICE_PROFILE}" \ $( [ "${REDROID_ADB_INSECURE:-1}" = "1" ] && printf '%s ' '-e' 'REDROID_ADB_INSECURE=1' ) \ $( [ -e /dev/kvm ] && printf '%s ' '--device' '/dev/kvm' ) \ $( [ -d /dev/dri ] && printf '%s ' '-v /dev/dri:/dev/dri' ) \ $( [ -d /dev/dma_heap ] && printf '%s ' '-v /dev/dma_heap:/dev/dma_heap' ) \ $( redroid_binderfs_ready && printf '%s ' '-v' '/dev/binderfs:/dev/binderfs' ) \ $( redroid_legacy_binder_devices_ready && printf '%s ' '--device' '/dev/binder' '--device' '/dev/hwbinder' '--device' '/dev/vndbinder' ) \ $( [ -e /dev/rga ] && printf '%s ' '--device /dev/rga' ) \ $( [ -e /dev/mpp_service ] && printf '%s ' '--device /dev/mpp_service' ) \ "$image" ${REDROID_BOOT_ARGS:-} >/dev/null 2>&1 || log "WARN: Redroid create failed; check /dev/kvm, binderfs, and image availability" } configure_adb_udev() { log "Configuring Android udev access" cat >/etc/udev/rules.d/51-live-android.rules </dev/null | grep -qxF "$ANDROID_PANEL_DIR"; then git config --global --add safe.directory "$ANDROID_PANEL_DIR" fi if [ ! -d "$ANDROID_PANEL_DIR/.git" ]; then rm -rf "$ANDROID_PANEL_DIR" if ! retry_run 3 5 git clone "$ANDROID_PANEL_GIT_URL" "$ANDROID_PANEL_DIR"; then log "WARN: Android web panel clone failed; skipping ws-scrcpy install for now" return 0 fi else if ! retry_run 3 5 git -C "$ANDROID_PANEL_DIR" pull --ff-only; then log "WARN: Android web panel update failed; keeping previous ws-scrcpy checkout" return 0 fi fi python3 - </dev/null | awk '{print $1}')" if ! runuser -u live -- bash -lc "test -x '$ANDROID_PANEL_DIR/.venv/bin/python' && '$ANDROID_PANEL_DIR/.venv/bin/python' -V >/dev/null 2>&1"; then rm -rf "$ANDROID_PANEL_DIR/.venv" if ! runuser -u live -- bash -lc "python3 -m venv '$ANDROID_PANEL_DIR/.venv'"; then log "WARN: Android panel venv failed; using a shim over the system Python" rm -rf "$ANDROID_PANEL_DIR/.venv" create_python_venv_shim "$ANDROID_PANEL_DIR/.venv" chown -R live:live "$ANDROID_PANEL_DIR/.venv" fi else log "Reusing Android panel virtual environment" fi if [ ! -f "$panel_req_stamp" ] || [ "$(cat "$panel_req_stamp" 2>/dev/null)" != "$panel_req_hash" ]; then runuser -u live -- bash -lc ". '$ANDROID_PANEL_DIR/.venv/bin/activate' && python -m pip install --upgrade pip && python -m pip install -r '$ANDROID_PANEL_DIR/requirements.txt'" printf '%s\n' "$panel_req_hash" >"$panel_req_stamp" chown live:live "$panel_req_stamp" 2>/dev/null || true else log "Android panel requirements unchanged; skipping pip install" fi allow_tcp_port_if_firewall_active "$ANDROID_PANEL_PORT" cat >/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 "Trying ttyd release fallback" local ttyd_url tmp_ttyd arch api_url asset_name arch="$(uname -m)" case "$arch" in x86_64|amd64) asset_name='ttyd\.(x86_64|amd64)$' ;; aarch64|arm64) asset_name='ttyd\.(aarch64|arm64)$' ;; armv7l|armv6l) asset_name='ttyd\.arm' ;; *) asset_name="" ;; esac if [ -n "$asset_name" ] && have curl && have jq; then api_url="https://api.github.com/repos/tsl0922/ttyd/releases/latest" ttyd_url="$(curl -fsSL "$api_url" | jq -r --arg re "$asset_name" '.assets[] | select(.name | test($re)) | .browser_download_url' | head -n1)" if [ -n "$ttyd_url" ] && [ "$ttyd_url" != "null" ]; then tmp_ttyd="$(mktemp)" if curl -fL "$ttyd_url" -o "$tmp_ttyd"; then install -m 0755 "$tmp_ttyd" /usr/local/bin/ttyd rm -f "$tmp_ttyd" else rm -f "$tmp_ttyd" fi fi fi fi if ! have ttyd; then log "WebTTY skipped because ttyd is unavailable" return 0 fi allow_tcp_port_if_firewall_active "$WEBTTY_PORT" 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" <>"$target" <>"$target" <>"$target" <>"$target" </dev/null | awk 'NR > 1 && $4 ~ /:80$/ { found = 1 } END { exit(found ? 0 : 1) }'; then log "Port 80 is already in use; rebinding Homepage to 3080" export HOMEPAGE_PORT=3080 if [ -f "$STACK_ENV" ] && grep -q '^HOMEPAGE_PORT=80' "$STACK_ENV"; then sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$STACK_ENV" || true fi fi render_homepage_services compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d } prepare_filebrowser() { if [ "${ENABLE_FILEBROWSER:-1}" != "1" ] || ! need_docker; then return 0 fi log "Preparing File Browser" local fb_image="filebrowser/filebrowser:latest" local fb_db_dir="$ROOT_DIR/services/filebrowser/database" local fb_cfg_dir="$ROOT_DIR/services/filebrowser/config" local fb_db="$fb_db_dir/filebrowser.db" local fb_settings="$fb_cfg_dir/settings.json" local fb_tmp_json fb_hash mkdir -p "$ROOT_DIR/services/filebrowser/database" "$ROOT_DIR/services/filebrowser/config" rm -f "$fb_db" touch "$fb_db" chmod -R 0777 "$ROOT_DIR/services/filebrowser/database" "$ROOT_DIR/services/filebrowser/config" compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/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_mitmproxy() { if [ "${ENABLE_MITMPROXY:-1}" != "1" ] || ! need_docker; then return 0 fi log "Preparing mitmproxy" mkdir -p "$ROOT_DIR/services/mitmproxy/data" allow_tcp_port_if_firewall_active "$MITMPROXY_WEB_PORT" allow_tcp_port_if_firewall_active "$MITMPROXY_PROXY_PORT" compose_in_service_dir "$ROOT_DIR/services/mitmproxy/docker-compose.yml" up -d || \ log "WARN: mitmproxy failed to start" } prepare_vscode_server() { if [ "${ENABLE_VSCODE_SERVER:-1}" != "1" ] || ! need_docker; then return 0 fi log "Preparing code-server" mkdir -p "$ROOT_DIR/services/vscode-server/config" allow_tcp_port_if_firewall_active "$VSCODE_SERVER_PORT" compose_in_service_dir "$ROOT_DIR/services/vscode-server/docker-compose.yml" up -d || \ log "WARN: code-server failed to start" } prepare_uptime_kuma() { if [ "${ENABLE_UPTIME_KUMA:-1}" != "1" ] || ! need_docker; then return 0 fi log "Preparing Uptime Kuma" mkdir -p "$ROOT_DIR/services/uptime-kuma/data" allow_tcp_port_if_firewall_active "$UPTIME_KUMA_PORT" compose_in_service_dir "$ROOT_DIR/services/uptime-kuma/docker-compose.yml" up -d || \ log "WARN: Uptime Kuma failed to start" } prepare_openclaw() { if [ "${ENABLE_OPENCLAW:-1}" != "1" ] || ! need_docker; then return 0 fi log "Preparing OpenClaw" mkdir -p "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_WORKSPACE_DIR" chmod -R 0777 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_WORKSPACE_DIR" 2>/dev/null || true allow_tcp_port_if_firewall_active "$OPENCLAW_GATEWAY_PORT" allow_tcp_port_if_firewall_active "$OPENCLAW_BRIDGE_PORT" compose_in_service_dir "$ROOT_DIR/services/openclaw/docker-compose.yml" up -d || \ log "WARN: OpenClaw failed to start" } install_pulseaudio_service() { if [ "${ENABLE_PULSEAUDIO:-1}" != "1" ]; then return 0 fi log "Installing PulseAudio service" export DEBIAN_FRONTEND=noninteractive apt_install pulseaudio pulseaudio-utils || true cat >/etc/systemd/system/live-pulseaudio.service </etc/systemd/system/live-kmscube.service </dev/null 2>&1 || true fi } prepare_srs() { if [ "${ENABLE_SRS:-1}" != "1" ] || ! need_docker; then return 0 fi log "Starting SRS" if [ "${SRS_HTTP_PORT:-8080}" = "8080" ] && ss -ltn 2>/dev/null | awk 'NR > 1 && $4 ~ /:8080$/ { found = 1 } END { exit(found ? 0 : 1) }'; then log "Port 8080 is already in use; rebinding SRS HTTP to 8081" export SRS_HTTP_PORT=8081 if [ -f "$STACK_ENV" ] && grep -q '^SRS_HTTP_PORT=8080' "$STACK_ENV"; then sed -i 's/^SRS_HTTP_PORT=8080$/SRS_HTTP_PORT=8081/' "$STACK_ENV" || true fi fi 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 || log "WARN: apt update failed before caddy install; continuing" apt-get install -y -qq caddy || true fi fi for svc in nginx apache2 httpd; do if systemctl is-active --quiet "$svc" 2>/dev/null; then log "Stopping $svc to free port 80 for live-edge" systemctl stop "$svc" >/dev/null 2>&1 || true systemctl disable "$svc" >/dev/null 2>&1 || true fi done 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 <