diff --git a/.opencode/plans/revert-runremote.md b/.opencode/plans/revert-runremote.md new file mode 100644 index 0000000..e27b63e --- /dev/null +++ b/.opencode/plans/revert-runremote.md @@ -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 | \ No newline at end of file diff --git a/internal/sshmanager/deploy.go b/internal/sshmanager/deploy.go index 9f2569c..396e628 100644 --- a/internal/sshmanager/deploy.go +++ b/internal/sshmanager/deploy.go @@ -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) diff --git a/web/src/pages/Machines.tsx b/web/src/pages/Machines.tsx index be37896..e154b1c 100644 --- a/web/src/pages/Machines.tsx +++ b/web/src/pages/Machines.tsx @@ -660,7 +660,7 @@ export default function Machines() {

Deployment successful