Files
gitlab-instance-0a899031_sh/d2ypp2/scripts/linux/lib/common.sh
root 088a5a6429 s
2026-05-18 11:04:52 +00:00

1937 lines
65 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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" <<EOF
#!/usr/bin/env bash
export VIRTUAL_ENV="$target"
export PATH="$target/bin:\$PATH"
EOF
chmod +x "$target/bin/activate"
ln -sf "$python_path" "$target/bin/python"
ln -sf "$python_path" "$target/bin/python3"
if [ -n "$pip_path" ]; then
ln -sf "$pip_path" "$target/bin/pip"
ln -sf "$pip_path" "$target/bin/pip3"
fi
}
load_env() {
mkdir -p "$CONFIG_DIR"
if [ ! -f "$STACK_ENV" ] && [ -f "$STACK_ENV_EXAMPLE" ]; then
cp "$STACK_ENV_EXAMPLE" "$STACK_ENV"
fi
sed -i 's/\r$//' "$STACK_ENV" "$STACK_ENV_EXAMPLE" 2>/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 <<EOF
binder_linux
EOF
cat >/etc/modprobe.d/live-redroid.conf <<EOF
options binder_linux devices=binder,hwbinder,vndbinder
EOF
cat >/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 <<UNIT
[Unit]
Description=Live Super Hub Control Plane
After=network-online.target docker.service
Wants=network-online.target
# 避免构建/依赖短暂失败时触发 systemd 默认「5 次/10s」后永久停跑表现为 live-edge 502、:8001 无监听)
StartLimitIntervalSec=0
[Service]
User=live
WorkingDirectory=$ROOT_DIR
Environment=HOST=0.0.0.0
Environment=PORT=$PORT
Environment=LIVE_NOFILE_SOFT=$LIVE_NOFILE_SOFT
LimitNOFILE=$LIVE_NOFILE_SOFT
ExecStart=$ROOT_DIR/start.sh
Restart=always
RestartSec=5
# 首次 npm build / 冷启动较慢;过小会导致主进程在 listen 前被 SIGTERM
TimeoutStartSec=300
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now live-console.service
}
configure_sudoers() {
log "Configuring sudoers for live control plane"
cat >/etc/sudoers.d/live-control-plane <<SUDOERS
live ALL=(root) NOPASSWD: /bin/bash $SERVICE_CTL *
SUDOERS
chmod 440 /etc/sudoers.d/live-control-plane
}
detect_mdns_interfaces() {
local primary iface path found=0
local -a ifaces=()
primary="$(ip route show default 2>/dev/null | awk 'NR==1 {print $5}')"
if [ -n "$primary" ] && [ -d "/sys/class/net/$primary" ]; then
ifaces+=("$primary")
found=1
fi
for path in /sys/class/net/*; do
[ -e "$path" ] || continue
iface="${path##*/}"
[ "$iface" = "lo" ] && continue
if [ -d "$path/wireless" ]; then
case " ${ifaces[*]} " in
*" $iface "*) ;;
*)
ifaces+=("$iface")
found=1
;;
esac
fi
done
if [ "$found" -eq 0 ]; then
for path in /sys/class/net/*; do
[ -e "$path" ] || continue
iface="${path##*/}"
case "$iface" in
lo|docker*|br-*|veth*|virbr*|tun*|tap*|zt*|tailscale*|wg*|sit*|dummy*)
continue
;;
esac
ifaces+=("$iface")
found=1
break
done
fi
if [ "${#ifaces[@]}" -gt 0 ]; then
local joined
joined="$(IFS=,; echo "${ifaces[*]}")"
printf '%s\n' "$joined"
fi
}
detect_preferred_lan_ip() {
local src iface candidate path name
src="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}')"
if [ -n "$src" ]; then
printf '%s\n' "$src"
return 0
fi
iface="$(ip route show default 2>/dev/null | awk 'NR==1 {print $5}')"
if [ -n "$iface" ]; then
src="$(ip -4 addr show dev "$iface" 2>/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 - <<PY
from pathlib import Path
import re
hosts = Path("/etc/hosts")
host_alias = "${HOSTNAME_ALIAS}"
preferred_lan_ip = "${preferred_lan_ip}".strip()
host_lines = hosts.read_text(encoding="utf-8").splitlines()
next_host_lines = []
loopback_written = False
lan_written = False
for raw in host_lines:
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
next_host_lines.append(raw)
continue
parts = raw.split()
names = parts[1:]
if parts[0] == "127.0.1.1":
next_host_lines.append(f"127.0.1.1\t{host_alias}")
loopback_written = True
continue
if f"{host_alias}.local" in names:
if preferred_lan_ip and not lan_written:
next_host_lines.append(f"{preferred_lan_ip}\t{host_alias}.local")
lan_written = True
continue
next_host_lines.append(raw)
if not loopback_written:
next_host_lines.append(f"127.0.1.1\t{host_alias}")
if preferred_lan_ip and not lan_written:
next_host_lines.append(f"{preferred_lan_ip}\t{host_alias}.local")
hosts.write_text("\n".join(next_host_lines) + "\n", encoding="utf-8")
path = Path("/etc/avahi/avahi-daemon.conf")
raw_lines = path.read_text(encoding="utf-8").splitlines()
server_keys = {
"use-ipv4": "yes",
"use-ipv6": "no",
}
mdns_interfaces = "${mdns_interfaces}".strip()
if mdns_interfaces:
server_keys["allow-interfaces"] = mdns_interfaces
key_pattern = re.compile(r"^\s*[#;]?\s*([A-Za-z0-9-]+)\s*=.*$")
section_pattern = re.compile(r"^\s*\[([^\]]+)\]\s*$")
lines = []
current_section = None
server_seen = set()
server_found = False
def flush_server_defaults():
missing = [f"{key}={value}" for key, value in server_keys.items() if key not in server_seen]
if missing:
lines.extend(missing)
for raw in raw_lines:
section_match = section_pattern.match(raw)
if section_match:
if current_section == "server":
flush_server_defaults()
current_section = section_match.group(1).strip().lower()
server_found = server_found or current_section == "server"
lines.append(raw)
continue
key_match = key_pattern.match(raw)
if key_match and key_match.group(1) in server_keys:
key = key_match.group(1)
if current_section == "server":
if key not in server_seen:
lines.append(f"{key}={server_keys[key]}")
server_seen.add(key)
continue
lines.append(raw)
if server_found:
if current_section == "server":
flush_server_defaults()
else:
if lines and lines[-1] != "":
lines.append("")
lines.append("[server]")
flush_server_defaults()
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
if [ "$DISABLE_IPV6" = "1" ]; then
cat >/etc/sysctl.d/99-live-disable-ipv6.conf <<CONF
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.ipv6.conf.lo.disable_ipv6=1
CONF
sysctl --system >/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 <<JSON
{
"ExtensionInstallForcelist": [
"dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx"
],
"AutoplayAllowed": true,
"VideoCaptureAllowed": true,
"AudioCaptureAllowed": true,
"DeveloperToolsAvailability": 1
}
JSON
cp /etc/chromium/policies/managed/live-browser.json /etc/chromium-browser/policies/managed/live-browser.json 2>/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 <<RULES
SUBSYSTEM=="usb", ATTR{idVendor}=="2207", MODE="0660", GROUP="plugdev", TAG+="uaccess"
SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", MODE="0660", GROUP="plugdev", TAG+="uaccess"
RULES
udevadm control --reload-rules || true
udevadm trigger || true
}
install_android_panel() {
if [ "${ENABLE_ANDROID_PANEL:-1}" != "1" ]; then
return 0
fi
log "Installing Android web panel (ws-scrcpy)"
mkdir -p "$(dirname "$ANDROID_PANEL_DIR")"
ANDROID_PANEL_GIT_URL="${ANDROID_PANEL_GIT_URL:-https://github.com/baixin1228/web-scrcpy.git}"
if [ "$(id -u)" -eq 0 ] && ! git config --global --get-all safe.directory 2>/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 - <<PY
from pathlib import Path
import os
path = Path(r"$ANDROID_PANEL_DIR/app.py")
text = path.read_text(encoding="utf-8")
if "import os" not in text:
text = "import os\n" + text
text = text.replace(
"socketio.run(app, host='0.0.0.0', port=5000)",
"socketio.run(app, host='0.0.0.0', port=int(os.environ.get('ANDROID_PANEL_PORT', '5000')), allow_unsafe_werkzeug=True)",
)
mark = "# LIVE_ANDROID_PANEL_PATCH"
if mark not in text and "app = Flask(__name__)" in text:
text = text.replace(
"app = Flask(__name__)",
"app = Flask(__name__)\n\n"
+ mark
+ "\n@app.after_request\ndef _live_allow_embed(resp):\n resp.headers.pop('X-Frame-Options', None)\n return resp\n",
1,
)
old_block = (
"@socketio.on('connect')\n"
"def handle_connect():\n"
" global scpy_ctx, client_sid\n"
" print('Client connected')\n"
"\n"
" if scpy_ctx is not None:\n"
" print(f'reject connection, client {scpy_ctx} is already connected')\n"
" return False\n"
" else:\n"
" client_sid = request.sid\n"
" scpy_ctx = Scrcpy()\n"
" scpy_ctx.scrcpy_start(send_video_data, video_bit_rate)\n"
" socketio.start_background_task(video_send_task)\n"
" print(f'connectioned, client {scpy_ctx}')\n"
)
new_block = (
"@socketio.on('connect')\n"
"def handle_connect():\n"
" global scpy_ctx, client_sid\n"
" print('Client connected')\n"
"\n"
" if scpy_ctx is not None:\n"
" print('live-android-panel: replacing previous scrcpy session (embed / multi-tab)')\n"
" try:\n"
" scpy_ctx.scrcpy_stop()\n"
" except Exception as _e:\n"
" print('live-android-panel: stop previous', _e)\n"
" scpy_ctx = None\n"
" client_sid = None\n"
" client_sid = request.sid\n"
" scpy_ctx = Scrcpy()\n"
" scpy_ctx.scrcpy_start(send_video_data, video_bit_rate)\n"
" socketio.start_background_task(video_send_task)\n"
" print(f'connectioned, client {scpy_ctx}')\n"
)
if old_block in text:
text = text.replace(old_block, new_block, 1)
else:
broken_block = (
"@socketio.on('connect')\n"
"def handle_connect():\n"
" global scpy_ctx, client_sid\n"
" print('Client connected')\n"
"\n"
" if scpy_ctx is not None:\n"
" print('live-android-panel: replacing previous scrcpy session (embed / multi-tab)')\n"
" try:\n"
" scpy_ctx.scrcpy_stop()\n"
" except Exception as _e:\n"
" print('live-android-panel: stop previous', _e)\n"
" scpy_ctx = None\n"
" client_sid = None\n"
" client_sid = request.sid\n"
" scpy_ctx = Scrcpy()\n"
" scpy_ctx.scrcpy_start(send_video_data, video_bit_rate)\n"
" socketio.start_background_task(video_send_task)\n"
" print(f'connectioned, client {scpy_ctx}')\n"
)
if broken_block in text:
text = text.replace(broken_block, new_block, 1)
path.write_text(text, encoding="utf-8")
PY
chown -R live:live "$ANDROID_PANEL_DIR"
local panel_req_hash panel_req_stamp
panel_req_stamp="$ANDROID_PANEL_DIR/.venv/.requirements.sha256"
panel_req_hash="$(sha256sum "$ANDROID_PANEL_DIR/requirements.txt" 2>/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 <<UNIT
[Unit]
Description=Live Android Web Panel
After=network-online.target
Wants=network-online.target
[Service]
User=live
WorkingDirectory=$ANDROID_PANEL_DIR
Environment=ANDROID_PANEL_PORT=$ANDROID_PANEL_PORT
ExecStart=$ANDROID_PANEL_DIR/.venv/bin/python $ANDROID_PANEL_DIR/app.py --video_bit_rate $ANDROID_PANEL_VIDEO_BIT_RATE
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now live-android-panel.service
}
install_webtty() {
if [ "${ENABLE_WEBTTY:-1}" != "1" ]; then
return 0
fi
log "Installing WebTTY"
export DEBIAN_FRONTEND=noninteractive
if ! apt-get install -y --no-install-recommends ttyd >/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 <<UNIT
[Unit]
Description=Live WebTTY
After=network-online.target
Wants=network-online.target
[Service]
User=live
WorkingDirectory=$ROOT_DIR
ExecStart=$(command -v ttyd) -W -p $WEBTTY_PORT -c $WEBTTY_USER:$WEBTTY_PASS $WEBTTY_SHELL
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now live-webtty.service
}
install_cockpit() {
if [ "${ENABLE_COCKPIT:-1}" != "1" ]; then
return 0
fi
log "Installing Cockpit"
export DEBIAN_FRONTEND=noninteractive
apt_install cockpit cockpit-networkmanager
systemctl enable --now cockpit.socket
}
install_netdata() {
if [ "${ENABLE_NETDATA:-1}" != "1" ]; then
return 0
fi
log "Installing Netdata"
export DEBIAN_FRONTEND=noninteractive
apt_install netdata
python3 - <<PY
from pathlib import Path
path = Path("/etc/netdata/netdata.conf")
text = path.read_text(encoding="utf-8")
if "bind socket to IP = 127.0.0.1" in text:
text = text.replace("bind socket to IP = 127.0.0.1", "bind socket to IP = 0.0.0.0")
elif "bind socket to IP =" not in text:
text += "\n[global]\n\tbind socket to IP = 0.0.0.0\n"
path.write_text(text, encoding="utf-8")
PY
allow_tcp_port_if_firewall_active "$NETDATA_PORT"
systemctl enable --now netdata
systemctl restart netdata
}
render_homepage_services() {
local target="$ROOT_DIR/services/homepage/config/services.yaml"
local primary_console
if [ "${ENABLE_LIVE_EDGE_PROXY:-1}" = "1" ]; then
primary_console="http://${HOSTNAME_ALIAS}.local"
else
primary_console="http://${HOSTNAME_ALIAS}.local:${PORT}"
fi
log "Rendering Homepage service links"
cat >"$target" <<YAML
- Infrastructure:
- Live Console:
href: ${primary_console}
description: Product control center (Caddy :80 when edge proxy enabled)
- Hub Console (direct port):
href: http://${HOSTNAME_ALIAS}.local:${PORT}
description: Direct access to FastAPI port
YAML
if [ "${ENABLE_WEBTTY:-1}" = "1" ]; then
cat >>"$target" <<YAML
- WebTTY:
href: http://${HOSTNAME_ALIAS}.local:${WEBTTY_PORT}
description: Browser terminal as the live user
YAML
fi
if [ "${ENABLE_COCKPIT:-1}" = "1" ]; then
cat >>"$target" <<YAML
- Cockpit:
href: https://${HOSTNAME_ALIAS}.local:9090
description: System administration
YAML
fi
if [ "${ENABLE_FILEBROWSER:-1}" = "1" ]; then
cat >>"$target" <<YAML
- File Browser:
href: http://${HOSTNAME_ALIAS}.local:${FILEBROWSER_PORT}
description: Beginner-friendly file manager
YAML
fi
if [ "${ENABLE_ANDROID_PANEL:-1}" = "1" ]; then
cat >>"$target" <<YAML
- Android Center:
href: http://${HOSTNAME_ALIAS}.local:${PORT}/android
description: Device center with preview, ADB controls and embedded web-scrcpy
YAML
fi
if [ "${ENABLE_SHELLCRASH:-1}" = "1" ]; then
cat >>"$target" <<YAML
- ShellCrash:
href: http://${HOSTNAME_ALIAS}.local:${PORT}/shellcrash
description: Dedicated YAML, JSON and rule editing workspace
YAML
fi
if [ "${ENABLE_NETDATA:-1}" = "1" ]; then
cat >>"$target" <<YAML
- Netdata:
href: http://${HOSTNAME_ALIAS}.local:${NETDATA_PORT}
description: Metrics and health monitoring
YAML
fi
if [ "${ENABLE_MITMPROXY:-1}" = "1" ]; then
cat >>"$target" <<YAML
- mitmproxy:
href: http://${HOSTNAME_ALIAS}.local:${MITMPROXY_WEB_PORT}
description: Web proxy capture and traffic inspection
YAML
fi
if [ "${ENABLE_VSCODE_SERVER:-1}" = "1" ]; then
cat >>"$target" <<YAML
- VS Code Server:
href: http://${HOSTNAME_ALIAS}.local:${VSCODE_SERVER_PORT}
description: Browser IDE mounted to the live workspace
YAML
fi
if [ "${ENABLE_UPTIME_KUMA:-1}" = "1" ]; then
cat >>"$target" <<YAML
- Uptime Kuma:
href: http://${HOSTNAME_ALIAS}.local:${UPTIME_KUMA_PORT}
description: Endpoint health checks and alert dashboards
YAML
fi
if [ "${ENABLE_SRS:-1}" = "1" ]; then
cat >>"$target" <<YAML
- SRS:
href: http://${HOSTNAME_ALIAS}.local:${SRS_HTTP_PORT}
description: RTMP, SRT and preview endpoint
YAML
fi
if [ "${ENABLE_OPENCLAW:-1}" = "1" ]; then
cat >>"$target" <<YAML
- OpenClaw:
href: http://${HOSTNAME_ALIAS}.local:${OPENCLAW_GATEWAY_PORT}
description: Multi-agent automation gateway and web workspace
YAML
fi
}
prepare_homepage() {
if [ "${ENABLE_HOMEPAGE:-1}" != "1" ] || ! need_docker; then
return 0
fi
log "Preparing Homepage"
if [ "${HOMEPAGE_PORT:-80}" = "80" ] && ss -ltn 2>/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 <<UNIT
[Unit]
Description=Live PulseAudio TCP bridge
After=network-online.target
Wants=network-online.target
[Service]
User=live
RuntimeDirectory=live-pulse
RuntimeDirectoryMode=0755
StateDirectory=live-pulse
Environment=PULSE_RUNTIME_PATH=/run/live-pulse
Environment=PULSE_STATE_PATH=/var/lib/live-pulse
ExecStartPre=-/usr/bin/pulseaudio -k
ExecStart=/usr/bin/pulseaudio --daemonize=no --log-target=journal --exit-idle-time=-1 --file=/dev/null -n -L "module-native-protocol-tcp port=${PULSEAUDIO_TCP_PORT} auth-anonymous=1" -L "module-null-sink sink_name=live_sink sink_properties=device.description=live_sink"
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now live-pulseaudio.service || true
}
install_kmscube_service() {
if [ "${ENABLE_KMSCUBE:-1}" != "1" ]; then
return 0
fi
log "Installing kmscube diagnostics service"
export DEBIAN_FRONTEND=noninteractive
apt_install kmscube || true
chmod +x "$ROOT_DIR/scripts/linux/run_kmscube.sh"
cat >/etc/systemd/system/live-kmscube.service <<UNIT
[Unit]
Description=Live kmscube DRM diagnostics
After=multi-user.target
ConditionPathExists=${KMSCUBE_DRM_DEVICE}
[Service]
Environment=KMSCUBE_DRM_DEVICE=${KMSCUBE_DRM_DEVICE}
ExecStart=${ROOT_DIR}/scripts/linux/run_kmscube.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
if [ "${KMSCUBE_AUTOSTART:-0}" = "1" ]; then
systemctl enable --now live-kmscube.service || true
else
systemctl disable 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 <<UNIT
[Unit]
Description=Live edge proxy (http://${HOSTNAME_ALIAS}.local -> :${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 <<TXT
Profile: $profile
Primary URL: http://${HOSTNAME_ALIAS}.local (Caddy → :${PORT} when live-edge is running)
Control Plane (direct): http://${HOSTNAME_ALIAS}.local:${PORT}
Homepage: http://${HOSTNAME_ALIAS}.local:${HOMEPAGE_PORT}
WebTTY: http://${HOSTNAME_ALIAS}.local:${WEBTTY_PORT}
TXT
}