feat: harden console sessions + complete protocol support
- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener) - wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field) - passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
This commit is contained in:
+2
-1
@@ -7,4 +7,5 @@ docs
|
||||
installer/*.exe
|
||||
installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
@@ -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: "<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 |
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.5
|
||||
image: guacamole/guacd:1.6
|
||||
restart: unless-stopped
|
||||
|
||||
server:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+15
-1
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Passphrase{" "}
|
||||
<span className="text-text-tertiary font-normal">(optional — for an encrypted private key)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function ServerConsolePage() {
|
||||
const [sshUsername, setSshUsername] = useState<string>("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<string | null>(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" && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={vncPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
|
||||
Connect
|
||||
</Button>
|
||||
|
||||
+8
-2
@@ -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<KeyWithAssignments>(`/keys/${keyId}`);
|
||||
},
|
||||
|
||||
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
|
||||
uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise<Key> {
|
||||
return request<Key>("/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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
+21
-6
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user