Bump version to 1.0.23

This commit is contained in:
2026-07-09 17:33:04 -04:00
parent be7d47c0e1
commit 88cc7e88e6
15 changed files with 632 additions and 381 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=syncserver
VERSION?=1.0.22
VERSION?=1.0.23
GO?=go
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_FLAGS=CGO_ENABLED=0
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncserver/internal/syncengine"
)
var version = "1.0.22"
var version = "1.0.23"
func main() {
cfgPath := flag.String("config", "", "Path to config.yaml")
+8
View File
@@ -26,9 +26,17 @@ type MachineResponse struct {
WakeTimeoutSeconds int `json:"wake_timeout_seconds"`
WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"`
FingerprintConfirmed bool `json:"fingerprint_confirmed"`
HostKeyFingerprint *string `json:"host_key_fingerprint"`
Status string `json:"status"`
}
type TestConnectionResponse struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type SyncPairRequest struct {
Name string `json:"name"`
SourceMachineID *int64 `json:"source_machine_id"`
+102 -1
View File
@@ -1,16 +1,19 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/syncserver/internal/models"
"github.com/syncserver/internal/sshmanager"
"github.com/syncserver/internal/syncengine"
"github.com/syncserver/internal/wol"
)
@@ -218,6 +221,103 @@ func (h *MachineHandler) TestWoL(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true, "sent": 3})
}
func (h *MachineHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewMachineRepository(h.db)
m, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "machine not found")
return
}
if err != nil {
slog.Error("failed to fetch machine", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch machine")
return
}
knownHostsPath, err := sshmanager.EnsureKnownHosts(filepath.Join("/var/lib/syncserver", "ssh"))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to ensure known_hosts")
return
}
privKeyPath := filepath.Join("/var/lib/syncserver", "ssh", "id_ed25519")
if m.SSHKeyID != nil {
sshKeyRepo := models.NewSSHKeyRepository(h.db)
sshKey, err := sshKeyRepo.GetByID(*m.SSHKeyID)
if err == nil && sshKey.PrivateKeyPath != "" {
privKeyPath = sshKey.PrivateKeyPath
}
}
result, err := sshmanager.TestSSHConnection(
context.Background(),
m.Host, m.Port, m.SSHUser,
privKeyPath, knownHostsPath,
m.FingerprintConfirmed,
)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, TestConnectionResponse{
Success: result.Success,
Output: result.Output,
Error: result.Error,
Fingerprint: result.Fingerprint,
})
}
func (h *MachineHandler) ApproveFingerprint(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewMachineRepository(h.db)
m, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "machine not found")
return
}
if err != nil {
slog.Error("failed to fetch machine", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch machine")
return
}
var req struct {
Fingerprint string `json:"fingerprint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Fingerprint == "" {
writeError(w, http.StatusBadRequest, "fingerprint is required")
return
}
if err := repo.UpdateFingerprint(id, true, req.Fingerprint); err != nil {
slog.Error("failed to update fingerprint", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to update fingerprint")
return
}
sshDir := filepath.Join("/var/lib/syncserver", "ssh")
if err := sshmanager.AddKnownHost(sshDir, m.Host, m.Port, []byte(req.Fingerprint)); err != nil {
slog.Warn("failed to add known_host entry", "host", m.Host, "error", err)
}
m.FingerprintConfirmed = true
m.HostKeyFingerprint = &req.Fingerprint
writeJSON(w, machineToResp(*m))
}
func (h *MachineHandler) Refresh(w http.ResponseWriter, r *http.Request) {
if h.engine == nil {
writeError(w, http.StatusInternalServerError, "engine not available")
@@ -269,8 +369,9 @@ func machineToResp(m models.Machine) MachineResponse {
WoLEnabled: m.WoLEnabled,
BroadcastAddr: m.BroadcastAddr,
WakeTimeoutSeconds: m.WakeTimeoutSeconds,
WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds,
WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds,
FingerprintConfirmed: m.FingerprintConfirmed,
HostKeyFingerprint: m.HostKeyFingerprint,
Status: status,
}
}
+2
View File
@@ -55,6 +55,8 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve
r.Put("/{id}", machineHandler.Update)
r.Delete("/{id}", machineHandler.Delete)
r.Post("/{id}/test-wol", machineHandler.TestWoL)
r.Post("/{id}/test-connection", machineHandler.TestConnection)
r.Post("/{id}/approve-fingerprint", machineHandler.ApproveFingerprint)
})
r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) {
@@ -0,0 +1,3 @@
-- 0004_machine_host_key.sql
ALTER TABLE machines ADD COLUMN host_key_fingerprint TEXT;
+26 -9
View File
@@ -18,6 +18,7 @@ type Machine struct {
WakeTimeoutSeconds int `db:"wake_timeout_seconds" json:"wake_timeout_seconds"`
WakeCheckIntervalSeconds int `db:"wake_check_interval_seconds" json:"wake_check_interval_seconds"`
FingerprintConfirmed bool `db:"fingerprint_confirmed" json:"fingerprint_confirmed"`
HostKeyFingerprint *string `db:"host_key_fingerprint" json:"host_key_fingerprint"`
Status string `db:"status" json:"status"`
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -35,11 +36,11 @@ func (r *MachineRepository) Create(m *Machine) (int64, error) {
res, err := r.db.Exec(`
INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
fingerprint_confirmed, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
fingerprint_confirmed, host_key_fingerprint, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds,
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.Status,
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.HostKeyFingerprint, m.Status,
)
if err != nil {
return 0, err
@@ -51,7 +52,7 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
rows, err := r.db.Query(`
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
fingerprint_confirmed, status, last_seen_at, created_at
fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at
FROM machines ORDER BY name`)
if err != nil {
return nil, err
@@ -64,10 +65,11 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
var mac, bcast sql.NullString
var keyID sql.NullInt64
var lastSeen sql.NullTime
var hostKeyFP sql.NullString
err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
&m.Status, &lastSeen, &m.CreatedAt)
&hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt)
if err != nil {
return nil, err
}
@@ -81,6 +83,9 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
if bcast.Valid {
m.BroadcastAddr = &bcast.String
}
if hostKeyFP.Valid {
m.HostKeyFingerprint = &hostKeyFP.String
}
if lastSeen.Valid {
m.LastSeenAt = &lastSeen.Time
}
@@ -94,15 +99,16 @@ func (r *MachineRepository) GetByID(id int64) (*Machine, error) {
var mac, bcast sql.NullString
var keyID sql.NullInt64
var lastSeen sql.NullTime
var hostKeyFP sql.NullString
err := r.db.QueryRow(`
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
fingerprint_confirmed, status, last_seen_at, created_at
fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at
FROM machines WHERE id = ?`, id).Scan(
&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
&m.Status, &lastSeen, &m.CreatedAt)
&hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt)
if err != nil {
return nil, err
}
@@ -116,6 +122,9 @@ func (r *MachineRepository) GetByID(id int64) (*Machine, error) {
if bcast.Valid {
m.BroadcastAddr = &bcast.String
}
if hostKeyFP.Valid {
m.HostKeyFingerprint = &hostKeyFP.String
}
if lastSeen.Valid {
m.LastSeenAt = &lastSeen.Time
}
@@ -126,11 +135,11 @@ func (r *MachineRepository) Update(m *Machine) error {
_, err := r.db.Exec(`
UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?,
mac_address=?, wol_enabled=?, broadcast_addr=?, wake_timeout_seconds=?,
wake_check_interval_seconds=?, fingerprint_confirmed=?, status=?, last_seen_at=?
wake_check_interval_seconds=?, fingerprint_confirmed=?, host_key_fingerprint=?, status=?, last_seen_at=?
WHERE id=?`,
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds,
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed),
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.HostKeyFingerprint,
m.Status, m.LastSeenAt, m.ID,
)
return err
@@ -141,6 +150,14 @@ func (r *MachineRepository) Delete(id int64) error {
return err
}
func (r *MachineRepository) UpdateFingerprint(id int64, confirmed bool, fingerprint string) error {
_, err := r.db.Exec(
"UPDATE machines SET fingerprint_confirmed = ?, host_key_fingerprint = ? WHERE id = ?",
boolToInt(confirmed), fingerprint, id,
)
return err
}
func (r *MachineRepository) UpdateStatus(id int64, status string) error {
_, err := r.db.Exec(
"UPDATE machines SET status = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?",
+33 -29
View File
@@ -3,6 +3,8 @@ package sshmanager
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"os"
@@ -36,19 +38,30 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
auths = append(auths, ssh.PublicKeys(signer))
}
hostKeyPolicy := ssh.InsecureIgnoreHostKey()
if strictHostKeyChecking && knownHostsPath != "" {
hostKeyCallback, err := getHostKeyCallback(knownHostsPath, host, port)
if err != nil {
return &ConnResult{Success: false, Error: fmt.Sprintf("known_hosts: %v", err)}, nil
var capturedFingerprint string
hostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
h := sha256.Sum256(key.Marshal())
capturedFingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(h[:])
if strictHostKeyChecking && knownHostsPath != "" {
kh, err := GetKnownHost(filepath.Dir(knownHostsPath), host, port)
if err != nil {
return fmt.Errorf("checking known_hosts: %w", err)
}
if kh == nil {
return fmt.Errorf("host key not found in known_hosts: %s", hostname)
}
wantFP := kh.Fingerprint
if capturedFingerprint != wantFP {
return fmt.Errorf("host key mismatch: got %s, want %s", capturedFingerprint, wantFP)
}
}
hostKeyPolicy = hostKeyCallback
return nil
}
cfg := &ssh.ClientConfig{
User: user,
Auth: auths,
HostKeyCallback: hostKeyPolicy,
HostKeyCallback: hostKeyCallback,
Timeout: 10 * time.Second,
}
@@ -59,20 +72,22 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
if err != nil {
if strings.Contains(err.Error(), "known_hosts") || strings.Contains(err.Error(), "host key") {
return &ConnResult{
Success: false,
Error: fmt.Sprintf("host key verification failed: %v", err),
Success: false,
Error: fmt.Sprintf("host key verification failed: %v", err),
Fingerprint: capturedFingerprint,
}, nil
}
return &ConnResult{
Success: false,
Error: fmt.Sprintf("connection failed: %v", err),
Success: false,
Error: fmt.Sprintf("connection failed: %v", err),
Fingerprint: capturedFingerprint,
}, nil
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err)}, nil
return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err), Fingerprint: capturedFingerprint}, nil
}
defer session.Close()
@@ -82,26 +97,15 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
if err := session.Run("echo ok && uname -a"); err != nil {
return &ConnResult{
Success: false,
Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()),
Success: false,
Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()),
Fingerprint: capturedFingerprint,
}, nil
}
return &ConnResult{
Success: true,
Output: stdout.String(),
Success: true,
Output: stdout.String(),
Fingerprint: capturedFingerprint,
}, nil
}
func getHostKeyCallback(knownHostsPath, host string, port int) (ssh.HostKeyCallback, error) {
return ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
kh, err := GetKnownHost(filepath.Dir(knownHostsPath), host, port)
if err != nil {
return fmt.Errorf("checking known_hosts: %w", err)
}
if kh == nil {
return fmt.Errorf("host key not found in known_hosts: %s", hostname)
}
return nil
}), nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/">
<title>SyncServer</title>
<script type="module" crossorigin src="./assets/index-BCf_AnPS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BA6Z4BXQ.css">
<script type="module" crossorigin src="./assets/index-B2TqqDPF.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BepSbXPY.css">
</head>
<body>
<div id="root"></div>
+8
View File
@@ -51,9 +51,17 @@ export interface Machine {
wake_timeout_seconds: number;
wake_check_interval_seconds: number;
fingerprint_confirmed: boolean;
host_key_fingerprint: string | null;
status: string;
}
export interface TestConnectionResponse {
success: boolean;
output?: string;
error?: string;
fingerprint?: string;
}
export interface SyncPair {
id: number;
name: string;
+105 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { api, Machine, SSHKey } from '../api/client';
import { api, Machine, SSHKey, TestConnectionResponse } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
@@ -26,7 +26,7 @@ import {
import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton';
import { Card } from '@/components/ui/Card';
import { Pencil, Trash2, Plus, Server, Zap } from 'lucide-react';
import { Pencil, Trash2, Plus, Server, Zap, Cable } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { subscribeMachineStatus } from '@/lib/sse';
@@ -67,6 +67,7 @@ export default function Machines() {
const [form, setForm] = useState<MachineForm>(defaultForm);
const [loading, setLoading] = useState(false);
const [probing, setProbing] = useState(false);
const [connModal, setConnModal] = useState<{ machine: Machine | null; result: TestConnectionResponse | null; loading: boolean }>({ machine: null, result: null, loading: false });
useEffect(() => {
load();
@@ -183,6 +184,35 @@ export default function Machines() {
}
}
async function handleTestConnection(m: Machine) {
setConnModal({ machine: m, result: null, loading: true });
try {
const result = await api<TestConnectionResponse>(`/api/machines/${m.id}/test-connection`, { method: 'POST' });
setConnModal({ machine: m, result, loading: false });
} catch (e: unknown) {
setConnModal({ machine: m, result: { success: false, error: (e as Error).message }, loading: false });
}
}
async function handleApproveFingerprint() {
if (!connModal.machine || !connModal.result?.fingerprint) return;
try {
const updated = await api<Machine>(`/api/machines/${connModal.machine.id}/approve-fingerprint`, {
method: 'POST',
body: { fingerprint: connModal.result.fingerprint },
});
setMachines(prev => prev.map(m => m.id === updated.id ? updated : m));
toast.success('Fingerprint approved');
setConnModal({ machine: null, result: null, loading: false });
} catch (e: unknown) {
toast.error(`Approve failed: ${(e as Error).message}`);
}
}
function closeConnModal() {
setConnModal({ machine: null, result: null, loading: false });
}
function keyLabel(id: number | null) {
if (!id) return 'Server Key';
const k = sshKeys.find(k => k.id === id);
@@ -277,6 +307,14 @@ export default function Machines() {
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleTestConnection(m)}
title="Test SSH Connection"
>
<Cable className="h-3.5 w-3.5" />
</Button>
{m.wol_enabled && (
<Button
variant="ghost"
@@ -512,6 +550,71 @@ export default function Machines() {
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={connModal.machine !== null} onOpenChange={v => !v && closeConnModal()}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>SSH Connection Test</ModalTitle>
<ModalDescription>
{connModal.machine?.name} ({connModal.machine?.host}:{connModal.machine?.port})
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
{connModal.loading && (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)}
{!connModal.loading && connModal.result && (
<div className="space-y-4">
{connModal.result.success ? (
<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-1">Connection successful</p>
<pre className="text-xs text-fg-muted whitespace-pre-wrap">{connModal.result.output}</pre>
</div>
) : (
<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-1">Connection failed</p>
<p className="text-xs text-fg-muted">{connModal.result.error}</p>
</div>
)}
{connModal.result.fingerprint && (
<div className="space-y-2">
<p className="text-sm font-medium text-fg">Host Key Fingerprint</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-surface-raised rounded-card px-3 py-2 text-fg-muted border border-border">
{connModal.result.fingerprint}
</code>
<CopyButton text={connModal.result.fingerprint} />
</div>
{connModal.machine && !connModal.machine.fingerprint_confirmed && (
<div className="flex items-center gap-2 mt-2">
<Badge variant="pending" label="Not verified" />
<span className="text-xs text-fg-muted">Approve to trust this fingerprint</span>
</div>
)}
{connModal.machine && connModal.machine.fingerprint_confirmed && (
<div className="flex items-center gap-2 mt-2">
<Badge variant="success" label="Verified" />
</div>
)}
</div>
)}
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={closeConnModal}>
Close
</Button>
{!connModal.loading && connModal.result && !connModal.result.success && connModal.result.fingerprint && connModal.machine && !connModal.machine.fingerprint_confirmed && (
<Button onClick={handleApproveFingerprint}>
Approve &amp; Trust Fingerprint
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}