1166 lines
40 KiB
Bash
Executable File
1166 lines
40 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/install/server.sh - install and setup all server related apps and environments
|
|
set -euo pipefail
|
|
ORIG_USER=${SUDO_USER:-$(id -un)}
|
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
SCRIPT_REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd)
|
|
DEFAULT_REPO_URL=ssh://git@scm.bstein.dev:2242/bstein/lesavka.git
|
|
export TMPDIR=${TMPDIR:-/var/tmp}
|
|
|
|
REF=${LESAVKA_REF:-master} # fallback
|
|
REPO_URL=${LESAVKA_REPO_URL:-}
|
|
USER_HOME=$(getent passwd "$ORIG_USER" | cut -d: -f6)
|
|
INSTALL_UVC_CODEC=${LESAVKA_INSTALL_UVC_CODEC:-mjpeg}
|
|
INSTALL_SERVER_BIND_ADDR=${LESAVKA_INSTALL_SERVER_BIND_ADDR:-0.0.0.0:50051}
|
|
LESAVKA_TLS_DIR=${LESAVKA_TLS_DIR:-/etc/lesavka/pki}
|
|
LESAVKA_CLIENT_BUNDLE=${LESAVKA_CLIENT_BUNDLE:-/etc/lesavka/lesavka-client-pki.tar.gz}
|
|
|
|
manifest_package_version() {
|
|
local manifest=$1
|
|
[[ -f $manifest ]] || return 1
|
|
awk -F'"' '
|
|
$0 ~ /^\[package\]/ { in_package=1; next }
|
|
in_package && $0 ~ /^\[/ { exit }
|
|
in_package && $0 ~ /^[[:space:]]*version[[:space:]]*=/ { print $2; exit }
|
|
' "$manifest"
|
|
}
|
|
|
|
render_uvc_env_file() {
|
|
cat <<EOF
|
|
# generated by lesavka/scripts/install/server.sh
|
|
# Edit only for local UVC hardware overrides; rerunning the installer refreshes defaults.
|
|
LESAVKA_UVC_DEBUG=${LESAVKA_UVC_DEBUG:-1}
|
|
LESAVKA_UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
|
LESAVKA_UVC_LIMIT_PCT=${LESAVKA_UVC_LIMIT_PCT:-100}
|
|
LESAVKA_UVC_FPS=${LESAVKA_UVC_FPS:-20}
|
|
LESAVKA_UVC_INTERVAL=${LESAVKA_UVC_INTERVAL:-500000}
|
|
LESAVKA_UVC_WIDTH=${LESAVKA_UVC_WIDTH:-640}
|
|
LESAVKA_UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-480}
|
|
LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}
|
|
LESAVKA_UVC_BLOCKING=${LESAVKA_UVC_BLOCKING:-1}
|
|
LESAVKA_UVC_CONTROL_READ_ONLY=${LESAVKA_UVC_CONTROL_READ_ONLY:-0}
|
|
LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0}
|
|
EOF
|
|
}
|
|
|
|
append_san_entry() {
|
|
local value=$1
|
|
[[ -n $value ]] || return 0
|
|
case "$value" in
|
|
IP:*)
|
|
TLS_SAN_IPS+=("${value#IP:}")
|
|
;;
|
|
DNS:*)
|
|
TLS_SAN_DNS+=("${value#DNS:}")
|
|
;;
|
|
*[!0-9.]*)
|
|
TLS_SAN_DNS+=("$value")
|
|
;;
|
|
*)
|
|
TLS_SAN_IPS+=("$value")
|
|
;;
|
|
esac
|
|
}
|
|
|
|
render_server_cert_ext() {
|
|
local ext_file=$1
|
|
local dns_index=1
|
|
local ip_index=1
|
|
{
|
|
echo "basicConstraints = CA:FALSE"
|
|
echo "keyUsage = digitalSignature,keyEncipherment"
|
|
echo "extendedKeyUsage = serverAuth"
|
|
echo "subjectAltName = @alt_names"
|
|
echo "[alt_names]"
|
|
local value
|
|
for value in "${TLS_SAN_DNS[@]}"; do
|
|
[[ -n $value ]] || continue
|
|
printf 'DNS.%d = %s\n' "$dns_index" "$value"
|
|
dns_index=$((dns_index + 1))
|
|
done
|
|
for value in "${TLS_SAN_IPS[@]}"; do
|
|
[[ -n $value ]] || continue
|
|
printf 'IP.%d = %s\n' "$ip_index" "$value"
|
|
ip_index=$((ip_index + 1))
|
|
done
|
|
} >"$ext_file"
|
|
}
|
|
|
|
ensure_server_tls_pki() {
|
|
echo "==> 5c. TLS/mTLS identity"
|
|
sudo install -d -m 0750 "$LESAVKA_TLS_DIR"
|
|
|
|
if ! sudo test -s "$LESAVKA_TLS_DIR/ca.key" || ! sudo test -s "$LESAVKA_TLS_DIR/ca.crt"; then
|
|
echo " ↪ generating Lesavka local CA"
|
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/ca.key" 4096 >/dev/null 2>&1
|
|
sudo openssl req -x509 -new -nodes \
|
|
-key "$LESAVKA_TLS_DIR/ca.key" \
|
|
-sha256 -days "${LESAVKA_TLS_CA_DAYS:-3650}" \
|
|
-subj "/CN=Lesavka Local Relay CA" \
|
|
-out "$LESAVKA_TLS_DIR/ca.crt" >/dev/null 2>&1
|
|
fi
|
|
|
|
TLS_SAN_DNS=(lesavka-server "$(hostname -s 2>/dev/null || true)" "$(hostname -f 2>/dev/null || true)")
|
|
TLS_SAN_IPS=(127.0.0.1 38.28.125.112)
|
|
local extra_san
|
|
IFS=',' read -r -a extra_san <<<"${LESAVKA_TLS_SAN:-}"
|
|
local san
|
|
for san in "${extra_san[@]}"; do
|
|
append_san_entry "${san//[[:space:]]/}"
|
|
done
|
|
|
|
local ext_file client_ext_file
|
|
ext_file=$(mktemp)
|
|
client_ext_file=$(mktemp)
|
|
render_server_cert_ext "$ext_file"
|
|
{
|
|
echo "basicConstraints = CA:FALSE"
|
|
echo "keyUsage = digitalSignature,keyEncipherment"
|
|
echo "extendedKeyUsage = clientAuth"
|
|
} >"$client_ext_file"
|
|
|
|
if ! sudo test -s "$LESAVKA_TLS_DIR/server.key" || ! sudo test -s "$LESAVKA_TLS_DIR/server.crt"; then
|
|
echo " ↪ generating server certificate"
|
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/server.key" 2048 >/dev/null 2>&1
|
|
sudo openssl req -new \
|
|
-key "$LESAVKA_TLS_DIR/server.key" \
|
|
-subj "/CN=lesavka-server" \
|
|
-out "$LESAVKA_TLS_DIR/server.csr" >/dev/null 2>&1
|
|
sudo openssl x509 -req \
|
|
-in "$LESAVKA_TLS_DIR/server.csr" \
|
|
-CA "$LESAVKA_TLS_DIR/ca.crt" \
|
|
-CAkey "$LESAVKA_TLS_DIR/ca.key" \
|
|
-CAcreateserial \
|
|
-out "$LESAVKA_TLS_DIR/server.crt" \
|
|
-days "${LESAVKA_TLS_CERT_DAYS:-825}" \
|
|
-sha256 \
|
|
-extfile "$ext_file" >/dev/null 2>&1
|
|
sudo rm -f "$LESAVKA_TLS_DIR/server.csr"
|
|
fi
|
|
|
|
if ! sudo test -s "$LESAVKA_TLS_DIR/client.key" || ! sudo test -s "$LESAVKA_TLS_DIR/client.crt"; then
|
|
echo " ↪ generating default client certificate"
|
|
sudo openssl genrsa -out "$LESAVKA_TLS_DIR/client.key" 2048 >/dev/null 2>&1
|
|
sudo openssl req -new \
|
|
-key "$LESAVKA_TLS_DIR/client.key" \
|
|
-subj "/CN=lesavka-client" \
|
|
-out "$LESAVKA_TLS_DIR/client.csr" >/dev/null 2>&1
|
|
sudo openssl x509 -req \
|
|
-in "$LESAVKA_TLS_DIR/client.csr" \
|
|
-CA "$LESAVKA_TLS_DIR/ca.crt" \
|
|
-CAkey "$LESAVKA_TLS_DIR/ca.key" \
|
|
-CAcreateserial \
|
|
-out "$LESAVKA_TLS_DIR/client.crt" \
|
|
-days "${LESAVKA_TLS_CERT_DAYS:-825}" \
|
|
-sha256 \
|
|
-extfile "$client_ext_file" >/dev/null 2>&1
|
|
sudo rm -f "$LESAVKA_TLS_DIR/client.csr"
|
|
fi
|
|
|
|
sudo chmod 0600 "$LESAVKA_TLS_DIR/"*.key
|
|
sudo chmod 0644 "$LESAVKA_TLS_DIR/"*.crt
|
|
rm -f "$ext_file" "$client_ext_file"
|
|
|
|
local bundle_tmp
|
|
bundle_tmp=$(mktemp -d)
|
|
sudo cp "$LESAVKA_TLS_DIR/ca.crt" "$bundle_tmp/ca.crt"
|
|
sudo cp "$LESAVKA_TLS_DIR/client.crt" "$bundle_tmp/client.crt"
|
|
sudo cp "$LESAVKA_TLS_DIR/client.key" "$bundle_tmp/client.key"
|
|
sudo tar -C "$bundle_tmp" -czf "$LESAVKA_CLIENT_BUNDLE" ca.crt client.crt client.key
|
|
sudo chown "$ORIG_USER":"$ORIG_USER" "$LESAVKA_CLIENT_BUNDLE"
|
|
sudo chmod 0600 "$LESAVKA_CLIENT_BUNDLE"
|
|
sudo rm -rf "$bundle_tmp"
|
|
echo " ↪ client enrollment bundle: $LESAVKA_CLIENT_BUNDLE"
|
|
}
|
|
|
|
find_uvc_output_node() {
|
|
local by_path_root=/dev/v4l/by-path
|
|
local ctrl=""
|
|
ctrl=$(ls /sys/class/udc 2>/dev/null | head -n1 || true)
|
|
if [[ -n $ctrl && -e "$by_path_root/platform-$ctrl-video-index0" ]]; then
|
|
printf '%s\n' "$by_path_root/platform-$ctrl-video-index0"
|
|
return 0
|
|
fi
|
|
|
|
local candidate
|
|
shopt -s nullglob
|
|
for candidate in "$by_path_root"/platform-*-video-index0; do
|
|
printf '%s\n' "$candidate"
|
|
shopt -u nullglob
|
|
return 0
|
|
done
|
|
shopt -u nullglob
|
|
return 1
|
|
}
|
|
|
|
wait_for_uvc_output_node() {
|
|
local node=""
|
|
for _ in {1..50}; do
|
|
if node=$(find_uvc_output_node); then
|
|
printf '%s\n' "$node"
|
|
return 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
validate_uvc_gadget_ready() {
|
|
if [[ -n ${LESAVKA_DISABLE_UVC:-} ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ ! -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ]]; then
|
|
echo "❌ UVC gadget function is missing after rebuild; refusing to continue with a half-applied install." >&2
|
|
return 1
|
|
fi
|
|
|
|
local node=""
|
|
if ! node=$(wait_for_uvc_output_node); then
|
|
echo "❌ UVC gadget video-output node did not appear after rebuild; refusing to continue." >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "✅ UVC gadget output ready at ${node}"
|
|
}
|
|
|
|
uvc_gadget_present() {
|
|
[[ -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ]]
|
|
}
|
|
|
|
udc_state() {
|
|
local udc=""
|
|
udc=$(ls /sys/class/udc 2>/dev/null | head -n1 || true)
|
|
if [[ -z $udc ]]; then
|
|
echo "unknown"
|
|
return 0
|
|
fi
|
|
cat "/sys/class/udc/$udc/state" 2>/dev/null || echo "unknown"
|
|
}
|
|
|
|
is_attached_state() {
|
|
case "$1" in
|
|
configured|addressed|default|suspended|unknown)
|
|
return 0
|
|
;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
server_bind_addr() {
|
|
printf '%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
|
}
|
|
|
|
server_bind_port() {
|
|
local bind_addr port
|
|
bind_addr=$(server_bind_addr)
|
|
port=${bind_addr##*:}
|
|
[[ $port =~ ^[0-9]+$ ]] || return 1
|
|
printf '%s\n' "$port"
|
|
}
|
|
|
|
list_server_listener_pids() {
|
|
local port
|
|
port=$(server_bind_port) || return 1
|
|
{
|
|
sudo lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true
|
|
list_server_listener_pids_proc "$port"
|
|
} | sed '/^$/d' | sort -u
|
|
}
|
|
|
|
list_server_bound_inactive_lines() {
|
|
local port=$1
|
|
sudo ss -H -B -tn "sport = :$port" 2>/dev/null | sed '/^$/d' || true
|
|
}
|
|
|
|
list_server_listener_inodes_proc() {
|
|
local port=$1 hex_port
|
|
hex_port=$(printf '%04X' "$port")
|
|
sudo awk -v target="$hex_port" '
|
|
FNR == 1 { next }
|
|
$4 == "0A" {
|
|
split($2, local, ":")
|
|
if (toupper(local[2]) == target) print $10
|
|
}
|
|
' /proc/net/tcp /proc/net/tcp6 2>/dev/null | sed '/^$/d' | sort -u
|
|
}
|
|
|
|
list_server_listener_pids_proc() {
|
|
local port=$1
|
|
local -a inodes=()
|
|
mapfile -t inodes < <(list_server_listener_inodes_proc "$port")
|
|
if (( ${#inodes[@]} == 0 )); then
|
|
return 0
|
|
fi
|
|
|
|
sudo bash -s -- "${inodes[@]}" <<'EOF'
|
|
set -euo pipefail
|
|
inodes=("$@")
|
|
shopt -s nullglob
|
|
for fd in /proc/[0-9]*/fd/*; do
|
|
target=$(readlink "$fd" 2>/dev/null || true)
|
|
for inode in "${inodes[@]}"; do
|
|
if [[ "$target" == "socket:[$inode]" ]]; then
|
|
pid=${fd#/proc/}
|
|
pid=${pid%%/*}
|
|
printf '%s\n' "$pid"
|
|
break
|
|
fi
|
|
done
|
|
done | sort -u
|
|
EOF
|
|
}
|
|
|
|
server_listener_presence_detail() {
|
|
local port=$1
|
|
local -a inodes=()
|
|
mapfile -t inodes < <(list_server_listener_inodes_proc "$port")
|
|
if (( ${#inodes[@]} == 0 )); then
|
|
printf 'no /proc listener entries for :%s' "$port"
|
|
return 0
|
|
fi
|
|
printf 'listener inodes on :%s => %s' "$port" "${inodes[*]}"
|
|
}
|
|
|
|
server_bound_inactive_detail() {
|
|
local port=$1
|
|
local detail
|
|
detail=$(list_server_bound_inactive_lines "$port" | paste -sd ';' -)
|
|
if [[ -z $detail ]]; then
|
|
printf 'no bound-inactive tcp sockets for :%s' "$port"
|
|
return 0
|
|
fi
|
|
printf 'bound-inactive tcp sockets on :%s => %s' "$port" "$detail"
|
|
}
|
|
|
|
destroy_hidden_server_listener() {
|
|
local port=$1
|
|
echo "⚠️ TCP :$port is listening without a visible owning PID; asking the kernel to drop the stale socket state."
|
|
sudo ss -K state connected "sport = :$port or dport = :$port" >/dev/null 2>&1 || true
|
|
sudo ss -K state listening "sport = :$port" >/dev/null 2>&1 || true
|
|
|
|
for _ in {1..30}; do
|
|
if ! list_server_listener_inodes_proc "$port" | grep -q .; then
|
|
if list_server_bound_inactive_lines "$port" | grep -q .; then
|
|
echo "❌ TCP :$port stopped listening but remains bound after kernel cleanup; $(server_bound_inactive_detail "$port")." >&2
|
|
return 1
|
|
fi
|
|
echo "✅ kernel dropped the hidden TCP :$port socket state."
|
|
return 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
echo "❌ TCP :$port still appears to be listening after kernel socket cleanup; $(server_listener_presence_detail "$port")." >&2
|
|
return 1
|
|
}
|
|
|
|
clear_stale_server_listener() {
|
|
local port pid cmdline found=0 unexpected=0
|
|
port=$(server_bind_port) || {
|
|
echo "⚠️ could not parse LESAVKA_SERVER_BIND_ADDR='$(server_bind_addr)'; skipping stale-listener cleanup."
|
|
return 0
|
|
}
|
|
|
|
while read -r pid; do
|
|
[[ -n $pid ]] || continue
|
|
found=1
|
|
cmdline=$(sudo ps -o args= -p "$pid" 2>/dev/null || true)
|
|
if [[ $cmdline == *"/usr/local/bin/lesavka-server"* ]] || [[ $cmdline == *"lesavka-server"* ]]; then
|
|
echo "⚠️ clearing stale lesavka-server listener on :$port (pid $pid)."
|
|
sudo kill -TERM "$pid" 2>/dev/null || true
|
|
else
|
|
echo "❌ TCP :$port is already owned by an unexpected process (pid $pid): ${cmdline:-<unknown>}." >&2
|
|
unexpected=1
|
|
fi
|
|
done < <(list_server_listener_pids || true)
|
|
|
|
if [[ "$unexpected" != "0" ]]; then
|
|
return 1
|
|
fi
|
|
if [[ "$found" == "0" ]]; then
|
|
if list_server_listener_inodes_proc "$port" | grep -q .; then
|
|
if destroy_hidden_server_listener "$port"; then
|
|
return 0
|
|
fi
|
|
echo "❌ TCP :$port is listening but no owning PID could be identified; $(server_listener_presence_detail "$port")." >&2
|
|
return 1
|
|
fi
|
|
if list_server_bound_inactive_lines "$port" | grep -q .; then
|
|
echo "❌ TCP :$port is not listening but remains bound; $(server_bound_inactive_detail "$port")." >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
for _ in {1..30}; do
|
|
if ! list_server_listener_pids | grep -q .; then
|
|
echo "✅ cleared stale lesavka-server listeners on :$port."
|
|
return 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
while read -r pid; do
|
|
[[ -n $pid ]] || continue
|
|
cmdline=$(sudo ps -o args= -p "$pid" 2>/dev/null || true)
|
|
if [[ $cmdline == *"/usr/local/bin/lesavka-server"* ]] || [[ $cmdline == *"lesavka-server"* ]]; then
|
|
echo "⚠️ stale lesavka-server listener on :$port survived SIGTERM; sending SIGKILL to pid $pid."
|
|
sudo kill -KILL "$pid" 2>/dev/null || true
|
|
else
|
|
echo "❌ TCP :$port remained busy after cleanup and is owned by an unexpected process (pid $pid): ${cmdline:-<unknown>}." >&2
|
|
return 1
|
|
fi
|
|
done < <(list_server_listener_pids || true)
|
|
|
|
for _ in {1..30}; do
|
|
if ! list_server_listener_pids | grep -q .; then
|
|
echo "✅ cleared stale lesavka-server listeners on :$port."
|
|
return 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
if list_server_listener_inodes_proc "$port" | grep -q .; then
|
|
echo "❌ TCP :$port still appears to be listening after cleanup; $(server_listener_presence_detail "$port")." >&2
|
|
return 1
|
|
fi
|
|
if list_server_bound_inactive_lines "$port" | grep -q .; then
|
|
echo "❌ TCP :$port stopped listening but remains bound after cleanup; $(server_bound_inactive_detail "$port")." >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "❌ lesavka-server listener on :$port survived cleanup; refusing to start a duplicate server." >&2
|
|
return 1
|
|
}
|
|
|
|
wait_for_unit_running() {
|
|
local unit=$1
|
|
for _ in {1..50}; do
|
|
if systemctl is-active --quiet "$unit"; then
|
|
if [[ $(systemctl show "$unit" -p SubState --value 2>/dev/null || true) == "running" ]]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
return 1
|
|
}
|
|
|
|
validate_server_ready() {
|
|
local bind_addr port
|
|
bind_addr=$(server_bind_addr)
|
|
port=$(server_bind_port) || {
|
|
echo "❌ could not parse LESAVKA_SERVER_BIND_ADDR='${bind_addr}' while validating server readiness." >&2
|
|
return 1
|
|
}
|
|
if wait_for_unit_running lesavka-server; then
|
|
for _ in {1..50}; do
|
|
if list_server_listener_inodes_proc "$port" | grep -q .; then
|
|
echo "✅ lesavka-server is active and listening on ${bind_addr}."
|
|
return 0
|
|
fi
|
|
sleep 0.2
|
|
done
|
|
echo "❌ lesavka-server reached active/running state but did not open TCP :${port}." >&2
|
|
sudo journalctl -b -u lesavka-server -n 80 --no-pager >&2 || true
|
|
return 1
|
|
fi
|
|
|
|
echo "❌ lesavka-server failed to reach active/running state on ${bind_addr}." >&2
|
|
sudo systemctl status lesavka-server --no-pager >&2 || true
|
|
if [[ -s /tmp/lesavka-server.stderr ]]; then
|
|
echo "---- /tmp/lesavka-server.stderr (tail) ----" >&2
|
|
sudo tail -n 40 /tmp/lesavka-server.stderr >&2 || true
|
|
echo "------------------------------------------" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
normalize_hdmi_connector() {
|
|
local name="$1"
|
|
if [[ $name =~ (HDMI-A-[0-9]+)$ ]]; then
|
|
printf '%s\n' "${BASH_REMATCH[1]}"
|
|
else
|
|
printf '%s\n' "$name"
|
|
fi
|
|
}
|
|
|
|
read_existing_hdmi_connector() {
|
|
local line value
|
|
line=$(grep -E '^LESAVKA_HDMI_CONNECTOR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 || true)
|
|
[[ -n $line ]] || return 0
|
|
value=${line#*=}
|
|
value=${value%\"}
|
|
value=${value#\"}
|
|
value=${value%\'}
|
|
value=${value#\'}
|
|
normalize_hdmi_connector "$value"
|
|
}
|
|
|
|
boot_config_path() {
|
|
for path in /boot/config.txt /boot/firmware/config.txt; do
|
|
if [[ -e $path ]]; then
|
|
printf '%s\n' "$path"
|
|
return 0
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
boot_cmdline_path() {
|
|
for path in /boot/cmdline.txt /boot/firmware/cmdline.txt; do
|
|
if [[ -e $path ]]; then
|
|
printf '%s\n' "$path"
|
|
return 0
|
|
fi
|
|
done
|
|
return 0
|
|
}
|
|
|
|
runtime_forced_hdmi_connectors() {
|
|
[[ -r /proc/cmdline ]] || return 0
|
|
tr ' ' '\n' </proc/cmdline |
|
|
sed -n 's/^video=\(HDMI-A-[0-9]\+\):.*e$/\1/p' |
|
|
sort -u
|
|
}
|
|
|
|
remove_lesavka_hdmi_config_force() {
|
|
local cfg tmp
|
|
cfg=$(boot_config_path)
|
|
[[ -n $cfg ]] || return 0
|
|
tmp=$(mktemp)
|
|
awk '
|
|
/^# lesavka: force HDMI/ {next}
|
|
/^hdmi_force_hotplug:[0-9]+=1$/ {next}
|
|
/^hdmi_group:[0-9]+=2$/ {next}
|
|
/^hdmi_mode:[0-9]+=82$/ {next}
|
|
{print}
|
|
' "$cfg" >"$tmp"
|
|
sudo install -m 0644 "$tmp" "$cfg"
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
write_hdmi_cmdline_force() {
|
|
local connector="${1:-}" cmd token tmp line
|
|
cmd=$(boot_cmdline_path)
|
|
[[ -n $cmd ]] || return 0
|
|
tmp=$(mktemp)
|
|
line=$(cat "$cmd")
|
|
for token in $line; do
|
|
case "$token" in
|
|
video=HDMI-A-*:1920x1080@60e)
|
|
continue
|
|
;;
|
|
esac
|
|
printf '%s ' "$token" >>"$tmp"
|
|
done
|
|
if [[ -n $connector ]]; then
|
|
printf 'video=%s:1920x1080@60e ' "$connector" >>"$tmp"
|
|
fi
|
|
sed -i 's/[[:space:]]*$//' "$tmp"
|
|
printf '\n' >>"$tmp"
|
|
sudo install -m 0644 "$tmp" "$cmd"
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
clear_lesavka_hdmi_boot_force() {
|
|
remove_lesavka_hdmi_config_force
|
|
write_hdmi_cmdline_force ""
|
|
}
|
|
|
|
write_hdmi_boot_force() {
|
|
local connector="$1" idx cfg
|
|
connector=$(normalize_hdmi_connector "$connector")
|
|
if [[ ! $connector =~ ^HDMI-A-([0-9]+)$ ]]; then
|
|
echo "⚠️ cannot write HDMI boot force for unexpected connector '$connector'." >&2
|
|
return 0
|
|
fi
|
|
|
|
idx=$((BASH_REMATCH[1] - 1))
|
|
cfg=$(boot_config_path)
|
|
remove_lesavka_hdmi_config_force
|
|
if [[ -n $cfg ]]; then
|
|
{
|
|
echo
|
|
echo "# lesavka: force HDMI mode for capture dongle ($connector)"
|
|
printf 'hdmi_force_hotplug:%s=1\n' "$idx"
|
|
printf 'hdmi_group:%s=2\n' "$idx"
|
|
printf 'hdmi_mode:%s=82\n' "$idx"
|
|
} | sudo tee -a "$cfg" >/dev/null
|
|
fi
|
|
write_hdmi_cmdline_force "$connector"
|
|
}
|
|
|
|
prepare_hdmi_detection_boot_state() {
|
|
local -a forced=()
|
|
|
|
if [[ -n ${LESAVKA_HDMI_CONNECTOR:-} ]]; then
|
|
return 0
|
|
fi
|
|
|
|
mapfile -t forced < <(runtime_forced_hdmi_connectors)
|
|
if (( ${#forced[@]} <= 1 )); then
|
|
return 0
|
|
fi
|
|
|
|
echo "⚠️ current boot forces multiple HDMI connectors: ${forced[*]}" >&2
|
|
echo " Linux will report every forced connector as connected, so auto-detection cannot be trusted yet." >&2
|
|
clear_lesavka_hdmi_boot_force
|
|
echo "✅ removed Lesavka-managed dual-HDMI force settings from boot config." >&2
|
|
echo " Reboot Theia, then rerun this installer so it can see the real connected HDMI port." >&2
|
|
echo " If both ports are physically connected after reboot, rerun once with LESAVKA_HDMI_CONNECTOR=HDMI-A-N." >&2
|
|
exit 1
|
|
}
|
|
|
|
describe_hdmi_connectors() {
|
|
local dev name status id edid_bytes
|
|
for dev in /sys/class/drm/card*-HDMI-A-*; do
|
|
[[ -e $dev/status ]] || continue
|
|
name=$(basename "$dev")
|
|
status=$(cat "$dev/status" 2>/dev/null || true)
|
|
id=$(cat "$dev/connector_id" 2>/dev/null || true)
|
|
edid_bytes=$(wc -c <"$dev/edid" 2>/dev/null || echo 0)
|
|
printf ' %s (%s): %s, edid=%sB\n' "$(normalize_hdmi_connector "$name")" "${id:-no-id}" "${status:-unknown}" "${edid_bytes:-0}" >&2
|
|
done
|
|
}
|
|
|
|
detect_connected_hdmi_connector() {
|
|
local dev name stable_name status score existing increment edid_bytes
|
|
local best="" best_score=0 best_count=0
|
|
declare -A scores=()
|
|
|
|
# HDMI status can briefly flap during gadget/display bring-up, so sample a
|
|
# few times. Store the logical connector suffix, not the DRM card prefix, so
|
|
# this stays valid if Linux renumbers cardN across boots.
|
|
for _ in 1 2 3 4 5; do
|
|
for dev in /sys/class/drm/card*-HDMI-A-*; do
|
|
[[ -e $dev/status ]] || continue
|
|
name=$(basename "$dev")
|
|
stable_name=$(normalize_hdmi_connector "$name")
|
|
status=$(cat "$dev/status" 2>/dev/null || true)
|
|
if [[ $status == connected ]]; then
|
|
increment=1
|
|
# A boot-forced HDMI connector also reports "connected" on Raspberry Pi,
|
|
# even when no sink is present. Real EDID is stronger evidence than the
|
|
# status bit, so prefer it before falling back to older install state.
|
|
edid_bytes=$(wc -c <"$dev/edid" 2>/dev/null || echo 0)
|
|
if [[ ${edid_bytes:-0} =~ ^[0-9]+$ ]] && (( edid_bytes > 0 )); then
|
|
increment=20
|
|
fi
|
|
scores[$stable_name]=$(( ${scores[$stable_name]:-0} + increment ))
|
|
fi
|
|
done
|
|
sleep 0.2
|
|
done
|
|
|
|
for name in "${!scores[@]}"; do
|
|
score=${scores[$name]}
|
|
if (( score > best_score )); then
|
|
best=$name
|
|
best_score=$score
|
|
fi
|
|
done
|
|
|
|
(( best_score > 0 )) || return 0
|
|
|
|
for name in "${!scores[@]}"; do
|
|
if (( scores[$name] == best_score )); then
|
|
best_count=$((best_count + 1))
|
|
fi
|
|
done
|
|
|
|
if (( best_count == 1 )); then
|
|
printf '%s\n' "$best"
|
|
return 0
|
|
fi
|
|
|
|
existing=$(read_existing_hdmi_connector)
|
|
if [[ -n $existing && ${scores[$existing]:-0} -eq $best_score ]]; then
|
|
echo "⚠️ multiple HDMI connectors are equally connected; preserving existing LESAVKA_HDMI_CONNECTOR=$existing." >&2
|
|
describe_hdmi_connectors
|
|
printf '%s\n' "$existing"
|
|
return 0
|
|
fi
|
|
|
|
echo "❌ multiple HDMI connectors are equally connected; refusing to guess." >&2
|
|
describe_hdmi_connectors
|
|
echo " Disconnect the non-target HDMI output or run once with LESAVKA_HDMI_CONNECTOR=HDMI-A-N." >&2
|
|
return 1
|
|
}
|
|
|
|
resolve_hdmi_connector() {
|
|
local detected existing
|
|
if [[ -n ${LESAVKA_HDMI_CONNECTOR:-} ]]; then
|
|
normalize_hdmi_connector "$LESAVKA_HDMI_CONNECTOR"
|
|
return 0
|
|
fi
|
|
|
|
if ! detected=$(detect_connected_hdmi_connector); then
|
|
return 1
|
|
fi
|
|
if [[ -n $detected ]]; then
|
|
printf '%s\n' "$detected"
|
|
return 0
|
|
fi
|
|
|
|
existing=$(read_existing_hdmi_connector)
|
|
if [[ -n $existing ]]; then
|
|
echo "⚠️ no connected HDMI connector detected; preserving existing LESAVKA_HDMI_CONNECTOR=$existing." >&2
|
|
printf '%s\n' "$existing"
|
|
fi
|
|
}
|
|
|
|
run_as_user() {
|
|
sudo -u "$ORIG_USER" env HOME="$USER_HOME" SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-}" "$@"
|
|
}
|
|
|
|
CAPTURE_DISCOVERY_RELAY_PRESENT=0
|
|
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=0
|
|
CAPTURE_DISCOVERY_POWER_BORROWED=0
|
|
|
|
prepare_capture_power_for_discovery() {
|
|
if ! systemctl list-unit-files | grep -q '^relay.service'; then
|
|
return 0
|
|
fi
|
|
|
|
CAPTURE_DISCOVERY_RELAY_PRESENT=1
|
|
if systemctl is-active --quiet relay.service; then
|
|
CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE=1
|
|
return 0
|
|
fi
|
|
|
|
echo " ↪ borrowing relay GPIO power for capture discovery"
|
|
sudo systemctl start relay.service
|
|
CAPTURE_DISCOVERY_POWER_BORROWED=1
|
|
sudo udevadm settle --timeout=10 || true
|
|
sleep 2
|
|
}
|
|
|
|
restore_capture_power_after_discovery() {
|
|
if [ "${CAPTURE_DISCOVERY_RELAY_PRESENT:-0}" -eq 0 ]; then
|
|
return 0
|
|
fi
|
|
if [ "${CAPTURE_DISCOVERY_POWER_BORROWED:-0}" -eq 0 ]; then
|
|
return 0
|
|
fi
|
|
if [ "${CAPTURE_DISCOVERY_RELAY_WAS_ACTIVE:-0}" -ne 0 ]; then
|
|
return 0
|
|
fi
|
|
|
|
echo " ↪ returning relay GPIO power control to Lesavka auto mode"
|
|
sudo systemctl stop relay.service || true
|
|
CAPTURE_DISCOVERY_POWER_BORROWED=0
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-r|--ref) REF="$2"; shift 2 ;;
|
|
-h|--help)
|
|
echo "Usage: $0 [--ref <branch|commit>]"; exit 0 ;;
|
|
*) echo "Unknown option: $1"; exit 1 ;;
|
|
esac
|
|
done
|
|
echo "==> Using git ref: $REF"
|
|
mkdir -p "$TMPDIR"
|
|
|
|
if [[ -z $REPO_URL ]] && [[ -d $SCRIPT_REPO_ROOT/.git ]]; then
|
|
REPO_URL=$(git -C "$SCRIPT_REPO_ROOT" config --get remote.origin.url || true)
|
|
fi
|
|
REPO_URL=${REPO_URL:-$DEFAULT_REPO_URL}
|
|
|
|
echo "==> 1a. Base packages"
|
|
sudo pacman -Sq --needed --noconfirm git \
|
|
rustup \
|
|
protobuf \
|
|
abseil-cpp \
|
|
gcc \
|
|
alsa-utils \
|
|
pipewire \
|
|
pipewire-pulse \
|
|
tailscale \
|
|
base-devel \
|
|
v4l-utils \
|
|
gstreamer \
|
|
gst-plugins-base \
|
|
gst-plugins-base-libs \
|
|
gst-plugins-good \
|
|
gst-plugins-bad \
|
|
gst-plugins-bad-libs \
|
|
gst-plugins-ugly \
|
|
gst-libav \
|
|
tcpdump \
|
|
lsof \
|
|
openssl
|
|
if ! command -v yay >/dev/null 2>&1; then
|
|
echo "==> 1b. installing yay from AUR ..."
|
|
run_as_user env TMPDIR="$TMPDIR" bash -c '
|
|
rm -rf "$TMPDIR/yay" &&
|
|
cd "$TMPDIR" && git clone --depth 1 https://aur.archlinux.org/yay.git &&
|
|
cd yay && makepkg -si --noconfirm'
|
|
fi
|
|
# yay -S --noconfirm grpcurl-bin
|
|
|
|
echo "==> 1c. GPIO permissions for relay"
|
|
echo 'z /dev/gpiochip* 0660 root gpio -' | sudo tee /etc/tmpfiles.d/gpiochip.conf >/dev/null
|
|
sudo systemd-tmpfiles --create /etc/tmpfiles.d/gpiochip.conf || true
|
|
|
|
echo "==> 1d. Audio permissions for diagnostics"
|
|
if getent group audio >/dev/null 2>&1 && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
|
|
sudo usermod -aG audio "${SUDO_USER}" || true
|
|
fi
|
|
|
|
echo "==> 2a. Kernel-driver tweaks"
|
|
cat <<'EOF' | sudo tee /etc/modprobe.d/gc311-stream.conf >/dev/null
|
|
options uvcvideo quirks=0x200 timeout=10000
|
|
EOF
|
|
prepare_hdmi_detection_boot_state
|
|
|
|
echo "==> 2b. Predictable /dev names for each capture card"
|
|
prepare_capture_power_for_discovery
|
|
trap restore_capture_power_after_discovery EXIT
|
|
|
|
discover_gc_capture_pairs() {
|
|
for dev in /dev/video*; do
|
|
props=$(sudo udevadm info -q property -n "$dev" 2>/dev/null || true)
|
|
index=$(cat "/sys/class/video4linux/$(basename "$dev")/index" 2>/dev/null || true)
|
|
if echo "$props" | grep -q 'ID_VENDOR_ID=07ca' \
|
|
&& echo "$props" | grep -q 'ID_MODEL_ID=3311' \
|
|
&& [ "$index" = "0" ]; then
|
|
tag=$(printf '%s\n' "$props" | awk -F= '/^ID_PATH_TAG=/{print $2}')
|
|
if [ -n "$tag" ]; then
|
|
printf '%s %s\n' "$tag" "$dev"
|
|
fi
|
|
fi
|
|
done | sort -u
|
|
}
|
|
|
|
mapfile -t GC_CAPTURE_PAIRS < <(discover_gc_capture_pairs)
|
|
if [ "${#GC_CAPTURE_PAIRS[@]}" -ne 2 ]; then
|
|
for _ in {1..20}; do
|
|
sleep 0.5
|
|
mapfile -t GC_CAPTURE_PAIRS < <(discover_gc_capture_pairs)
|
|
[ "${#GC_CAPTURE_PAIRS[@]}" -eq 2 ] && break
|
|
done
|
|
fi
|
|
|
|
if [ "${#GC_CAPTURE_PAIRS[@]}" -ne 2 ]; then
|
|
echo "⚠️ GC311 capture cards not fully present; skipping udev eye-link refresh." >&2
|
|
if [ "${#GC_CAPTURE_PAIRS[@]}" -eq 0 ]; then
|
|
echo " Detected: none" >&2
|
|
else
|
|
printf ' Detected: %s\n' "${GC_CAPTURE_PAIRS[@]}" >&2
|
|
fi
|
|
echo " The server install will continue, and existing /dev/lesavka_* links stay untouched." >&2
|
|
else
|
|
LEFT_TAG=${GC_CAPTURE_PAIRS[0]%% *}
|
|
LEFT_DEV=${GC_CAPTURE_PAIRS[0]#* }
|
|
RIGHT_TAG=${GC_CAPTURE_PAIRS[1]%% *}
|
|
RIGHT_DEV=${GC_CAPTURE_PAIRS[1]#* }
|
|
|
|
if [ -z "$LEFT_TAG" ] || [ -z "$RIGHT_TAG" ] || [ "$LEFT_TAG" = "$RIGHT_TAG" ]; then
|
|
echo "⚠️ GC311 cards were detected, but the capture path tags are incomplete or duplicated." >&2
|
|
printf ' Left candidate: %s\n' "${GC_CAPTURE_PAIRS[0]:-missing}" >&2
|
|
printf ' Right candidate: %s\n' "${GC_CAPTURE_PAIRS[1]:-missing}" >&2
|
|
echo " Skipping udev eye-link refresh and preserving any existing /dev/lesavka_* links." >&2
|
|
else
|
|
printf ' ↪ Left card: %s (%s)\n' "$LEFT_DEV" "$LEFT_TAG"
|
|
printf ' ↪ Right card: %s (%s)\n' "$RIGHT_DEV" "$RIGHT_TAG"
|
|
|
|
sudo tee /etc/udev/rules.d/85-gc311.rules >/dev/null <<EOF
|
|
# auto-generated by lesavka/scripts/daemon/install-server.sh - DO NOT EDIT
|
|
SUBSYSTEM=="video4linux", ATTR{index}=="0", ENV{ID_PATH_TAG}=="$LEFT_TAG", SYMLINK+="lesavka_l_eye"
|
|
SUBSYSTEM=="video4linux", ATTR{index}=="0", ENV{ID_PATH_TAG}=="$RIGHT_TAG", SYMLINK+="lesavka_r_eye"
|
|
EOF
|
|
|
|
sudo udevadm control --reload
|
|
sudo udevadm trigger --subsystem-match=video4linux
|
|
sudo udevadm settle
|
|
fi
|
|
fi
|
|
restore_capture_power_after_discovery
|
|
trap - EXIT
|
|
|
|
echo "==> 3. Rust toolchain"
|
|
sudo rustup default stable
|
|
run_as_user rustup default stable
|
|
|
|
echo "==> 4a. Source checkout"
|
|
SRC_DIR=/var/src/lesavka
|
|
if [[ ! -d $SRC_DIR ]]; then
|
|
sudo mkdir -p /var/src
|
|
sudo chown "$ORIG_USER":"$ORIG_USER" /var/src
|
|
fi
|
|
if [[ -d $SRC_DIR/.git ]]; then
|
|
run_as_user git -C "$SRC_DIR" fetch --all --tags --prune
|
|
else
|
|
run_as_user git clone "$REPO_URL" "$SRC_DIR"
|
|
fi
|
|
|
|
if run_as_user git -C "$SRC_DIR" rev-parse --verify --quiet "origin/$REF" >/dev/null; then
|
|
run_as_user git -C "$SRC_DIR" checkout -B "$REF" "origin/$REF"
|
|
else
|
|
run_as_user git -C "$SRC_DIR" checkout --force "$REF"
|
|
fi
|
|
|
|
echo "==> 4b. Kernel upgrade (optional)"
|
|
if [[ "${LESAVKA_KERNEL_UPDATE:-0}" != "0" ]]; then
|
|
sudo LESAVKA_KERNEL_BUILD_USER="$ORIG_USER" bash "$SRC_DIR/scripts/kernel/build-linux-rpi.sh"
|
|
else
|
|
echo "⚠️ skipping kernel upgrade (LESAVKA_KERNEL_UPDATE=0)"
|
|
fi
|
|
|
|
echo "==> 4c. Source build"
|
|
run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC_DIR/server' && cargo clean && cargo build --release --bins"
|
|
|
|
echo "==> 5. Install binaries"
|
|
sudo install -Dm755 "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server
|
|
sudo install -Dm755 "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc
|
|
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh
|
|
sudo install -Dm755 "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh
|
|
sudo install -Dm755 "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity
|
|
|
|
echo "==> 5b. Runtime environment defaults"
|
|
sudo install -d -m 0755 /etc/lesavka
|
|
ensure_server_tls_pki
|
|
HDMI_CONNECTOR=$(resolve_hdmi_connector)
|
|
if [[ -n $HDMI_CONNECTOR ]]; then
|
|
echo " ↪ HDMI connector: $HDMI_CONNECTOR"
|
|
write_hdmi_boot_force "$HDMI_CONNECTOR"
|
|
else
|
|
echo "⚠️ no connected HDMI connector detected; leaving LESAVKA_HDMI_CONNECTOR unset." >&2
|
|
fi
|
|
{
|
|
echo "# generated by lesavka/scripts/install/server.sh"
|
|
echo "# Edit only for local hardware overrides; rerunning the installer refreshes defaults."
|
|
if [[ -n $HDMI_CONNECTOR ]]; then
|
|
printf 'LESAVKA_HDMI_CONNECTOR=%s\n' "$HDMI_CONNECTOR"
|
|
fi
|
|
printf 'LESAVKA_CAM_OUTPUT=%s\n' "${LESAVKA_INSTALL_CAM_OUTPUT:-uvc}"
|
|
printf 'LESAVKA_CAM_WIDTH=%s\n' "${LESAVKA_CAM_WIDTH:-1920}"
|
|
printf 'LESAVKA_CAM_HEIGHT=%s\n' "${LESAVKA_CAM_HEIGHT:-1080}"
|
|
printf 'LESAVKA_CAM_FPS=%s\n' "${LESAVKA_CAM_FPS:-30}"
|
|
printf 'LESAVKA_HDMI_WIDTH=%s\n' "${LESAVKA_HDMI_WIDTH:-1920}"
|
|
printf 'LESAVKA_HDMI_HEIGHT=%s\n' "${LESAVKA_HDMI_HEIGHT:-1080}"
|
|
printf 'LESAVKA_HDMI_SINK=%s\n' "${LESAVKA_HDMI_SINK:-fbdevsink}"
|
|
printf 'LESAVKA_HDMI_FBDEV=%s\n' "${LESAVKA_HDMI_FBDEV:-/dev/fb0}"
|
|
printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}"
|
|
printf 'LESAVKA_HDMI_PRESENTATION_DELAY_US=%s\n' "${LESAVKA_HDMI_PRESENTATION_DELAY_US:-180000}"
|
|
printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}"
|
|
printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}"
|
|
printf 'LESAVKA_UAC_HDMI_COMPENSATION_US=%s\n' "${LESAVKA_UAC_HDMI_COMPENSATION_US:-205000}"
|
|
printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' "${LESAVKA_UAC_SESSION_CLOCK_ALIGN:-0}"
|
|
printf 'LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS=%s\n' "${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-1000}"
|
|
printf 'LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_AUDIO_PLAYOUT_OFFSET_US:--45000}"
|
|
printf 'LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US=%s\n' "${LESAVKA_UPSTREAM_VIDEO_PLAYOUT_OFFSET_US:-0}"
|
|
printf 'LESAVKA_UPSTREAM_PAIR_SLACK_US=%s\n' "${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}"
|
|
printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}"
|
|
printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}"
|
|
printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}"
|
|
printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}"
|
|
printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}"
|
|
printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}"
|
|
printf 'LESAVKA_TLS_CLIENT_CA=%s\n' "${LESAVKA_TLS_CLIENT_CA:-$LESAVKA_TLS_DIR/ca.crt}"
|
|
} | sudo tee /etc/lesavka/server.env >/dev/null
|
|
|
|
UVC_ENV_TMP=$(mktemp)
|
|
render_uvc_env_file >"$UVC_ENV_TMP"
|
|
UVC_ENV_CHANGED=1
|
|
if sudo test -f /etc/lesavka/uvc.env && sudo cmp -s "$UVC_ENV_TMP" /etc/lesavka/uvc.env; then
|
|
UVC_ENV_CHANGED=0
|
|
fi
|
|
sudo install -m 0644 "$UVC_ENV_TMP" /etc/lesavka/uvc.env
|
|
rm -f "$UVC_ENV_TMP"
|
|
|
|
echo "==> 6a. Systemd units - lesavka-core"
|
|
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-core.service >/dev/null
|
|
[Unit]
|
|
Description=lesavka USB gadget bring-up
|
|
After=sys-kernel-config.mount
|
|
Requires=sys-kernel-config.mount
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/usr/local/bin/lesavka-core.sh
|
|
RemainAfterExit=yes
|
|
Environment=LESAVKA_UVC_FALLBACK=0
|
|
EnvironmentFile=-/etc/lesavka/server.env
|
|
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_MODULE
|
|
AmbientCapabilities=CAP_SYS_MODULE
|
|
MountFlags=slave
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
|
|
echo "==> 6b. Systemd units - lesavka-server"
|
|
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-server.service >/dev/null
|
|
[Unit]
|
|
Description=lesavka gRPC relay
|
|
After=network.target lesavka-core.service lesavka-uvc.service
|
|
Wants=lesavka-uvc.service
|
|
StartLimitIntervalSec=30
|
|
StartLimitBurst=10
|
|
|
|
[Service]
|
|
ExecStartPre=/usr/local/bin/lesavka-core.sh --attach
|
|
ExecStart=/usr/local/bin/lesavka-server
|
|
TimeoutStopSec=10
|
|
KillSignal=SIGTERM
|
|
KillMode=control-group
|
|
Restart=always
|
|
Environment=RUST_LOG=lesavka_server=info,lesavka_server::audio=info,lesavka_server::video=debug,lesavka_server::gadget=info
|
|
Environment=RUST_BACKTRACE=1
|
|
Environment=GST_DEBUG="*:2,alsasink:6,alsasrc:6"
|
|
Environment=LESAVKA_UVC_EXTERNAL=1
|
|
Environment=LESAVKA_EYE_ADAPTIVE=1
|
|
Environment=LESAVKA_EYE_MIN_FPS=12
|
|
Environment=LESAVKA_EYE_FPS=20
|
|
Environment=LESAVKA_MIC_INIT_ATTEMPTS=5
|
|
Environment=LESAVKA_MIC_INIT_DELAY_MS=250
|
|
Environment=LESAVKA_SERVER_LOG_PATH=/var/log/lesavka/server.log
|
|
EnvironmentFile=-/etc/lesavka/uvc.env
|
|
EnvironmentFile=-/etc/lesavka/server.env
|
|
Restart=always
|
|
RestartSec=5
|
|
StandardError=append:/var/log/lesavka/server.stderr
|
|
User=root
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
|
|
echo "==> 6c. Systemd units - initialization"
|
|
sudo install -d -m 0755 /var/log/lesavka
|
|
sudo rm -f /tmp/lesavka-server.log
|
|
sudo truncate -s 0 /var/log/lesavka/server.log
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable lesavka-core lesavka-server
|
|
|
|
UDC_STATE=$(udc_state)
|
|
FORCE_GADGET_REBUILD=0
|
|
GADGET_REBUILD_REASON=""
|
|
if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && ! uvc_gadget_present; then
|
|
FORCE_GADGET_REBUILD=1
|
|
GADGET_REBUILD_REASON="UVC function is missing from the live gadget"
|
|
echo "⚠️ UVC function is missing from the live gadget; forcing a rebuild before server start."
|
|
fi
|
|
if [[ "$UVC_ENV_CHANGED" == "1" ]] && is_attached_state "$UDC_STATE"; then
|
|
FORCE_GADGET_REBUILD=1
|
|
GADGET_REBUILD_REASON="UVC runtime settings changed while the host is attached"
|
|
echo "⚠️ UVC runtime settings changed while the host is attached; forcing a gadget rebuild so the new descriptors take effect."
|
|
fi
|
|
if [[ -n ${LESAVKA_FORCE_GADGET_REBUILD:-} ]]; then
|
|
FORCE_GADGET_REBUILD=1
|
|
GADGET_REBUILD_REASON="explicit LESAVKA_FORCE_GADGET_REBUILD request"
|
|
echo "⚠️ explicit LESAVKA_FORCE_GADGET_REBUILD request; forcing a gadget rebuild before server start."
|
|
fi
|
|
if [[ "$FORCE_GADGET_REBUILD" == "1" && -z ${LESAVKA_ALLOW_GADGET_RESET:-} ]] && is_attached_state "$UDC_STATE"; then
|
|
echo "⚠️ ${GADGET_REBUILD_REASON:-Gadget state} requires a rebuild, but UDC state is '$UDC_STATE' and hard reset was not allowed." >&2
|
|
echo " Preserving the attached gadget to avoid wedging the Pi USB controller." >&2
|
|
echo " Run during a maintenance window with LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 if a hard rebuild is required." >&2
|
|
FORCE_GADGET_REBUILD=0
|
|
fi
|
|
if [[ "$FORCE_GADGET_REBUILD" == "1" ]] || ! is_attached_state "$UDC_STATE"; then
|
|
echo "⚠️ UDC state is '$UDC_STATE' - forcing a Lesavka gadget rebuild before server start."
|
|
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
|
|
sudo systemctl stop lesavka-uvc >/dev/null 2>&1 || true
|
|
sudo systemctl reset-failed lesavka-uvc >/dev/null 2>&1 || true
|
|
CORE_REBUILD_ENV=(
|
|
"LESAVKA_ALLOW_GADGET_RESET=1"
|
|
"LESAVKA_ATTACH_WRITE_UDC=1"
|
|
"LESAVKA_DETACH_CLEAR_UDC=1"
|
|
"LESAVKA_UVC_FALLBACK=0"
|
|
"LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}"
|
|
)
|
|
if [[ -n ${LESAVKA_RELOAD_UVCVIDEO:-} ]]; then
|
|
CORE_REBUILD_ENV+=("LESAVKA_RELOAD_UVCVIDEO=${LESAVKA_RELOAD_UVCVIDEO}")
|
|
fi
|
|
sudo env "${CORE_REBUILD_ENV[@]}" /usr/local/bin/lesavka-core.sh
|
|
sudo systemctl reset-failed lesavka-core >/dev/null 2>&1 || true
|
|
echo "✅ lesavka-core gadget rebuilt directly."
|
|
elif [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]]; then
|
|
echo "✅ UDC state is '$UDC_STATE'; LESAVKA_ALLOW_GADGET_RESET permits recovery, but no hard gadget rebuild is needed."
|
|
else
|
|
echo "⚠️ UDC state is '$UDC_STATE' - skipping lesavka-core restart."
|
|
echo " Set LESAVKA_ALLOW_GADGET_RESET=1 LESAVKA_FORCE_GADGET_REBUILD=1 to force during a maintenance window."
|
|
fi
|
|
|
|
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc.service >/dev/null
|
|
[Unit]
|
|
Description=lesavka UVC control helper
|
|
After=lesavka-core.service
|
|
Requires=lesavka-core.service
|
|
|
|
[Service]
|
|
ExecStart=/usr/local/bin/lesavka-uvc.sh
|
|
Restart=always
|
|
RestartSec=2
|
|
KillSignal=SIGTERM
|
|
KillMode=control-group
|
|
TimeoutStopSec=10
|
|
StandardError=append:/var/log/lesavka/uvc.stderr
|
|
User=root
|
|
EnvironmentFile=-/etc/lesavka/uvc.env
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable lesavka-uvc
|
|
|
|
echo "==> 6d. Systemd units - remove legacy reboot watchdog"
|
|
sudo systemctl stop lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>&1 || true
|
|
sudo systemctl disable lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>&1 || true
|
|
sudo systemctl unmask lesavka-watchdog.timer lesavka-watchdog.service >/dev/null 2>&1 || true
|
|
sudo rm -f /etc/systemd/system/lesavka-watchdog.timer \
|
|
/etc/systemd/system/lesavka-watchdog.service \
|
|
/usr/local/bin/lesavka-watchdog.sh \
|
|
/etc/lesavka/watchdog.touch
|
|
|
|
sudo systemctl daemon-reload
|
|
|
|
if [[ "$UVC_ENV_CHANGED" == "1" ]] && systemctl is-active --quiet lesavka-uvc; then
|
|
sudo systemctl restart lesavka-uvc
|
|
echo "✅ lesavka-uvc restarted with the refreshed UVC runtime settings."
|
|
elif systemctl is-active --quiet lesavka-uvc; then
|
|
echo "✅ lesavka-uvc already active; runtime settings unchanged."
|
|
else
|
|
sudo truncate -s 0 /var/log/lesavka/uvc.stderr
|
|
sudo systemctl start lesavka-uvc
|
|
echo "✅ lesavka-uvc started to attach the UVC gadget to the host."
|
|
fi
|
|
|
|
validate_uvc_gadget_ready
|
|
|
|
sudo truncate -s 0 /var/log/lesavka/server.stderr
|
|
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
|
|
clear_stale_server_listener
|
|
sudo systemctl reset-failed lesavka-server >/dev/null 2>&1 || true
|
|
sudo systemctl restart lesavka-server
|
|
validate_server_ready
|
|
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
|
|
INSTALLED_SHA=$(git -C "$SCRIPT_REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)
|
|
PERSISTED_CAM_OUTPUT=$(grep '^LESAVKA_CAM_OUTPUT=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
|
|
PERSISTED_SERVER_BIND_ADDR=$(grep '^LESAVKA_SERVER_BIND_ADDR=' /etc/lesavka/server.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
|
|
PERSISTED_UVC_CODEC=$(grep '^LESAVKA_UVC_CODEC=' /etc/lesavka/uvc.env 2>/dev/null | tail -n1 | cut -d= -f2- || true)
|
|
echo "✅ lesavka-server installed and restarted..."
|
|
if [[ -n $INSTALLED_VERSION || -n $INSTALLED_SHA ]]; then
|
|
echo "➡️ Installed: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}"
|
|
fi
|
|
if [[ -n $PERSISTED_SERVER_BIND_ADDR ]]; then
|
|
echo "➡️ Server bind: ${PERSISTED_SERVER_BIND_ADDR}"
|
|
fi
|
|
if [[ -n $PERSISTED_CAM_OUTPUT ]]; then
|
|
echo "➡️ Camera output: ${PERSISTED_CAM_OUTPUT}"
|
|
fi
|
|
if [[ -n $PERSISTED_UVC_CODEC ]]; then
|
|
echo "➡️ UVC codec: ${PERSISTED_UVC_CODEC}"
|
|
fi
|
|
echo "➡️ Client TLS bundle: ${LESAVKA_CLIENT_BUNDLE}"
|
|
echo "➡️ Client install can use: sudo env LESAVKA_CLIENT_PKI_BUNDLE=${LESAVKA_CLIENT_BUNDLE} ./scripts/install/client.sh"
|
|
echo "➡️ Status: sudo systemctl status lesavka-server --no-pager"
|
|
echo "➡️ Logs: sudo journalctl -u lesavka-server -f --no-pager"
|
|
echo "✅ Installed version: lesavka-server ${INSTALLED_VERSION:-unknown}${INSTALLED_SHA:+ ($INSTALLED_SHA)}"
|