Compare commits

...
8 Commits
Author SHA1 Message Date
domrichardson 73a06227ba fix: Fixed endpoint
Server Deploy / deploy (push) Successful in 1m20s
2026-07-03 11:56:49 +01:00
domrichardson 7c30d26878 feat: Updated secret group yaml view
Server Deploy / deploy (push) Successful in 1m38s
2026-07-03 11:33:20 +01:00
domrichardson 19596ff2a3 feat: Secret management
Server Deploy / deploy (push) Successful in 1m33s
2026-07-03 10:37:43 +01:00
domrichardson c3c16083f7 feat: Audit and settings
Server Deploy / deploy (push) Successful in 1m26s
2026-06-25 11:30:26 +01:00
domrichardson e37a09ef0d feat: Servers status icon
Server Deploy / deploy (push) Successful in 2m32s
2026-06-25 10:10:43 +01:00
domrichardson 02e84ed548 feat: Updates button
Server Deploy / deploy (push) Successful in 1m25s
2026-06-25 09:51:45 +01:00
domrichardson 7e66b23ef8 fix: fixes to command stream
Agent Release / build (push) Successful in 33s
Server Deploy / deploy (push) Successful in 1m59s
2026-06-25 09:12:20 +01:00
domrichardson 5c91db0d4c feat: Added package management
Server Deploy / deploy (push) Successful in 3m44s
Agent Release / build (push) Successful in 10m21s
2026-06-24 16:31:51 +01:00
33 changed files with 3754 additions and 819 deletions
+22 -1
View File
@@ -11,6 +11,7 @@ import (
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/keepalive"
)
func init() {
@@ -26,7 +27,15 @@ func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
var dialOpts []grpc.DialOption
// Send a ping every 30s so proxies with a 60s idle timeout don't kill the
// long-lived CommandStream when no commands are flowing.
dialOpts := []grpc.DialOption{
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: false,
}),
}
if useTLS {
tlsCfg := &tls.Config{
@@ -105,6 +114,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) {
+30 -4
View File
@@ -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...)
+67
View File
@@ -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, " ", "_"))
+239
View File
@@ -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, "-")
}
+707
View File
@@ -0,0 +1,707 @@
# Custom Secrets Vault + ESO Webhook Integration
Self-hosted secrets API at `https://keymanager.hostxtra.co.uk/secrets`
backed by MongoDB with Gin, exposed to K3s via ESO's Webhook provider.
---
## Architecture
```
MongoDB (encrypted at rest)
└── secrets collection: { group, key, encryptedValue, updatedAt }
↑ CRUD via Go API (Gin)
Go API (keymanager secrets service)
└── GET /secrets/:group ← ESO webhook calls this
└── PUT /secrets/:group ← admin writes secrets to a group
└── DELETE /secrets/:group ← admin deletes entire group
└── DELETE /secrets/:group/:key ← admin deletes one key from a group
↓ bearer token auth (read token for ESO, admin token for writes)
ESO Webhook ClusterSecretStore
└── ExternalSecret (per namespace)
└── K8s Secret
└── Deployment env vars
```
**Data model:** A "group" is a logical namespace for a set of related secrets —
e.g. `myapp-prod`, `postgres`, `infra`. Each group contains one or more
key/value pairs stored individually as encrypted documents in MongoDB.
**Encryption:** AES-256-GCM per value, random nonce per write, master key
loaded from the `MASTER_KEY` environment variable (32-byte hex string).
---
## Part 1 — The Go API
### 1.1 — Project structure
```
keymanager/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── crypto/
│ │ └── crypto.go
│ ├── store/
│ │ └── store.go
│ └── api/
│ └── api.go
├── go.mod
└── Dockerfile
```
### 1.2 — `go.mod`
```
module github.com/yourusername/keymanager
go 1.23
require (
go.mongodb.org/mongo-driver v1.17.0
github.com/gin-gonic/gin v1.10.0
)
```
### 1.3 — `internal/crypto/crypto.go`
AES-256-GCM encryption. Each value gets a unique random nonce so identical
plaintext values produce different ciphertext on every write.
```go
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// Encrypt encrypts plaintext using AES-256-GCM.
// Returns base64(nonce + ciphertext).
func Encrypt(key []byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts a base64(nonce + ciphertext) produced by Encrypt.
func Decrypt(key []byte, encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
```
### 1.4 — `internal/store/store.go`
MongoDB storage. Each document represents one key within a group.
```go
package store
import (
"context"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type SecretDoc struct {
Group string `bson:"group"`
Key string `bson:"key"`
EncryptedValue string `bson:"encryptedValue"`
UpdatedAt time.Time `bson:"updatedAt"`
}
type Store struct {
col *mongo.Collection
}
func New(client *mongo.Client, dbName string) (*Store, error) {
col := client.Database(dbName).Collection("secrets")
// Unique compound index on (group, key)
_, err := col.Indexes().CreateOne(context.Background(), mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
if err != nil {
return nil, err
}
return &Store{col: col}, nil
}
// GetGroup returns all SecretDocs belonging to the given group.
func (s *Store) GetGroup(ctx context.Context, group string) ([]SecretDoc, error) {
cursor, err := s.col.Find(ctx, bson.M{"group": group})
if err != nil {
return nil, err
}
var docs []SecretDoc
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
return docs, nil
}
// Upsert writes or updates a single key within a group.
func (s *Store) Upsert(ctx context.Context, group, key, encryptedValue string) error {
filter := bson.M{"group": group, "key": key}
update := bson.M{"$set": bson.M{
"encryptedValue": encryptedValue,
"updatedAt": time.Now(),
}}
_, err := s.col.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true))
return err
}
// DeleteGroup removes all keys belonging to a group.
func (s *Store) DeleteGroup(ctx context.Context, group string) error {
_, err := s.col.DeleteMany(ctx, bson.M{"group": group})
return err
}
// DeleteKey removes a single key from a group.
func (s *Store) DeleteKey(ctx context.Context, group, key string) error {
_, err := s.col.DeleteOne(ctx, bson.M{"group": group, "key": key})
return err
}
```
### 1.5 — `internal/api/api.go`
Gin handlers. Two token tiers: ESO gets a read-only token, admins get a write token.
```go
package api
import (
"encoding/hex"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/yourusername/keymanager/internal/crypto"
"github.com/yourusername/keymanager/internal/store"
)
type API struct {
store *store.Store
masterKey []byte
esoToken string
adminToken string
}
func New(s *store.Store) *API {
keyHex := os.Getenv("MASTER_KEY")
key, err := hex.DecodeString(keyHex)
if err != nil || len(key) != 32 {
panic("MASTER_KEY must be a 64-character hex string (32 bytes)")
}
return &API{
store: s,
masterKey: key,
esoToken: os.Getenv("ESO_TOKEN"),
adminToken: os.Getenv("ADMIN_TOKEN"),
}
}
func (a *API) RegisterRoutes(r *gin.Engine) {
secrets := r.Group("/secrets")
// Read routes — ESO token
secrets.GET("/:group", a.bearerAuth(a.esoToken), a.getGroup)
// Write routes — admin token
secrets.PUT("/:group", a.bearerAuth(a.adminToken), a.putGroup)
secrets.DELETE("/:group", a.bearerAuth(a.adminToken), a.deleteGroup)
secrets.DELETE("/:group/:key", a.bearerAuth(a.adminToken), a.deleteKey)
}
// bearerAuth returns a Gin middleware that validates a Bearer token.
func (a *API) bearerAuth(expected string) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
const prefix = "Bearer "
if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
token := auth[len(prefix):]
if token != expected {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
// getGroup handles GET /secrets/:group
// Returns a flat JSON object { "KEY": "value", ... } for ESO to consume.
// Returns 404 if the group has no secrets — ESO treats 404 as "deleted".
func (a *API) getGroup(c *gin.Context) {
group := c.Param("group")
docs, err := a.store.GetGroup(c.Request.Context(), group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
if len(docs) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
result := make(map[string]string, len(docs))
for _, doc := range docs {
val, err := crypto.Decrypt(a.masterKey, doc.EncryptedValue)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "decrypt error"})
return
}
result[doc.Key] = val
}
c.JSON(http.StatusOK, result)
}
// putGroup handles PUT /secrets/:group
// Body: { "KEY": "value", ... } — upserts each key in the group.
func (a *API) putGroup(c *gin.Context) {
group := c.Param("group")
var payload map[string]string
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(payload) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
return
}
for key, val := range payload {
encrypted, err := crypto.Encrypt(a.masterKey, val)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt error"})
return
}
if err := a.store.Upsert(c.Request.Context(), group, key, encrypted); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
}
c.Status(http.StatusNoContent)
}
// deleteGroup handles DELETE /secrets/:group
// Removes the entire group and all its keys.
func (a *API) deleteGroup(c *gin.Context) {
group := c.Param("group")
if err := a.store.DeleteGroup(c.Request.Context(), group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
c.Status(http.StatusNoContent)
}
// deleteKey handles DELETE /secrets/:group/:key
// Removes a single key from a group.
func (a *API) deleteKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := a.store.DeleteKey(c.Request.Context(), group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
c.Status(http.StatusNoContent)
}
```
### 1.6 — `cmd/server/main.go`
```go
package main
import (
"context"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"github.com/yourusername/keymanager/internal/api"
"github.com/yourusername/keymanager/internal/store"
)
func main() {
mongoURI := os.Getenv("MONGODB_URI")
if mongoURI == "" {
mongoURI = "mongodb://localhost:27017"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
if err != nil {
log.Fatalf("MongoDB connect: %v", err)
}
if err := client.Ping(ctx, nil); err != nil {
log.Fatalf("MongoDB ping: %v", err)
}
log.Println("Connected to MongoDB")
s, err := store.New(client, "keymanager")
if err != nil {
log.Fatalf("Store init: %v", err)
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
a := api.New(s)
a.RegisterRoutes(r)
log.Println("Secrets API listening on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatalf("Server error: %v", err)
}
}
```
### 1.7 — `Dockerfile`
```dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o secrets-api ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/secrets-api .
EXPOSE 8080
CMD ["./secrets-api"]
```
---
## Part 2 — Deploying the API
### 2.1 — Generate your secrets
```bash
# 32-byte master key — back this up in your password manager
openssl rand -hex 32
# ESO read token
openssl rand -hex 32
# Admin token
openssl rand -hex 32
```
### 2.2 — Docker Compose
```yaml
services:
secrets-api:
image: ghcr.io/yourusername/keymanager-secrets:latest
restart: unless-stopped
environment:
MONGODB_URI: mongodb://mongo:27017
MASTER_KEY: "<your-32-byte-hex-key>"
ESO_TOKEN: "<your-eso-token>"
ADMIN_TOKEN: "<your-admin-token>"
ports:
- "127.0.0.1:8082:8080"
```
### 2.3 — Caddyfile
```caddyfile
keymanager.hostxtra.co.uk {
# ... existing KeyManager routes ...
handle /secrets* {
reverse_proxy localhost:8082
}
}
```
```bash
caddy reload --config /etc/caddy/Caddyfile
```
### 2.4 — Smoke test
```bash
export ADMIN="<your-admin-token>"
export ESO="<your-eso-token>"
export BASE="https://keymanager.hostxtra.co.uk/secrets"
# Write a group
curl -s -X PUT $BASE/myapp-prod \
-H "Authorization: Bearer $ADMIN" \
-H "Content-Type: application/json" \
-d '{"DB_PASSWORD": "supersecret123", "API_KEY": "myapikey456"}'
# → 204 No Content
# Read back (as ESO would)
curl -s $BASE/myapp-prod \
-H "Authorization: Bearer $ESO"
# → {"API_KEY":"myapikey456","DB_PASSWORD":"supersecret123"}
# Delete a single key
curl -s -X DELETE $BASE/myapp-prod/API_KEY \
-H "Authorization: Bearer $ADMIN"
# → 204 No Content
# Confirm it's gone
curl -s $BASE/myapp-prod \
-H "Authorization: Bearer $ESO"
# → {"DB_PASSWORD":"supersecret123"}
# Delete the whole group
curl -s -X DELETE $BASE/myapp-prod \
-H "Authorization: Bearer $ADMIN"
# → 204 No Content
# Confirm 404
curl -s -o /dev/null -w "%{http_code}" $BASE/myapp-prod \
-H "Authorization: Bearer $ESO"
# → 404
```
---
## Part 3 — ESO Webhook Integration
### 3.1 — Install ESO
```bash
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm upgrade --install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace \
--set installCRDs=true \
--wait
```
### 3.2 — Store ESO token as a K8s Secret
The `external-secrets.io/type=webhook` label is required — without it the
webhook provider is not permitted to read the secret.
```bash
kubectl create secret generic keymanager-eso-token \
--namespace external-secrets \
--from-literal=token="<your-eso-token>"
kubectl label secret keymanager-eso-token \
--namespace external-secrets \
external-secrets.io/type=webhook
```
### 3.3 — ClusterSecretStore
```yaml
# cluster-secret-store.yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: keymanager-store
spec:
provider:
webhook:
url: "https://keymanager.hostxtra.co.uk/secrets/{{ .remoteRef.key }}"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: keymanager-eso-token
namespace: external-secrets
```
```bash
kubectl apply -f cluster-secret-store.yaml
kubectl get clustersecretstore keymanager-store
```
### 3.4 — ExternalSecret
The `remoteRef.key` value is the group name — ESO substitutes it into the
URL template, calling `GET /secrets/myapp-prod`.
```yaml
# external-secret.yaml
apiVersion: v1
kind: Namespace
metadata:
name: myapp
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: myapp-secrets
namespace: myapp
spec:
refreshInterval: 15m
secretStoreRef:
name: keymanager-store
kind: ClusterSecretStore
target:
name: myapp-secrets
creationPolicy: Owner
dataFrom:
- extract:
key: myapp-prod # group name → GET /secrets/myapp-prod
```
Or to pull specific keys from a group:
```yaml
data:
- secretKey: DB_PASSWORD
remoteRef:
key: myapp-prod # group name
property: DB_PASSWORD # key within the group's JSON response
```
```bash
kubectl apply -f external-secret.yaml
kubectl get externalsecret myapp-secrets -n myapp
# STATUS: SecretSynced
# Decode and verify values
kubectl get secret myapp-secrets -n myapp -o json | \
jq '.data | map_values(@base64d)'
```
### 3.5 — Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: myapp
spec:
replicas: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
annotations:
reloader.stakater.com/auto: "true" # optional: auto-restart on secret change
spec:
containers:
- name: myapp
image: your-image:latest
envFrom:
- secretRef:
name: myapp-secrets
```
---
## Part 4 — Day-to-Day Secret Management
```bash
export ADMIN="<your-admin-token>"
export BASE="https://keymanager.hostxtra.co.uk/secrets"
# Create or update a group (upserts — safe to re-run)
curl -s -X PUT $BASE/postgres \
-H "Authorization: Bearer $ADMIN" \
-H "Content-Type: application/json" \
-d '{"POSTGRES_PASSWORD": "dbpass", "POSTGRES_USER": "app"}'
# Add a new key to an existing group (existing keys are untouched)
curl -s -X PUT $BASE/postgres \
-H "Authorization: Bearer $ADMIN" \
-H "Content-Type: application/json" \
-d '{"POSTGRES_DB": "mydb"}'
# Remove a single key from a group
curl -s -X DELETE $BASE/postgres/POSTGRES_USER \
-H "Authorization: Bearer $ADMIN"
# Remove an entire group
curl -s -X DELETE $BASE/postgres \
-H "Authorization: Bearer $ADMIN"
# Force ESO to re-sync immediately after a rotation
kubectl annotate externalsecret myapp-secrets -n myapp \
force-sync=$(date +%s) --overwrite
```
---
## Quick Reference
| Endpoint | Token | Description |
|---|---|---|
| `GET /secrets/:group` | ESO token | Returns all keys in group as JSON |
| `PUT /secrets/:group` | Admin token | Upserts keys into group |
| `DELETE /secrets/:group` | Admin token | Deletes entire group |
| `DELETE /secrets/:group/:key` | Admin token | Deletes one key from group |
-17
View File
@@ -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"
-219
View File
@@ -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."
-124
View File
@@ -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."
+21 -3
View File
@@ -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;
}
}
+5 -1
View File
@@ -23,6 +23,10 @@ func main() {
}
log.Println("connected to MongoDB")
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if err := auth.InitRedis(redisAddr); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
@@ -38,7 +42,7 @@ func main() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := services.MarkOfflineServers(5 * time.Minute); err != nil {
if err := services.MarkOfflineServers(); err != nil {
log.Printf("mark offline error: %v", err)
}
}
+105
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
@@ -11,10 +12,24 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
func actorFromCtx(c *gin.Context) string {
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
return sess.Email
}
return "admin"
}
func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/update", handleUpdateScript)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Lives under /api (so the reverse
// proxy routes it to the backend) but on a distinct subpath to avoid
// colliding with the session-authed GET /api/secrets/:group. Returns a
// group as flat JSON.
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
@@ -33,9 +48,24 @@ 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)
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
apiGroup.GET("/secrets", listSecretGroups)
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
@@ -74,6 +104,7 @@ func newServer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -119,10 +150,16 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hostname := id
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -159,6 +196,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -191,6 +229,7 @@ func createKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
@@ -226,10 +265,16 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
label := id
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -248,6 +293,7 @@ func assignKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -259,6 +305,7 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -284,12 +331,29 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
})
}
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
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
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 == "" {
@@ -346,6 +410,47 @@ echo "vantage-agent updated to ${VERSION} and restarted."
c.String(http.StatusOK, script)
}
func listAuditEvents(c *gin.Context) {
limit := int64(100)
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(body.Alerts, body.Email); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
)
// groupNamePattern restricts group and key names to characters that are safe
// in URLs and Kubernetes/env contexts.
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
if len(values) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, values)
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
// be created with at least one key/value pair.
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
Values map[string]string `json:"values"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validName(body.Group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
if len(body.Values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "a group must be created with at least one key"})
return
}
for k := range body.Values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(secrets) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
// putSecretGroup upserts one or more keys into an existing (or new) group.
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
var values map[string]string
if err := c.ShouldBindJSON(&values); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
return
}
for k := range values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+51 -4
View File
@@ -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})
}
+37 -1
View File
@@ -5,12 +5,15 @@ import (
"fmt"
"log"
"net"
"time"
"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"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
)
@@ -67,6 +70,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()
@@ -126,7 +149,20 @@ func StartGRPC(port int) error {
return fmt.Errorf("failed to listen: %w", err)
}
s := grpc.NewServer()
s := grpc.NewServer(
// Accept client keepalive pings as fast as every 20s so the 30s agent
// ping interval is always within the allowed window.
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: false,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
// Server also pings the client after 45s of inactivity so both
// sides can detect a dead connection without waiting for a timeout.
Time: 45 * time.Second,
Timeout: 10 * time.Second,
}),
)
pb.RegisterVantageServer(s, &vantageServer{})
log.Printf("gRPC server listening on :%d", port)
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
EventType string `bson:"event_type" json:"event_type"`
Actor string `bson:"actor" json:"actor"`
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Secret is a single key/value pair within a group. The value is stored
// encrypted (AES-256-GCM) and is never serialized to JSON.
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Group string `bson:"group" json:"group"`
Key string `bson:"key" json:"key"`
EncryptedValue string `bson:"encrypted_value" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// GroupSummary describes a group in the list view.
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
UpdatedAt time.Time `json:"updated_at"`
}
+21 -13
View File
@@ -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"`
}
+39
View File
@@ -0,0 +1,39 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
// SecretsSettings holds configuration for the secrets vault / ESO integration.
// The read token is stored as a SHA-256 hash and never returned to clients.
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
}
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"context"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func LogEvent(eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
EventType: eventType,
Actor: actor,
ServerID: serverID,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
}
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
log.Printf("audit log error: %v", err)
}
}
func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var events []models.AuditEvent
if err := cursor.All(ctx, &events); err != nil {
return nil, err
}
return events, nil
}
+9 -2
View File
@@ -22,7 +22,9 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
// encryptString encrypts a plaintext value with AES-256-GCM using the
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -43,7 +45,8 @@ func encryptPrivateKey(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
// decryptString reverses encryptString.
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -70,3 +73,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
}
return string(plaintext), nil
}
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
+12
View File
@@ -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) {
+174
View File
@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"sort"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
{Key: "updated_at", Value: bson.D{{Key: "$max", Value: "$updated_at"}}},
}}},
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := db.Col("secrets").Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var rows []struct {
Group string `bson:"_id"`
KeyCount int `bson:"key_count"`
UpdatedAt time.Time `bson:"updated_at"`
}
if err := cursor.All(ctx, &rows); err != nil {
return nil, err
}
groups := make([]models.GroupSummary, 0, len(rows))
for _, r := range rows {
groups = append(groups, models.GroupSummary{
Group: r.Group,
KeyCount: r.KeyCount,
UpdatedAt: r.UpdatedAt,
})
}
return groups, nil
}
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []models.Secret
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
return docs, nil
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
if err != nil {
return nil, err
}
result := make(map[string]string, len(docs))
for _, doc := range docs {
val, err := decryptString(doc.EncryptedValue)
if err != nil {
return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err)
}
result[doc.Key] = val
}
return result, nil
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
if err != nil {
return "", err
}
return decryptString(doc.EncryptedValue)
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for key, val := range values {
encrypted, err := encryptString(val)
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"$set": bson.M{
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}
}
return nil
}
// SortedKeys returns the map keys sorted — handy for stable audit messages.
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
return err
}
+54 -2
View File
@@ -182,12 +182,64 @@ func DeleteServer(serverID string) error {
return err
}
func MarkOfflineServers(threshold time.Duration) error {
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() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
_, err := db.Col("servers").UpdateMany(ctx,
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
+220
View File
@@ -0,0 +1,220 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func GetSettings() (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
return &cp, nil
}
if err != nil {
return nil, err
}
s.Secrets.ReadTokenSet = s.Secrets.ReadTokenHash != ""
return &s, nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return "", err
}
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
if token == "" {
return false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{"alerts": alerts, "email": email}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS.
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load audit log.</div>
) : events && events.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</Card>
</div>
);
}
+311
View File
@@ -0,0 +1,311 @@
"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, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-text-secondary">{label}</span>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs leading-relaxed text-text-primary">{yaml}</pre>
</div>
);
}
function YamlModal({ group, onClose }: { group: string; onClose: () => void }) {
const [namespace, setNamespace] = useState(group);
const readUrl = typeof window !== "undefined" ? window.location.origin : "https://vantage.example.com";
const ns = namespace.trim() || group;
const externalSecret = `apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ${group}
namespace: ${ns}
spec:
refreshInterval: 15m
secretStoreRef:
name: ${STORE_NAME}
kind: ClusterSecretStore
target:
name: ${group}
creationPolicy: Owner
dataFrom:
- extract:
key: ${group}`;
const clusterStore = `apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: ${STORE_NAME}
spec:
provider:
webhook:
url: "${readUrl}/api/secrets/{{ .remoteRef.key }}/values"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: vantage-eso-token
namespace: external-secrets`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">
Kubernetes manifests for <span className="font-mono">{group}</span>
</h2>
<p className="mb-5 text-sm text-text-secondary">Apply the ExternalSecret in your app&apos;s namespace to sync this group into a Kubernetes Secret via ESO.</p>
<div className="mb-5">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Namespace</label>
<input type="text" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={group} className={`${inputClass} font-mono`} />
</div>
<div className="space-y-5">
<CopyBlock label="ExternalSecret (apply per namespace)" yaml={externalSecret} />
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary">One-time cluster setup: ClusterSecretStore</summary>
<p className="mb-3 mt-2 text-xs text-text-tertiary">
Apply this once per cluster. It requires a Secret named <span className="font-mono">vantage-eso-token</span> in the <span className="font-mono">external-secrets</span>{" "}
namespace holding the read token from Settings, labelled <span className="font-mono">external-secrets.io/type=webhook</span>.
</p>
<CopyBlock label="ClusterSecretStore" yaml={clusterStore} />
</details>
</div>
<div className="mt-6 flex justify-end">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const {
mutate: add,
isPending,
error,
} = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="API_KEY" className={`${inputClass} font-mono`} />
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="myapikey456" className={`${inputClass} font-mono`} />
</div>
<Button variant="primary" loading={isPending} disabled={!key.trim() || !value} onClick={() => add()}>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const [showYaml, setShowYaml] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => setShowYaml(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="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</Card>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="supersecret123"
className={`${inputClass} font-mono`}
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Group
</Button>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+477 -414
View File
@@ -4,73 +4,191 @@ 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,328 @@ 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} />}
{showUpdatesModal && server.available_updates && (
<UpdatesModal
updates={server.available_updates}
onClose={() => setShowUpdatesModal(false)}
onApply={() => applyUpdates()}
isApplying={isApplying}
applySuccess={applySuccess}
/>
)}
<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">
{server.available_updates && server.available_updates.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowUpdatesModal(true)}
className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
</Button>
)}
<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>
);
}
+40 -14
View File
@@ -2,19 +2,40 @@
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Server, ServerStatus } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { api, Server } from "@/lib/api";
import { Button, Card } 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";
}
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus {
if (server.status === "offline" || server.status === "pending") return "offline";
if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update";
if (server.available_updates && server.available_updates.length > 0) return "has-package-updates";
return "ok";
}
const DOT_CLASSES: Record<DotStatus, string> = {
offline: "bg-danger",
"needs-update": "bg-orange-500",
"has-package-updates": "bg-yellow-400",
ok: "bg-success",
};
const DOT_LABELS: Record<DotStatus, string> = {
offline: "Offline",
"needs-update": "Agent needs updating",
"has-package-updates": "Package updates available",
ok: "OK",
};
function StatusDot({ status }: { status: DotStatus }) {
return (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
</span>
);
}
function formatLastSeen(dateStr: string): string {
@@ -39,6 +60,13 @@ export default function ServersPage() {
refetchInterval: 30_000,
});
const { data: latestVersionData } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: api.getLatestAgentVersion,
staleTime: 5 * 60_000,
});
const latestVersion = latestVersionData?.version;
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
@@ -96,9 +124,7 @@ export default function ServersPage() {
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td>
<Badge variant={statusVariant(server.status)}>
{server.status}
</Badge>
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td>
<span className="text-text-secondary">
+393
View File
@@ -0,0 +1,393 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, AlertSettings, EmailSettings } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none ${
enabled ? "bg-accent" : "bg-surface-2 border border-border"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}
function ToggleRow({
label,
description,
enabled,
onChange,
}: {
label: string;
description: string;
enabled: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-secondary">{description}</p>
</div>
<Toggle enabled={enabled} onChange={onChange} />
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
const inputClass =
"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 SecretsTokenCard({
tokenSet,
rotatedAt,
}: {
tokenSet: boolean;
rotatedAt?: string;
}) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined"
? `${window.location.origin}/api/secrets/<group>/values`
: "/api/secrets/<group>/values";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
async function copy() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
/>
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">
Copy this token now it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && (
<p className="mt-2 text-xs text-text-tertiary">
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</Card>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
});
// Webhook / offline alerting state
const [alertsEnabled, setAlertsEnabled] = useState(false);
const [webhookURL, setWebhookURL] = useState("");
const [thresholdMinutes, setThresholdMinutes] = useState(5);
// Email state
const [emailEnabled, setEmailEnabled] = useState(false);
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
const [fromAddr, setFromAddr] = useState("");
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
const [useTLS, setUseTLS] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!settings) return;
setAlertsEnabled(settings.alerts.enabled);
setWebhookURL(settings.alerts.webhook_url ?? "");
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setEmailEnabled(settings.email?.enabled ?? false);
setSmtpHost(settings.email?.smtp_host ?? "");
setSmtpPort(settings.email?.smtp_port || 587);
setSmtpUser(settings.email?.username ?? "");
setSmtpPass(settings.email?.password ?? "");
setFromAddr(settings.email?.from_addr ?? "");
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
setUseTLS(settings.email?.use_tls ?? false);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: { alerts: AlertSettings; email: EmailSettings }) =>
api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const toList = toAddrs
.split(",")
.map((s) => s.trim())
.filter(Boolean);
save({
alerts: {
enabled: alertsEnabled,
webhook_url: webhookURL,
offline_threshold_minutes: thresholdMinutes,
},
email: {
enabled: emailEnabled,
smtp_host: smtpHost,
smtp_port: smtpPort,
username: smtpUser,
password: smtpPass,
from_addr: fromAddr,
to_addrs: toList,
use_tls: useTLS,
},
});
}
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>
);
}
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure alerting and monitoring behaviour</p>
</div>
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
{/* Webhook alerting */}
<Card>
<CardHeader>
<CardTitle>Webhook Alerting</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
POST a JSON payload to a URL when a server goes offline. Compatible with Slack,
Discord, n8n, and any service that accepts JSON.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable webhook alerts"
description="Webhook fires only when this is on"
enabled={alertsEnabled}
onChange={setAlertsEnabled}
/>
<Field
label="Webhook URL"
hint={`POST body: { event, hostname, server_id, ip_address, timestamp, message }`}
>
<input
type="url"
value={webhookURL}
onChange={(e) => setWebhookURL(e.target.value)}
placeholder="https://hooks.slack.com/... or https://discord.com/api/webhooks/..."
className={inputClass}
/>
</Field>
<Field
label="Offline threshold (minutes)"
hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum."
>
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
className="w-32 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"
/>
</Field>
</div>
</Card>
{/* Email alerting */}
<Card>
<CardHeader>
<CardTitle>Email Notifications</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Send an email when a server goes offline. Uses the same offline threshold as the
webhook setting above.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable email alerts"
description="Emails are only sent when this is on"
enabled={emailEnabled}
onChange={setEmailEnabled}
/>
<div className="grid grid-cols-3 gap-3">
<Field label="SMTP Host" hint="">
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.gmail.com"
className={inputClass}
/>
</Field>
<Field label="Port" hint="">
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
placeholder="587"
className={inputClass}
/>
</Field>
<div className="flex flex-col justify-center pt-5">
<ToggleRow
label="TLS (port 465)"
description="Use implicit TLS instead of STARTTLS"
enabled={useTLS}
onChange={(v) => {
setUseTLS(v);
setSmtpPort(v ? 465 : 587);
}}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Username">
<input
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
className={inputClass}
autoComplete="username"
/>
</Field>
<Field label="Password">
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="App password or SMTP password"
className={inputClass}
autoComplete="new-password"
/>
</Field>
</div>
<Field label="From address">
<input
type="email"
value={fromAddr}
onChange={(e) => setFromAddr(e.target.value)}
placeholder="vantage@example.com"
className={inputClass}
/>
</Field>
<Field
label="To addresses"
hint="Separate multiple addresses with commas"
>
<input
type="text"
value={toAddrs}
onChange={(e) => setToAddrs(e.target.value)}
placeholder="admin@example.com, ops@example.com"
className={inputClass}
/>
</Field>
</div>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<div className="mt-6 max-w-xl">
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}
/>
</div>
</div>
);
}
+28
View File
@@ -27,9 +27,37 @@ function KeyIcon() {
);
}
function SecretIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
}
function AuditIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
}
function SettingsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
export function Sidebar() {
+126
View File
@@ -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 {
@@ -34,6 +42,56 @@ export interface Assignment {
revoked_at: string | null;
}
export interface AuditEvent {
id: string;
event_type: string;
actor: string;
server_id?: string;
key_id?: string;
details: string;
created_at: string;
}
export interface AlertSettings {
enabled: boolean;
webhook_url: string;
offline_threshold_minutes: number;
}
export interface EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
username: string;
password: string;
from_addr: string;
to_addrs: string[];
use_tls: boolean;
}
export interface SecretsSettings {
read_token_set: boolean;
rotated_at?: string;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
}
export interface SecretGroupSummary {
group: string;
key_count: number;
updated_at: string;
}
export interface Secret {
group: string;
key: string;
updated_at: string;
}
export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
@@ -127,6 +185,74 @@ export const api = {
});
},
applyUpdates(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/apply-updates`, {
method: "POST",
});
},
// Audit
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
},
// Settings
getSettings(): Promise<Settings> {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings }): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
},
rotateSecretsToken(): Promise<{ token: string }> {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
// Secrets
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
return request<{ group: string }>("/secrets", {
method: "POST",
body: JSON.stringify({ group, values }),
});
},
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
},
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
revealSecret(group: string, key: string): Promise<{ value: string }> {
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
method: "POST",
body: JSON.stringify({ key }),
});
},
deleteSecret(group: string, key: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
method: "DELETE",
});
},
deleteSecretGroup(group: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
File diff suppressed because one or more lines are too long