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>
This commit is contained in:
2026-07-30 13:24:17 -06:00
parent 5b83a57d18
commit dcba1e0a87
7 changed files with 343 additions and 51 deletions

View File

@@ -44,12 +44,19 @@ describe('currentPhaseLabel', () => {
});
describe('expectedStepCount', () => {
it('cuenta verificar-servicio solo en modo servicio', () => {
// `cras-install.ts` corta con `if (autostart !== 'service') return;` antes de ese paso.
it('cuenta verificar-servicio solo en los modos que dejan algo arrancado', () => {
// `cras-install.ts` corta antes de ese paso cuando el modo no arranca nada.
expect(expectedStepCount('service')).toBe(11);
expect(expectedStepCount('desktop')).toBe(10);
expect(expectedStepCount('none')).toBe(10);
});
it('el servicio de usuario también verifica, aunque no use privilegios', () => {
// Si esto se quedara comparando solo con 'service', la barra de una instalación sin
// privilegios llegaría al 100 % con un paso todavía por delante.
expect(expectedStepCount('user-service')).toBe(11);
expect(progressPercent(10, 'user-service', 'running')).toBe(91);
});
});
describe('progressPercent', () => {

View File

@@ -17,7 +17,12 @@ export interface InstallStepView {
ok: boolean;
}
export type AutostartMode = 'service' | 'desktop' | 'none';
/**
* `user-service` instala bajo el home del usuario con un unit de systemd **de usuario**: no
* necesita root ni sudo, así que bastan el usuario y la contraseña SSH que el panel ya guarda.
* Los otros modos de servicio escriben en /opt y /etc/systemd/system, que sí exigen privilegios.
*/
export type AutostartMode = 'service' | 'user-service' | 'desktop' | 'none';
export type RunStatus = 'running' | 'completed' | 'failed';
/**
@@ -87,7 +92,10 @@ export function currentPhaseLabel(steps: InstallStepView[]): string {
/** Pasos esperados del camino feliz según el modo de arranque. */
export function expectedStepCount(autostart: AutostartMode): number {
return autostart === 'service'
// Los dos modos de servicio —de sistema y de usuario— emiten `verificar-servicio`; los que
// no dejan nada arrancado, no. Si esto se quedara comparando solo con 'service', la barra de
// una instalación en modo usuario llegaría al 100 % con un paso todavía por delante.
return autostart === 'service' || autostart === 'user-service'
? INSTALL_STEP_SEQUENCE.length
: INSTALL_STEP_SEQUENCE.length - 1;
}

View File

@@ -7,7 +7,13 @@
* hace `echo '{password}' | sudo -S ...`.
*/
import { describe, expect, it, vi } from 'vitest';
import { execRemote, psEncoded, shQuote, InstallError } from './cras-install';
import {
execRemote,
probeLinuxElevation,
psEncoded,
shQuote,
InstallError
} from './cras-install';
/** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */
function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) {
@@ -200,3 +206,91 @@ describe('no fuga de secretos en los comandos remotos', () => {
expect(decoded).toContain('-PanelEnvFile');
});
});
/**
* Sonda de elevación en Linux. No tenía ninguna prueba, y es la que decide si una instalación
* se aborta antes de transferir 270 MB — y ahora también si puede seguir sin privilegios.
*
* Lo que se cubre son los diagnósticos que estaban MAL: `requiretty` se reportaba como "sin
* privilegios", y el remedio que se ofrecía (configurar NOPASSWD) no arregla ese caso; y una
* regla NOPASSWD acotada a otros comandos se veía idéntica a no tener ninguna.
*/
describe('probeLinuxElevation', () => {
/** Responde por comando, con lo que devolvería el destino real. */
function sftpFor(responses: Record<string, { code?: number; stdout?: string; stderr?: string }>) {
return fakeSftp((command) => {
for (const [needle, result] of Object.entries(responses)) {
if (command.includes(needle)) return result;
}
return { code: 1, stdout: '', stderr: '' };
});
}
it('root: sin prefijo de elevación', async () => {
const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '0' } });
const r = await probeLinuxElevation(sftp as never);
expect(r).toMatchObject({ elevation: 'root', prefix: '', label: 'root' });
});
it('sudo sin password: prefija con -n, nunca con -S', async () => {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 0 }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.elevation).toBe('sudo-sin-password');
expect(r.prefix).toContain('-n');
expect(r.prefix).not.toContain('-S');
});
it('requiretty se distingue de "sin privilegios": el remedio es el opuesto', async () => {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.elevation).toBe('requiretty');
// Lo importante del mensaje: que NO mande a configurar NOPASSWD, que no arregla esto.
expect(r.detail).toMatch(/requiretty/);
expect(r.detail).toMatch(/NO lo arregla/);
});
it('detecta reglas NOPASSWD acotadas a otros comandos', async () => {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
'sudo -n -l': { code: 0, stdout: 'User srvmid_db may run:\n (root) NOPASSWD: /usr/bin/systemctl' }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.elevation).toBe('ninguna');
expect(r.detail).toMatch(/acotadas/);
expect(r.detail).toContain('systemctl');
});
it('sin privilegios de ninguna clase, y sin prefijo que pueda elevar', async () => {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr: 'sudo: a password is required' },
'sudo -n -l': { code: 1, stdout: '' }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.elevation).toBe('ninguna');
expect(r.prefix).toBe('');
expect(r.detail).toMatch(/contraseña, que el panel nunca envía/);
});
it('NUNCA construye un prefijo que le pida la contraseña a sudo', async () => {
// La política del módulo: ni por argv ni por stdin. El modo sin privilegios existe
// precisamente para no tener que romperla.
for (const stderr of ['sudo: a password is required', 'sudo: sorry, you must have a tty']) {
const { sftp } = sftpFor({
'id -u': { code: 0, stdout: '1000' },
'sudo -n true': { code: 1, stderr },
'sudo -n -l': { code: 1 }
});
const r = await probeLinuxElevation(sftp as never);
expect(r.prefix).not.toMatch(/-S/);
expect(r.prefix).not.toMatch(/echo/);
}
});
});

View File

@@ -12,8 +12,11 @@
* subido por SFTP, no como argumento: `ps` y el historial del destino son legibles por
* otros usuarios. AServers hace `echo '{password}' | sudo -S ...`, que expone el password
* y además permite inyección de comandos.
* - **Se exige root o `sudo -n`** (sudo sin password). Nunca se le pasa el password a sudo.
* Si no hay privilegios se aborta ANTES de subir 270 MB.
* - **Nunca se le pasa el password a sudo**, ni por argv ni por stdin. Para los modos que
* instalan en /opt y registran un servicio de sistema se exige root o `sudo -n` (sudo sin
* password), y si no hay ninguno se aborta ANTES de subir 270 MB. El modo `user-service`
* evita el problema en vez de rodearlo: instala bajo el home con un unit de systemd de
* usuario, así que no necesita elevación alguna y le bastan las credenciales SSH.
* - **El sha256 se verifica en el destino** antes de extraer, no solo al cachear: así se
* detecta una transferencia corrupta.
* - **El progreso se persiste paso a paso** en cras_install_runs.steps para que la UI lo
@@ -35,6 +38,7 @@ import {
type InstallMode
} from './cras-releases';
import { ensureCached } from './cras-artifacts';
import type { AutostartMode } from '$lib/cras-install-progress';
import { logger } from './logger';
import {
effectiveArch,
@@ -149,7 +153,7 @@ export interface InstallRequest {
/** Token de servicio que se sembrará en el .env del destino. */
panelApiToken: string;
/** Modo de arranque a registrar en el destino. */
autostart?: 'service' | 'desktop' | 'none';
autostart?: AutostartMode;
}
export interface InstallOutcome {
@@ -495,7 +499,21 @@ async function installLinux(
// un Windows (o en un WSL dentro de un Windows) hay que abortar aquí y no tras subir 270 MB.
const systemEvidence = await assertSystemMatches(sftp, 'linux');
const privileged = await resolveLinuxPrivilege(sftp, target.ssh_username);
// El modo `user-service` instala en el home con un unit de usuario, así que no necesita
// ninguna elevación. Los modos de sistema sí: ahí la falta de privilegios se aborta aquí,
// antes de transferir 270 MB, porque fallar después es desperdicio y deja basura en /tmp.
const autostartMode = request.autostart ?? 'service';
const privileged = await probeLinuxElevation(sftp);
if (autostartMode !== 'user-service' && !privileged.prefix && privileged.elevation !== 'root') {
throw new InstallError(
409,
`El usuario '${target.ssh_username}' no puede elevar privilegios en este servidor: ` +
`${privileged.detail} Para instalar en ${installPath} y registrar el servicio de ` +
'sistema hacen falta. Alternativas: usar una cuenta root, dar NOPASSWD a ese ' +
'usuario, o elegir el arranque "Servicio de usuario", que instala en el home y no ' +
'necesita privilegios.'
);
}
await appendInstallStep(
runId,
'precondiciones',
@@ -599,37 +617,83 @@ async function installLinux(
}
}
interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. */
/**
* Vías de elevación en Linux, de mejor a peor.
*
* `requiretty` se distingue de `ninguna` porque el remedio es el opuesto: con requiretty,
* agregar NOPASSWD no sirve de nada —sudo rechaza antes de mirar la política— y el consejo de
* "configura NOPASSWD" manda al operador a hacer algo inútil. Es un fallo de sudo, no de permisos.
*/
export type LinuxElevation = 'root' | 'sudo-sin-password' | 'requiretty' | 'ninguna';
export interface LinuxPrivilege {
/** Prefijo a poner delante de los comandos que requieren root. Vacío si no hay elevación. */
prefix: string;
label: string;
elevation: LinuxElevation;
/** Qué falta exactamente, en términos accionables. Vacío cuando sí hay elevación. */
detail: string;
}
/**
* Resuelve cómo obtener privilegios: root directo o `sudo -n` (sin password).
*
* Nunca se le pasa el password a sudo por stdin. Si no hay ninguna de las dos vías se
* aborta aquí, antes de transferir el artefacto: fallar tras subir 270 MB es desperdicio y
* deja basura en /tmp del destino.
* `Defaults requiretty` en el sudoers del destino (aún común en derivados de RHEL) hace fallar
* cualquier sudo lanzado sobre un `exec` de SSH, que no tiene tty. El mensaje es estable desde
* hace dos décadas, así que la firma de stderr es fiable — y se clasifica sobre la SONDA, que es
* un comando controlado de una línea, no sobre la salida del instalador.
*/
async function resolveLinuxPrivilege(
sftp: SftpClient,
sshUsername: string
): Promise<LinuxPrivilege> {
function isRequireTty(stderr: string): boolean {
return /must have a tty|no tty present/i.test(stderr);
}
/**
* Sonda de elevación. La comparten el instalador y la pantalla de Verificar para que no puedan
* contradecirse: antes cada uno tenía su propia copia y ya diferían en la etiqueta.
*
* Nunca se le pasa el password a sudo. La vía sin privilegios no es un error aquí: el modo de
* instalación `user-service` no necesita ninguno, así que quien decide si falta algo es el
* llamador, no esta función.
*/
export async function probeLinuxElevation(sftp: SftpClient): Promise<LinuxPrivilege> {
const id = await execRemote(sftp, 'id -u');
if (id.code === 0 && id.stdout.trim() === '0') {
return { prefix: '', label: 'root' };
return { prefix: '', label: 'root', elevation: 'root', detail: '' };
}
const sudo = await execRemote(sftp, 'sudo -n true');
if (sudo.code === 0) {
return { prefix: 'sudo -n ', label: 'sudo sin password' };
return {
prefix: 'sudo -n ',
label: 'sudo sin password',
elevation: 'sudo-sin-password',
detail: ''
};
}
throw new InstallError(
409,
`El usuario '${sshUsername}' no es root y no tiene sudo sin password. ` +
'Configura NOPASSWD para ese usuario o usa una cuenta root: el instalador necesita ' +
'privilegios para el servicio systemd y, por seguridad, no se le pasa la contraseña a sudo.'
);
if (isRequireTty(sudo.stderr)) {
return {
prefix: '',
label: 'sudo bloqueado por requiretty',
elevation: 'requiretty',
detail:
'el sudoers del destino tiene `Defaults requiretty` y el panel ejecuta sin tty. ' +
'Agregar NOPASSWD NO lo arregla: hay que quitar esa opción o excluir al usuario ' +
'con `Defaults:<usuario> !requiretty`.'
};
}
// `sudo -n true` da falso negativo cuando existe una regla NOPASSWD acotada a comandos
// concretos: `true` no está en ella, pero el comando real sí podría estarlo. Listar las
// reglas lo distingue, y de paso le dice al operador qué SÍ tiene concedido.
const list = await execRemote(sftp, 'sudo -n -l 2>&1 || true');
const scoped = list.code === 0 && /NOPASSWD:/i.test(list.stdout);
return {
prefix: '',
label: 'sin privilegios',
elevation: 'ninguna',
detail: scoped
? 'tiene reglas NOPASSWD pero acotadas a otros comandos: ' +
truncate(list.stdout.replace(/\s+/g, ' '))
: 'no es root y su sudo pide contraseña, que el panel nunca envía.'
};
}
async function verifyLinuxDeployment(
@@ -637,12 +701,18 @@ async function verifyLinuxDeployment(
runId: number,
release: CrasRelease,
privileged: LinuxPrivilege,
autostart: 'service' | 'desktop' | 'none',
prefix: string
autostart: AutostartMode,
// Se llama installPath y no `prefix` a propósito: en el cuerpo convive con
// `privileged.prefix`, que es el prefijo de ELEVACIÓN. Dos cosas distintas con el mismo
// nombre en el mismo alcance es exactamente donde se cuela un bug silencioso.
installPath: string
): Promise<void> {
// El sello config/.version lo escribe el bootstrap del binario; es más confiable que
// stdout de --version, sobre todo por paridad con Windows (console=False).
const stamp = await execRemote(sftp, `cat ${shQuote(`${prefix}/config/.version`)} 2>/dev/null`);
const stamp = await execRemote(
sftp,
`cat ${shQuote(`${installPath}/config/.version`)} 2>/dev/null`
);
const deployed = stamp.stdout.trim();
if (deployed && deployed !== release.version) {
throw new InstallError(
@@ -657,18 +727,56 @@ async function verifyLinuxDeployment(
deployed ? `config/.version = ${deployed}` : 'sello aún no escrito (se creará al arrancar)'
);
if (autostart !== 'service') return;
if (autostart !== 'service' && autostart !== 'user-service') return;
const active = await execRemote(sftp, `${privileged.prefix}systemctl is-active cloudrestoreas`);
// El unit de usuario NO se consulta con el prefijo de elevación: vive en el bus del propio
// usuario, y preguntarlo como root apuntaría al bus equivocado y respondería 'inactive'
// sobre un servicio que sí está corriendo. XDG_RUNTIME_DIR va explícito porque un `exec` de
// SSH no es una sesión de login y no siempre lo trae.
const userMode = autostart === 'user-service';
const active = await execRemote(
sftp,
userMode
? 'XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cloudrestoreas'
: `${privileged.prefix}systemctl is-active cloudrestoreas`
);
const state = active.stdout.trim() || active.stderr.trim();
if (state !== 'active') {
// En modo usuario el arranque puede haber quedado por cron (@reboot) en vez de systemd,
// cuando el destino no permite lingering. Ahí no hay unit que consultar y el proceso se
// comprueba directamente, que es lo que de verdad importa.
if (userMode) {
// El ancla ^ es obligatoria: sin ella el `sh -c` que corre este mismo pgrep lleva la
// ruta en su propia línea de comandos y haría match consigo mismo, reportando vivo un
// agente que no arrancó.
const alive = await execRemote(
sftp,
`pgrep -f ${shQuote(`^${installPath}/CloudRestoreAS`)} >/dev/null && echo si || echo no`
);
if (alive.stdout.trim() === 'si') {
await appendInstallStep(
runId,
'verificar-servicio',
true,
'proceso vivo (arranque por cron; systemd de usuario no disponible)'
);
return;
}
}
throw new InstallError(
502,
`El servicio cloudrestoreas no quedó activo (estado: ${state || 'desconocido'}). ` +
'Revisa journalctl -u cloudrestoreas en el servidor.'
(userMode
? 'Revisa `systemctl --user status cloudrestoreas` en el servidor.'
: 'Revisa journalctl -u cloudrestoreas en el servidor.')
);
}
await appendInstallStep(runId, 'verificar-servicio', true, 'cloudrestoreas active');
await appendInstallStep(
runId,
'verificar-servicio',
true,
userMode ? 'cloudrestoreas active (systemd de usuario)' : 'cloudrestoreas active'
);
}
// ============================================================================
@@ -835,7 +943,7 @@ async function verifyWindowsDeployment(
sftp: SftpClient,
runId: number,
release: CrasRelease,
autostart: 'service' | 'desktop' | 'none',
autostart: AutostartMode,
prefix: string
): Promise<void> {
const stamp = await execRemote(

View File

@@ -17,7 +17,7 @@ import net from 'node:net';
import SftpClient from 'ssh2-sftp-client';
import { getRestoreTargetSsh, type RestoreTargetSsh } from './controldesk-pg';
import { execRemote, psEncoded, shQuote } from './cras-install';
import { execRemote, probeLinuxElevation, psEncoded, shQuote } from './cras-install';
import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
import { logger } from './logger';
@@ -504,20 +504,65 @@ async function inspectLinux(
const checks: VerifyCheck[] = [];
const prefix = effectiveInstallPath(reportedInstallPath, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
const id = await execRemote(sftp, 'id -u', CHECK_TIMEOUT_MS);
let privileged = id.code === 0 && id.stdout.trim() === '0';
let privLabel = 'root';
if (!privileged) {
const sudo = await execRemote(sftp, 'sudo -n true', CHECK_TIMEOUT_MS);
privileged = sudo.code === 0;
privLabel = privileged ? 'sudo sin contraseña' : 'sin privilegios';
}
// La sonda es la MISMA que usa el instalador. Antes había aquí una copia paralela, y ya
// diferían en la etiqueta: dos pantallas contradiciéndose sobre el mismo hecho.
const elevation = await probeLinuxElevation(sftp);
const privileged = elevation.elevation === 'root' || elevation.elevation === 'sudo-sin-password';
checks.push(
check(
'privilegios',
'Privilegios para instalar',
privileged ? 'ok' : 'warn',
privileged ? privLabel : 'no es root y no tiene sudo sin contraseña'
privileged ? elevation.label : `${elevation.label}${elevation.detail}`
)
);
// Qué haría falta para instalar SIN privilegios. Sin esto, el operador solo sabe que le
// faltan permisos, no cuál de los dos obstáculos tiene ni si puede rodearlos.
const caps = await execRemote(
sftp,
[
`echo "owner=$(stat -c '%U' ${shQuote(prefix)} 2>/dev/null || echo -)"`,
`echo "writable=$(test -w ${shQuote(prefix)} && echo si || echo no)"`,
`echo "home_writable=$(test -w "$HOME" && echo si || echo no)"`,
'echo "unit_system=$(test -f /etc/systemd/system/cloudrestoreas.service && echo si || echo no)"',
'echo "unit_user=$(test -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/cloudrestoreas.service" && echo si || echo no)"',
'echo "crontab=$(command -v crontab >/dev/null 2>&1 && echo si || echo no)"',
'echo "linger=$(loginctl show-user "$(id -un)" --property=Linger 2>/dev/null | cut -d= -f2)"'
].join('; '),
CHECK_TIMEOUT_MS
);
const cap = new Map(
caps.stdout
.split('\n')
.map((line) => line.trim().split('='))
.filter((parts) => parts.length === 2)
.map(([k, v]) => [k, v] as const)
);
const prefixWritable = cap.get('writable') === 'si';
const unitSystem = cap.get('unit_system') === 'si';
checks.push(
check(
'ruta-escribible',
'Ruta de instalación escribible',
prefixWritable ? 'ok' : 'warn',
prefixWritable
? `${prefix} (dueño: ${cap.get('owner') ?? '?'})`
: `${prefix} es de ${cap.get('owner') ?? '?'}: actualizar el binario ahí necesita privilegios`
)
);
const rootlessViable =
cap.get('home_writable') === 'si' &&
(cap.get('crontab') === 'si' || cap.get('linger') === 'yes');
checks.push(
check(
'instalacion-sin-privilegios',
'Instalación sin privilegios posible',
rootlessViable ? 'ok' : 'warn',
rootlessViable
? `sí: home escribible, ${cap.get('crontab') === 'si' ? 'hay crontab' : 'lingering activo'}` +
(unitSystem ? '. Ojo: ya hay un unit de SISTEMA que habría que retirar con root.' : '')
: 'no: sin home escribible y sin crontab ni lingering no hay dónde dejarlo arrancado'
)
);
@@ -581,7 +626,16 @@ async function inspectLinux(
agent_could_apply: false,
notes: privileged
? []
: ['Antes hay que resolver los privilegios: se necesita root o sudo sin contraseña.']
: rootlessViable
? [
`Sin privilegios (${elevation.detail}), pero no hacen falta: elige el ` +
'arranque "Servicio de usuario" al instalar y apunta la ruta al home ' +
'del usuario. Bastan el usuario y la contraseña SSH ya registrados.'
]
: [
`Sin privilegios: ${elevation.detail}`,
'Y tampoco es viable la instalación sin privilegios en este servidor.'
]
}
};
}

View File

@@ -49,6 +49,7 @@ import {
packageLocation
} from '$lib/server/gitea-packages';
import { installCrasOnTarget, InstallError } from '$lib/server/cras-install';
import type { AutostartMode } from '$lib/cras-install-progress';
import { logger } from '$lib/server/logger';
async function requireAdmin(cookies: import('@sveltejs/kit').Cookies) {
@@ -491,8 +492,16 @@ export const actions: Actions = {
return fail(400, { error: 'Servidor o versión inválidos.' });
}
const mode = modeRaw === 'update' ? 'update' : 'install';
const autostart =
autostartRaw === 'desktop' ? 'desktop' : autostartRaw === 'none' ? 'none' : 'service';
// Lista blanca explícita en vez de una cadena de ternarios: con cuatro modos, el patrón
// anterior convertía cualquier valor no reconocido en 'service', que es justo el que
// exige privilegios. Un formulario viejo pediría el modo más restrictivo sin quererlo.
const autostart: AutostartMode =
autostartRaw === 'desktop' ||
autostartRaw === 'none' ||
autostartRaw === 'user-service' ||
autostartRaw === 'service'
? autostartRaw
: 'service';
const platformAck = data.get('platform_ack')?.toString() ?? '';
// Red de seguridad del formulario. Se valida ANTES de abrir el run porque la única guarda

View File

@@ -1226,12 +1226,24 @@
<option value="service">
Servicio 24/7 headless (recomendado en servidor)
</option>
{#if installTarget?.platform === 'linux'}
<option value="user-service">
Servicio 24/7 de usuario (sin privilegios)
</option>
{/if}
<option value="desktop">Al iniciar sesión (requiere escritorio)</option>
<option value="none">Solo instalar, sin arranque automático</option>
</select>
<p class="mt-1 text-xs text-slate-500">
{#if installAutostart === 'user-service'}
No necesita privilegios: instala en la ruta indicada arriba —que debe
ser escribible por el usuario SSH, normalmente bajo su home— y registra
un servicio de systemd <em>de usuario</em>. Bastan el usuario y la
contraseña que ya tiene el servidor registrados.
{:else}
En Linux requiere root o sudo sin contraseña; en Windows, cuenta
administradora. Se valida antes de transferir el artefacto.
{/if}
</p>
</div>
</fieldset>