1937 lines
65 KiB
Bash
1937 lines
65 KiB
Bash
#!/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
|
||
}
|