Files
gitlab-instance-0a899031_do…/scripts/linux/lib/common.sh
2026-04-06 00:13:31 -05:00

962 lines
29 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"
apt-get update
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 "$@"
}
have() {
command -v "$1" >/dev/null 2>&1
}
need_docker() {
[ "${ENABLE_SRS:-1}" = "1" ] || [ "${ENABLE_HOMEPAGE:-1}" = "1" ] || [ "${ENABLE_FILEBROWSER:-1}" = "1" ] || [ "${ENABLE_NEKO:-0}" = "1" ]
}
compose_run() {
local compose_file="$1"
shift
if docker compose version >/dev/null 2>&1; then
docker compose -f "$compose_file" "$@"
else
docker-compose -f "$compose_file" "$@"
fi
}
compose_in_service_dir() {
local compose_file="$1"
shift
local d base
d="$(cd "$(dirname "$compose_file")" && pwd)"
base="$(basename "$compose_file")"
(
cd "$d" || exit 1
if docker compose version >/dev/null 2>&1; then
docker compose -f "$base" "$@"
else
docker-compose -f "$base" "$@"
fi
)
}
as_live() {
runuser -u live -- bash -lc "cd '$ROOT_DIR' && $*"
}
load_env() {
mkdir -p "$CONFIG_DIR"
if [ ! -f "$STACK_ENV" ] && [ -f "$STACK_ENV_EXAMPLE" ]; then
cp "$STACK_ENV_EXAMPLE" "$STACK_ENV"
fi
if [ -f "$STACK_ENV" ]; then
set -a
. "$STACK_ENV"
set +a
fi
HOSTNAME_ALIAS="${HOSTNAME_ALIAS:-live}"
PORT="${PORT:-8001}"
WEBTTY_PORT="${WEBTTY_PORT:-7681}"
HOMEPAGE_PORT="${HOMEPAGE_PORT:-3080}"
FILEBROWSER_PORT="${FILEBROWSER_PORT:-8082}"
NETDATA_PORT="${NETDATA_PORT:-19999}"
SRS_HTTP_PORT="${SRS_HTTP_PORT:-8080}"
SRS_API_PORT="${SRS_API_PORT:-1985}"
SRS_RTMP_PORT="${SRS_RTMP_PORT:-1935}"
SRS_SRT_PORT="${SRS_SRT_PORT:-10080}"
SRS_WEBRTC_PORT="${SRS_WEBRTC_PORT:-8088}"
SHELLCRASH_DIR="${SHELLCRASH_DIR:-/etc/ShellCrash}"
WEBTTY_USER="${WEBTTY_USER:-live}"
WEBTTY_PASS="${WEBTTY_PASS:-12345678}"
FILEBROWSER_USER="${FILEBROWSER_USER:-live}"
FILEBROWSER_PASS="${FILEBROWSER_PASS:-12345678}"
WIFI_CONNECTION_NAME="${WIFI_CONNECTION_NAME:-live-wifi}"
WIFI_SSID="${WIFI_SSID:-live}"
WIFI_PSK="${WIFI_PSK:-12345678}"
BROWSER_PROFILE_DIR="${BROWSER_PROFILE_DIR:-/var/lib/live-browser/profile}"
DISABLE_IPV6="${DISABLE_IPV6:-1}"
ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}"
ANDROID_PANEL_DIR="${ANDROID_PANEL_DIR:-/opt/live-modules/web-scrcpy}"
ANDROID_PANEL_VIDEO_BIT_RATE="${ANDROID_PANEL_VIDEO_BIT_RATE:-1024000}"
ENABLE_LIVE_EDGE_PROXY="${ENABLE_LIVE_EDGE_PROXY:-1}"
}
ensure_live_user() {
if ! id -u live >/dev/null 2>&1; then
log "Creating live user"
useradd -m -s /bin/bash live
fi
echo "live:12345678" | chpasswd
usermod -aG sudo,audio,video,plugdev,render,gpio,spidev,pwm,i2c live 2>/dev/null || true
if getent group docker >/dev/null 2>&1; then
usermod -aG docker live 2>/dev/null || true
fi
}
install_base_packages() {
log "Installing base packages"
export DEBIAN_FRONTEND=noninteractive
apt_install \
git curl wget jq unzip xz-utils ca-certificates sudo \
python3 python3-venv python3-pip build-essential \
avahi-daemon avahi-utils libnss-mdns \
network-manager dnsmasq net-tools \
bluez
systemctl enable --now NetworkManager 2>/dev/null || true
}
install_media_packages() {
log "Installing media and Android tools"
export DEBIAN_FRONTEND=noninteractive
apt_install ffmpeg mpv adb xinit xserver-xorg openbox dbus-x11
apt_install scrcpy || true
}
install_nodejs() {
if have node; then
local major
major="$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0)"
if [ "$major" -ge 20 ]; then
return 0
fi
fi
log "Installing Node.js (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_stack() {
if ! need_docker; then
return 0
fi
if have docker && (docker compose version >/dev/null 2>&1 || have docker-compose); then
return 0
fi
log "Installing Docker"
apt_install docker.io docker-compose
systemctl enable --now docker
usermod -aG docker live || true
}
prepare_python_runtime() {
log "Preparing Python virtual environment"
chown -R live:live "$ROOT_DIR"
as_live "python3 -m venv .venv" || {
log "venv create failed; retry once with --clear"
rm -rf "$ROOT_DIR/.venv"
as_live "python3 -m venv .venv" || log "WARN: venv still failed"
}
if ! as_live ". .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt"; then
log "pip install failed; retry with --no-cache-dir"
as_live ". .venv/bin/activate && pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt" || \
log "WARN: Python deps incomplete; control plane may fail until fixed manually"
fi
}
prepare_web_console() {
log "Building web console"
if as_live "cd web-console && if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm run build"; then
return 0
fi
log "web console build failed; cleaning node_modules and retrying"
as_live "cd web-console && rm -rf node_modules .next && npm install && npm run build" || \
log "WARN: web console build still failed — service may serve stale assets until rebuilt"
}
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
[Service]
User=live
WorkingDirectory=$ROOT_DIR
Environment=HOST=0.0.0.0
Environment=PORT=$PORT
ExecStart=$ROOT_DIR/start.sh
Restart=always
RestartSec=5
# 避免构建/依赖短暂失败时触发 systemd 默认「5 次/10s」后永久停跑表现为 live-edge 502、:8001 无监听)
StartLimitIntervalSec=0
# 首次 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
}
configure_mdns() {
if [ "${ENABLE_MDNS:-1}" != "1" ]; then
return 0
fi
log "Configuring hostname and mDNS"
hostnamectl set-hostname "$HOSTNAME_ALIAS"
local mdns_interfaces
mdns_interfaces="$(detect_mdns_interfaces)"
python3 - <<PY
from pathlib import Path
import re
hosts = Path("/etc/hosts")
hosts_text = hosts.read_text(encoding="utf-8")
if f"127.0.1.1\t${HOSTNAME_ALIAS}" not in hosts_text and f"127.0.1.1 ${HOSTNAME_ALIAS}" not in hosts_text:
lines = []
replaced = False
for raw in hosts_text.splitlines():
if raw.startswith("127.0.1.1"):
lines.append(f"127.0.1.1\t${HOSTNAME_ALIAS}")
replaced = True
else:
lines.append(raw)
if not replaced:
lines.append(f"127.0.1.1\t${HOSTNAME_ALIAS}")
hosts.write_text("\n".join(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
apt_install chromium || apt_install chromium-browser || true
mkdir -p /etc/chromium/policies/managed /etc/chromium-browser/policies/managed
cat >/etc/chromium/policies/managed/live-browser.json <<JSON
{
"ExtensionInstallForcelist": [
"dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx"
],
"DeveloperToolsAvailability": 1
}
JSON
cp /etc/chromium/policies/managed/live-browser.json /etc/chromium-browser/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:-0}" != "1" ]; then
return 0
fi
if ! have docker || ! (docker compose version >/dev/null 2>&1 || have docker-compose); then
log "Neko skipped: docker compose unavailable"
return 0
fi
log "Installing Neko Chromium (Docker, m1k1o/neko)"
set -a
[ -f "$STACK_ENV" ] && . "$STACK_ENV"
set +a
local neko_dir="$ROOT_DIR/services/neko"
local neko_prof="${NEKO_PROFILE_HOST_DIR:-$neko_dir/data/chromium-profile}"
mkdir -p "$neko_prof"
chmod 777 "$neko_prof" 2>/dev/null || true
export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}"
export NEKO_USER_PASS="${NEKO_USER_PASS:-12345678}"
export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-12345678}"
export NEKO_PROFILE_HOST_DIR="$neko_prof"
export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1920x1080@30}"
export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-52000-52100}"
export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52100}"
export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}"
if [ -z "${NEKO_WEBRTC_NAT1TO1:-}" ]; then
export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')"
else
export NEKO_WEBRTC_NAT1TO1
fi
export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}"
compose_in_service_dir "$neko_dir/docker-compose.yml" up -d
}
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 [ ! -d "$ANDROID_PANEL_DIR/.git" ]; then
rm -rf "$ANDROID_PANEL_DIR"
git clone "$ANDROID_PANEL_GIT_URL" "$ANDROID_PANEL_DIR"
else
git -C "$ANDROID_PANEL_DIR" pull --ff-only
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 = (
" 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"
)
new_block = (
" 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"
)
if old_block in text:
text = text.replace(old_block, new_block, 1)
path.write_text(text, encoding="utf-8")
PY
chown -R live:live "$ANDROID_PANEL_DIR"
# Git 2.35+ refuses root git in repos owned by another user unless listed here.
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
runuser -u live -- bash -lc "python3 -m venv '$ANDROID_PANEL_DIR/.venv' && . '$ANDROID_PANEL_DIR/.venv/bin/activate' && pip install --upgrade pip && pip install -r '$ANDROID_PANEL_DIR/requirements.txt'"
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 "WebTTY skipped because ttyd is unavailable"
return 0
fi
systemctl disable --now ttyd.service >/dev/null 2>&1 || true
cat >/etc/systemd/system/live-webtty.service <<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
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_SRS:-1}" = "1" ]; then
cat >>"$target" <<YAML
- SRS:
href: http://${HOSTNAME_ALIAS}.local:${SRS_HTTP_PORT}
description: RTMP, SRT and preview endpoint
YAML
fi
}
prepare_homepage() {
if [ "${ENABLE_HOMEPAGE:-1}" != "1" ] || ! need_docker; then
return 0
fi
log "Preparing Homepage"
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_srs() {
if [ "${ENABLE_SRS:-1}" != "1" ] || ! need_docker; then
return 0
fi
log "Starting SRS"
mkdir -p "$ROOT_DIR/services/srs/data"
compose_in_service_dir "$ROOT_DIR/services/srs/docker-compose.yml" up -d
}
run_hardware_probe() {
log "Probing hardware profile"
as_live ". .venv/bin/activate && python scripts/hardware_probe.py --write"
}
install_live_edge_proxy() {
if [ "${ENABLE_LIVE_EDGE_PROXY:-1}" != "1" ]; then
return 0
fi
log "Installing edge proxy (Caddy :80 → control plane :${PORT})"
mkdir -p /opt/live/generated
local lp="$ROOT_DIR/live-platform"
if [ -f "$lp/tools/render_caddyfile.py" ]; then
python3 "$lp/tools/render_caddyfile.py" "$HOSTNAME_ALIAS" "$PORT" /opt/live/generated/Caddyfile
else
log "WARN: live-platform/tools/render_caddyfile.py missing, skip Caddyfile"
return 0
fi
# 若 Homepage 仍占 80下线并改绑 3080 后重启
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx live-homepage; then
if docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -E 'live-homepage' | grep -qE '0\.0\.0\.0:80->|:::80->'; then
log "Rebinding Homepage from host :80 → :3080 (free port for Caddy)"
export HOMEPAGE_PORT=3080
if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then
sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true
fi
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true
render_homepage_services
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true
fi
fi
if ! command -v caddy >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq caddy || true
fi
fi
if ! command -v caddy >/dev/null 2>&1; then
log "Installing Caddy from GitHub release (apt had no caddy)"
local arch raw
raw="$(uname -m)"
case "$raw" in
aarch64|arm64) arch="arm64" ;;
x86_64|amd64) arch="amd64" ;;
armv7l|armv6l) arch="armv7" ;;
*)
log "WARN: unknown arch $raw — install caddy manually"
return 0
;;
esac
local ver="2.8.4"
local tmp="/tmp/caddy-${ver}-linux-${arch}.tar.gz"
curl -fsSL "https://github.com/caddyserver/caddy/releases/download/v${ver}/caddy_${ver}_linux_${arch}.tar.gz" -o "$tmp"
tar -xzf "$tmp" -C /tmp caddy
install -m0755 /tmp/caddy /usr/local/bin/caddy
rm -f "$tmp" /tmp/caddy
fi
systemctl stop caddy >/dev/null 2>&1 || true
systemctl disable caddy >/dev/null 2>&1 || true
local caddy_bin
caddy_bin="$(command -v caddy || true)"
[ -n "$caddy_bin" ] || caddy_bin="/usr/local/bin/caddy"
cat >/etc/systemd/system/live-edge.service <<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
}