diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go index db3910c..19a1363 100644 --- a/agent/internal/grpc/client.go +++ b/agent/internal/grpc/client.go @@ -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) { diff --git a/agent/internal/grpc/pb/vantage.pb.go b/agent/internal/grpc/pb/vantage.pb.go index ee3b194..37a9b65 100644 --- a/agent/internal/grpc/pb/vantage.pb.go +++ b/agent/internal/grpc/pb/vantage.pb.go @@ -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...) diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go index b16e693..76d099b 100644 --- a/agent/internal/sync/sync.go +++ b/agent/internal/sync/sync.go @@ -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, " ", "_")) diff --git a/agent/internal/updates/updates.go b/agent/internal/updates/updates.go new file mode 100644 index 0000000..a2eecf7 --- /dev/null +++ b/agent/internal/updates/updates.go @@ -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, "-") +} diff --git a/deploy/docker-compose.migrate.yml b/deploy/docker-compose.migrate.yml deleted file mode 100644 index 65221bf..0000000 --- a/deploy/docker-compose.migrate.yml +++ /dev/null @@ -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" diff --git a/deploy/migrate/agent-migrate.sh b/deploy/migrate/agent-migrate.sh deleted file mode 100644 index 001d6a4..0000000 --- a/deploy/migrate/agent-migrate.sh +++ /dev/null @@ -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." diff --git a/deploy/migrate/server-migrate.sh b/deploy/migrate/server-migrate.sh deleted file mode 100644 index c4a97e1..0000000 --- a/deploy/migrate/server-migrate.sh +++ /dev/null @@ -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." diff --git a/proto/vantage/v1/vantage.proto b/proto/vantage/v1/vantage.proto index ca6662b..942d730 100644 --- a/proto/vantage/v1/vantage.proto +++ b/proto/vantage/v1/vantage.proto @@ -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; } } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index e322601..d26658e 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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 == "" { diff --git a/server/internal/grpc/pb/vantage.pb.go b/server/internal/grpc/pb/vantage.pb.go index e69212b..223317b 100644 --- a/server/internal/grpc/pb/vantage.pb.go +++ b/server/internal/grpc/pb/vantage.pb.go @@ -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}) } diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index fe8e9ed..f2d0f1f 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -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() diff --git a/server/internal/models/server.go b/server/internal/models/server.go index 330e79b..fd76f27 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -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"` } diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index 76b5917..2e0d268 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -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) { diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index d5216d2..c7fcce4 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -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() diff --git a/web/app/servers/[id]/page.tsx b/web/app/servers/[id]/page.tsx index 78a7f3e..f758e39 100644 --- a/web/app/servers/[id]/page.tsx +++ b/web/app/servers/[id]/page.tsx @@ -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 = { - rsa: [2048, 3072, 4096], - ecdsa: [256, 384, 521], + rsa: [2048, 3072, 4096], + ecdsa: [256, 384, 521], }; const DEFAULT_SIZE: Record = { - 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(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(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 ( +
+
+
+
+

Generate SSH Key

+ +
+ +
+
+ + 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" + /> +
+ +
+ +
+ {(["ed25519", "rsa", "ecdsa"] as const).map((t) => ( + + ))} +
+ {keyType === "ed25519" &&

Modern, fast, and secure. Recommended for new keys.

} + {keyType === "rsa" &&

Widely compatible with older systems.

} + {keyType === "ecdsa" &&

Elliptic curve — shorter keys, good compatibility.

} +
+ + {sizes && ( +
+ + +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+
+
+
+ ); +} + + +function UpdatesModal({ + updates, + onClose, + onApply, + isApplying, + applySuccess, +}: { + updates: PackageUpdate[]; + onClose: () => void; + onApply: () => void; + isApplying: boolean; + applySuccess: boolean; +}) { return (
-
+
-

Generate SSH Key

+
+

Available OS Updates

+

{updates.length} package{updates.length !== 1 ? "s" : ""} available

+
-
-
- - 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" - /> -
- -
- -
- {(["ed25519", "rsa", "ecdsa"] as const).map(t => ( - +
+ + + + + + + + + + {updates.map((u) => ( + + + + + ))} - - {keyType === "ed25519" && ( -

Modern, fast, and secure. Recommended for new keys.

- )} - {keyType === "rsa" && ( -

Widely compatible with older systems.

- )} - {keyType === "ecdsa" && ( -

Elliptic curve — shorter keys, good compatibility.

- )} - - - {sizes && ( -
- - -
- )} - -
- - 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" - /> -
- -
- - 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" - /> -
- -
- - -
- - - - ); -} - -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 ( -
-
-
- ); - } - - if (error || !server) { - return ( -
-
- Server not found or failed to load. +
+
PackageCurrentAvailable
{u.name}{u.current_version || "—"}{u.new_version}
-
- ); - } - return ( -
- {showGenerateModal && ( - setShowGenerateModal(false)} - onSubmit={opts => generateKey(opts)} - isPending={isGenerating} - /> - )} - -
-
-
- - ← Servers - -
-
-

{server.hostname}

- {server.status} -
-

{server.ip_address}

-
-
- - {!confirmDelete ? ( - - ) : ( -
- Are you sure? - - -
- )} -
-
- -
- - - Update Agent - -
-
- Installed: - - {server.agent_version ? `v${server.agent_version}` : "unknown"} - -
-
- Latest: - - {latestVersion ? `v${latestVersion.version}` : "—"} - -
- {latestVersion && server.agent_version && server.agent_version !== latestVersion.version && ( - update available - )} - {latestVersion && server.agent_version && server.agent_version === latestVersion.version && ( - up to date - )} -
-
- -
- ${" "} - {api.getUpdateCommand()} - -
-
-
-
- -
- - - Details - -
-
-
Server ID
-
{server.server_id}
-
-
-
OS
-
{server.os_info}
-
-
-
Agent Version
-
- {server.agent_version ? `v${server.agent_version}` : "unknown"} -
-
-
-
Last Seen
-
{server.last_seen ? formatDate(server.last_seen) : "Never"}
-
-
-
Registered
-
{formatDate(server.created_at)}
-
-
-
- -
- -
-

- Installed Keys - - {server.keys?.filter(k => !k.revoked_at).length ?? 0} active - -

- - - -
- - {!server.keys || server.keys.length === 0 ? ( -
-

No keys assigned to this server.

- - - -
- ) : ( - - - - - - - - - - - - {server.keys.filter(a => a.key).map((assignment) => ( - - - - - - - - - ))} - -
LabelFingerprintSourceStatusAssigned -
- {assignment.key.label} - - - {assignment.key.fingerprint} - - - - {assignment.key.source} - - - - {assignment.revoked_at ? "revoked" : "active"} - - - - {formatDate(assignment.assigned_at)} - - - - - -
- )} -
+ +

Upgrade runs in the background. This may take several minutes.

); } +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 ( +
+
+
+ ); + } + + if (error || !server) { + return ( +
+
Server not found or failed to load.
+
+ ); + } + + return ( +
+ {showGenerateModal && setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />} + +
+
+
+ + ← Servers + +
+
+

{server.hostname}

+ {server.status} +
+

{server.ip_address}

+
+
+ + {!confirmDelete ? ( + + ) : ( +
+ Are you sure? + + +
+ )} +
+
+ +
+ + + Update Agent + +
+
+ Installed: + {server.agent_version ? `v${server.agent_version}` : "unknown"} +
+
+ Latest: + {latestVersion ? `v${latestVersion.version}` : "—"} +
+ {latestVersion && server.agent_version && server.agent_version !== latestVersion.version && update available} + {latestVersion && server.agent_version && server.agent_version === latestVersion.version && up to date} +
+
+ +
+ $ {api.getUpdateCommand()} + +
+
+
+
+ +
+ + + Details + +
+
+
Server ID
+
{server.server_id}
+
+
+
OS
+
{server.os_info}
+
+
+
Agent Version
+
{server.agent_version ? `v${server.agent_version}` : "unknown"}
+
+
+
Last Seen
+
{server.last_seen ? formatDate(server.last_seen) : "Never"}
+
+
+
Registered
+
{formatDate(server.created_at)}
+
+
+
+ +
+ +
+

+ Installed SSH Keys + {server.keys?.filter((k) => !k.revoked_at).length ?? 0} active +

+ + + +
+ + {!server.keys || server.keys.length === 0 ? ( +
+

No keys assigned to this server.

+ + + +
+ ) : ( + + + + + + + + + + + + {server.keys + .filter((a) => a.key) + .map((assignment) => ( + + + + + + + + + ))} + +
LabelFingerprintSourceStatusAssigned +
+ {assignment.key.label} + + {assignment.key.fingerprint} + + {assignment.key.source} + + {assignment.revoked_at ? "revoked" : "active"} + + {formatDate(assignment.assigned_at)} + + + + +
+ )} +
+
+
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 509f25f..44c1c6b 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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 { return request("/keys");