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)
Option A (validation):
- handlers_syncpairs.go: reject create/update when both SourceMachineID
and DestMachineID are set; clear error message explains the constraint
- engine.go: detect 'both remote' rsync error at runtime and surface it
as error_code=remote_to_remote_unsupported with a human-readable message
Option B (remote-to-remote support):
- rsync_runner.go: add RunRemote() method that SSHs to the source machine
and runs rsync locally there (src=local path, dst=user@host:/path),
streaming output back through the onLine callback
- engine.go: when both srcMachine and dstMachine are non-nil, use
RunRemote() instead of Run(), SSHing to srcMachine and running rsync
from there. Also wake dstMachine via WoL when both sides are remote.
r.Context() is cancelled when the HTTP handler returns (after 201 is sent),
causing the job to be immediately marked as cancelled_shutdown before WoL
even runs. Use context.Background() so the job goroutine runs independently
of the HTTP request lifecycle.
- internal/db/db.go: Use _pragma syntax so modernc.org/sqlite actually
applies busy_timeout(5000) and journal_mode(WAL). Eliminates SQLITE_BUSY
500s when concurrent reads hit a writer holding the DELETE-mode lock.
- internal/api/handlers_*.go: Add slog.Error before every writeError with
StatusInternalServerError so real errors appear in logs (30+ sites across
handlers_jobs, handlers_machines, handlers_syncpairs, handlers_sshkeys).