Files
couchos/setup.sh
jackmhny 7b60e7b5e9
Some checks failed
ci / verify (push) Has been cancelled
fix: harden deployment checks
2026-08-03 02:54:53 +02:00

471 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODE="apply"
PHASE="all"
CHECK_FAILURES=0
COUCH_USER="couch"
COUCH_HOME="/home/${COUCH_USER}"
INSTALL_ROOT="${COUCH_HOME}/couchos"
RUNTIME_CONFIG_PATH="${COUCH_HOME}/.config/couchd/runtime.json"
TOKEN_PATH="${COUCH_HOME}/.config/couchd/token"
COUCH_UID=""
MISSING_UID_PLACEHOLDER="<missing-couch-uid>"
TOKEN_PLACEHOLDER="$(<"${ROOT_DIR}/templates/couchd.token.example")"
usage() {
cat <<'EOF'
Usage: ./setup.sh [--phase root|couch|all] [--check] [--dry-run]
Safe, idempotent CouchOS setup entrypoint.
- `--check` reports PASS/FAIL and exits nonzero when any validation fails.
- `--dry-run` prints intended changes without applying them.
- `--phase root` covers system setup and stable install location.
- `--phase couch` covers runtime config and per-user desktop/session state.
EOF
}
log() {
printf '[setup] %s\n' "$*"
}
record_check() {
local status="$1"
shift
printf '[check] %s %s\n' "${status}" "$*"
if [[ "${status}" == "FAIL" ]]; then
CHECK_FAILURES=$((CHECK_FAILURES + 1))
fi
}
run_cmd() {
if [[ "${MODE}" == "check" ]]; then
return 0
fi
if [[ "${MODE}" == "dry-run" ]]; then
printf '[dry-run] %s\n' "$*"
return 0
fi
"$@"
}
need_root() {
if [[ "${EUID}" -ne 0 && "${MODE}" != "check" ]]; then
printf 'root privileges are required for this phase\n' >&2
exit 1
fi
}
resolve_couch_uid() {
COUCH_UID="$(id -u "${COUCH_USER}")"
}
effective_couch_uid() {
if [[ -n "${COUCH_UID}" ]]; then
printf '%s\n' "${COUCH_UID}"
else
printf '%s\n' "${MISSING_UID_PLACEHOLDER}"
fi
}
check_path_exists() {
local path="$1"
if [[ -e "${path}" ]]; then
record_check PASS "${path} exists"
else
record_check FAIL "${path} missing"
fi
}
check_command_success() {
local description="$1"
shift
if "$@" >/dev/null 2>&1; then
record_check PASS "${description}"
else
record_check FAIL "${description}"
fi
}
install_file() {
local source="$1"
local target="$2"
local mode="$3"
local owner="$4"
local group="$5"
log "install ${target}"
if [[ "${MODE}" == "check" ]]; then
if [[ -f "${target}" ]]; then
record_check PASS "${target} present"
else
record_check FAIL "${target} missing"
fi
return 0
fi
run_cmd install -D -m "${mode}" -o "${owner}" -g "${group}" "${source}" "${target}"
}
install_text_file() {
local target="$1"
local mode="$2"
local owner="$3"
local group="$4"
local content="$5"
if [[ "${MODE}" == "check" ]]; then
check_path_exists "${target}"
return 0
fi
run_cmd install -d -m 0755 -o "${owner}" -g "${group}" "$(dirname "${target}")"
local tmp
tmp="$(mktemp)"
printf '%s' "${content}" >"${tmp}"
run_cmd install -m "${mode}" -o "${owner}" -g "${group}" "${tmp}" "${target}"
rm -f "${tmp}"
}
ensure_couch_user() {
if id -u "${COUCH_USER}" >/dev/null 2>&1; then
log "user ${COUCH_USER} already present"
resolve_couch_uid
else
if [[ "${MODE}" == "check" ]]; then
record_check FAIL "couch user missing"
COUCH_UID=""
return 0
fi
run_cmd useradd --create-home --home-dir "${COUCH_HOME}" --shell /bin/bash --user-group "${COUCH_USER}"
if id -u "${COUCH_USER}" >/dev/null 2>&1; then
resolve_couch_uid
else
COUCH_UID=""
fi
fi
}
remove_group_membership() {
local group_name="$1"
if ! id -u "${COUCH_USER}" >/dev/null 2>&1; then
if [[ "${MODE}" == "check" ]]; then
record_check SKIP "${COUCH_USER} group check for ${group_name}: user missing"
fi
return 0
fi
if ! getent group "${group_name}" >/dev/null 2>&1; then
return 0
fi
if id -nG "${COUCH_USER}" | tr ' ' '\n' | grep -Fxq "${group_name}"; then
if [[ "${MODE}" == "check" ]]; then
record_check FAIL "${COUCH_USER} is still a member of ${group_name}"
else
run_cmd gpasswd -d "${COUCH_USER}" "${group_name}"
fi
elif [[ "${MODE}" == "check" ]]; then
record_check PASS "${COUCH_USER} is not a member of ${group_name}"
fi
}
ensure_repo_install() {
if [[ "${MODE}" == "check" ]]; then
check_path_exists "${INSTALL_ROOT}/src/couchd/app.py"
return 0
fi
run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" "${INSTALL_ROOT}"
run_cmd bash -lc "cd '${ROOT_DIR}' && tar --exclude='.git' --exclude='__pycache__' --exclude='.pytest_cache' --exclude='artifacts' -cf - . | tar -C '${INSTALL_ROOT}' -xf -"
run_cmd chown -R "${COUCH_USER}:${COUCH_USER}" "${INSTALL_ROOT}"
run_cmd chmod 0700 "${COUCH_HOME}"
}
ensure_runtime_config() {
local controller_address="${COUCHOS_XBOX_BLUETOOTH_ADDRESS:-}"
local bind_address="${COUCHOS_BIND_ADDRESS:-100.64.0.15}"
local existing_runtime="{}"
local runtime_uid
runtime_uid="$(effective_couch_uid)"
if [[ -f "${RUNTIME_CONFIG_PATH}" ]]; then
existing_runtime="$(cat "${RUNTIME_CONFIG_PATH}")"
fi
if [[ "${MODE}" == "check" ]]; then
check_path_exists "${RUNTIME_CONFIG_PATH}"
if [[ -f "${TOKEN_PATH}" ]]; then
local token_mode
token_mode="$(stat -c '%a' "${TOKEN_PATH}" 2>/dev/null || true)"
if [[ "${token_mode}" == "600" ]]; then
record_check PASS "${TOKEN_PATH} mode is 0600"
else
record_check FAIL "${TOKEN_PATH} mode is not 0600"
fi
if [[ "$(tr -d '\r\n' <"${TOKEN_PATH}")" == "${TOKEN_PLACEHOLDER}" ]]; then
record_check FAIL "${TOKEN_PATH} still uses the template placeholder token"
else
record_check PASS "${TOKEN_PATH} does not use the template placeholder token"
fi
else
record_check FAIL "${TOKEN_PATH} missing"
fi
return 0
fi
run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" "${COUCH_HOME}/.config/couchd"
if [[ ! -f "${TOKEN_PATH}" ]]; then
if [[ "${MODE}" == "dry-run" ]]; then
printf '[dry-run] generate random bearer token at %s (mode 0600)\n' "${TOKEN_PATH}"
else
local token_tmp
token_tmp="$(mktemp)"
python3 - <<'PY' >"${token_tmp}"
import secrets
print(secrets.token_urlsafe(32))
PY
install -m 0600 -o "${COUCH_USER}" -g "${COUCH_USER}" "${token_tmp}" "${TOKEN_PATH}"
rm -f "${token_tmp}"
fi
fi
if [[ "${MODE}" == "dry-run" ]]; then
printf '[dry-run] generate %s with xdg_runtime_dir=/run/user/%s\n' "${RUNTIME_CONFIG_PATH}" "${runtime_uid}"
return 0
fi
EXISTING_RUNTIME_JSON="${existing_runtime}" \
RUNTIME_TARGET="${RUNTIME_CONFIG_PATH}" \
CONTROLLER_ADDRESS="${controller_address}" \
BIND_ADDRESS="${bind_address}" \
COUCH_UID="${runtime_uid}" \
python3 <<'PY'
import json
import os
from pathlib import Path
existing = json.loads(os.environ["EXISTING_RUNTIME_JSON"])
target = Path(os.environ["RUNTIME_TARGET"])
couch_home = Path("/home/couch")
payload = {
"bind_address": os.environ["BIND_ADDRESS"],
"port": int(existing.get("port", 8765)),
"token_file": str(couch_home / ".config" / "couchd" / "token"),
"couch_home": str(couch_home),
"iso_path": existing.get("iso_path"),
"state_file": str(couch_home / ".local" / "state" / "couchd" / "state.json"),
"display": existing.get("display", ":0"),
"xdg_runtime_dir": f"/run/user/{os.environ['COUCH_UID']}",
"xbox_bluetooth_address": os.environ["CONTROLLER_ADDRESS"] or existing.get("xbox_bluetooth_address"),
"launcher_shortcut": existing.get("launcher_shortcut"),
}
target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
PY
run_cmd chown "${COUCH_USER}:${COUCH_USER}" "${RUNTIME_CONFIG_PATH}"
run_cmd chmod 0640 "${RUNTIME_CONFIG_PATH}"
}
configure_user_session_files() {
local xprofile_content='xset s off
xset -dpms
xset s noblank
'
local xfwm4_content='<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfwm4" version="1.0">
<property name="general" type="empty">
<property name="use_compositing" type="bool" value="false"/>
</property>
</channel>
'
local power_content='<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-power-manager" version="1.0">
<property name="xfce4-power-manager" type="empty">
<property name="blank-on-ac" type="int" value="0"/>
<property name="dpms-enabled" type="bool" value="false"/>
<property name="inactivity-on-ac" type="int" value="0"/>
<property name="logind-handle-lid-switch" type="bool" value="false"/>
</property>
</channel>
'
local notifyd_content='<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-notifyd" version="1.0">
<property name="do-not-disturb" type="bool" value="true"/>
</channel>
'
local screensaver_content='<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-screensaver" version="1.0">
<property name="lock" type="bool" value="false"/>
<property name="saver" type="empty">
<property name="enabled" type="bool" value="false"/>
<property name="mode" type="int" value="0"/>
</property>
</channel>
'
local autostart_override='[Desktop Entry]
Type=Application
Hidden=true
X-GNOME-Autostart-enabled=false
'
local couchd_autostart='[Desktop Entry]
Type=Application
Version=1.0
Name=CouchOS Control Daemon
OnlyShowIn=XFCE;
Exec=sh -lc "systemctl --user import-environment DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS; systemctl --user daemon-reload; systemctl --user start couchd.service"
X-GNOME-Autostart-enabled=true
'
local display_audio_autostart='[Desktop Entry]
Type=Application
Version=1.0
Name=CouchOS HDMI Display And Audio
OnlyShowIn=XFCE;
Exec=sh -lc "systemctl --user import-environment DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS; systemctl --user daemon-reload; systemctl --user start couch-display-audio.service"
X-GNOME-Autostart-enabled=true
'
install_text_file "${COUCH_HOME}/.xprofile" 0644 "${COUCH_USER}" "${COUCH_USER}" "${xprofile_content}"
install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${xfwm4_content}"
install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-power-manager.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${power_content}"
install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-notifyd.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${notifyd_content}"
install_text_file "${COUCH_HOME}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml" 0644 "${COUCH_USER}" "${COUCH_USER}" "${screensaver_content}"
install_text_file "${COUCH_HOME}/.config/autostart/xfce4-notifyd.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}"
install_text_file "${COUCH_HOME}/.config/autostart/update-notifier.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}"
install_text_file "${COUCH_HOME}/.config/autostart/light-locker.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}"
install_text_file "${COUCH_HOME}/.config/autostart/xscreensaver.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${autostart_override}"
install_text_file "${COUCH_HOME}/.config/autostart/couchd.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${couchd_autostart}"
install_text_file "${COUCH_HOME}/.config/autostart/couch-display-audio.desktop" 0644 "${COUCH_USER}" "${COUCH_USER}" "${display_audio_autostart}"
}
root_phase() {
need_root
log "root phase"
ensure_couch_user
run_cmd apt-get update
run_cmd apt-get install -y \
lightdm xfce4 xfce4-goodies unattended-upgrades x11-xserver-utils pulseaudio pavucontrol \
bluez blueman rfkill mesa-utils vulkan-tools jq curl
install_file "${ROOT_DIR}/config/lightdm/50-couchos.conf" "/etc/lightdm/lightdm.conf.d/50-couchos.conf" 0644 root root
install_file "${ROOT_DIR}/config/logind/50-couchos.conf" "/etc/systemd/logind.conf.d/50-couchos.conf" 0644 root root
install_file "${ROOT_DIR}/systemd/systemd-user/couchd.service" "/etc/systemd/user/couchd.service" 0644 root root
install_file "${ROOT_DIR}/systemd/systemd-user/couch-display-audio.service" "/etc/systemd/user/couch-display-audio.service" 0644 root root
install_text_file "/etc/apt/apt.conf.d/52couchos-unattended" 0644 root root $'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n'
remove_group_membership sudo
remove_group_membership adm
ensure_repo_install
if [[ "${MODE}" == "check" ]]; then
check_command_success "couch home mode is 0700" test "$(stat -c '%a' "${COUCH_HOME}" 2>/dev/null)" = "700"
else
run_cmd loginctl enable-linger "${COUCH_USER}"
run_cmd systemctl daemon-reload
fi
}
couch_phase() {
need_root
log "couch phase"
ensure_couch_user
run_cmd install -d -m 0700 -o "${COUCH_USER}" -g "${COUCH_USER}" \
"${COUCH_HOME}/Applications" "${COUCH_HOME}/Slippi" "${COUCH_HOME}/bin" "${COUCH_HOME}/.local/state/couchd"
ensure_runtime_config
configure_user_session_files
if [[ "${MODE}" != "check" ]]; then
run_cmd ln -sfn "${INSTALL_ROOT}/scripts/launch_melee.sh" "${COUCH_HOME}/bin/launch_melee.sh"
run_cmd ln -sfn "${INSTALL_ROOT}/scripts/open_slippi.sh" "${COUCH_HOME}/bin/open_slippi.sh"
run_cmd ln -sfn "${INSTALL_ROOT}/scripts/stop_game.sh" "${COUCH_HOME}/bin/stop_game.sh"
run_cmd ln -sfn "${INSTALL_ROOT}/scripts/slippi_first_run_helper.sh" "${COUCH_HOME}/bin/slippi_first_run_helper.sh"
run_cmd chown -h "${COUCH_USER}:${COUCH_USER}" "${COUCH_HOME}/bin/"*.sh
fi
}
check_runtime_sanity() {
if [[ ! -f "${RUNTIME_CONFIG_PATH}" ]]; then
record_check FAIL "${RUNTIME_CONFIG_PATH} missing"
return 0
fi
if [[ -z "${COUCH_UID}" ]]; then
record_check FAIL "runtime config could not be verified because couch uid is unresolved"
return 0
fi
if RUNTIME_TARGET="${RUNTIME_CONFIG_PATH}" COUCH_UID="${COUCH_UID}" python3 <<'PY'
import json
import os
from pathlib import Path
target = Path(os.environ["RUNTIME_TARGET"])
data = json.loads(target.read_text(encoding="utf-8"))
failures = []
if data.get("xdg_runtime_dir") != f"/run/user/{os.environ['COUCH_UID']}":
failures.append("runtime xdg_runtime_dir does not match couch uid")
if data.get("launcher_shortcut") == ["/usr/bin/xdg-open", "slippi://play"]:
failures.append("runtime launcher_shortcut still uses unverified slippi://play")
iso_path = data.get("iso_path")
if iso_path is not None and not str(iso_path).startswith("/home/couch/"):
failures.append("runtime iso_path is outside /home/couch")
if failures:
for failure in failures:
print(failure)
raise SystemExit(1)
PY
then
record_check PASS "runtime config placeholders resolved safely"
else
record_check FAIL "runtime config placeholders resolved safely"
fi
}
main() {
while [[ $# -gt 0 ]]; do
case "$1" in
--phase)
PHASE="$2"
shift 2
;;
--check)
MODE="check"
shift
;;
--dry-run)
MODE="dry-run"
shift
;;
--help|-h)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
case "${PHASE}" in
root) root_phase ;;
couch) couch_phase ;;
all)
root_phase
couch_phase
;;
*)
printf 'unknown phase: %s\n' "${PHASE}" >&2
exit 1
;;
esac
if [[ "${MODE}" == "check" ]]; then
if id -u "${COUCH_USER}" >/dev/null 2>&1; then
resolve_couch_uid
fi
check_runtime_sanity
if [[ "${CHECK_FAILURES}" -gt 0 ]]; then
exit 1
fi
fi
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi