diff --git a/.gitignore b/.gitignore index c50d078..a0f9a58 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ docs installer/*.exe installer/*.msi installer/nssm.zip -installer/checksums-msi.txt \ No newline at end of file +installer/checksums-msi.txt +.next \ No newline at end of file diff --git a/custom-secrets-vault-gin-guide.md b/custom-secrets-vault-gin-guide.md deleted file mode 100644 index 8802631..0000000 --- a/custom-secrets-vault-gin-guide.md +++ /dev/null @@ -1,707 +0,0 @@ -# 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: "" - ESO_TOKEN: "" - 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="" -export ESO="" -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="" - -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="" -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 | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fbe6edf..b3fce82 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -23,7 +23,7 @@ services: retries: 5 guacd: - image: guacamole/guacd:1.5.5 + image: guacamole/guacd:1.6 restart: unless-stopped server: diff --git a/server/internal/api/console.go b/server/internal/api/console.go index f57ea1f..dc4c7c2 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -45,7 +45,7 @@ func consoleConnect(c *gin.Context) { return } - if body.Protocol == "rdp" && (body.RDPUsername != "" || body.RDPPassword != "") { + if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") { if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -82,31 +82,46 @@ func consoleTunnel(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } + + // User-bound: the caller (authenticated via session cookie) must be the same + // user who opened the session. Blocks a leaked token being used by someone else. + if actor := actorFromCtx(c); actor != sess.User { + c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"}) + return + } + + // Single-use: atomically spend the token so a replay within its TTL is rejected. + if err := services.ConsumeSessionToken(sessionID); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"}) + return + } + srv, err := services.GetServer(sess.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - // Decrypt private key in-memory only (ssh). - var privKey string + // Decrypt private key + passphrase in-memory only (ssh). + var privKey, passphrase string if sess.Protocol == "ssh" && sess.KeyID != "" { privKey, err = services.GetPrivateKey(sess.KeyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) return } + passphrase, _ = services.GetPassphrase(sess.KeyID) } var rdpUser, rdpPass string - if sess.Protocol == "rdp" { + if sess.Protocol == "rdp" || sess.Protocol == "vnc" { rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"}) return } } - gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, rdpUser, rdpPass) + gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 011a9ab..0f53076 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -222,13 +222,14 @@ func createKey(c *gin.Context) { Label string `json:"label" binding:"required"` PublicKey string `json:"public_key" binding:"required"` PrivateKey string `json:"private_key"` + Passphrase string `json:"passphrase"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey) + key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index d601aaf..21a63a4 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -57,7 +57,8 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey) + // Agent-generated keys carry no passphrase over the wire (proto has no field). + key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } diff --git a/server/internal/models/console_session.go b/server/internal/models/console_session.go index 107b106..dd0581d 100644 --- a/server/internal/models/console_session.go +++ b/server/internal/models/console_session.go @@ -17,6 +17,10 @@ type ConsoleSession struct { EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` + // TokenConsumedAt marks the one-time session token as spent. Set atomically + // when the tunnel opens; a second open with the same token is rejected. + TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"` + SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"` RDPUserEnc string `bson:"rdp_user_enc,omitempty" json:"-"` diff --git a/server/internal/models/key.go b/server/internal/models/key.go index 42c6121..99ebdee 100644 --- a/server/internal/models/key.go +++ b/server/internal/models/key.go @@ -16,5 +16,7 @@ type Key struct { GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"` PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"` HasPrivateKey bool `bson:"-" json:"has_private_key"` + PassphraseEncrypted string `bson:"passphrase_enc,omitempty" json:"-"` + HasPassphrase bool `bson:"-" json:"has_passphrase"` CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/services/console.go b/server/internal/services/console.go index 20ad0e9..749e79f 100644 --- a/server/internal/services/console.go +++ b/server/internal/services/console.go @@ -87,9 +87,10 @@ func portOr(v, def int) string { } // BuildGuacParams assembles the guacd connection parameter map for a protocol. -// privateKey is the decrypted SSH private key (ssh only); rdpUser/rdpPass are -// used for rdp. None of these values are persisted or logged by the caller. -func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser, rdpPass string) (*GuacParams, error) { +// privateKey/passphrase are the decrypted SSH private key and its optional +// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries +// the password for vnc. None of these values are persisted or logged by the caller. +func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) { host := srv.IPAddress switch protocol { case "ssh": @@ -104,6 +105,9 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser, if privateKey != "" { p["private-key"] = privateKey } + if passphrase != "" { + p["passphrase"] = passphrase + } return &GuacParams{Protocol: "ssh", Params: p}, nil case "rdp": return &GuacParams{Protocol: "rdp", Params: map[string]string{ @@ -214,6 +218,26 @@ func SetConsoleSSHUser(sessionID, username string) error { return err } +// ConsumeSessionToken atomically marks a session's one-time token as spent. +// It returns an error if the token was already consumed (replay) or the session +// does not exist, so the tunnel can be opened at most once per issued token. +func ConsumeSessionToken(sessionID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now() + res, err := db.Col("console_sessions").UpdateOne(ctx, + bson.M{"session_id": sessionID, "token_consumed_at": nil}, + bson.M{"$set": bson.M{"token_consumed_at": now}}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return fmt.Errorf("session token already used") + } + return nil +} + func EndConsoleSession(sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/console_test.go b/server/internal/services/console_test.go index 01eb1c1..14b54a9 100644 --- a/server/internal/services/console_test.go +++ b/server/internal/services/console_test.go @@ -46,7 +46,7 @@ func TestSessionTokenTampered(t *testing.T) { func TestBuildGuacParamsSSH(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} - p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "") + p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "") if err != nil { t.Fatalf("err: %v", err) } @@ -66,7 +66,7 @@ func TestBuildGuacParamsSSH(t *testing.T) { func TestBuildGuacParamsRDP(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389} - p, err := BuildGuacParams(srv, "rdp", "", "", "administrator", "s3cret") + p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret") if err != nil { t.Fatalf("err: %v", err) } @@ -80,7 +80,32 @@ func TestBuildGuacParamsRDP(t *testing.T) { func TestBuildGuacParamsUnknownProtocol(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.9"} - if _, err := BuildGuacParams(srv, "telnet", "", "", "", ""); err == nil { + if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil { t.Fatalf("expected error for unknown protocol") } } + +func TestBuildGuacParamsSSHPassphrase(t *testing.T) { + srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} + p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "") + if err != nil { + t.Fatalf("err: %v", err) + } + if p.Params["username"] != "deploy" { + t.Fatalf("username %q", p.Params["username"]) + } + if p.Params["passphrase"] != "s3cret-phrase" { + t.Fatalf("missing passphrase: %+v", p.Params) + } +} + +func TestBuildGuacParamsVNC(t *testing.T) { + srv := &models.Server{IPAddress: "10.0.0.7"} + p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass") + if err != nil { + t.Fatalf("err: %v", err) + } + if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" { + t.Fatalf("bad vnc params: %+v", p.Params) + } +} diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index ad6b4a1..e8d19c7 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -33,9 +33,10 @@ func computeFingerprint(pubKey string) string { func setKeyMeta(k *models.Key) { k.HasPrivateKey = k.PrivateKeyEncrypted != "" + k.HasPassphrase = k.PassphraseEncrypted != "" } -func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) { +func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { key := &models.Key{ KeyID: uuid.NewString(), Label: label, @@ -52,6 +53,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) } key.PrivateKeyEncrypted = enc } + if passphrase != "" { + enc, err := encryptString(passphrase) + if err != nil { + return nil, fmt.Errorf("encrypt passphrase: %w", err) + } + key.PassphraseEncrypted = enc + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -90,6 +98,22 @@ func GetPrivateKey(keyID string) (string, error) { return decryptPrivateKey(key.PrivateKeyEncrypted) } +// GetPassphrase returns the decrypted passphrase for a key, or an empty string +// if the key has none stored. +func GetPassphrase(keyID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var key models.Key + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + return "", err + } + if key.PassphraseEncrypted == "" { + return "", nil + } + return decryptString(key.PassphraseEncrypted) +} + type KeyWithCount struct { models.Key `bson:",inline"` AssignedCount int `bson:"-" json:"assigned_count"` diff --git a/web/app/keys/page.tsx b/web/app/keys/page.tsx index cbfe106..b7e1bd9 100644 --- a/web/app/keys/page.tsx +++ b/web/app/keys/page.tsx @@ -12,9 +12,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) { const [label, setLabel] = useState(""); const [publicKey, setPublicKey] = useState(""); const [privateKey, setPrivateKey] = useState(""); + const [passphrase, setPassphrase] = useState(""); const { mutate: upload, isPending, error } = useMutation({ - mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined), + mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["keys"] }); onClose(); @@ -70,6 +71,19 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) { className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none" /> +
+ + setPassphrase(e.target.value)} + autoComplete="new-password" + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +
diff --git a/web/app/servers/[id]/console/page.tsx b/web/app/servers/[id]/console/page.tsx index 3c1007f..6b8c5be 100644 --- a/web/app/servers/[id]/console/page.tsx +++ b/web/app/servers/[id]/console/page.tsx @@ -22,6 +22,7 @@ export default function ServerConsolePage() { const [sshUsername, setSshUsername] = useState("root"); const [rdpUsername, setRdpUsername] = useState(""); const [rdpPassword, setRdpPassword] = useState(""); + const [vncPassword, setVncPassword] = useState(""); const [connecting, setConnecting] = useState(false); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); @@ -77,6 +78,8 @@ export default function ServerConsolePage() { } else if (protocol === "rdp") { body.rdp_username = rdpUsername || undefined; body.rdp_password = rdpPassword || undefined; + } else if (protocol === "vnc") { + body.rdp_password = vncPassword || undefined; } const { token, ws_path } = await api.connectConsole(body); @@ -205,6 +208,19 @@ export default function ServerConsolePage() { )} + {protocol === "vnc" && ( +
+ + setVncPassword(e.target.value)} + autoComplete="new-password" + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ )} + diff --git a/web/lib/api.ts b/web/lib/api.ts index 8f55e44..a78f3ca 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -46,6 +46,7 @@ export interface Key { source: KeySource; generated_by_server_id?: string; has_private_key: boolean; + has_passphrase?: boolean; created_at: string; assigned_count?: number; } @@ -278,10 +279,15 @@ export const api = { return request(`/keys/${keyId}`); }, - uploadKey(label: string, public_key: string, private_key?: string): Promise { + uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise { return request("/keys", { method: "POST", - body: JSON.stringify({ label, public_key, private_key: private_key || undefined }), + body: JSON.stringify({ + label, + public_key, + private_key: private_key || undefined, + passphrase: passphrase || undefined, + }), }); }, diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/tsconfig.json b/web/tsconfig.json index e7ff90f..9e9bbf7 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -10,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -18,9 +22,20 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "target": "ES2017" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }