Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a06227ba | ||
|
|
7c30d26878 | ||
|
|
19596ff2a3 | ||
|
|
c3c16083f7 | ||
|
|
e37a09ef0d | ||
|
|
02e84ed548 | ||
|
|
7e66b23ef8 |
@@ -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{
|
||||
|
||||
@@ -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 |
|
||||
+5
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -37,6 +52,20 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
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)
|
||||
@@ -75,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 == "" {
|
||||
@@ -120,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})
|
||||
}
|
||||
|
||||
@@ -160,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,
|
||||
@@ -192,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)
|
||||
}
|
||||
|
||||
@@ -227,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})
|
||||
}
|
||||
|
||||
@@ -249,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)
|
||||
}
|
||||
|
||||
@@ -260,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})
|
||||
}
|
||||
|
||||
@@ -285,6 +331,7 @@ 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,
|
||||
@@ -303,6 +350,7 @@ func applyUpdates(c *gin.Context) {
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -362,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")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/encoding"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
@@ -147,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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -197,12 +197,49 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func MarkOfflineServers(threshold time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
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},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -314,6 +314,15 @@ export default function ServerDetailPage() {
|
||||
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>
|
||||
@@ -329,6 +338,18 @@ export default function ServerDetailPage() {
|
||||
<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
|
||||
|
||||
+40
-14
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
+112
@@ -42,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;
|
||||
@@ -141,6 +191,68 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// 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
Reference in New Issue
Block a user