When the host shuts down, sshd is killed before sending exit-status,
causing Go ssh library to return "wait: remote command exited without
exit status" — treated as failure even though the shutdown worked.
Changes:
- shutdown.go: wrap shutdown-like commands with nohup so the SSH
session exits cleanly before sshd is killed by shutdown
- IsShutdownCommand() helper detects shutdown/poweroff/halt/reboot commands
- handlers_machines.go: classify expected shutdown-side-effect errors
(no exit status, connection refused/reset) as success so the UI
shows a green toast instead of a false error
The old ApproveFingerprint passed the SHA256 fingerprint string to
AddKnownHost which expected authorized_key format, causing known_hosts
entries to be corrupted and subsequent SSH connections (including shutdown)
to fail with "host key not found".
Changes:
- dialSSH now returns (conn, fingerprint, pubKey, error) with the raw
ssh.PublicKey captured from the server
- New ConnectForApproval() wraps dialSSH with strictHostKeyChecking=false
for the approval handshake
- ApproveFingerprint now opens an SSH connection to the host (non-strict),
captures the real public key, and writes it in authorized_keys format
to known_hosts via AddKnownHost
- shutdown.go updated to handle the new 4-value dialSSH return
- Supports optional host_key field in request body for direct key submission
Backend:
- New migration: add shutdown_command TEXT column to machines table
- Machine model updated with ShutdownCommand field (Create/GetAll/GetByID/Update)
- MachineRequest/MachineResponse DTOs updated with shutdown_command field
- New ShutdownResponse DTO
- New POST /api/machines/{id}/shutdown handler via SSH
- Refactor sshmanager/testconn.go: extract dialSSH() helper shared with shutdown.go
- New sshmanager/shutdown.go: RunRemoteCommand with 15s timeout
Frontend:
- New shutdownMachine() API helper and ShutdownResponse type in client.ts
- New shutdown_command field in MachineForm
- Power button (amber) in machines table actions
- Shutdown confirmation modal with WoL warning notice
- shutdown_command input field in machine edit/create form
- Machine interface updated with shutdown_command and last_seen_at fields
The status field was being decorated with "(last seen ...)" suffix
which broke frontend statusVariant() matching and prevented the
LastSeen column from showing the separate timestamp.
Changes:
- machineToResp() now returns clean status ("online"/"offline")
- MachineResponse includes last_seen_at and created_at as separate fields
- Fix whitespace typo in WakeCheckIntervalSeconds field
- New Last Seen column showing last_seen_at timestamp per machine
- Status badges: online=green (success), offline=red (error), waking/pending=amber
- Update Machine interface with last_seen_at and created_at fields
RunRemote was adding user@host: prefixes on top of the paths already
pre-prefixed by engine.go in the remote-to-remote branch, causing
"both remote" rsync errors. The source path was also incorrectly
prefixed with srcUserHost:, making rsync reject the command entirely.
The fix: buildArgs already orders args correctly for rsync (source
then dest), so just use args[-2] and args[-1] as-is without any
additional prefixing.
- Fix Bug A: RunRemote was re-prefixing destination with user@host: when
engine.go:135 already pre-prefixed it for remote-to-remote, causing
"admin@host:admin@host:/path" to be passed to rsync
- Fix Bug B: Run used args[1:] when inserting -e ssh, silently dropping
the first rsync flag (e.g. -aP became flag-less)
- Fix Bug C: RunRemote extracted source/dest in wrong order for pull
direction (buildArgs reverses them but RunRemote assumed push order)
- Add rsync_runner_test.go covering buildArgs, RunRemote prefixing,
and flag preservation
DeployResult struct fields were serializing as PascalCase (Success,
Messages, Errors) but the TypeScript frontend expected camelCase
(success, messages, errors). Adding json:"..." tags fixes the mismatch.
ssh-keyscan failures no longer set Success=false. Keys were uploaded
successfully which is the critical part. Only session errors remain as
errors, ssh-keyscan failures are advisory.
Deploy-keys now only:
- Creates /var/lib/syncserver/ssh/keys on remote
- Uploads private keys of all other machines to that path
- Populates known_hosts with all other machine hosts
No longer touches authorized_keys or reads server public key.
When deploying keys to a machine, upload ALL private keys from ALL
other machines (not just sync pair peers). Also populate known_hosts
with all other machine hosts. Creates a full mesh where any machine
can SSH to any other.
- sshmanager/deploy.go: change knownHostsHost string parameter to
knownHostsHosts []string for multi-host ssh-keyscan
- handlers_machines.go: replace sync-pair-based key detection with
loop over all machines, deduplicating by local key path
- sshmanager/deploy.go: StdinPipe() must be called BEFORE Start(),
not after. Reordered the calls to fix "ssh: StdinPipe after
process started" error.
- Return DeployResult instead of nil error on SSH dial failure
so the frontend always gets a parseable response.
- Add slog.Debug for key upload, slog.Warn for ssh-keyscan and
authorized_keys failures, slog.Info for final result summary.
- 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
- sshmanager/deploy.go: new DeployKeysToMachine function that uploads
private keys, populates known_hosts via ssh-keyscan, and adds server
pub key to authorized_keys on remote machines
- handlers_machines.go: new DeployKeys handler with auto-detection of
keys needed per sync pair (source->dest uploads dest key, dest->source
uploads source key)
- router.go: POST /machines/{id}/deploy-keys route
- client.ts: deployKeys() API method
- Machines.tsx: Deploy Keys button + modal with result display
- Regenerate qnap.key and baby-nas.key to OpenSSH native format (387 bytes vs 119 PKCS8)
- Pre-install qnap.key on Baby NAS at /var/lib/syncserver/ssh/keys/
- Pre-populate Baby NAS known_hosts with Qnap host keys
- Simplify RunRemote: LXC SSH to Baby NAS, Baby NAS runs rsync with local qnap.key
- Remove wrapper script approach (rsync rejects remote-to-remote)