Compare commits

...

2 Commits

Author SHA1 Message Date
yamaray bc50948455 Aclarar autenticacion por clave asimetrica 2026-06-04 18:31:08 +02:00
yamaray d7fe855ec0 Usar clave SSH global del monitor 2026-06-04 17:52:24 +02:00
5 changed files with 233 additions and 42 deletions
+30 -13
View File
@@ -12,7 +12,7 @@ Navegador
v v
Contenedor monitor-rpi Contenedor monitor-rpi
| |
| SSH con usuario/password o clave privada | SSH con usuario/password o clave asimetrica
v v
Raspberry Pi activas Raspberry Pi activas
``` ```
@@ -118,20 +118,36 @@ Usuario SSH.
### `password` ### `password`
Password SSH. Se guarda en `config.json`, por lo que el archivo debe permanecer protegido. Si `privateKeyPath` esta configurado, la clave privada tiene prioridad y no se usa `password`. Password SSH. Se guarda en `config.json`, por lo que el archivo debe permanecer protegido. Si el dispositivo usa `authMethod: "key"`, no se usa `password`.
### `authMethod`
Metodo de autenticacion SSH por dispositivo. Valores recomendados:
- `password`: usa `username` y `password`.
- `key`: usa `username` y la clave privada global configurada en `sshPrivateKeyPath`. Operativamente, instala la clave publica del monitor en la RPi.
### `privateKeyPath` ### `privateKeyPath`
Ruta de la clave privada SSH dentro del contenedor o del host donde corre el monitor. Debe apuntar a una clave privada legible por el proceso del monitor. Para uso en Docker, monta la clave o una carpeta `.ssh` dentro del contenedor. Las claves con passphrase requieren agente SSH disponible; para monitorizacion unattended suele usarse una clave sin passphrase protegida por permisos de archivo. Campo heredado para configuraciones antiguas con clave privada por dispositivo. La interfaz nueva usa la clave global del monitor y deja este campo vacio.
## Autenticacion SSH por Clave Privada ## Autenticacion SSH por Clave Asimetrica
El monitor soporta dos metodos de autenticacion por dispositivo: El monitor soporta dos metodos de autenticacion por dispositivo:
- Password SSH: usar `password` y dejar `privateKeyPath` vacio. - Password SSH: usar `password` y dejar `privateKeyPath` vacio.
- Clave privada: dejar `password` vacio y configurar `privateKeyPath`. - Clave asimetrica: dejar `password` vacio, seleccionar `authMethod: "key"` y copiar la clave publica del monitor en la RPi.
Cuando `privateKeyPath` tiene valor, el backend usa clave privada y no usa el password. Cuando un dispositivo tiene `authMethod: "key"`, el backend usa la clave privada global configurada en `sshPrivateKeyPath`. El campo `privateKeyPath` por dispositivo queda solo como compatibilidad con configuraciones antiguas.
### Configuracion global de claves
```json
"sshPrivateKeyPath": "/ssh/monitor_rpi_ed25519",
"sshPublicKeyPath": "/ssh/monitor_rpi_ed25519.pub"
```
La clave privada se queda solo en el monitor. La clave publica se muestra en el configurador y puede copiarse o descargarse para instalarla en las RPi remotas.
### 1. Guardar la clave privada en el volumen del monitor ### 1. Guardar la clave privada en el volumen del monitor
@@ -144,15 +160,15 @@ En la RPi donde corre el contenedor, guarda las claves en:
Ejemplo: Ejemplo:
```text ```text
/home/yamaray/docker/monitorRPi/ssh/carabanes_monitor_ed25519 /home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519
/home/yamaray/docker/monitorRPi/ssh/carabanes_monitor_ed25519.pub /home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519.pub
``` ```
La clave privada debe quedar protegida: La clave privada debe quedar protegida:
```bash ```bash
chmod 700 /home/yamaray/docker/monitorRPi/ssh chmod 700 /home/yamaray/docker/monitorRPi/ssh
chmod 600 /home/yamaray/docker/monitorRPi/ssh/carabanes_monitor_ed25519 chmod 600 /home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519
``` ```
El `docker-compose.yml` debe montar esa carpeta dentro del contenedor: El `docker-compose.yml` debe montar esa carpeta dentro del contenedor:
@@ -167,8 +183,9 @@ volumes:
Dentro de la configuracion del dispositivo, la ruta debe ser la ruta vista por el contenedor: Dentro de la configuracion del dispositivo, la ruta debe ser la ruta vista por el contenedor:
```json ```json
"authMethod": "key",
"password": "", "password": "",
"privateKeyPath": "/ssh/carabanes_monitor_ed25519" "privateKeyPath": ""
``` ```
### 2. Instalar la clave publica en la Raspberry destino ### 2. Instalar la clave publica en la Raspberry destino
@@ -187,7 +204,7 @@ chmod 600 ~/.ssh/authorized_keys
En `authorized_keys` pega el contenido de la clave publica: En `authorized_keys` pega el contenido de la clave publica:
```text ```text
carabanes_monitor_ed25519.pub monitor_rpi_ed25519.pub
``` ```
El archivo correcto en Linux/OpenSSH es: El archivo correcto en Linux/OpenSSH es:
@@ -203,13 +220,13 @@ No es `authorised_keys`.
Una vez montada la clave, puedes probar desde la RPi host: Una vez montada la clave, puedes probar desde la RPi host:
```bash ```bash
docker exec -it monitor-rpi ssh -i /ssh/carabanes_monitor_ed25519 -o IdentitiesOnly=yes pi@HOST_REMOTO docker exec -it monitor-rpi ssh -i /ssh/monitor_rpi_ed25519 -o IdentitiesOnly=yes pi@HOST_REMOTO
``` ```
Si el dispositivo usa un puerto distinto: Si el dispositivo usa un puerto distinto:
```bash ```bash
docker exec -it monitor-rpi ssh -i /ssh/carabanes_monitor_ed25519 -o IdentitiesOnly=yes -p 34000 pi@192.168.0.1 docker exec -it monitor-rpi ssh -i /ssh/monitor_rpi_ed25519 -o IdentitiesOnly=yes -p 34000 pi@192.168.0.1
``` ```
Si la conexion funciona sin pedir password, el monitor tambien podra consultar ese dispositivo. Si la conexion funciona sin pedir password, el monitor tambien podra consultar ese dispositivo.
+10
View File
@@ -3,6 +3,8 @@
"refreshIntervalSeconds": 30, "refreshIntervalSeconds": 30,
"idleScanIntervalSeconds": 300, "idleScanIntervalSeconds": 300,
"sshTimeoutSeconds": 8, "sshTimeoutSeconds": 8,
"sshPrivateKeyPath": "/ssh/monitor_rpi_ed25519",
"sshPublicKeyPath": "/ssh/monitor_rpi_ed25519.pub",
"temperatureThresholdsC": { "temperatureThresholdsC": {
"warning": 60, "warning": 60,
"hot": 70, "hot": 70,
@@ -37,6 +39,7 @@
"host": "192.168.0.46", "host": "192.168.0.46",
"username": "pi", "username": "pi",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Home Assistant Local", "role": "Home Assistant Local",
"port": 22, "port": 22,
@@ -50,6 +53,7 @@
"host": "192.168.0.57", "host": "192.168.0.57",
"username": "pi", "username": "pi",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Home Assistant Cabaña", "role": "Home Assistant Cabaña",
"port": 22, "port": 22,
@@ -63,6 +67,7 @@
"host": "192.168.0.53", "host": "192.168.0.53",
"username": "yamaray", "username": "yamaray",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Docker", "role": "Docker",
"port": 22, "port": 22,
@@ -76,6 +81,7 @@
"host": "192.168.0.130", "host": "192.168.0.130",
"username": "pi", "username": "pi",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Docker - Daniel", "role": "Docker - Daniel",
"port": 22, "port": 22,
@@ -89,6 +95,7 @@
"host": "192.168.0.254", "host": "192.168.0.254",
"username": "yamaray", "username": "yamaray",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "nginx - wireguard ", "role": "nginx - wireguard ",
"port": 22, "port": 22,
@@ -102,6 +109,7 @@
"host": "192.168.0.37", "host": "192.168.0.37",
"username": "pi", "username": "pi",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "MQTT - varios", "role": "MQTT - varios",
"port": 22, "port": 22,
@@ -115,6 +123,7 @@
"host": "192.168.0.60", "host": "192.168.0.60",
"username": "yamaray", "username": "yamaray",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Meshcore-Interface", "role": "Meshcore-Interface",
"port": 22, "port": 22,
@@ -128,6 +137,7 @@
"host": "192.168.1.47", "host": "192.168.1.47",
"username": "pi", "username": "pi",
"password": "", "password": "",
"authMethod": "password",
"privateKeyPath": "", "privateKeyPath": "",
"role": "Reserva", "role": "Reserva",
"port": 22, "port": 22,
+117 -14
View File
@@ -154,6 +154,45 @@
gap: 10px; gap: 10px;
} }
.ssh-key-panel {
margin-top: 16px;
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel-2);
}
.ssh-key-fields {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 10px;
}
.public-key-box {
display: grid;
gap: 8px;
}
.public-key-box textarea {
width: 100%;
min-height: 78px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--input);
color: var(--text);
padding: 10px;
font: 13px ui-monospace, "Cascadia Code", Consolas, monospace;
}
.public-key-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.threshold-row { .threshold-row {
display: grid; display: grid;
grid-template-columns: 110px repeat(3, minmax(120px, 1fr)); grid-template-columns: 110px repeat(3, minmax(120px, 1fr));
@@ -198,9 +237,8 @@
.field-port { grid-column: span 1; } .field-port { grid-column: span 1; }
.field-username { grid-column: span 2; } .field-username { grid-column: span 2; }
.field-auth { grid-column: span 2; } .field-auth { grid-column: span 2; }
.password-credential, .key-credential { grid-column: span 4; } .password-credential { grid-column: span 4; }
.field-delete { grid-column: span 2; } .field-delete { grid-column: span 2; }
.device[data-auth-method="password"] .key-credential { display: none; }
.device[data-auth-method="key"] .password-credential { display: none; } .device[data-auth-method="key"] .password-credential { display: none; }
.credential-note { .credential-note {
@@ -262,6 +300,17 @@
} }
button:hover, a:hover { border-color: var(--info); } button:hover, a:hover { border-color: var(--info); }
.secondary-action {
background: var(--panel);
border-color: var(--info);
color: var(--info);
font-weight: 650;
}
.secondary-action:hover {
background: color-mix(in srgb, var(--info) 18%, var(--panel));
}
.danger { .danger {
background: var(--offline-bg); background: var(--offline-bg);
border-color: var(--offline-line); border-color: var(--offline-line);
@@ -396,11 +445,12 @@
@media (max-width: 1180px) { @media (max-width: 1180px) {
.device { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: start; } .device { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: start; }
.field-name, .field-role, .field-host, .password-credential, .key-credential { grid-column: span 3; } .field-name, .field-role, .field-host, .password-credential { grid-column: span 3; }
.field-id, .field-model, .field-location, .field-username, .field-auth, .field-active, .field-delete { grid-column: span 2; } .field-id, .field-model, .field-location, .field-username, .field-auth, .field-active, .field-delete { grid-column: span 2; }
.field-port { grid-column: span 1; } .field-port { grid-column: span 1; }
.credential-note { grid-column: span 4; } .credential-note { grid-column: span 4; }
.top-grid { grid-template-columns: repeat(2, 1fr); } .top-grid { grid-template-columns: repeat(2, 1fr); }
.ssh-key-fields { grid-template-columns: 1fr; }
.threshold-row { grid-template-columns: 90px repeat(3, minmax(100px, 1fr)); } .threshold-row { grid-template-columns: 90px repeat(3, minmax(100px, 1fr)); }
} }
@@ -410,7 +460,7 @@
.top-grid, .device { grid-template-columns: 1fr; } .top-grid, .device { grid-template-columns: 1fr; }
.field-active, .field-name, .field-id, .field-role, .field-model, .field-location, .field-active, .field-name, .field-id, .field-role, .field-model, .field-location,
.field-host, .field-port, .field-username, .field-auth, .password-credential, .field-host, .field-port, .field-username, .field-auth, .password-credential,
.key-credential, .credential-note, .field-delete { grid-column: span 1; } .credential-note, .field-delete { grid-column: span 1; }
.threshold-row { grid-template-columns: 1fr; } .threshold-row { grid-template-columns: 1fr; }
} }
</style> </style>
@@ -452,6 +502,26 @@
<input id="sshTimeoutSeconds" type="number" min="2" step="1"> <input id="sshTimeoutSeconds" type="number" min="2" step="1">
</label> </label>
</div> </div>
<div class="ssh-key-panel">
<h2>Clave SSH del monitor</h2>
<div class="ssh-key-fields">
<label title="Ruta de la clave privada dentro del contenedor. Esta clave se queda solo en el monitor.">Clave privada global
<input id="sshPrivateKeyPath" value="/ssh/monitor_rpi_ed25519">
</label>
<label title="Ruta de la clave publica dentro del contenedor. Esta es la clave que se copia a las RPi remotas.">Clave publica global
<input id="sshPublicKeyPath" value="/ssh/monitor_rpi_ed25519.pub">
</label>
</div>
<div class="public-key-box">
<label title="Copia esta clave publica en ~/.ssh/authorized_keys del usuario SSH de la RPi remota.">Clave publica para instalar en las RPi
<textarea id="sshPublicKey" readonly placeholder="Cargando clave publica..."></textarea>
</label>
<div class="public-key-actions">
<button class="secondary-action" id="copyPublicKey" type="button">Copiar clave publica</button>
<a class="secondary-action" id="downloadPublicKey" href="/api/ssh-public-key/download">Descargar .pub</a>
</div>
</div>
</div>
<div class="threshold-groups"> <div class="threshold-groups">
<div class="threshold-row"> <div class="threshold-row">
<div class="threshold-title">Temperatura</div> <div class="threshold-title">Temperatura</div>
@@ -588,9 +658,10 @@
const devicesEl = document.querySelector("#devices"); const devicesEl = document.querySelector("#devices");
const statusEl = document.querySelector("#status"); const statusEl = document.querySelector("#status");
const dashboardLink = document.querySelector("#dashboardLink"); const dashboardLink = document.querySelector("#dashboardLink");
const publicKeyEl = document.querySelector("#sshPublicKey");
function deviceTemplate(device, index) { function deviceTemplate(device, index) {
const authMethod = device.privateKeyPath ? "key" : "password"; const authMethod = device.authMethod || (device.privateKeyPath ? "key" : "password");
return ` return `
<div class="device" data-index="${index}" data-auth-method="${authMethod}"> <div class="device" data-index="${index}" data-auth-method="${authMethod}">
<label class="field-name" title="Nombre visible de la tarjeta en el dashboard.">Nombre <label class="field-name" title="Nombre visible de la tarjeta en el dashboard.">Nombre
@@ -622,17 +693,14 @@
<label class="field-auth" title="Metodo de autenticacion SSH que usara el monitor para este dispositivo.">Auth SSH <label class="field-auth" title="Metodo de autenticacion SSH que usara el monitor para este dispositivo.">Auth SSH
<select data-auth-method> <select data-auth-method>
<option value="password" ${authMethod === "password" ? "selected" : ""}>Password</option> <option value="password" ${authMethod === "password" ? "selected" : ""}>Password</option>
<option value="key" ${authMethod === "key" ? "selected" : ""}>Clave privada</option> <option value="key" ${authMethod === "key" ? "selected" : ""}>Clave asimetrica</option>
</select> </select>
</label> </label>
<label class="password-credential" title="Password SSH. Se guarda en config.json dentro del volumen persistente. Si eliges clave privada, se guardara vacio.">Password <label class="password-credential" title="Password SSH. Se guarda en config.json dentro del volumen persistente. Si eliges clave asimetrica, se guardara vacio.">Password
<input data-field="password" type="password" value="${device.password || ""}"> <input data-field="password" type="password" value="${device.password || ""}">
</label> </label>
<label class="key-credential" title="Ruta de la clave privada SSH dentro del contenedor. Ejemplo: /ssh/carabanes_monitor_ed25519. Si eliges password, se guardara vacia.">Clave privada
<input data-field="privateKeyPath" value="${device.privateKeyPath || ""}">
</label>
<div class="credential-note"> <div class="credential-note">
Metodo activo: <strong>${authMethod === "key" ? "Clave privada" : "Password"}</strong> Metodo activo: <strong>${authMethod === "key" ? "Clave asimetrica: copiar clave publica del monitor en la RPi" : "Password"}</strong>
</div> </div>
<label class="field-active" title="Si esta activo, este dispositivo se consulta por SSH. Si esta inactivo, se muestra deshabilitado y no se escanea.">Activo <label class="field-active" title="Si esta activo, este dispositivo se consulta por SSH. Si esta inactivo, se muestra deshabilitado y no se escanea.">Activo
<input data-field="active" type="checkbox" ${device.active ? "checked" : ""}> <input data-field="active" type="checkbox" ${device.active ? "checked" : ""}>
@@ -648,6 +716,8 @@
document.querySelector("#refreshIntervalSeconds").value = state.config.refreshIntervalSeconds || 30; document.querySelector("#refreshIntervalSeconds").value = state.config.refreshIntervalSeconds || 30;
document.querySelector("#idleScanIntervalSeconds").value = state.config.idleScanIntervalSeconds || 300; document.querySelector("#idleScanIntervalSeconds").value = state.config.idleScanIntervalSeconds || 300;
document.querySelector("#sshTimeoutSeconds").value = state.config.sshTimeoutSeconds || 8; document.querySelector("#sshTimeoutSeconds").value = state.config.sshTimeoutSeconds || 8;
document.querySelector("#sshPrivateKeyPath").value = state.config.sshPrivateKeyPath || "/ssh/monitor_rpi_ed25519";
document.querySelector("#sshPublicKeyPath").value = state.config.sshPublicKeyPath || `${document.querySelector("#sshPrivateKeyPath").value}.pub`;
document.querySelector("#warningTemp").value = state.config.temperatureThresholdsC?.warning || 60; document.querySelector("#warningTemp").value = state.config.temperatureThresholdsC?.warning || 60;
document.querySelector("#hotTemp").value = state.config.temperatureThresholdsC?.hot || 70; document.querySelector("#hotTemp").value = state.config.temperatureThresholdsC?.hot || 70;
document.querySelector("#criticalTemp").value = state.config.temperatureThresholdsC?.critical || 80; document.querySelector("#criticalTemp").value = state.config.temperatureThresholdsC?.critical || 80;
@@ -671,6 +741,8 @@
state.config.refreshIntervalSeconds = Number(document.querySelector("#refreshIntervalSeconds").value || 30); state.config.refreshIntervalSeconds = Number(document.querySelector("#refreshIntervalSeconds").value || 30);
state.config.idleScanIntervalSeconds = Number(document.querySelector("#idleScanIntervalSeconds").value || 300); state.config.idleScanIntervalSeconds = Number(document.querySelector("#idleScanIntervalSeconds").value || 300);
state.config.sshTimeoutSeconds = Number(document.querySelector("#sshTimeoutSeconds").value || 8); state.config.sshTimeoutSeconds = Number(document.querySelector("#sshTimeoutSeconds").value || 8);
state.config.sshPrivateKeyPath = document.querySelector("#sshPrivateKeyPath").value || "/ssh/monitor_rpi_ed25519";
state.config.sshPublicKeyPath = document.querySelector("#sshPublicKeyPath").value || `${state.config.sshPrivateKeyPath}.pub`;
state.config.temperatureThresholdsC = { state.config.temperatureThresholdsC = {
...(state.config.temperatureThresholdsC || {}), ...(state.config.temperatureThresholdsC || {}),
warning: Number(document.querySelector("#warningTemp").value || 60), warning: Number(document.querySelector("#warningTemp").value || 60),
@@ -709,8 +781,9 @@
else device[field] = input.value; else device[field] = input.value;
}); });
const authMethod = row.querySelector("[data-auth-method]")?.value || "password"; const authMethod = row.querySelector("[data-auth-method]")?.value || "password";
if (authMethod === "key") device.password = ""; device.authMethod = authMethod === "key" ? "key" : "password";
else device.privateKeyPath = ""; device.privateKeyPath = "";
if (device.authMethod === "key") device.password = "";
return device; return device;
}); });
} }
@@ -719,6 +792,18 @@
const res = await fetch("/api/config", { cache: "no-store" }); const res = await fetch("/api/config", { cache: "no-store" });
state.config = await res.json(); state.config = await res.json();
render(); render();
loadPublicKey().catch((error) => {
publicKeyEl.value = error.message;
});
}
async function loadPublicKey() {
publicKeyEl.value = "Cargando clave publica...";
const res = await fetch("/api/ssh-public-key", { cache: "no-store" });
const payload = await res.json();
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
publicKeyEl.value = payload.publicKey || "";
document.querySelector("#sshPublicKeyPath").value = payload.publicKeyPath || document.querySelector("#sshPublicKeyPath").value;
} }
async function save() { async function save() {
@@ -762,6 +847,7 @@
username: "pi", username: "pi",
password: "", password: "",
privateKeyPath: "", privateKeyPath: "",
authMethod: "password",
model: "RPi 4", model: "RPi 4",
role: "", role: "",
location: "Rack", location: "Rack",
@@ -797,7 +883,24 @@
const row = selector.closest(".device"); const row = selector.closest(".device");
row.dataset.authMethod = selector.value; row.dataset.authMethod = selector.value;
const note = row.querySelector(".credential-note strong"); const note = row.querySelector(".credential-note strong");
if (note) note.textContent = selector.value === "key" ? "Clave privada" : "Password"; if (note) note.textContent = selector.value === "key" ? "Clave asimetrica: copiar clave publica del monitor en la RPi" : "Password";
});
document.querySelector("#copyPublicKey").addEventListener("click", async () => {
const button = document.querySelector("#copyPublicKey");
try {
await navigator.clipboard.writeText(publicKeyEl.value);
button.classList.add("action-ok");
button.textContent = "Copiada";
setTimeout(() => {
button.classList.remove("action-ok");
button.textContent = "Copiar clave publica";
}, 1200);
} catch {
publicKeyEl.select();
statusEl.className = "status save-error";
statusEl.textContent = "No se pudo copiar automaticamente. Selecciona la clave y copiala manualmente.";
}
}); });
load().catch((error) => { load().catch((error) => {
+11 -9
View File
@@ -86,26 +86,28 @@
<li><code>name</code>: nombre visible.</li> <li><code>name</code>: nombre visible.</li>
<li><code>host</code>: IP o DNS.</li> <li><code>host</code>: IP o DNS.</li>
<li><code>port</code>: puerto SSH.</li> <li><code>port</code>: puerto SSH.</li>
<li><code>username/password</code> o <code>privateKeyPath</code>: credenciales SSH. Si hay clave privada configurada, tiene prioridad sobre el password.</li> <li><code>username/password</code> o <code>authMethod: key</code>: credenciales SSH. Con clave asimetrica, copia la publica del monitor en la RPi.</li>
<li><code>model</code>: modelo visible.</li> <li><code>model</code>: modelo visible.</li>
<li><code>role</code>: funcion del equipo.</li> <li><code>role</code>: funcion del equipo.</li>
<li><code>location</code>: ubicacion.</li> <li><code>location</code>: ubicacion.</li>
</ul> </ul>
<h2>Autenticacion SSH por clave privada</h2> <h2>Autenticacion SSH por clave asimetrica</h2>
<p>Por dispositivo se puede usar password o clave privada. Si <code>privateKeyPath</code> tiene valor, el backend usa la clave privada y no usa el password.</p> <p>Por dispositivo se puede usar password o clave asimetrica. Si el dispositivo usa <code>authMethod: key</code>, el backend usa la clave privada global del monitor y no usa el password.</p>
<h3>Clave en el contenedor</h3> <h3>Clave en el contenedor</h3>
<p>Guarda la clave privada en la RPi donde corre el monitor:</p> <p>Guarda la clave privada en la RPi donde corre el monitor:</p>
<pre><code>/home/yamaray/docker/monitorRPi/ssh/carabanes_monitor_ed25519</code></pre> <pre><code>/home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519
/home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519.pub</code></pre>
<p>Protege permisos:</p> <p>Protege permisos:</p>
<pre><code>chmod 700 /home/yamaray/docker/monitorRPi/ssh <pre><code>chmod 700 /home/yamaray/docker/monitorRPi/ssh
chmod 600 /home/yamaray/docker/monitorRPi/ssh/carabanes_monitor_ed25519</code></pre> chmod 600 /home/yamaray/docker/monitorRPi/ssh/monitor_rpi_ed25519</code></pre>
<p>El compose debe montar la carpeta como solo lectura:</p> <p>El compose debe montar la carpeta como solo lectura:</p>
<pre><code>- /home/yamaray/docker/monitorRPi/ssh:/ssh:ro</code></pre> <pre><code>- /home/yamaray/docker/monitorRPi/ssh:/ssh:ro</code></pre>
<p>En el configurador usa la ruta vista desde el contenedor:</p> <p>En el configurador general usa las rutas vistas desde el contenedor:</p>
<pre><code>password: "" <pre><code>sshPrivateKeyPath: /ssh/monitor_rpi_ed25519
privateKeyPath: /ssh/carabanes_monitor_ed25519</code></pre> sshPublicKeyPath: /ssh/monitor_rpi_ed25519.pub</code></pre>
<p>En cada dispositivo selecciona <code>Auth SSH = Clave asimetrica</code>. El dispositivo solo necesita IP/host, puerto y usuario SSH.</p>
<h3>Clave publica en la Raspberry destino</h3> <h3>Clave publica en la Raspberry destino</h3>
<p>En el usuario SSH remoto, pega la clave publica en:</p> <p>En el usuario SSH remoto, pega la clave publica en:</p>
@@ -118,7 +120,7 @@ chmod 600 ~/.ssh/authorized_keys</code></pre>
<p>El nombre correcto del archivo es <code>authorized_keys</code>, no <code>authorised_keys</code>.</p> <p>El nombre correcto del archivo es <code>authorized_keys</code>, no <code>authorised_keys</code>.</p>
<h3>Prueba manual</h3> <h3>Prueba manual</h3>
<pre><code>docker exec -it monitor-rpi ssh -i /ssh/carabanes_monitor_ed25519 -o IdentitiesOnly=yes -p 34000 pi@192.168.0.1</code></pre> <pre><code>docker exec -it monitor-rpi ssh -i /ssh/monitor_rpi_ed25519 -o IdentitiesOnly=yes -p 34000 pi@192.168.0.1</code></pre>
<p>Si entra sin pedir password, el monitor podra usar esa clave para el scan.</p> <p>Si entra sin pedir password, el monitor podra usar esa clave para el scan.</p>
<h2>API</h2> <h2>API</h2>
+65 -6
View File
@@ -10,6 +10,8 @@ const DEFAULT_CONFIG_PATH = path.join(ROOT, "config.json");
const PORT = Number(process.env.PORT || 8787); const PORT = Number(process.env.PORT || 8787);
const AUTH_USERNAME = process.env.MONITOR_USERNAME || ""; const AUTH_USERNAME = process.env.MONITOR_USERNAME || "";
const AUTH_PASSWORD = process.env.MONITOR_PASSWORD || ""; const AUTH_PASSWORD = process.env.MONITOR_PASSWORD || "";
const DEFAULT_SSH_PRIVATE_KEY_PATH = "/ssh/monitor_rpi_ed25519";
const DEFAULT_SSH_PUBLIC_KEY_PATH = "/ssh/monitor_rpi_ed25519.pub";
let config = null; let config = null;
let latestStatus = null; let latestStatus = null;
@@ -42,21 +44,26 @@ async function readConfig() {
parsed.refreshIntervalSeconds = Number(parsed.refreshIntervalSeconds || 30); parsed.refreshIntervalSeconds = Number(parsed.refreshIntervalSeconds || 30);
parsed.idleScanIntervalSeconds = Number(parsed.idleScanIntervalSeconds || 300); parsed.idleScanIntervalSeconds = Number(parsed.idleScanIntervalSeconds || 300);
parsed.sshTimeoutSeconds = Number(parsed.sshTimeoutSeconds || 8); parsed.sshTimeoutSeconds = Number(parsed.sshTimeoutSeconds || 8);
parsed.sshPrivateKeyPath = String(parsed.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH);
parsed.sshPublicKeyPath = String(parsed.sshPublicKeyPath || `${parsed.sshPrivateKeyPath}.pub` || DEFAULT_SSH_PUBLIC_KEY_PATH);
parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC); parsed.temperatureThresholdsC = normalizeTemperatureThresholds(parsed.temperatureThresholdsC);
parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent); parsed.metricThresholdsPercent = normalizeMetricThresholds(parsed.metricThresholdsPercent);
parsed.devices = Array.isArray(parsed.devices) ? parsed.devices : []; parsed.devices = Array.isArray(parsed.devices) ? parsed.devices.map(normalizeDeviceConfig) : [];
return parsed; return parsed;
} }
async function writeConfig(nextConfig) { async function writeConfig(nextConfig) {
const sshPrivateKeyPath = String(nextConfig.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH);
const normalized = { const normalized = {
...nextConfig, ...nextConfig,
refreshIntervalSeconds: Number(nextConfig.refreshIntervalSeconds || 30), refreshIntervalSeconds: Number(nextConfig.refreshIntervalSeconds || 30),
idleScanIntervalSeconds: Number(nextConfig.idleScanIntervalSeconds || 300), idleScanIntervalSeconds: Number(nextConfig.idleScanIntervalSeconds || 300),
sshTimeoutSeconds: Number(nextConfig.sshTimeoutSeconds || 8), sshTimeoutSeconds: Number(nextConfig.sshTimeoutSeconds || 8),
sshPrivateKeyPath,
sshPublicKeyPath: String(nextConfig.sshPublicKeyPath || `${sshPrivateKeyPath}.pub` || DEFAULT_SSH_PUBLIC_KEY_PATH),
temperatureThresholdsC: normalizeTemperatureThresholds(nextConfig.temperatureThresholdsC), temperatureThresholdsC: normalizeTemperatureThresholds(nextConfig.temperatureThresholdsC),
metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent), metricThresholdsPercent: normalizeMetricThresholds(nextConfig.metricThresholdsPercent),
devices: Array.isArray(nextConfig.devices) ? nextConfig.devices : [] devices: Array.isArray(nextConfig.devices) ? nextConfig.devices.map(normalizeDeviceConfig) : []
}; };
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8"); await fs.writeFile(CONFIG_PATH, JSON.stringify(normalized, null, 2) + "\n", "utf8");
@@ -64,6 +71,15 @@ async function writeConfig(nextConfig) {
restartScanner(); restartScanner();
} }
function normalizeDeviceConfig(device = {}) {
const authMethod = device.authMethod || (device.privateKeyPath ? "key" : "password");
return {
...device,
authMethod: authMethod === "key" ? "key" : "password",
privateKeyPath: device.privateKeyPath || ""
};
}
function classifyTemp(tempC) { function classifyTemp(tempC) {
const thresholds = config.temperatureThresholdsC || {}; const thresholds = config.temperatureThresholdsC || {};
if (tempC >= (thresholds.critical || 80)) return "critical"; if (tempC >= (thresholds.critical || 80)) return "critical";
@@ -364,8 +380,12 @@ function runCommandWithInput(command, args, input, timeoutMs) {
} }
async function sshMetrics(device) { async function sshMetrics(device) {
const hasPrivateKey = Boolean(device.privateKeyPath); const usesPrivateKey = device.authMethod === "key" || Boolean(device.privateKeyPath);
if (!hasPrivateKey && !device.password) { const privateKeyPath = device.privateKeyPath || config.sshPrivateKeyPath;
if (usesPrivateKey && !privateKeyPath) {
throw new Error("Clave privada SSH global no configurada");
}
if (!usesPrivateKey && !device.password) {
throw new Error("Credenciales SSH no configuradas"); throw new Error("Credenciales SSH no configuradas");
} }
@@ -388,10 +408,10 @@ async function sshMetrics(device) {
let command = "ssh"; let command = "ssh";
let args = sshArgs; let args = sshArgs;
if (hasPrivateKey) { if (usesPrivateKey) {
args = [ args = [
"-i", "-i",
device.privateKeyPath, privateKeyPath,
"-o", "-o",
"BatchMode=yes", "BatchMode=yes",
"-o", "-o",
@@ -627,6 +647,21 @@ function sendJson(res, statusCode, payload) {
res.end(body); res.end(body);
} }
function sendText(res, statusCode, body, headers = {}) {
res.writeHead(statusCode, {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
...headers
});
res.end(body);
}
async function readSshPublicKey() {
const publicKeyPath = config.sshPublicKeyPath || `${config.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH}.pub`;
const publicKey = (await fs.readFile(publicKeyPath, "utf8")).trim();
return { publicKeyPath, publicKey };
}
function isAuthEnabled() { function isAuthEnabled() {
return Boolean(AUTH_USERNAME && AUTH_PASSWORD); return Boolean(AUTH_USERNAME && AUTH_PASSWORD);
} }
@@ -737,6 +772,30 @@ async function handleRequest(req, res) {
return; return;
} }
if (req.method === "GET" && pathname === "/api/ssh-public-key") {
try {
sendJson(res, 200, await readSshPublicKey());
} catch (error) {
sendJson(res, 404, {
error: `No se pudo leer la clave publica SSH: ${error.message}`,
publicKeyPath: config.sshPublicKeyPath || `${config.sshPrivateKeyPath || DEFAULT_SSH_PRIVATE_KEY_PATH}.pub`
});
}
return;
}
if (req.method === "GET" && pathname === "/api/ssh-public-key/download") {
try {
const { publicKeyPath, publicKey } = await readSshPublicKey();
sendText(res, 200, `${publicKey}\n`, {
"Content-Disposition": `attachment; filename="${path.basename(publicKeyPath)}"`
});
} catch (error) {
sendText(res, 404, `No se pudo leer la clave publica SSH: ${error.message}\n`);
}
return;
}
if (req.method === "POST" && pathname === "/api/config") { if (req.method === "POST" && pathname === "/api/config") {
const body = await readBody(req); const body = await readBody(req);
await writeConfig(JSON.parse(body)); await writeConfig(JSON.parse(body));