fix: deploy keys modal crash on null/undefined arrays
- sshmanager/deploy.go: initialize DeployResult with empty slices instead of nil to prevent null serialization in JSON - Machines.tsx: use ?? [] fallback for messages and errors arrays in deploy keys modal to handle null/undefined gracefully
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# Revertir RunRemote a approach SSH-a-source sin envío base64
|
||||
|
||||
## Estado
|
||||
|
||||
Estamos en plan mode (READ-ONLY). Los cambios de código Go están bloqueados. Este documento describe los cambios necesarios para proceder cuando salgamos de plan mode.
|
||||
|
||||
## Contexto
|
||||
|
||||
Las claves SSH ya están pre-instaladas en cada máquina:
|
||||
|
||||
| Máquina | Clave instalada | Pública autorizada en |
|
||||
|---|---|---|
|
||||
| Baby NAS (10.5.1.20) | `/var/lib/syncserver/ssh/keys/qnap.key` (OpenSSH nativo, 387 bytes) | qnap.key.pub ya en Qnap `/mnt/HDA_ROOT/.config/ssh/authorized_keys` |
|
||||
| Qnap (10.5.0.144) | n/a (server) | baby-nas.key.pub ya en Baby NAS `/root/.ssh/authorized_keys` |
|
||||
| Baby NAS known_hosts | `/var/lib/syncserver/ssh/known_hosts` (3 host keys de Qnap en formato hashed) | — |
|
||||
|
||||
Test crítico exitoso:
|
||||
```
|
||||
ssh -i /var/lib/syncserver/ssh/keys/baby-nas.key root@10.5.1.20 \
|
||||
"ssh -i /var/lib/syncserver/ssh/keys/qnap.key -o StrictHostKeyChecking=yes \
|
||||
admin@10.5.0.144 echo QNAP-FROM-BABY"
|
||||
→ QNAP-FROM-BABY (exit 0)
|
||||
```
|
||||
|
||||
## Cambios pendientes
|
||||
|
||||
### 1. `internal/syncengine/rsync_runner.go`
|
||||
|
||||
**Eliminar** la función `buildWrapperScript` (líneas 86-101 del archivo actual).
|
||||
|
||||
**Reemplazar** la función `RunRemote` actual con la versión SSH-a-source:
|
||||
|
||||
```go
|
||||
func (r *RsyncRunner) RunRemote(ctx context.Context, pair *SyncPairConfig, src *MachineKeys, dst *MachineKeys, destKey string, onLine func(stream string, line string)) (*RsyncResult, error) {
|
||||
if src.Port == 0 {
|
||||
src.Port = 22
|
||||
}
|
||||
if src.PrivKey == "" {
|
||||
src.PrivKey = filepath.Join(r.sshDir, "id_ed25519")
|
||||
}
|
||||
if destKey == "" {
|
||||
destKey = filepath.Join(r.sshDir, "id_ed25519")
|
||||
}
|
||||
|
||||
destUserHost := fmt.Sprintf("%s@%s", dst.SSHUser, dst.Host)
|
||||
args := r.buildArgs(pair)
|
||||
|
||||
innerSSH := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s",
|
||||
destKey, filepath.Join(r.sshDir, "known_hosts"))
|
||||
rsyncFlags := strings.Join(args[:len(args)-2], " ")
|
||||
sourcePath := args[len(args)-2]
|
||||
destPath := args[len(args)-1]
|
||||
|
||||
remoteCmd := fmt.Sprintf("rsync %s -e %q %s %s",
|
||||
rsyncFlags, innerSSH, sourcePath, destUserHost+":"+destPath)
|
||||
|
||||
sshArgs := []string{
|
||||
"-i", src.PrivKey,
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UserKnownHostsFile=" + filepath.Join(r.sshDir, "known_hosts"),
|
||||
"-p", fmt.Sprintf("%d", src.Port),
|
||||
fmt.Sprintf("%s@%s", src.SSHUser, src.Host),
|
||||
}
|
||||
sshArgs = append(sshArgs, remoteCmd)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
||||
return r.runCmd(ctx, cmd, onLine)
|
||||
}
|
||||
```
|
||||
|
||||
**Modificar** el struct `MachineKeys` para agregar `SSHUser`:
|
||||
|
||||
```go
|
||||
type MachineKeys struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
SSHUser string
|
||||
PrivKey string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `internal/syncengine/engine.go`
|
||||
|
||||
Actualizar la llamada a `RunRemote` (líneas 263-283 actuales) para usar la nueva firma con `destPrivKeyPath` como argumento separado:
|
||||
|
||||
```go
|
||||
if isRemoteToRemote(srcMachine, dstMachine) {
|
||||
destPrivKeyPath, err := e.resolveSSHKey(dstMachine)
|
||||
if err != nil {
|
||||
slog.Warn("failed to resolve destination SSH key, using server key", "error", err)
|
||||
destPrivKeyPath = ""
|
||||
}
|
||||
result, err = runner.RunRemote(jobCtx, cfg,
|
||||
&syncengine.MachineKeys{
|
||||
Host: srcMachine.Host,
|
||||
Port: srcMachine.Port,
|
||||
User: srcMachine.SSHUser,
|
||||
SSHUser: srcMachine.SSHUser,
|
||||
PrivKey: privKey,
|
||||
},
|
||||
&syncengine.MachineKeys{
|
||||
Host: dstMachine.Host,
|
||||
Port: dstMachine.Port,
|
||||
User: dstMachine.SSHUser,
|
||||
SSHUser: dstMachine.SSHUser,
|
||||
PrivKey: destPrivKeyPath,
|
||||
},
|
||||
destPrivKeyPath,
|
||||
onLine)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Build y deploy
|
||||
|
||||
```bash
|
||||
go build ./cmd/server
|
||||
make clean && make package
|
||||
./scripts/bump-version.sh # bump a 1.0.28
|
||||
git add -f cmd/server/main.go Makefile internal/syncengine/
|
||||
git commit -m "fix: RunRemote uses SSH-to-source, dest key pre-installed on source"
|
||||
git push
|
||||
|
||||
# Deploy manual:
|
||||
scp dist/syncserver_1.0.28_amd64.deb root@10.5.1.30:/root/move-data-nas/dist/
|
||||
ssh root@10.5.1.30 "cd /root/move-data-nas && dpkg -i dist/syncserver_1.0.28_amd64.deb"
|
||||
```
|
||||
|
||||
### 4. Test
|
||||
|
||||
Trigger job 23 desde UI o via API. Verificar en DB:
|
||||
```bash
|
||||
ssh root@10.5.1.30 'sqlite3 /var/lib/syncserver/app.db \
|
||||
"SELECT id, status, error_code, substr(error_message,1,200) FROM jobs WHERE id=24;"'
|
||||
```
|
||||
|
||||
Esperado: `status=success`, `error_code=NULL`, `error_message=NULL`.
|
||||
|
||||
## Por qué este approach funciona
|
||||
|
||||
Antes:
|
||||
- LXC SSH a Baby NAS
|
||||
- Baby NAS ejecuta `echo 'BASE64' | base64 -d > /tmp/syncserver-dest-key && rsync ...`
|
||||
- El base64 round-trip generaba archivo corrupto o vacío
|
||||
- Baby NAS OpenSSH 8.9 no podía parsear el formato PKCS8
|
||||
|
||||
Ahora:
|
||||
- LXC SSH a Baby NAS (usa baby-nas.key)
|
||||
- Baby NAS ejecuta `rsync -e ssh -i /var/lib/syncserver/ssh/keys/qnap.key ...`
|
||||
- qnap.key YA está en Baby NAS en formato OpenSSH nativo (parseable por 8.9)
|
||||
- Host key de Qnap YA está en known_hosts de Baby NAS (no prompt)
|
||||
- Pública de qnap.key YA está en authorized_keys de Qnap (auth pasa)
|
||||
|
||||
## Comportamiento esperado
|
||||
|
||||
```
|
||||
job started job_id=24 pair=Peliculas
|
||||
ssh -i baby-nas.key root@10.5.1.20 'rsync -aP -e "ssh -i qnap.key -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=..." /mnt/storage/multimedia/peliculas admin@10.5.0.144:/share/media/peliculas'
|
||||
sending incremental file list
|
||||
...
|
||||
sent X bytes received Y bytes Z bytes/sec
|
||||
total size is T speedup is S
|
||||
job completed job_id=24 pair=Peliculas
|
||||
```
|
||||
|
||||
## Si falla
|
||||
|
||||
| Error | Causa | Fix |
|
||||
|---|---|---|
|
||||
| Load key invalid format | clave corrupta en Baby NAS | re-copiar qnap.key |
|
||||
| Permission denied (publickey) | pública no está en Qnap | re-agregar a authorized_keys |
|
||||
| Host key verification failed | known_hosts desincronizado | re-correr ssh-keyscan |
|
||||
| Connection timed out | problema L2/L3 real (no era el caso) | investigar red |
|
||||
@@ -26,7 +26,7 @@ type DeployResult struct {
|
||||
}
|
||||
|
||||
func DeployKeysToMachine(ctx context.Context, serverKeyPath, serverPubKey string, host string, port int, user string, keys []DeployKey, knownHostsHost string, addServerPubKey bool) (*DeployResult, error) {
|
||||
result := &DeployResult{Success: true}
|
||||
result := &DeployResult{Success: true, Messages: []string{}, Errors: []string{}}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
|
||||
@@ -660,7 +660,7 @@ export default function Machines() {
|
||||
<div className="rounded-card bg-emerald-500/10 border border-emerald-500/30 p-4">
|
||||
<p className="text-sm font-medium text-emerald-400 mb-2">Deployment successful</p>
|
||||
<ul className="space-y-1">
|
||||
{deployModal.result.messages.map((msg, i) => (
|
||||
{(deployModal.result.messages ?? []).map((msg, i) => (
|
||||
<li key={i} className="text-xs text-fg-muted flex items-start gap-2">
|
||||
<span className="text-emerald-400 mt-0.5">✓</span>
|
||||
{msg}
|
||||
@@ -672,7 +672,7 @@ export default function Machines() {
|
||||
<div className="rounded-card bg-rose-500/10 border border-rose-500/30 p-4">
|
||||
<p className="text-sm font-medium text-rose-400 mb-2">Deployment failed</p>
|
||||
<ul className="space-y-1">
|
||||
{deployModal.result.errors.map((err, i) => (
|
||||
{(deployModal.result.errors ?? []).map((err, i) => (
|
||||
<li key={i} className="text-xs text-fg-muted flex items-start gap-2">
|
||||
<span className="text-rose-400 mt-0.5">✗</span>
|
||||
{err}
|
||||
|
||||
Reference in New Issue
Block a user