From f3304e04df09d38d425c30ee91f531a164438384 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:31:51 +0100 Subject: [PATCH] feat: Added package management --- internal/grpc/client.go | 12 ++ internal/grpc/pb/vantage.pb.go | 34 ++++- internal/sync/sync.go | 67 +++++++++ internal/updates/updates.go | 239 +++++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 internal/updates/updates.go diff --git a/internal/grpc/client.go b/internal/grpc/client.go index db3910c..19a1363 100644 --- a/internal/grpc/client.go +++ b/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/internal/grpc/pb/vantage.pb.go b/internal/grpc/pb/vantage.pb.go index ee3b194..37a9b65 100644 --- a/internal/grpc/pb/vantage.pb.go +++ b/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/internal/sync/sync.go b/internal/sync/sync.go index b16e693..76d099b 100644 --- a/internal/sync/sync.go +++ b/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/internal/updates/updates.go b/internal/updates/updates.go new file mode 100644 index 0000000..a2eecf7 --- /dev/null +++ b/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, "-") +}