Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c91db0d4c |
@@ -105,6 +105,18 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey,
|
||||
return resp.KeyId, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.PackageUpdate) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.client.ReportUpdates(ctx, &pb.ReportUpdatesRequest{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
Updates: updates,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
|
||||
@@ -46,11 +46,28 @@ type UploadKeyResponse struct {
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -137,6 +154,7 @@ type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -184,6 +202,14 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/agent/internal/keys"
|
||||
"github.com/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
@@ -61,6 +62,9 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
// Start the command stream alongside the poll loop.
|
||||
go runCommandStream(ctx, cfg)
|
||||
|
||||
// Check for OS updates on startup and then hourly.
|
||||
go runUpdateCheck(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -173,9 +177,72 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.UpdateAgent != nil {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
const interval = time.Hour
|
||||
|
||||
doCheck := func() {
|
||||
pkgs, err := updates.CheckAvailable()
|
||||
if err != nil {
|
||||
log.Printf("update check error: %v", err)
|
||||
return
|
||||
}
|
||||
pbUpdates := make([]pb.PackageUpdate, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
pbUpdates[i] = pb.PackageUpdate{
|
||||
Name: p.Name,
|
||||
CurrentVersion: p.CurrentVersion,
|
||||
NewVersion: p.NewVersion,
|
||||
}
|
||||
}
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("update report dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, pbUpdates); err != nil {
|
||||
log.Printf("ReportUpdates error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d available OS updates", len(pkgs))
|
||||
}
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
doCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
|
||||
if err := updates.ApplyAll(); err != nil {
|
||||
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
|
||||
|
||||
// Re-report the (now empty) update list so the server reflects the new state.
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
NewVersion string
|
||||
}
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckAvailable returns the list of packages with available upgrades.
|
||||
// Returns nil, nil when no supported package manager is found.
|
||||
func CheckAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyAll runs a full non-interactive upgrade using the detected package manager.
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
// Refresh lists first, then upgrade.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
// Best-effort refresh; ignore errors (cached data is fine).
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Format: package/suite version arch [upgradable from: old-ver]
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
// Exit code 100 means updates are available — not an error.
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
// name.arch new-version repo
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
// Format: package old-version -> new-version
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Data rows start with "v |" (available) or "i |" (installed but updatable).
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
services:
|
||||
migrate:
|
||||
image: mongo:8
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrate/server-migrate.sh:/migrate.sh:ro
|
||||
command: bash /migrate.sh
|
||||
environment:
|
||||
MONGO_HOST: mongo
|
||||
MONGO_PORT: "27017"
|
||||
SRC_DB: keymanager
|
||||
DST_DB: vantage
|
||||
# Set DROP_SRC=true to automatically drop the keymanager database after migration
|
||||
DROP_SRC: "false"
|
||||
restart: "no"
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Migrates an existing keymanager-agent installation to vantage-agent.
|
||||
# Run as root on each managed server.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
|
||||
|
||||
GITEA_HOST="${GITEA_HOST:-}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Detect old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
OLD_BINARY="/usr/local/bin/keymanager-agent"
|
||||
OLD_CONFIG_DIR="/etc/keymanager"
|
||||
OLD_CONFIG="$OLD_CONFIG_DIR/config.yaml"
|
||||
OLD_SERVICE="keymanager-agent"
|
||||
OLD_SERVICE_FILE="/etc/systemd/system/${OLD_SERVICE}.service"
|
||||
OLD_SSH_CONF="/root/.ssh/keymanager.conf"
|
||||
OLD_SSH_CONFIG="/root/.ssh/config"
|
||||
|
||||
NEW_BINARY="/usr/local/bin/vantage-agent"
|
||||
NEW_CONFIG_DIR="/etc/vantage"
|
||||
NEW_CONFIG="$NEW_CONFIG_DIR/config.yaml"
|
||||
NEW_SERVICE="vantage-agent"
|
||||
NEW_SERVICE_FILE="/etc/systemd/system/${NEW_SERVICE}.service"
|
||||
NEW_SSH_CONF="/root/.ssh/vantage.conf"
|
||||
|
||||
if [ ! -f "$OLD_CONFIG" ] && [ ! -f "$OLD_BINARY" ]; then
|
||||
warn "No keymanager-agent installation found — nothing to migrate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Found keymanager-agent installation. Starting migration to vantage-agent..."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Stop and disable old service
|
||||
# ---------------------------------------------------------------------------
|
||||
if systemctl is-active --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
info "Stopping $OLD_SERVICE..."
|
||||
systemctl stop "$OLD_SERVICE"
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$OLD_SERVICE" 2>/dev/null; then
|
||||
systemctl disable "$OLD_SERVICE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Migrate config directory
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_CONFIG" ] && [ ! -f "$NEW_CONFIG" ]; then
|
||||
info "Migrating config: $OLD_CONFIG -> $NEW_CONFIG"
|
||||
mkdir -p "$NEW_CONFIG_DIR"
|
||||
chmod 0700 "$NEW_CONFIG_DIR"
|
||||
cp "$OLD_CONFIG" "$NEW_CONFIG"
|
||||
chmod 0600 "$NEW_CONFIG"
|
||||
elif [ -f "$NEW_CONFIG" ]; then
|
||||
warn "$NEW_CONFIG already exists — skipping config copy."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Migrate SSH managed conf file
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$OLD_SSH_CONF" ]; then
|
||||
info "Migrating SSH conf: $OLD_SSH_CONF -> $NEW_SSH_CONF"
|
||||
|
||||
# Rewrite IdentityFile paths: /root/.ssh/keymanager_* -> /root/.ssh/vantage_*
|
||||
sed 's|/root/\.ssh/keymanager_|/root/.ssh/vantage_|g' "$OLD_SSH_CONF" > "$NEW_SSH_CONF"
|
||||
chmod 0600 "$NEW_SSH_CONF"
|
||||
fi
|
||||
|
||||
# Update Include directive in /root/.ssh/config
|
||||
if [ -f "$OLD_SSH_CONFIG" ]; then
|
||||
if grep -q "Include /root/.ssh/keymanager.conf" "$OLD_SSH_CONFIG"; then
|
||||
info "Updating Include directive in $OLD_SSH_CONFIG"
|
||||
sed -i 's|Include /root/\.ssh/keymanager\.conf|Include /root/.ssh/vantage.conf|g' "$OLD_SSH_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Rename generated key files
|
||||
# ---------------------------------------------------------------------------
|
||||
shopt -s nullglob
|
||||
OLD_KEYS=(/root/.ssh/keymanager_*)
|
||||
if [ ${#OLD_KEYS[@]} -gt 0 ]; then
|
||||
info "Renaming ${#OLD_KEYS[@]} key file(s)..."
|
||||
for old_path in "${OLD_KEYS[@]}"; do
|
||||
filename=$(basename "$old_path")
|
||||
new_filename="${filename/keymanager_/vantage_}"
|
||||
new_path="/root/.ssh/$new_filename"
|
||||
if [ ! -e "$new_path" ]; then
|
||||
cp "$old_path" "$new_path"
|
||||
chmod "$(stat -c '%a' "$old_path")" "$new_path"
|
||||
info " $old_path -> $new_path"
|
||||
else
|
||||
warn " $new_path already exists — skipping"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
shopt -u nullglob
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Download new vantage-agent binary
|
||||
# ---------------------------------------------------------------------------
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64) ARCH="arm64" ;;
|
||||
*) die "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$GITEA_HOST" ] && [ -n "$GITEA_OWNER" ]; then
|
||||
info "Fetching latest vantage-agent release from $GITEA_HOST..."
|
||||
|
||||
RELEASE_JSON=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/${GITEA_OWNER}/vantage/releases?limit=1&type=tag" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$RELEASE_JSON" ]; then
|
||||
DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*vantage-agent-linux-${ARCH}\"" | head -1 | cut -d'"' -f4)
|
||||
CHECKSUM_URL=$(echo "$RELEASE_JSON" | grep -o "\"browser_download_url\":\"[^\"]*checksums\.txt\"" | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$DOWNLOAD_URL" ]; then
|
||||
info "Downloading $DOWNLOAD_URL..."
|
||||
TMP_BIN="/tmp/vantage-agent-new"
|
||||
curl -fsSL -o "$TMP_BIN" "$DOWNLOAD_URL"
|
||||
|
||||
if [ -n "$CHECKSUM_URL" ]; then
|
||||
TMP_SUMS="/tmp/vantage-checksums.txt"
|
||||
curl -fsSL -o "$TMP_SUMS" "$CHECKSUM_URL"
|
||||
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" "$TMP_SUMS" | awk '{print $1}')
|
||||
ACTUAL=$(sha256sum "$TMP_BIN" | awk '{print $1}')
|
||||
[ "$EXPECTED" = "$ACTUAL" ] || die "Checksum mismatch! Expected $EXPECTED, got $ACTUAL"
|
||||
rm -f "$TMP_SUMS"
|
||||
info "Checksum verified."
|
||||
fi
|
||||
|
||||
chmod 0755 "$TMP_BIN"
|
||||
mv "$TMP_BIN" "$NEW_BINARY"
|
||||
info "Installed $NEW_BINARY"
|
||||
else
|
||||
warn "Could not find vantage-agent binary in release — skipping binary install."
|
||||
fi
|
||||
else
|
||||
warn "Could not reach Gitea API — skipping binary download."
|
||||
fi
|
||||
elif [ -f "$OLD_BINARY" ]; then
|
||||
warn "GITEA_HOST/GITEA_OWNER not set — skipping binary download."
|
||||
warn "You must manually install the vantage-agent binary to $NEW_BINARY before starting the service."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Install new systemd service
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Installing $NEW_SERVICE_FILE..."
|
||||
cat > "$NEW_SERVICE_FILE" <<'EOF'
|
||||
[Unit]
|
||||
Description=Vantage Agent
|
||||
Documentation=https://github.com/your-org/vantage
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/vantage-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=vantage-agent
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=false
|
||||
ProtectHome=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$NEW_SERVICE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Start new service (only if binary exists)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -f "$NEW_BINARY" ]; then
|
||||
info "Starting $NEW_SERVICE..."
|
||||
systemctl start "$NEW_SERVICE"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$NEW_SERVICE"; then
|
||||
info "vantage-agent is running."
|
||||
else
|
||||
warn "vantage-agent failed to start. Check: journalctl -u vantage-agent"
|
||||
fi
|
||||
else
|
||||
warn "Binary not yet installed — service NOT started."
|
||||
warn "Install the binary then run: systemctl start vantage-agent"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Clean up old installation
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Cleaning up old keymanager-agent files..."
|
||||
rm -f "$OLD_SERVICE_FILE"
|
||||
rm -f "$OLD_BINARY"
|
||||
rm -rf "$OLD_CONFIG_DIR"
|
||||
rm -f "$OLD_SSH_CONF"
|
||||
|
||||
shopt -s nullglob
|
||||
for old_key in /root/.ssh/keymanager_*; do
|
||||
rm -f "$old_key"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
systemctl daemon-reload
|
||||
info "Migration complete."
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs inside the migration container.
|
||||
# Copies all collections + indexes from $SRC_DB to $DST_DB,
|
||||
# verifies document counts, then optionally drops the source.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[migrate]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[migrate]${NC} $*"; }
|
||||
die() { echo -e "${RED}[migrate]${NC} $*" >&2; exit 1; }
|
||||
|
||||
MONGO_HOST="${MONGO_HOST:-mongo}"
|
||||
MONGO_PORT="${MONGO_PORT:-27017}"
|
||||
SRC_DB="${SRC_DB:-keymanager}"
|
||||
DST_DB="${DST_DB:-vantage}"
|
||||
DROP_SRC="${DROP_SRC:-false}"
|
||||
|
||||
MONGO_URI="mongodb://${MONGO_HOST}:${MONGO_PORT}"
|
||||
|
||||
mongosh_eval() {
|
||||
local db="$1"; local script="$2"
|
||||
mongosh --quiet "${MONGO_URI}/${db}" --eval "$script"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Wait for MongoDB to be reachable
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Waiting for MongoDB at ${MONGO_HOST}:${MONGO_PORT}..."
|
||||
for i in $(seq 1 30); do
|
||||
mongosh --quiet "${MONGO_URI}/admin" --eval "db.adminCommand('ping')" >/dev/null 2>&1 && break
|
||||
[ "$i" -eq 30 ] && die "MongoDB not reachable after 30 attempts."
|
||||
sleep 2
|
||||
done
|
||||
info "MongoDB is ready."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Check source database
|
||||
# ---------------------------------------------------------------------------
|
||||
SRC_COLLECTIONS=$(mongosh_eval admin "
|
||||
const names = db.getSiblingDB('${SRC_DB}').getCollectionNames();
|
||||
print(names.join(','));
|
||||
")
|
||||
|
||||
if [ -z "$SRC_COLLECTIONS" ] || [ "$SRC_COLLECTIONS" = "," ]; then
|
||||
warn "Source database '${SRC_DB}' has no collections — nothing to migrate."
|
||||
warn "If this is a fresh deployment, '${DST_DB}' will be created automatically."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Collections in '${SRC_DB}': ${SRC_COLLECTIONS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Copy all collections via \$out
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Copying collections from '${SRC_DB}' to '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const cols = src.getCollectionNames();
|
||||
cols.forEach(function(name) {
|
||||
src[name].aggregate([{ \\\$out: { db: '${DST_DB}', coll: name } }]);
|
||||
print('Copied: ' + name);
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Recreate indexes
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Recreating indexes in '${DST_DB}'..."
|
||||
|
||||
mongosh_eval admin "
|
||||
const src = db.getSiblingDB('${SRC_DB}');
|
||||
const dst = db.getSiblingDB('${DST_DB}');
|
||||
src.getCollectionNames().forEach(function(col) {
|
||||
src[col].getIndexes().forEach(function(idx) {
|
||||
if (idx.name === '_id_') return;
|
||||
const opts = { name: idx.name };
|
||||
if (idx.unique) opts.unique = true;
|
||||
if (idx.sparse) opts.sparse = true;
|
||||
if (idx.expireAfterSeconds !== undefined) opts.expireAfterSeconds = idx.expireAfterSeconds;
|
||||
try {
|
||||
dst[col].createIndex(idx.key, opts);
|
||||
print('Index: ' + col + '.' + idx.name);
|
||||
} catch(e) {
|
||||
print('Skipped index ' + idx.name + ' on ' + col + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Verify document counts
|
||||
# ---------------------------------------------------------------------------
|
||||
info "Verifying document counts..."
|
||||
|
||||
MISMATCH=0
|
||||
IFS=',' read -ra COLS <<< "$SRC_COLLECTIONS"
|
||||
for col in "${COLS[@]}"; do
|
||||
[ -z "$col" ] && continue
|
||||
SRC_N=$(mongosh_eval "$SRC_DB" "print(db['${col}'].countDocuments())")
|
||||
DST_N=$(mongosh_eval "$DST_DB" "print(db['${col}'].countDocuments())")
|
||||
if [ "$SRC_N" = "$DST_N" ]; then
|
||||
info " ${col}: ${SRC_N} docs OK"
|
||||
else
|
||||
warn " ${col}: src=${SRC_N} dst=${DST_N} MISMATCH"
|
||||
MISMATCH=1
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$MISMATCH" -eq 1 ] && die "Count mismatch — source database NOT dropped. Investigate and re-run."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Optionally drop source database
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$DROP_SRC" = "true" ]; then
|
||||
info "Dropping source database '${SRC_DB}'..."
|
||||
mongosh_eval admin "db.getSiblingDB('${SRC_DB}').dropDatabase(); print('Dropped.');"
|
||||
info "Dropped '${SRC_DB}'."
|
||||
else
|
||||
warn "Source database '${SRC_DB}' kept. Set DROP_SRC=true to drop it automatically."
|
||||
fi
|
||||
|
||||
info "Migration complete."
|
||||
@@ -8,6 +8,7 @@ service Vantage {
|
||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
@@ -65,12 +66,29 @@ message CommandResult {
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message PackageUpdate {
|
||||
string name = 1;
|
||||
string current_version = 2;
|
||||
string new_version = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated PackageUpdate updates = 3;
|
||||
}
|
||||
|
||||
message ReportUpdatesResponse {}
|
||||
|
||||
message ApplyUpdatesCmd {}
|
||||
|
||||
message ServerCommand {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
GenerateKeyCmd generate_key = 2;
|
||||
DeleteKeyCmd delete_key = 3;
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.DELETE("/servers/:id", deleteServer)
|
||||
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||
apiGroup.POST("/servers/:id/update-agent", updateAgent)
|
||||
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
|
||||
|
||||
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
|
||||
|
||||
@@ -290,6 +291,21 @@ func updateAgent(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := os.Getenv("GITEA_HOST")
|
||||
if giteaHost == "" {
|
||||
|
||||
@@ -49,11 +49,28 @@ type UploadKeyResponse struct {
|
||||
|
||||
// CommandStream message types
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -142,6 +159,7 @@ type VantageServer interface {
|
||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
}
|
||||
|
||||
@@ -159,6 +177,10 @@ func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKey
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||
}
|
||||
@@ -169,6 +191,7 @@ type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -204,6 +227,14 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
@@ -225,6 +256,7 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
{MethodName: "Register", Handler: _Vantage_Register_Handler},
|
||||
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
|
||||
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
|
||||
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
@@ -282,6 +314,21 @@ func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, d
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportUpdatesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportUpdates(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportUpdates"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportUpdates(ctx, req.(*ReportUpdatesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -67,6 +68,26 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
|
||||
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdatesRequest) (*pb.ReportUpdatesResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
|
||||
pkgs := make([]models.PackageUpdate, len(req.Updates))
|
||||
for i, u := range req.Updates {
|
||||
pkgs[i] = models.PackageUpdate{
|
||||
Name: u.Name,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
NewVersion: u.NewVersion,
|
||||
}
|
||||
}
|
||||
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
|
||||
log.Printf("failed to store updates for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.ReportUpdatesResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
|
||||
// First message authenticates the agent and signals readiness.
|
||||
msg, err := stream.Recv()
|
||||
|
||||
@@ -6,17 +6,25 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
type PackageUpdate struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
|
||||
NewVersion string `bson:"new_version" json:"new_version"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hostname string `bson:"hostname" json:"hostname"`
|
||||
IPAddress string `bson:"ip_address" json:"ip_address"`
|
||||
OSInfo string `bson:"os_info" json:"os_info"`
|
||||
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
|
||||
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
|
||||
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -134,6 +134,18 @@ func DispatchUpdateAgent(serverID string) (string, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// DispatchApplyUpdates sends an apply-updates command to the named server's agent.
|
||||
func DispatchApplyUpdates(serverID string) error {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return fmt.Errorf("agent is not connected to the command stream")
|
||||
}
|
||||
cmd := &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
ApplyUpdates: &pb.ApplyUpdatesCmd{},
|
||||
}
|
||||
return Dispatcher.dispatch(serverID, cmd)
|
||||
}
|
||||
|
||||
// DispatchDeleteKey sends a delete-key command to the named server's agent.
|
||||
// It is best-effort: if the agent is offline the local files will remain until next connection.
|
||||
func DispatchDeleteKey(serverID, label string) {
|
||||
|
||||
@@ -182,6 +182,21 @@ func DeleteServer(serverID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
_, err := db.Col("servers").UpdateOne(ctx,
|
||||
bson.M{"server_id": serverID},
|
||||
bson.M{"$set": bson.M{
|
||||
"available_updates": pkgs,
|
||||
"updates_checked_at": now,
|
||||
}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkOfflineServers(threshold time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+457
-415
@@ -1,76 +1,194 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ServerStatus, GenerateKeyOptions } from "@/lib/api";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function statusVariant(status: ServerStatus) {
|
||||
switch (status) {
|
||||
case "active": return "success";
|
||||
case "pending": return "warning";
|
||||
case "offline": return "danger";
|
||||
}
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "offline":
|
||||
return "danger";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: Record<string, number> = {
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
rsa: 4096,
|
||||
ecdsa: 256,
|
||||
};
|
||||
|
||||
function GenerateKeyModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSubmit: (opts: GenerateKeyOptions) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
function GenerateKeyModal({ onClose, onSubmit, isPending }: { onClose: () => void; onSubmit: (opts: GenerateKeyOptions) => void; isPending: boolean }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [keySize, setKeySize] = useState<number>(4096);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||
setKeyType(t);
|
||||
if (t !== "ed25519") {
|
||||
setKeySize(DEFAULT_SIZE[t]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
label: label || "generated",
|
||||
key_type: keyType,
|
||||
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||
passphrase: passphrase || undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
const sizes = KEY_SIZES[keyType];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{keyType === "ed25519" && <p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>}
|
||||
{keyType === "rsa" && <p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>}
|
||||
{keyType === "ecdsa" && <p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve — shorter keys, good compatibility.</p>}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={(e) => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function UpdatesModal({
|
||||
updates,
|
||||
onClose,
|
||||
onApply,
|
||||
isApplying,
|
||||
applySuccess,
|
||||
}: {
|
||||
updates: PackageUpdate[];
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
isApplying: boolean;
|
||||
applySuccess: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Available OS Updates</h2>
|
||||
<p className="mt-0.5 text-sm text-text-secondary">{updates.length} package{updates.length !== 1 ? "s" : ""} available</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||
@@ -81,383 +199,307 @@ function GenerateKeyModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Label <span className="text-text-tertiary">(used as the key name in Vantage)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="e.g. server-deploy-key"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["ed25519", "rsa", "ecdsa"] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => handleKeyTypeChange(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
keyType === t
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
<div className="max-h-80 overflow-y-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Package</Th>
|
||||
<Th>Current</Th>
|
||||
<Th>Available</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td><span className="font-medium font-mono text-sm">{u.name}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-text-secondary">{u.current_version || "—"}</span></Td>
|
||||
<Td><span className="font-mono text-xs text-success">{u.new_version}</span></Td>
|
||||
</Tr>
|
||||
))}
|
||||
</div>
|
||||
{keyType === "ed25519" && (
|
||||
<p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>
|
||||
)}
|
||||
{keyType === "rsa" && (
|
||||
<p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>
|
||||
)}
|
||||
{keyType === "ecdsa" && (
|
||||
<p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve — shorter keys, good compatibility.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sizes && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||
<select
|
||||
value={keySize}
|
||||
onChange={e => setKeySize(Number(e.target.value))}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{sizes.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="e.g. user@hostname"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder="Optional passphrase"
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||
Generate Key
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
|
||||
const { data: server, isLoading, error } = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
setShowGenerateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !server) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
|
||||
Server not found or failed to load.
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showGenerateModal && (
|
||||
<GenerateKeyModal
|
||||
onClose={() => setShowGenerateModal(false)}
|
||||
onSubmit={opts => generateKey(opts)}
|
||||
isPending={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApply}>
|
||||
{applySuccess ? "Sent!" : "Apply Updates"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={isDeleting}
|
||||
onClick={() => deleteServer()}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">
|
||||
{latestVersion ? `v${latestVersion.version}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && (
|
||||
<Badge variant="warning">update available</Badge>
|
||||
)}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && (
|
||||
<Badge variant="success">up to date</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">$</span>{" "}
|
||||
<span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">
|
||||
{server.agent_version ? `v${server.agent_version}` : "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
|
||||
{server.keys?.filter(k => !k.revoked_at).length ?? 0} active
|
||||
</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">Manage Keys →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys.filter(a => a.key).map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{assignment.key.fingerprint}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>
|
||||
{assignment.key.source}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
|
||||
{assignment.revoked_at ? "revoked" : "active"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">
|
||||
{formatDate(assignment.assigned_at)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<p className="ml-auto text-xs text-text-tertiary">Upgrade runs in the background. This may take several minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
|
||||
const {
|
||||
data: server,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["servers", serverId],
|
||||
queryFn: () => api.getServer(serverId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||
onSuccess: () => {
|
||||
setShowGenerateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: ["agent-latest-version"],
|
||||
queryFn: () => api.getLatestAgentVersion(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const { mutate: triggerUpdate, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () => api.updateAgent(serverId),
|
||||
onSuccess: () => {
|
||||
setUpdateSuccess(true);
|
||||
setTimeout(() => setUpdateSuccess(false), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const { mutate: applyUpdates, isPending: isApplying } = useMutation({
|
||||
mutationFn: () => api.applyUpdates(serverId),
|
||||
onSuccess: () => {
|
||||
setApplySuccess(true);
|
||||
setTimeout(() => {
|
||||
setApplySuccess(false);
|
||||
setShowUpdatesModal(false);
|
||||
}, 2000);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteServer(serverId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
router.push("/servers");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !server) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
|
||||
← Servers
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowGenerateModal(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove Server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteServer()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update Agent</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">Installed: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Latest: </span>
|
||||
<span className="font-mono font-medium text-text-primary">{latestVersion ? `v${latestVersion.version}` : "—"}</span>
|
||||
</div>
|
||||
{latestVersion && server.agent_version && server.agent_version !== latestVersion.version && <Badge variant="warning">update available</Badge>}
|
||||
{latestVersion && server.agent_version && server.agent_version === latestVersion.version && <Badge variant="success">up to date</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isUpdating}
|
||||
onClick={() => triggerUpdate()}
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateSuccess ? "Update Sent!" : "Update Agent"}
|
||||
</Button>
|
||||
<div className="relative flex-1 min-w-64 rounded-lg border border-border bg-[#0a0c14] px-4 py-2.5 font-mono text-sm">
|
||||
<span className="text-accent">$</span> <span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||
setCopiedUpdate(true);
|
||||
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||
}}
|
||||
className="absolute right-2 top-1.5 rounded-md border border-border bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||
>
|
||||
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Server ID</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">OS</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Agent Version</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{server.agent_version ? `v${server.agent_version}` : "unknown"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Last Seen</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Registered</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Installed SSH Keys
|
||||
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{server.keys?.filter((k) => !k.revoked_at).length ?? 0} active</span>
|
||||
</h2>
|
||||
<Link href="/keys">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage Keys →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!server.keys || server.keys.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
|
||||
<Link href="/keys">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Assign a key
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Label</Th>
|
||||
<Th>Fingerprint</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Assigned</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{server.keys
|
||||
.filter((a) => a.key)
|
||||
.map((assignment) => (
|
||||
<Tr key={assignment.key_id}>
|
||||
<Td>
|
||||
<span className="font-medium">{assignment.key.label}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{assignment.key.fingerprint}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>{assignment.key.source}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-text-secondary text-xs">{formatDate(assignment.assigned_at)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/keys/${assignment.key_id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export type ServerStatus = "pending" | "active" | "offline";
|
||||
export type KeySource = "uploaded" | "generated";
|
||||
|
||||
export interface PackageUpdate {
|
||||
name: string;
|
||||
current_version?: string;
|
||||
new_version: string;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
server_id: string;
|
||||
@@ -11,6 +17,8 @@ export interface Server {
|
||||
agent_version?: string;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
available_updates?: PackageUpdate[];
|
||||
updates_checked_at?: string;
|
||||
}
|
||||
|
||||
export interface Key {
|
||||
@@ -127,6 +135,12 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
applyUpdates(serverId: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/servers/${serverId}/apply-updates`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
// Keys
|
||||
listKeys(): Promise<Key[]> {
|
||||
return request<Key[]>("/keys");
|
||||
|
||||
Reference in New Issue
Block a user