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 BINARY=syncserver
VERSION?=1.0.22 VERSION?=1.0.23
GO?=go GO?=go
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) 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 BUILD_FLAGS=CGO_ENABLED=0
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncserver/internal/syncengine" "github.com/syncserver/internal/syncengine"
) )
var version = "1.0.22" var version = "1.0.23"
func main() { func main() {
cfgPath := flag.String("config", "", "Path to config.yaml") cfgPath := flag.String("config", "", "Path to config.yaml")
+8
View File
@@ -26,9 +26,17 @@ type MachineResponse struct {
WakeTimeoutSeconds int `json:"wake_timeout_seconds"` WakeTimeoutSeconds int `json:"wake_timeout_seconds"`
WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"` WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"`
FingerprintConfirmed bool `json:"fingerprint_confirmed"` FingerprintConfirmed bool `json:"fingerprint_confirmed"`
HostKeyFingerprint *string `json:"host_key_fingerprint"`
Status string `json:"status"` 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 { type SyncPairRequest struct {
Name string `json:"name"` Name string `json:"name"`
SourceMachineID *int64 `json:"source_machine_id"` SourceMachineID *int64 `json:"source_machine_id"`
+101
View File
@@ -1,16 +1,19 @@
package api package api
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/syncserver/internal/models" "github.com/syncserver/internal/models"
"github.com/syncserver/internal/sshmanager"
"github.com/syncserver/internal/syncengine" "github.com/syncserver/internal/syncengine"
"github.com/syncserver/internal/wol" "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}) 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) { func (h *MachineHandler) Refresh(w http.ResponseWriter, r *http.Request) {
if h.engine == nil { if h.engine == nil {
writeError(w, http.StatusInternalServerError, "engine not available") writeError(w, http.StatusInternalServerError, "engine not available")
@@ -271,6 +371,7 @@ func machineToResp(m models.Machine) MachineResponse {
WakeTimeoutSeconds: m.WakeTimeoutSeconds, WakeTimeoutSeconds: m.WakeTimeoutSeconds,
WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds, WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds,
FingerprintConfirmed: m.FingerprintConfirmed, FingerprintConfirmed: m.FingerprintConfirmed,
HostKeyFingerprint: m.HostKeyFingerprint,
Status: status, 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.Put("/{id}", machineHandler.Update)
r.Delete("/{id}", machineHandler.Delete) r.Delete("/{id}", machineHandler.Delete)
r.Post("/{id}/test-wol", machineHandler.TestWoL) 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) { 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"` WakeTimeoutSeconds int `db:"wake_timeout_seconds" json:"wake_timeout_seconds"`
WakeCheckIntervalSeconds int `db:"wake_check_interval_seconds" json:"wake_check_interval_seconds"` WakeCheckIntervalSeconds int `db:"wake_check_interval_seconds" json:"wake_check_interval_seconds"`
FingerprintConfirmed bool `db:"fingerprint_confirmed" json:"fingerprint_confirmed"` FingerprintConfirmed bool `db:"fingerprint_confirmed" json:"fingerprint_confirmed"`
HostKeyFingerprint *string `db:"host_key_fingerprint" json:"host_key_fingerprint"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"`
CreatedAt time.Time `db:"created_at" json:"created_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(` res, err := r.db.Exec(`
INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address, INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
fingerprint_confirmed, status) fingerprint_confirmed, host_key_fingerprint, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, 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 { if err != nil {
return 0, err return 0, err
@@ -51,7 +52,7 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
rows, err := r.db.Query(` rows, err := r.db.Query(`
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, 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`) FROM machines ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -64,10 +65,11 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
var mac, bcast sql.NullString var mac, bcast sql.NullString
var keyID sql.NullInt64 var keyID sql.NullInt64
var lastSeen sql.NullTime var lastSeen sql.NullTime
var hostKeyFP sql.NullString
err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
&m.Status, &lastSeen, &m.CreatedAt) &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -81,6 +83,9 @@ func (r *MachineRepository) GetAll() ([]Machine, error) {
if bcast.Valid { if bcast.Valid {
m.BroadcastAddr = &bcast.String m.BroadcastAddr = &bcast.String
} }
if hostKeyFP.Valid {
m.HostKeyFingerprint = &hostKeyFP.String
}
if lastSeen.Valid { if lastSeen.Valid {
m.LastSeenAt = &lastSeen.Time m.LastSeenAt = &lastSeen.Time
} }
@@ -94,15 +99,16 @@ func (r *MachineRepository) GetByID(id int64) (*Machine, error) {
var mac, bcast sql.NullString var mac, bcast sql.NullString
var keyID sql.NullInt64 var keyID sql.NullInt64
var lastSeen sql.NullTime var lastSeen sql.NullTime
var hostKeyFP sql.NullString
err := r.db.QueryRow(` err := r.db.QueryRow(`
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, 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( FROM machines WHERE id = ?`, id).Scan(
&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, &m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
&m.Status, &lastSeen, &m.CreatedAt) &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -116,6 +122,9 @@ func (r *MachineRepository) GetByID(id int64) (*Machine, error) {
if bcast.Valid { if bcast.Valid {
m.BroadcastAddr = &bcast.String m.BroadcastAddr = &bcast.String
} }
if hostKeyFP.Valid {
m.HostKeyFingerprint = &hostKeyFP.String
}
if lastSeen.Valid { if lastSeen.Valid {
m.LastSeenAt = &lastSeen.Time m.LastSeenAt = &lastSeen.Time
} }
@@ -126,11 +135,11 @@ func (r *MachineRepository) Update(m *Machine) error {
_, err := r.db.Exec(` _, err := r.db.Exec(`
UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?, UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?,
mac_address=?, wol_enabled=?, broadcast_addr=?, wake_timeout_seconds=?, 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=?`, WHERE id=?`,
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, 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, m.Status, m.LastSeenAt, m.ID,
) )
return err return err
@@ -141,6 +150,14 @@ func (r *MachineRepository) Delete(id int64) error {
return err 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 { func (r *MachineRepository) UpdateStatus(id int64, status string) error {
_, err := r.db.Exec( _, err := r.db.Exec(
"UPDATE machines SET status = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE machines SET status = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?",
+23 -19
View File
@@ -3,6 +3,8 @@ package sshmanager
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"net" "net"
"os" "os"
@@ -36,19 +38,30 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
auths = append(auths, ssh.PublicKeys(signer)) auths = append(auths, ssh.PublicKeys(signer))
} }
hostKeyPolicy := ssh.InsecureIgnoreHostKey() 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 != "" { if strictHostKeyChecking && knownHostsPath != "" {
hostKeyCallback, err := getHostKeyCallback(knownHostsPath, host, port) kh, err := GetKnownHost(filepath.Dir(knownHostsPath), host, port)
if err != nil { if err != nil {
return &ConnResult{Success: false, Error: fmt.Sprintf("known_hosts: %v", err)}, nil return fmt.Errorf("checking known_hosts: %w", err)
} }
hostKeyPolicy = hostKeyCallback 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)
}
}
return nil
} }
cfg := &ssh.ClientConfig{ cfg := &ssh.ClientConfig{
User: user, User: user,
Auth: auths, Auth: auths,
HostKeyCallback: hostKeyPolicy, HostKeyCallback: hostKeyCallback,
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
} }
@@ -61,18 +74,20 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
return &ConnResult{ return &ConnResult{
Success: false, Success: false,
Error: fmt.Sprintf("host key verification failed: %v", err), Error: fmt.Sprintf("host key verification failed: %v", err),
Fingerprint: capturedFingerprint,
}, nil }, nil
} }
return &ConnResult{ return &ConnResult{
Success: false, Success: false,
Error: fmt.Sprintf("connection failed: %v", err), Error: fmt.Sprintf("connection failed: %v", err),
Fingerprint: capturedFingerprint,
}, nil }, nil
} }
defer conn.Close() defer conn.Close()
session, err := conn.NewSession() session, err := conn.NewSession()
if err != nil { 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() defer session.Close()
@@ -84,24 +99,13 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey
return &ConnResult{ return &ConnResult{
Success: false, Success: false,
Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()), Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()),
Fingerprint: capturedFingerprint,
}, nil }, nil
} }
return &ConnResult{ return &ConnResult{
Success: true, Success: true,
Output: stdout.String(), Output: stdout.String(),
Fingerprint: capturedFingerprint,
}, nil }, 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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/"> <base href="/">
<title>SyncServer</title> <title>SyncServer</title>
<script type="module" crossorigin src="./assets/index-BCf_AnPS.js"></script> <script type="module" crossorigin src="./assets/index-B2TqqDPF.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BA6Z4BXQ.css"> <link rel="stylesheet" crossorigin href="./assets/index-BepSbXPY.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8
View File
@@ -51,9 +51,17 @@ export interface Machine {
wake_timeout_seconds: number; wake_timeout_seconds: number;
wake_check_interval_seconds: number; wake_check_interval_seconds: number;
fingerprint_confirmed: boolean; fingerprint_confirmed: boolean;
host_key_fingerprint: string | null;
status: string; status: string;
} }
export interface TestConnectionResponse {
success: boolean;
output?: string;
error?: string;
fingerprint?: string;
}
export interface SyncPair { export interface SyncPair {
id: number; id: number;
name: string; name: string;
+105 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; 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 { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label'; import { Label } from '@/components/ui/Label';
@@ -26,7 +26,7 @@ import {
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton'; import { CopyButton } from '@/components/ui/CopyButton';
import { Card } from '@/components/ui/Card'; 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 { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { subscribeMachineStatus } from '@/lib/sse'; import { subscribeMachineStatus } from '@/lib/sse';
@@ -67,6 +67,7 @@ export default function Machines() {
const [form, setForm] = useState<MachineForm>(defaultForm); const [form, setForm] = useState<MachineForm>(defaultForm);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [probing, setProbing] = 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(() => { useEffect(() => {
load(); 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) { function keyLabel(id: number | null) {
if (!id) return 'Server Key'; if (!id) return 'Server Key';
const k = sshKeys.find(k => k.id === id); const k = sshKeys.find(k => k.id === id);
@@ -277,6 +307,14 @@ export default function Machines() {
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </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 && ( {m.wol_enabled && (
<Button <Button
variant="ghost" variant="ghost"
@@ -512,6 +550,71 @@ export default function Machines() {
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </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> </div>
); );
} }