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
+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>