fix/instalacion-desatendida #23

Merged
acazares merged 11 commits from fix/instalacion-desatendida into development 2026-07-31 15:00:06 +00:00

11 Commits

Author SHA1 Message Date
e98414456c Merge branch 'development' into fix/instalacion-desatendida
Conflicto en cras-install.ts por la PR #22, que metió feature/cras-update-in-place
en development con hashes distintos de los que esta rama ya traía rebasados. Los
dos hunks en conflicto eran la versión ANTERIOR de lo que esta rama sustituye:

- probeLinuxElevation sin la contraseña del panel (aquí ya recibe ssh_password y
  reconoce la elevación sudo-con-password).
- La invocación de install.sh sin alimentar el stdin de sudo ni asentar qué vía
  de elevación se tomó.

Resueltos a favor de esta rama, que incorpora lo de development más los cambios
propios. Verificado después del merge: 325 pruebas y typecheck limpio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:56:57 -06:00
026b109e82 feat(versiones-cras): arrancar el agente desde el panel, sin comandos a mano
El diagnóstico sin acción no sirve de nada. Verificar sabía detectar "instalado
pero detenido" y respondía con un comando de PowerShell y un botón de Copiar,
dejándole al operador entrar al servidor a pegarlo — aunque el panel ya tuviera
la sesión SSH, las credenciales y la elevación resueltas. `agent_could_apply`
estaba documentado como "informativo: la ejecución remota no está implementada".

Nuevo POST /versiones-cras/agent-start. Solo arranca: no instala, no actualiza y
no detiene nada, así que el peor caso es un servidor que sigue como estaba. Es
POST y no GET porque modifica el destino y una precarga del navegador no debe
dispararlo. Al terminar se re-verifica en lugar de creerle al comando: lo que
importa es el estado del servidor, no que no diera error.

El comando sigue mostrándose, como referencia para quien prefiera hacerlo a mano.
Cuando no hay tarea registrada no se ofrece el botón: ahí el remedio es
reinstalar, y un botón que no puede funcionar es peor que ninguno.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:48:49 -06:00
d84b4ef5f1 fix(cras-install): agotar la elevación y verificar de verdad el despliegue
Instalar y Actualizar delegaban en el operador en cuanto algo no era ideal.

Linux: probeLinuxElevation solo probaba `sudo -n`. Si sudo pedía contraseña, el
panel declaraba "sin privilegios" y abortaba, aunque tuviera esa contraseña
guardada y la estuviera usando para abrir la sesión SSH. La política que lo
prohibía era más estricta de lo que su propio motivo exige: lo que hace inseguro
el `echo '{pw}' | sudo -S` de AServers es que la contraseña acaba en el argv del
`sh -c`, legible con `ps` por cualquier usuario del destino. Por el stdin del
canal `exec` no pasa por ningún argv, ningún historial ni ningún proceso
intermedio, y al no concatenarse a un comando tampoco permite inyección. Es la
misma credencial con la que ya se autenticó la sesión, y no llega a install.sh:
la consume el sudo que lo invoca. La cadena queda root -> sudo -n -> sudo -S ->
actualización en sitio -> user-service.

Windows: el sello config\.version se leía UNA sola vez. Como el bootstrap está
acotado a 20s y desempacar un onefile de ~270 MB con Defender escaneando se pasa
de largo, el sello conservaba la versión anterior y una actualización correcta
fallaba con 502. En instalación nueva no se notaba (no hay sello previo), así que
rompía solo las actualizaciones. Ahora reintenta, como ya hacía Linux.

Y la verificación daba por buena cualquier tarea con un State no vacío. `Ready`
es una tarea registrada que NO está corriendo: justo lo que se ve cuando el
agente arrancó y murió. Ahora se comprueba el proceso vivo, y en todos los modos
— antes se salía antes de mirar nada si el arranque no era 'service'.

Las actualizaciones pasan por install.ps1 -UpdateInPlace, y su código 75 se
traduce al 409 amable que ya tenía Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:48:49 -06:00
054882afd8 Merge branch 'feature/cras-instalacion-sin-privilegios' into development 2026-07-31 07:24:03 -06:00
1f12b6796c Merge branch 'development' into feature/cras-instalacion-sin-privilegios
development solo traía 84a4c5e, el squash del PR #20 de esta misma rama, así que
el conflicto era este código contra una foto anterior de sí mismo. Verificado
línea por línea: todo lo que development aportaba era la versión previa de lo que
c433e1c y 7659806 reemplazaron, y nada más. Tras resolver, el árbol queda
idéntico a HEAD.

Se conserva HEAD en los cinco archivos. Tomar el lado de development habría
revertido tres cosas que importan:

- el docstring que afirmaba que un ORIGIN equivocado se habría manifestado por la
  protección CSRF de adapter-node, que es falso (csrf.checkOrigin está en false),
  más el consejo de "comenta PANEL_PUBLIC_URL" que con ORIGIN en localhost
  siembra loopback en los agentes;
- el `test -f` del check de configuración, que comprueba existencia y no lectura,
  y por eso salía verde justo en el caso roto;
- la lectura única de config/.version, que convierte una actualización correcta en
  "reporta 1.0.0, se esperaba 1.1.1".

308 tests en verde y tipos limpios tras el merge.
2026-07-30 16:53:49 -06:00
7659806505 fix(cras-verify): validar que el servicio pueda LEER su configuración, y loguear los 401
Dos huecos de diagnóstico que hacían pasar por sano lo que no lo estaba.

1. El check `config` usaba `test -f`, que comprueba existencia y no lectura, así
   que salía verde exactamente en el caso roto: una instalación con sudo deja
   config/.env en 0600 de root mientras el unit corre como una cuenta común, que
   no puede leerlo. El agente no arranca y la pantalla decía "Configuración
   presente: ok".

   Ahora se comprueba lectura y, además, el dueño frente al User= del unit —
   porque la sonda entra con la cuenta SSH, que no siempre es la del servicio, y
   un `test -r` desde la sesión no responde por el agente. El check se renombra a
   "Configuración legible por el servicio", que es lo que de verdad mide, y falla
   nombrando a los dos usuarios para que el remedio sea obvio.

   El cálculo del bit de lectura octal sale a una función pura probada: 0600 no
   deja leer a nadie más que al dueño, se mira el bit 4 y no el valor (620 y 611
   no son lectura), se ignora el dígito de setuid, y un modo ilegible NO se
   interpreta como permisivo — asumir lectura cuando stat devuelve '-' sería el
   error caro.

   La lectura de propiedades del unit se extrae a un helper compartido con el
   instalador. Ya hubo una divergencia por copiar esta lógica: la resolución de
   elevación existía duplicada y las dos pantallas acabaron diciendo cosas
   distintas del mismo servidor.

2. Ningún rechazo de token de servicio dejaba rastro: los seis endpoints solo
   logueaban el 500 de "token no configurado". Con el token rotado, todos los
   agentes quedan mudos —dejan de reportar versión, resolver rutas y registrar
   resultados— y desde el panel se ve igual que un agente apagado; además el
   trace_id que se le devuelve al agente no existía del lado servidor, así que
   era imposible correlacionar.

   Se agrega un helper que loguea el rechazo con trace_id, ruta e instancia, y
   distingue "sin header Authorization" de "token no coincide", que son un agente
   sin configurar y un token rotado: dos problemas con remedios distintos. El
   token NUNCA se registra, ni un fragmento suyo — es el mismo valor en todos los
   agentes, así que un prefijo en los logs ya acota el espacio de búsqueda. Hay
   prueba de eso.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:16:48 -06:00
e5df730f62 feat(cras-install): actualizar sin privilegios cuando no hay root ni sudo -n
En entornos donde no se usa root, ninguna de las dos vías que había servía: la
elevación abortaba con 409 y `user-service` exige mover la instalación al home,
que en un servidor con el agente ya instalado en /opt pide un paso privilegiado
para retirar el unit viejo.

Se agrega una tercera vía para ACTUALIZACIONES: dejar el unit como está y solo
reemplazar el binario, que es lo único que una actualización necesita. Corre
`install.sh --update-in-place` sin prefijo de elevación.

Tres precondiciones, comprobadas por SSH ANTES de subir 270 MB, cada una con su
propio motivo de rechazo porque cada una tiene un remedio distinto:

- El directorio de instalación debe ser escribible por la cuenta SSH. Es el único
  permiso que hace falta: `install` desvincula el destino antes de crearlo, así
  que un binario en ejecución no es obstáculo (eso es cosa de `cp`).
- El unit debe correr con ese mismo usuario. Si quedó con User=root —alguien
  instaló desde un `sudo -i`— la cuenta no puede señalizar el proceso. Un User
  vacío se trata como root, que es lo que hace systemd.
- El unit debe tener Restart=always, que es quien vuelve a levantarlo. Sin eso,
  señalizarlo lo dejaría muerto.

Y se rechaza si hay una restauración en curso, aquí y otra vez en el destino.

Dos correcciones de robustez en la verificación posterior:

- El sello config/.version se sondea en vez de leerse una vez. En esta vía se
  omite el bootstrap y el sello lo escribe el proceso al reiniciarse, así que
  durante unos segundos sigue teniendo la versión ANTERIOR: la lectura única
  convertía una actualización correcta en "reporta 1.0.0, se esperaba 1.1.1".
- `systemctl is-active` puede devolver 'activating' justo tras el reinicio, así
  que la evidencia que manda es el proceso vivo con el binario nuevo.

El código 75 (EX_TEMPFAIL) de install.sh se traduce a un 409 con el motivo real
—"está restaurando, reintenta"— en vez del 502 genérico que hacía pensar que la
instalación se había roto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:40:58 -06:00
c433e1c34e fix(versiones-cras): validar que la URL sembrada sea alcanzable y permitir instalar limpio
Dos bloqueos que impedían avanzar con la instalación en Linux.

1. La validación de PANEL_PUBLIC_URL solo comparaba que coincidiera con ORIGIN,
   y eso deja pasar el peor caso: que AMBAS valgan localhost, que es justo lo
   que produce el compose de desarrollo. Ahí no avisaba nada y la instalación
   sembraba loopback en el config/.env del destino, donde localhost es el propio
   destino y el agente acabaría hablando consigo mismo.

   Ahora se valida lo que de verdad importa —que la URL sea alcanzable desde
   otra máquina— y solo después el desajuste con ORIGIN. El mensaje también
   dejaba un consejo peligroso: "comenta PANEL_PUBLIC_URL para que tome ORIGIN"
   solo vale si ORIGIN sirve para sembrar; con ORIGIN en localhost, seguirlo
   empeora las cosas. Ese consejo ahora es condicional.

   De paso se corrige el docstring, que afirmaba que un ORIGIN equivocado se
   habría manifestado por la protección CSRF de adapter-node. No es cierto:
   svelte.config.js tiene csrf.checkOrigin en false, así que el Origin de los
   POST nunca se valida y ese razonamiento llevaba a conclusiones falsas.

2. El formulario fuerza mode='update' en cuanto el servidor tiene versión
   instalada, y una actualización exige que en la ruta destino ya viva algo.
   Eso hacía imposible mover una instalación a otra carpeta —por ejemplo al
   home, para instalar sin privilegios—: abortaba con "no hay una instalación".

   Se agrega una confirmación explícita siguiendo el molde de platformAck: el
   ack lleva la RUTA confirmada y no un booleano, así que una casilla marcada
   deja de valer si después se cambia el destino, y el servidor revalida en vez
   de confiar en la UI. La casilla advierte además que el agente anterior sigue
   corriendo: los dos reportarían con el mismo instance_key y se pisarían la
   carpeta de entrada registrada, que es por donde el panel enruta los
   respaldos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:03:35 -06:00
dcba1e0a87 feat(cras-install): instalación sin privilegios y diagnóstico preciso de elevación
El instalador exigía root o `sudo -n` (NOPASSWD), así que una cuenta con sudo
CON contraseña —el caso de los servidores Linux— no podía actualizarse desde el
panel. La contraseña guardada no ayudaba: se usa para autenticar SSH y a sudo
nunca se le entrega, por decisión de diseño contra el antipatrón de AServers
(`echo '{password}' | sudo -S`, que expone el secreto en argv).

En vez de rodear esa decisión, se quita la necesidad de privilegios. El agente
no necesita root para funcionar: su unit corre como el usuario que instala, y
las rutas de data_folder las escribe SQL Server, no él. install.sh solo pedía
privilegios por dos razones circunstanciales — el PREFIX por omisión en /opt y
el unit en /etc/systemd/system.

Modo nuevo `--user-service`: instala bajo el home y registra un unit de systemd
de usuario. Para sobrevivir al cierre de sesión intenta lingering y, si el
destino no lo permite, cae a @reboot en el crontab del usuario más un vigilante
cada 5 min que sustituye al Restart=always. Con eso, al panel le bastan el
usuario y la contraseña que ya tiene registrados.

Además, la sonda de elevación pasa a ser compartida entre instalar y verificar
(antes eran copias paralelas que ya diferían en la etiqueta) y distingue casos
que se reportaban idénticos:

- `requiretty` en el sudoers ya no se confunde con "sin privilegios": el remedio
  es el opuesto, porque agregar NOPASSWD no lo arregla.
- Una regla NOPASSWD acotada a otros comandos se reporta como tal, con la lista.
- Verificar informa si la ruta es escribible y si la instalación sin privilegios
  es viable en ese servidor, en vez de solo decir que faltan permisos.

Dos bugs encontrados al probarlo, ambos con prueba:

- `pgrep -f <ruta>` hacía match consigo mismo, porque la propia línea de cron
  del vigilante contiene esa ruta. Sin el ancla `^`, el vigilante creía que el
  agente corría y no lo rearrancaba nunca.
- `expectedStepCount` comparaba solo con 'service', así que la barra de una
  instalación en modo usuario habría llegado al 100 % con un paso pendiente.

`resolveLinuxPrivilege` no tenía ninguna prueba; se agregan seis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:24:17 -06:00
5b83a57d18 fix(cras-install): dejar de rechazar el layout normal de carpetas de trabajo
isInsideWorkFolder miraba la relación en los dos sentidos, así que también
rechazaba que las carpetas de trabajo colgaran de la de instalación. Ese es el
layout NORMAL, no un error: constants.py del agente define
DIR_ENTRADA = APP_DIR / "Entrada", así que una instalación correcta en
/opt/cloudrestoreas reporta input_folder = /opt/cloudrestoreas/Entrada.

Efecto: ninguna instalación ni actualización podía pasar, en Linux ni en
Windows. Fallaba en resolveInstallPath —antes de abrir el run, así que no
quedaba nada en la bitácora— con un mensaje que decía justo lo contrario de lo
que ocurría: "la ruta está dentro de una carpeta de trabajo". Solo se libraban
las instalaciones fuera de la ruta por omisión, por accidente del nombre.

Se conserva el peligro que el guard existe para atajar: el binario dentro de
Entrada/Procesados, donde el agente lo tomaría por un respaldo a procesar.

La función no tenía ninguna prueba. Se agregan las dos direcciones, el caso
exacto, la normalización de separador/caja/barra final y el prefijo que no es
de carpeta (/srv/entradas vs /srv/entrada).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:25:01 -06:00
d570c7dac2 fix(versiones-cras): permitir fijar la ruta de instalación y blindar la config de Gitea
Un agente anterior a 1.1.0 no reporta su install_path, así que el panel caía al
default de plataforma y ACTUALIZAR abortaba cuando la instalación vivía en otra
carpeta (p. ej. C:\Aduanasoft\CloudRestoreAS-win). Era un huevo-y-gallina: la
ruta se empieza a reportar en 1.1.x, que es justo lo que no se podía instalar.

Se agrega la captura manual de la ruta por servidor. No hace falta esquema nuevo
ni lógica de resolución nueva: el upsert del estado ya usa
install_path = COALESCE(EXCLUDED.install_path, actual), así que el valor
capturado sobrevive los reportes sin ruta del agente viejo, y resolveInstallPath
y cras-verify ya leen esa misma columna. Cuando el servidor quede en 1.1.x su
propio reporte lo sustituye por la ruta real.

De paso, dos fallos de configuración que costaron el diagnóstico:

- GITEA_TOKEN con los `<>` de la plantilla se veía como un 401 opaco de Gitea,
  idéntico al de un token revocado. Se valida la forma antes de llamar, el
  aviso sale al cargar la pantalla y 401 y 403 dejan de colapsar al mismo
  texto. Un rechazo ahora deja línea en los logs: no dejaba ninguna.
- PANEL_PUBLIC_URL sin el puerto apuntaba a otro servicio. Esa URL se siembra
  en el config/.env del destino también al ACTUALIZAR, así que rompería un
  agente que ya reportaba: se avisa y se aborta antes de tocar el servidor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:16:06 -06:00