feat(install): modo --user-service, instalación sin privilegios

El instalador solo necesitaba root por dos razones circunstanciales: el PREFIX
por omisión en /opt y el unit en /etc/systemd/system. El agente en sí no lo
necesita — su unit corre como el usuario que instala, y las rutas de data_folder
las escribe SQL Server vía las cláusulas MOVE del T-SQL, no el agente.

--user-service instala donde apunte PREFIX (escribible por el usuario, típicamente
bajo su home) y registra el unit en ~/.config/systemd/user/. Para que sobreviva al
cierre de sesión intenta habilitar lingering; si el destino no lo permite, cae a una
entrada @reboot en el crontab del usuario más un vigilante cada 5 min que cubre lo
que en systemd hace Restart=always. Ninguno de los dos mecanismos requiere root.

Con esto, el instalador remoto del panel puede actualizar un servidor cuya cuenta
SSH no es root ni tiene sudo sin contraseña, sin pasarle nunca la contraseña a sudo.

Detalles que costaron una prueba cada uno:

- `pgrep -f "$PREFIX/$BIN_NAME"` se auto-detectaba: la línea de cron del vigilante
  contiene esa ruta, así que el `sh -c` que la ejecuta hacía match consigo mismo y
  el vigilante nunca rearrancaba. Va anclado con `^`.
- El sed de la plantilla sustituía los marcadores dentro de su propio comentario,
  dejando rutas absolutas en un texto sin sentido.
- El bootstrap de 20 s era el único hijo que heredaba el stdin del canal SSH; ahora
  lleva `</dev/null`, lo que hace estructural que no pueda consumir nada de él.

Reinstalar es idempotente: no duplica entradas de cron.
This commit is contained in:
2026-07-30 13:24:50 -06:00
parent fd077dad42
commit 17c0dea4bf
2 changed files with 122 additions and 3 deletions

View File

@@ -9,9 +9,17 @@
#
# Uso:
# sudo ./install.sh --service # 24/7 headless vía systemd (recomendado en servidor)
# ./install.sh --user-service # 24/7 headless SIN root: unit de systemd de usuario
# ./install.sh --desktop # autostart de escritorio (requiere sesión gráfica)
# ./install.sh # solo instala; sin arranque automático
#
# --user-service no necesita privilegios: instala donde apunte PREFIX (que debe ser escribible
# por el usuario, típicamente bajo su home), registra el unit en ~/.config/systemd/user/ y lo
# arranca con `systemctl --user`. Para que sobreviva al cierre de sesión intenta habilitar
# lingering; si el destino no lo permite, cae a una entrada @reboot en el crontab del usuario
# más un vigilante que lo rearranca. Es la vía cuando la cuenta SSH no es root ni tiene sudo
# sin contraseña: al panel le bastan entonces el usuario y la contraseña que ya tiene guardados.
#
# Opciones:
# --panel-env-file <ruta> # archivo KEY=valor con CLOUDRESTORE_PANEL_* que se
# # fusiona en config/.env y se borra al terminar.
@@ -29,6 +37,7 @@ PANEL_ENV_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--service) MODE="service"; shift ;;
--user-service) MODE="user-service"; shift ;;
--desktop) MODE="desktop"; shift ;;
--panel-env-file)
[[ $# -ge 2 ]] || { echo "ERROR: --panel-env-file requiere una ruta" >&2; exit 2; }
@@ -44,6 +53,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX="${PREFIX:-/opt/cloudrestoreas}"
BIN_NAME="CloudRestoreAS"
UNIT_NAME="cloudrestoreas.service"
# Patrón para localizar el agente vivo. El ancla ^ NO es opcional: sin ella, la propia
# línea de cron del vigilante contiene esta ruta, así que el `sh -c` que la ejecuta hace
# match consigo mismo, el vigilante cree que el agente corre y no lo rearranca nunca.
PGREP_PAT="^$PREFIX/$BIN_NAME"
# Localizar el binario: dist/CloudRestoreAS, dist/CloudRestoreAS-linux, o junto al script.
SRC=""
@@ -71,8 +84,27 @@ echo "==============================================="
# tiene que detenerlo antes de reemplazar el binario. Se recuerda si estaba activo
# para volver a levantarlo al final incluso en los modos que no tocan systemd.
WAS_ACTIVE=0
# En modo usuario el unit vive en la instancia de systemd del propio usuario, que necesita
# XDG_RUNTIME_DIR: un `exec` de SSH no es una sesión de login y no siempre lo trae.
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
SYSTEMCTL_USER=(systemctl --user)
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
if [[ "$MODE" == "user-service" ]]; then
if "${SYSTEMCTL_USER[@]}" is-active --quiet "$UNIT_NAME" 2>/dev/null; then
WAS_ACTIVE=1
echo "Deteniendo $UNIT_NAME (usuario) para reemplazar el binario..."
"${SYSTEMCTL_USER[@]}" stop "$UNIT_NAME" >/dev/null 2>&1 || \
echo "AVISO: no se pudo detener $UNIT_NAME; la copia puede fallar." >&2
fi
# Sin systemd de usuario disponible el arranque pudo haber quedado por cron: el binario
# sigue vivo y sobrescribirlo daría ETXTBSY, así que se termina igual.
if [[ "$WAS_ACTIVE" -eq 0 ]] && pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
WAS_ACTIVE=1
echo "Terminando el proceso en curso para reemplazar el binario..."
pkill -f "$PGREP_PAT" >/dev/null 2>&1 || true
sleep 2
fi
elif systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
WAS_ACTIVE=1
echo "Deteniendo $UNIT_NAME para reemplazar el binario..."
systemctl stop "$UNIT_NAME" >/dev/null 2>&1 || \
@@ -94,7 +126,10 @@ echo "Binario instalado en $PREFIX/$BIN_NAME"
# corta con timeout; así el usuario ya puede editar config/.env antes de habilitar
# el servicio.
echo "Inicializando config/ (bootstrap)..."
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless >/dev/null 2>&1 || true )
# `</dev/null` no es cosmético: es el único hijo que heredaría el stdin del canal SSH cuando el
# instalador corre en remoto. Cerrárselo hace estructural —y no accidental— que nada de lo que
# venga por ese canal pueda ser consumido aquí.
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen timeout 20 "$PREFIX/$BIN_NAME" --headless </dev/null >/dev/null 2>&1 || true )
if [[ -f "$PREFIX/config/.env" ]]; then
echo "config/.env creado."
else
@@ -203,6 +238,67 @@ case "$MODE" in
echo " Estado : systemctl status cloudrestoreas"
echo " Logs : journalctl -u cloudrestoreas -f"
;;
user-service)
# Sin root: unit en el home y arranque con la instancia de systemd del propio usuario.
# Requisito previo: PREFIX escribible por este usuario (el panel lo apunta a su home).
UNIT_SRC="$SCRIPT_DIR/packaging/linux/cloudrestoreas-user.service"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
UNIT_DST="$UNIT_DIR/$UNIT_NAME"
if [[ ! -f "$UNIT_SRC" ]]; then
echo "ERROR: falta la plantilla del unit de usuario ($UNIT_SRC)" >&2
exit 1
fi
mkdir -p "$UNIT_DIR"
# Sin __USER__: un unit de usuario no admite User=, ya corre como su dueño.
sed -e "s|__WORKDIR__|$PREFIX|g" \
-e "s|__EXEC__|$PREFIX/$BIN_NAME|g" \
"$UNIT_SRC" > "$UNIT_DST"
STARTED=""
if "${SYSTEMCTL_USER[@]}" daemon-reload >/dev/null 2>&1 && \
"${SYSTEMCTL_USER[@]}" enable --now "$UNIT_NAME" >/dev/null 2>&1; then
STARTED="systemd-usuario"
# Sin lingering, la instancia de usuario muere al cerrar la última sesión y con ella el
# restaurador. Muchos polkit permiten habilitarlo para uno mismo; si no, no es fatal:
# se complementa con cron más abajo.
if loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
echo "Lingering habilitado: el servicio sobrevive al cierre de sesión."
else
echo "AVISO: no se pudo habilitar lingering; se agrega respaldo por cron." >&2
STARTED="systemd-usuario+cron"
fi
else
echo "AVISO: systemd de usuario no disponible; se usará cron." >&2
fi
# Respaldo (o sustituto) por cron: @reboot para el arranque y un vigilante cada 5 min que
# cubre lo que en systemd hace Restart=always. El crontab del usuario no requiere root.
if [[ "$STARTED" != "systemd-usuario" ]]; then
if command -v crontab >/dev/null 2>&1; then
CRON_CMD="cd $PREFIX && QT_QPA_PLATFORM=offscreen $PREFIX/$BIN_NAME --start-engine --headless >/dev/null 2>&1 &"
CRON_TAG="# cloudrestoreas-autostart"
# Se reescribe el crontab completo filtrando las entradas propias anteriores, para que
# reinstalar no acumule duplicados.
{
crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true
echo "@reboot $CRON_CMD $CRON_TAG"
echo "*/5 * * * * pgrep -f '$PGREP_PAT' >/dev/null || ($CRON_CMD) $CRON_TAG"
} | crontab -
STARTED="${STARTED:+$STARTED+}cron"
# El vigilante tarda hasta 5 min en actuar; se arranca ya para no dejar el hueco.
if ! pgrep -f "$PGREP_PAT" >/dev/null 2>&1; then
( cd "$PREFIX" && QT_QPA_PLATFORM=offscreen "$PREFIX/$BIN_NAME" --start-engine --headless </dev/null >/dev/null 2>&1 & )
fi
else
echo "ERROR: sin systemd de usuario y sin crontab no hay forma de dejarlo arrancado." >&2
echo " Instala cron, habilita lingering, o usa --service con privilegios." >&2
exit 1
fi
fi
echo "Servicio de usuario instalado y arrancado (mecanismo: $STARTED)."
echo " Estado : systemctl --user status cloudrestoreas"
echo " Logs : journalctl --user -u cloudrestoreas -f"
;;
desktop)
echo "Modo escritorio: la app registra su autostart .desktop al iniciarse."
echo "Ejecuta '$PREFIX/$BIN_NAME' en tu sesión gráfica."
@@ -215,7 +311,7 @@ esac
# Si se detuvo un servicio que estaba activo y el modo elegido no lo relevanta, se
# restaura: una actualización no debe dejar el restaurador apagado en silencio.
if [[ "$WAS_ACTIVE" -eq 1 && "$MODE" != "service" ]]; then
if [[ "$WAS_ACTIVE" -eq 1 && "$MODE" != "service" && "$MODE" != "user-service" ]]; then
echo "Reiniciando $UNIT_NAME (estaba activo antes de la actualización)..."
systemctl start "$UNIT_NAME" >/dev/null 2>&1 || \
echo "AVISO: no se pudo reiniciar $UNIT_NAME; hazlo a mano." >&2
@@ -224,4 +320,5 @@ fi
echo ""
echo "Siguiente paso: edita $PREFIX/config/.env (CLOUDRESTORE_PANEL_*) y reinicia."
[[ "$MODE" == "service" ]] && echo " Tras editar: sudo systemctl restart cloudrestoreas"
[[ "$MODE" == "user-service" ]] && echo " Tras editar: systemctl --user restart cloudrestoreas"
echo "Listo."