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
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).
- eventbus.go: Fix send-on-closed-channel panic in SubscribeGlobal by
using a done channel; add recover() in fan-out goroutine; track active
global subs for proper cleanup on unsubscribe
- config.go: Persist JWT secret to $DATA_DIR/.jwt_secret instead of
regenerating a random one on every restart (which invalidated all sessions)
- handlers_ws.go: Replace time.After with time.Ticker to fix timer leak in
SSE keepalive loop
- handlers_jobs.go: Add recover() in fire-and-forget job goroutine; fix nil
pointer deref when GetByID fails after job creation
- handlers_machines.go: Add recover() in ProbeAllMachines goroutine
- scheduler.go: Add recover() in scheduled job run goroutine
- engine.go: Add recover() in per-machine probe goroutines
Machine status probe on page load:
- POST /api/machines/refresh probes all machines in parallel (max 20 concurrent, 1.5s timeout)
- Updates DB status and broadcasts via SSE to all connected browser tabs
- Server-side throttle: ignores refresh requests within 10s
- Machines.tsx and Dashboard.tsx fire probe on mount
- Visual "Checking machine status..." indicator in Machines table
- MachineHandler now accepts *Engine for ProbeAllMachines access
Smart WoL: TCP pre-check before sending magic packet (3s timeout).
Machine status updates via SSE: online/offline tracked in DB and
broadcast to all connected browser tabs in real-time.
- Pre-check: if machine already reachable, skip WoL and mark online
- WoL path: send 3 magic packets, wait for SSH, mark online/offline
- Backend: setMachineStatus() helper + machine_status SSE event
- Frontend: subscribeMachineStatus() SSE helper for Machines + Dashboard
- IsReachable() helper in wol package for TCP reachability checks
Fix Wake-on-LAN: set SO_BROADCAST on UDP socket, send 3 magic packets,
expose broadcast_addr and wake timeout fields in UI, add Test Wake endpoint,
surface send errors in job status, bump default wake timeout to 180s.
Frontend:
- JobDetail loadLogs: fallback to [] when API returns null
- JobDetail loadJob: pairs ?? [] guard on /api/sync-pairs 500
- JobHistory: Array.isArray guard on job list response
- api client: return undefined for null body instead of throwing
Backend:
- handlers_jobs GetLog: return [] instead of null when no log rows
- router: custom recoverer middleware that logs panics to slog
with full stack trace, method, and path
Bug: AddKnownHost wrote raw SSH wire-protocol bytes directly to
known_hosts instead of the OpenSSH authorized-key one-line format.
This produced garbage entries that would break SSH verification
for newly added machines.
Fix: ssh.ParsePublicKey(keyData) + ssh.MarshalAuthorizedKey() to
produce canonical hostkey lines: hostname ssh-ed25519 AAAAB3...xn3c=
Bug: raw ed25519.PublicKey bytes were stored directly instead of
OpenSSH authorized-key format (ssh-ed25519 AAAA... label).
Fixes:
- internal/sshmanager/fingerprint.go: GenerateKeyPair now uses
ssh.NewPublicKey + ssh.MarshalAuthorizedKey
- internal/sshmanager/keys.go: EnsureServerKey uses same fix; also
regenerates .pub file from private key if stored value is corrupt
- internal/sshmanager/fingerprint.go: add MarshalED25519PublicKey,
PublicKeyFromPrivateKeyFile, RegeneratePublicKeyFromPrivateKeyFile
- internal/models/sshkey.go: add UpdatePublicKey
- internal/api/handlers_sshkeys.go: List+Get recover existing DB
records with corrupt public keys by regenerating from private key
file and updating the DB
Also adds golang.org/x/crypto/ssh dependency via go mod tidy.
- postinst: restart instead of start if service already enabled (upgrade case)
- embed.go: no-cache/no-store on index.html, immutable cache on hashed assets
- Fixes web not refreshing after dpkg upgrade
webui: use fs.Sub to fix embed FS root so static assets are found
Previously http.FileServer searched for assets/foo.css in the embed FS
rooted at dist/, but only paths starting with dist/ resolve. fs.Sub
strips the dist/ prefix so FileServer finds the actual files.
Full-stack Go monolith with embedded React frontend for orchestrating
rsync-over-SSH file synchronization with Wake-on-LAN support.
Features:
- JWT auth (HS256) with bcrypt password hashing
- CRUD for machines (with WoL config) and sync_pairs
- Ed25519 SSH key generation and known_hosts management
- WoL magic packet sender + TCP-connect waiter with backoff
- Sync engine: rsync subprocess, per-pair job queue, progress parsing
- Homebrew cron parser for scheduled syncs
- SSE stream for live job status (queued/waking_up/running/success/failed)
- React+TS+Vite+Tailwind SPA embedded via embed.FS
- Debian packaging with systemd unit, postinst/prerm/postrm
Tech stack:
- Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite)
- chi router for HTTP API
- TypeScript + React 18 + Tailwind CSS frontend
- Cross-compiled to Linux amd64 for Proxmox LXC deployment
Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron