diff --git a/.gitignore b/.gitignore index af4f27b..cb11e63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,74 @@ -.idea/ -.DS_Store +# Dependencies node_modules/ +.pnpm-store/ + +# Build outputs .svelte-kit/ +build/ +dist/ + +# Environment & secrets +.env +.env.* +!.env.example +*.pem +*.key +.encryption_key + +# Data directory (local development) +data/ +!data/.gitkeep + +# Test data (local testing) +test-data/ + +# Database files +*.db +*.db-journal +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log +npm-debug.log* + +# IDE & editors +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test coverage +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Lock files bun.lock -data/db -data/.encryption_key +bun.lockb +package-lock.json + +# Vite +vite.config.ts.timestamp-* + +# TypeScript +*.tsbuildinfo + +# Drizzle +drizzle/meta/*.json.bak +drizzle-pg/meta/*.json.bak + +# Docker +docker-compose.override.yml diff --git a/docs/VAULT_INTEGRATION.md b/docs/VAULT_INTEGRATION.md new file mode 100644 index 0000000..9c96484 --- /dev/null +++ b/docs/VAULT_INTEGRATION.md @@ -0,0 +1,432 @@ +# HashiCorp Vault Integration + +This documentation describes the native HashiCorp Vault integration in Dockhand. + +## Overview + +Dockhand can fetch secrets directly from HashiCorp Vault and store them as encrypted environment variables for Docker stacks. The integration supports: + +- **KV v2 Secrets Engine** +- **Authentication**: Token, AppRole, Kubernetes +- **Self-Signed Certificates** (optional) +- **Automatic Sync** on Git Stack deployment +- **Manual Sync** via API +- **Automatic Redeploy** when configured secrets change + +## Architecture + +```mermaid +flowchart LR + subgraph gitStack [Git Stack] + A[".secrets.yaml"] + end + + subgraph dockhand [Dockhand] + B[Vault Client] + C[(Encrypted DB)] + end + + subgraph vault [HashiCorp Vault] + D[KV v2 Engine] + end + + A --> B + B --> D + D --> B + B --> C +``` + +### Sync Process Flow + +```mermaid +sequenceDiagram + participant Git as Git Repository + participant Sync as syncGitStack + participant Parser as secrets-file.ts + participant VaultSync as vault-sync.ts + participant Vault as HashiCorp Vault + participant DB as Database + participant Docker as Docker Compose + + Git->>Sync: Pull changes + Sync->>Parser: Parse .secrets.yaml + Parser->>VaultSync: Secret mappings + VaultSync->>DB: Get existing secrets + VaultSync->>Vault: Fetch new secrets + Vault-->>VaultSync: Secret values + VaultSync->>VaultSync: Compare old vs new + VaultSync->>DB: Store encrypted secrets + VaultSync-->>Sync: SyncResult with triggerRedeploySecrets + alt triggerRedeploySecrets not empty + Sync->>Docker: Redeploy stack + end +``` + +### Sync Mode Options + +The sync process can be triggered in three different modes: + +```mermaid +flowchart TD + subgraph ui [UI Sync Buttons] + GitBtn["GitBranch - Sync Git Only"] + VaultBtn["KeyRound - Sync Vault Only"] + AllBtn["RefreshCw - Sync All"] + end + + subgraph api [API Endpoint] + GitAPI["/sync?mode=git"] + VaultAPI["/sync?mode=vault"] + AllAPI["/sync?mode=all"] + end + + subgraph backend [Backend Functions] + GitSync["syncGitStack(skipVault=true)"] + VaultSync["syncStackSecrets()"] + FullSync["syncGitStack() + syncStackSecrets()"] + end + + subgraph result [Results] + GitResult["Git changes only"] + VaultResult["Vault secrets updated"] + FullResult["Both synchronized"] + end + + GitBtn --> GitAPI --> GitSync --> GitResult + VaultBtn --> VaultAPI --> VaultSync --> VaultResult + AllBtn --> AllAPI --> FullSync --> FullResult +``` + +## Configuration + +### 1. Vault Settings (UI) + +Navigate to **Settings → Vault** and configure: + +| Field | Description | Example | +|-------|-------------|---------| +| Vault Address | URL of the Vault server | `https://vault.example.com:8200` | +| Namespace | Vault Enterprise Namespace (optional) | `my-namespace` | +| Default Secret Path | Default path for secrets | `kv/data` or `secret/data` | +| Accept Self-Signed Certs | Skip TLS verification | On/Off | + +### 2. Authentication + +#### Token Auth +- Simplest method +- Token is stored encrypted in the database + +#### AppRole Auth +- Recommended for automated systems +- Requires: Role ID + Secret ID + +#### Kubernetes Auth +- For Kubernetes deployments +- Uses Service Account Token automatically + +## Usage + +### .secrets.yaml File + +Create a `.secrets.yaml` file in the root directory of your Git stack: + +```yaml +# .secrets.yaml + +secrets: + # 1. Simple: Env var name = Vault key name + - ADMIN_PASSWORD + + # 2. Mapping: Different Vault key name + - name: DATABASE_URL + key: db_connection_string + + # 3. Custom path + key + - name: SHARED_API_KEY + path: kv/data/shared/api + key: api_key + +# Optional: Stack-wide path (overrides default from settings) +# vault: +# path: kv/data/myapp +``` + +### Automatic Redeploy on Secret Changes + +You can configure a stack to automatically redeploy when specific Vault secrets change. + +#### Redeploy Trigger Flow + +```mermaid +flowchart TD + subgraph sync [Git Sync Process] + A[syncGitStack] --> B[syncStackSecrets] + B --> C{Secrets Changed?} + C -->|No| D["updated = false"] + C -->|Yes| E{triggerRedeploy?} + E -->|No| D + E -->|Yes| F["updated = true"] + end + + subgraph deploy [Redeploy Decision] + F --> G[Stack Redeployed] + D --> H[No Redeploy] + end +``` + +#### Configuration Example + +```yaml +# .secrets.yaml + +vault: + path: secret/data/myapp + # Global: All secrets trigger redeploy (default: false) + triggerRedeploy: false + +secrets: + # This secret does NOT trigger redeploy (uses global default) + - name: API_KEY + key: api_key + + # This secret triggers redeploy (overrides global) + - name: DATABASE_PASSWORD + key: database_password + triggerRedeploy: true + + # This secret also triggers redeploy + - name: ADMIN_TOKEN + key: admin_token + triggerRedeploy: true +``` + +#### Logic + +| vault.triggerRedeploy | secret.triggerRedeploy | Result | +|-----------------------|------------------------|--------| +| false (default) | not set | No Redeploy | +| false (default) | true | Redeploy | +| false (default) | false | No Redeploy | +| true | not set | Redeploy | +| true | false | No Redeploy | +| true | true | Redeploy | + +**Note**: A redeploy is only triggered when: +1. The secret value has actually changed (old vs. new value is compared) +2. The secret is configured with `triggerRedeploy: true` (directly or via global default) + +### Automatic Sync + +Secrets are automatically synchronized when: +- A Git stack is deployed +- A Git stack is updated (git pull) + +### Manual Sync + +Via the API: +```bash +curl -X POST http://localhost:3000/api/stacks/{stack-name}/secrets/sync +``` + +### UI Sync Options + +For Git stacks, the Stacks page provides three separate sync buttons in the Actions column: + +| Icon | Color | Action | Description | +|------|-------|--------|-------------| +| GitBranch | Purple | Sync Git | Pull latest changes from Git repository only | +| KeyRound | Cyan | Sync Vault | Fetch latest secrets from Vault only | +| RefreshCw | Green | Sync All | Synchronize both Git and Vault (full sync) | + +**Use Cases:** + +- **Sync Git only**: When you've pushed code changes but Vault secrets haven't changed +- **Sync Vault only**: When you've updated secrets in Vault but the Git repository hasn't changed +- **Sync All**: For a complete synchronization of both sources + +Each button shows a spinner during the sync operation and displays a toast notification with the result. + +### Test Connection + +```bash +curl -X POST http://localhost:3000/api/vault/fetch-test \ + -H "Content-Type: application/json" \ + -d '{"path": "secret/data", "keys": ["my_secret_key"]}' +``` + +## API Endpoints + +### Vault Configuration + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/vault/config` | GET | Get Vault configuration | +| `/api/vault/config` | PUT | Save Vault configuration | +| `/api/vault/config` | DELETE | Delete Vault configuration | +| `/api/vault/test` | POST | Test connection | +| `/api/vault/fetch-test` | POST | Test secret fetching | +| `/api/stacks/[name]/secrets/sync` | POST | Sync secrets for a specific stack | + +### Git Stack Sync (with Mode Parameter) + +The sync endpoint supports a `mode` query parameter for granular control: + +| Endpoint | Method | Mode | Description | +|----------|--------|------|-------------| +| `/api/git/stacks/[id]/sync` | POST | `git` | Sync Git repository only (skip Vault) | +| `/api/git/stacks/[id]/sync` | POST | `vault` | Sync Vault secrets only (skip Git pull) | +| `/api/git/stacks/[id]/sync` | POST | `all` | Sync both Git and Vault (default) | + +**Examples:** + +```bash +# Sync only Git repository +curl -X POST http://localhost:3000/api/git/stacks/1/sync?mode=git + +# Sync only Vault secrets +curl -X POST http://localhost:3000/api/git/stacks/1/sync?mode=vault + +# Sync both (default behavior) +curl -X POST http://localhost:3000/api/git/stacks/1/sync?mode=all +curl -X POST http://localhost:3000/api/git/stacks/1/sync +``` + +**Response includes:** +- `mode`: The sync mode used +- `success`: Whether the sync succeeded +- `updated`: Whether changes were detected +- `deployed`: Whether an auto-deploy was triggered +- For vault mode: `synced` (number of secrets), `secretsChanged`, `triggerRedeploySecrets` + +## Database + +### Table: vault_config + +| Column | Type | Description | +|--------|------|-------------| +| id | INTEGER | Primary Key | +| address | TEXT | Vault Server URL | +| namespace | TEXT | Enterprise Namespace (optional) | +| default_path | TEXT | Default Secret Path | +| auth_method | TEXT | 'token', 'approle', 'kubernetes' | +| token | TEXT | Encrypted Token | +| role_id | TEXT | AppRole Role ID | +| secret_id | TEXT | Encrypted AppRole Secret ID | +| kube_role | TEXT | Kubernetes Auth Role | +| skip_tls_verify | BOOLEAN | Accept self-signed certs | +| enabled | BOOLEAN | Integration enabled | +| created_at | TEXT | Creation timestamp | +| updated_at | TEXT | Update timestamp | + +## Files + +### New Files + +| File | Description | +|------|-------------| +| `src/lib/server/vault.ts` | Vault Client class | +| `src/lib/server/secrets-file.ts` | .secrets.yaml parser | +| `src/lib/server/vault-sync.ts` | Sync logic | +| `src/routes/api/vault/config/+server.ts` | Config API | +| `src/routes/api/vault/test/+server.ts` | Connection Test API | +| `src/routes/api/vault/fetch-test/+server.ts` | Secret Fetch Test API | +| `src/routes/api/stacks/[name]/secrets/sync/+server.ts` | Stack Sync API | +| `src/routes/settings/vault/VaultTab.svelte` | UI Component | +| `drizzle/0004_add_vault_config.sql` | SQLite Migration | +| `drizzle/0005_add_vault_tls_skip.sql` | SQLite Migration (TLS) | +| `drizzle-pg/0004_add_vault_config.sql` | PostgreSQL Migration | +| `drizzle-pg/0005_add_vault_tls_skip.sql` | PostgreSQL Migration (TLS) | + +### Modified Files + +| File | Change | +|------|--------| +| `src/lib/server/db/schema/index.ts` | Added vaultConfig table | +| `src/lib/server/db/schema/pg-schema.ts` | Added vaultConfig table | +| `src/lib/server/db/drizzle.ts` | vaultConfig export | +| `src/lib/server/db.ts` | DB functions for Vault + upsertStackEnvVars | +| `src/lib/server/git.ts` | Vault Sync + triggerRedeploy logic + skipVault option | +| `src/lib/server/secrets-file.ts` | triggerRedeploy parser | +| `src/lib/server/vault-sync.ts` | Secret change detection | +| `src/routes/settings/+page.svelte` | Added Vault tab | +| `src/routes/stacks/+page.svelte` | Added 3 separate sync buttons (Git/Vault/All) | +| `src/routes/stacks/GitDeployProgressPopover.svelte` | Added mode prop for sync operations | +| `src/routes/api/git/stacks/[id]/sync/+server.ts` | Added mode parameter (git/vault/all) | +| `src/lib/config/grid-columns.ts` | Increased stacks actions column width to 250px | +| `package.json` | node-vault dependency | + +## Security + +- **Tokens and Secret IDs** are stored encrypted with AES-256-GCM +- **Secrets** are stored as encrypted environment variables in the database +- **TLS verification** is enabled by default +- **Self-signed certs** only accepted when explicitly enabled + +## Troubleshooting + +### Connection Error + +1. Check the Vault address (including port) +2. Check if self-signed certs need to be enabled +3. Test the connection via UI or API + +### Secret Not Found + +1. Check the path (KV v2 requires `/data/` in the path) +2. Use the fetch-test endpoint to see available keys: + ```bash + curl -X POST http://localhost:3000/api/vault/fetch-test \ + -H "Content-Type: application/json" \ + -d '{"path": "your/path", "keys": ["test"]}' + ``` + The response includes `availableKeys` with all available keys. + +### Authentication Error + +1. Check if the token is valid +2. For AppRole: Check Role ID and Secret ID +3. For Kubernetes: Check if the Service Account Token is available + +### Secrets Not Displayed in UI + +1. Check if the environment is correctly configured +2. If `git_stacks.environment_id` is NULL, it's automatically resolved from `stack_sources` +3. Vault secrets must be saved with the correct `environment_id` + +## Example: Complete Workflow + +1. **Configure Vault** (Settings → Vault) + - Address: `https://vault.example.com:8200` + - Default Path: `kv/data` + - Auth Method: Token + - Enter token + +2. **Test Connection** (Button in UI) + +3. **Create Secret in Vault** + - Path: `kv/myapp` + - Key: `database_password` + - Value: `secret123` + +4. **Create .secrets.yaml** (in Git repo) + ```yaml + vault: + path: kv/data/myapp + + secrets: + - name: DB_PASSWORD + key: database_password + triggerRedeploy: true + ``` + +5. **Deploy Git Stack** + - Secret is automatically fetched + - Stored as `DB_PASSWORD` environment variable + +6. **Start Docker Stack** + - `DB_PASSWORD` is available in the container + +7. **Update Secret in Vault** + - Change `database_password` value + - Next sync detects the change + - Stack automatically redeploys (because `triggerRedeploy: true`) diff --git a/docs/VAULT_TUTORIAL.md b/docs/VAULT_TUTORIAL.md new file mode 100644 index 0000000..2a98b27 --- /dev/null +++ b/docs/VAULT_TUTORIAL.md @@ -0,0 +1,815 @@ +# Vault Integration Tutorial + +This step-by-step tutorial guides you through setting up HashiCorp Vault integration with Dockhand. By the end, you will have a running stack that fetches secrets directly from Vault. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Setting Up HashiCorp Vault](#setting-up-hashicorp-vault) + - [Option A: Docker (Development)](#option-a-docker-development) + - [Option B: Existing Vault Server](#option-b-existing-vault-server) +3. [Configuring Vault](#configuring-vault) + - [Enable KV Secrets Engine](#enable-kv-secrets-engine) + - [Create Secrets](#create-secrets) + - [Authentication Setup](#authentication-setup) +4. [Configuring Dockhand](#configuring-dockhand) +5. [Creating a Test Stack](#creating-a-test-stack) +6. [Deploying and Verifying](#deploying-and-verifying) +7. [Testing Sync Modes](#testing-sync-modes) +8. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- Dockhand running (Docker or local development) +- Docker and Docker Compose installed +- Git installed +- (Optional) Vault CLI for advanced configuration + +### Installing Vault CLI (Optional) + +The Vault CLI is optional but recommended for advanced configuration. + +**Linux (Debian/Ubuntu):** +```bash +wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list +sudo apt update && sudo apt install vault +``` + +**Arch/CachyOS:** +```bash +sudo pacman -S vault +``` + +**macOS:** +```bash +brew install vault +``` + +--- + +## Setting Up HashiCorp Vault + +### Option A: Docker (Development) + +Start a development Vault server with Docker: + +```bash +docker run -d \ + --name vault-dev \ + --cap-add=IPC_LOCK \ + -p 8200:8200 \ + -e 'VAULT_DEV_ROOT_TOKEN_ID=myroot' \ + -e 'VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200' \ + hashicorp/vault:latest +``` + +Your Vault is now available at `http://localhost:8200` with the root token `myroot`. + +**Verify it's running:** + +```bash +# Using curl +curl http://localhost:8200/v1/sys/health + +# Using Vault CLI +export VAULT_ADDR='http://localhost:8200' +export VAULT_TOKEN='myroot' +vault status +``` + +### Option B: Existing Vault Server + +If you have an existing Vault server, note the following: + +- **Vault URL**: e.g., `https://vault.example.com:8200` +- **Root Token or AppRole credentials** +- **TLS**: If using self-signed certificates, you'll need to enable "Skip TLS Verify" in Dockhand + +--- + +## Configuring Vault + +### Enable KV Secrets Engine + +The KV (Key-Value) secrets engine stores your secrets. Dockhand supports KV version 2. + +#### Method 1: Vault CLI + +```bash +# Set environment variables +export VAULT_ADDR='http://localhost:8200' +export VAULT_TOKEN='myroot' + +# Enable KV v2 at path "secret" +vault secrets enable -path=secret kv-v2 + +# Verify +vault secrets list +``` + +#### Method 2: curl/HTTP API + +```bash +# Enable KV v2 secrets engine +curl --request POST \ + --header "X-Vault-Token: myroot" \ + --data '{"type": "kv-v2"}' \ + http://localhost:8200/v1/sys/mounts/secret + +# Verify +curl --header "X-Vault-Token: myroot" \ + http://localhost:8200/v1/sys/mounts | jq '.data.secret' +``` + +#### Method 3: Vault UI + +1. Open `http://localhost:8200` in your browser +2. Log in with token `myroot` +3. Click **Secrets Engines** in the sidebar +4. Click **Enable new engine** +5. Select **KV** and click **Next** +6. Set **Path** to `secret` +7. Select **Version 2** +8. Click **Enable Engine** + +--- + +### Create Secrets + +Now create the secrets that Dockhand will fetch. + +#### Method 1: Vault CLI + +```bash +export VAULT_ADDR='http://localhost:8200' +export VAULT_TOKEN='myroot' + +# Create secrets at path "secret/dockhand-test" +vault kv put secret/dockhand-test \ + DATABASE_PASSWORD="super-secret-db-password" \ + api_key="ak_1234567890abcdef" \ + admin_token="admin_xyz_token_123" + +# Verify the secrets +vault kv get secret/dockhand-test + +# Read a specific field +vault kv get -field=DATABASE_PASSWORD secret/dockhand-test +``` + +#### Method 2: curl/HTTP API + +```bash +# Create secrets (note: KV v2 uses /data/ in the path) +curl --request POST \ + --header "X-Vault-Token: myroot" \ + --header "Content-Type: application/json" \ + --data '{ + "data": { + "DATABASE_PASSWORD": "super-secret-db-password", + "api_key": "ak_1234567890abcdef", + "admin_token": "admin_xyz_token_123" + } + }' \ + http://localhost:8200/v1/secret/data/dockhand-test + +# Verify - read all secrets +curl --header "X-Vault-Token: myroot" \ + http://localhost:8200/v1/secret/data/dockhand-test | jq '.data.data' + +# Read a specific secret +curl --header "X-Vault-Token: myroot" \ + http://localhost:8200/v1/secret/data/dockhand-test | jq -r '.data.data.DATABASE_PASSWORD' +``` + +#### Method 3: Vault UI + +1. Go to **Secrets Engines** > **secret** +2. Click **Create secret** +3. Set **Path for this secret**: `dockhand-test` +4. Add the following key-value pairs: + | Key | Value | + |-----|-------| + | DATABASE_PASSWORD | super-secret-db-password | + | api_key | ak_1234567890abcdef | + | admin_token | admin_xyz_token_123 | +5. Click **Save** + +--- + +### Authentication Setup + +Dockhand supports three authentication methods. Choose the one that fits your needs. + +#### Token Authentication (Simplest) + +For development, use the root token directly. In production, create a specific token with limited permissions. + +**Create a limited token (CLI):** + +```bash +# Create a policy first +vault policy write dockhand-read - < **Auth Methods** +2. Click **Enable new method** +3. Select **AppRole** and click **Enable Method** +4. Click **Create role** +5. Name: `dockhand`, Policies: `dockhand-read` +6. Save and copy the Role ID from the role details +7. Generate a Secret ID from the role page + +--- + +## Configuring Dockhand + +Now configure Dockhand to connect to your Vault. + +### Method 1: Dockhand UI + +1. Open Dockhand at `http://localhost:3000` +2. Go to **Settings** > **Vault** tab +3. Fill in the configuration: + - **Vault URL**: `http://localhost:8200` (or your Vault address) + - **Authentication Method**: Token or AppRole + - **Token**: Your Vault token (if using Token auth) + - **Role ID / Secret ID**: Your AppRole credentials (if using AppRole) + - **Default Secret Path**: `secret/data/dockhand-test` + - **Accept Self-Signed Certificates**: Enable if using HTTPS with self-signed certs +4. Click **Test Connection** to verify +5. Click **Save** + +### Method 2: curl/HTTP API + +```bash +# Save Vault configuration +curl --request PUT \ + --header "Content-Type: application/json" \ + --data '{ + "enabled": true, + "url": "http://localhost:8200", + "authMethod": "token", + "token": "myroot", + "defaultPath": "secret/data/dockhand-test", + "skipTlsVerify": false + }' \ + http://localhost:3000/api/vault/config + +# For AppRole authentication: +curl --request PUT \ + --header "Content-Type: application/json" \ + --data '{ + "enabled": true, + "url": "http://localhost:8200", + "authMethod": "approle", + "roleId": "YOUR_ROLE_ID", + "secretId": "YOUR_SECRET_ID", + "defaultPath": "secret/data/dockhand-test", + "skipTlsVerify": false + }' \ + http://localhost:3000/api/vault/config + +# Verify configuration +curl http://localhost:3000/api/vault/config + +# Test connection +curl --request POST \ + http://localhost:3000/api/vault/test +``` + +--- + +## Creating a Test Stack + +### Option 1: Use the Example Repository + +Clone the test repository: + +```bash +git clone https://github.com/d0dg3r/dockhand-stack-test.git +``` + +Or use it directly in Dockhand by adding a Git stack with URL: +`https://github.com/d0dg3r/dockhand-stack-test.git` + +### Option 2: Create Your Own Stack + +Create a new directory with the following files. These files are also available at `docs/examples/vault-test/` in the Dockhand repository. + +#### compose.yaml + +```yaml +services: + env-viewer: + image: php:8-apache + ports: + - "8080:80" + volumes: + - ./index.php:/var/www/html/index.php:ro + environment: + # Default values (can be overridden by Vault secrets) + - APP_NAME=${APP_NAME:-Dockhand Vault Test} + - APP_ENV=${APP_ENV:-development} + # These will be injected from Vault + - DATABASE_PASSWORD=${DATABASE_PASSWORD:-not-set} + - API_KEY=${API_KEY:-not-set} + - ADMIN_TOKEN=${ADMIN_TOKEN:-not-set} +``` + +#### .secrets.yaml + +```yaml +# Vault Secret Mappings for Dockhand +# This file defines which secrets to fetch from HashiCorp Vault + +vault: + # Default path for all secrets (can be overridden per-secret) + path: secret/data/dockhand-test + # Global setting: trigger redeploy when ANY secret changes (default: false) + triggerRedeploy: false + +secrets: + # Simple mapping: environment variable name = Vault key name + - DATABASE_PASSWORD + + # Explicit mapping with different Vault key + - name: API_KEY + key: api_key + + # Secret that triggers automatic redeploy when changed + - name: ADMIN_TOKEN + key: admin_token + triggerRedeploy: true +``` + +#### .env + +```bash +# Non-sensitive environment variables +# These are loaded directly by Docker Compose + +APP_NAME=Dockhand Vault Test +APP_ENV=production +``` + +#### index.php + +```php + + + + + + <?= htmlspecialchars(getenv('APP_NAME') ?: 'Vault Test') ?> + + + +
+

+

Environment Variables Viewer

+ +
+

Vault Secrets from HashiCorp Vault

+
+ "; + echo "$name"; + echo "$displayValue"; + echo "
"; + } + ?> +
+
+ +
+

ENV Standard Environment Variables

+
+ "; + echo "$name"; + echo "" . htmlspecialchars($value) . ""; + echo "
"; + } + ?> +
+ + + + +``` + +### Add Stack to Dockhand + +#### Method 1: Dockhand UI + +1. Open Dockhand at `http://localhost:3000` +2. Go to **Stacks** page +3. Click **Add Stack** > **Git Stack** +4. Fill in: + - **Name**: `vault-test` + - **Repository URL**: `https://github.com/d0dg3r/dockhand-stack-test.git` (or your own repo) + - **Branch**: `main` + - **Environment**: Select your environment +5. Click **Create** + +#### Method 2: curl/HTTP API + +```bash +# First, get the environment ID +curl http://localhost:3000/api/environments + +# Create Git stack (replace ENVIRONMENT_ID with actual ID, e.g., 1) +curl --request POST \ + --header "Content-Type: application/json" \ + --data '{ + "url": "https://github.com/d0dg3r/dockhand-stack-test.git", + "branch": "main", + "name": "vault-test", + "environmentId": 1 + }' \ + http://localhost:3000/api/git/stacks +``` + +--- + +## Deploying and Verifying + +### Deploy the Stack + +#### Method 1: Dockhand UI + +1. Find your stack in the Stacks list +2. Click the **Sync All** button (green refresh icon) to sync Git and Vault +3. Click the **Deploy** button (play icon) +4. Wait for deployment to complete + +#### Method 2: curl/HTTP API + +```bash +# Get the Git stack ID first +curl http://localhost:3000/api/git/stacks + +# Sync Git and Vault (replace ID with your stack ID) +curl --request POST \ + http://localhost:3000/api/git/stacks/1/sync?mode=all + +# Deploy the stack +curl --request POST \ + http://localhost:3000/api/stacks/vault-test/deploy +``` + +### Verify Secrets + +1. Open `http://localhost:8080` in your browser +2. You should see the test page showing: + - **Vault Secrets**: DATABASE_PASSWORD, API_KEY, ADMIN_TOKEN (masked) + - **ENV Variables**: APP_NAME, APP_ENV + +If secrets show as "NOT SET", check: +- Vault connection in Settings > Vault +- Secrets path matches `.secrets.yaml` +- Stack has been synced after Vault configuration + +### View Secrets in Dockhand UI + +1. Go to **Stacks** in Dockhand +2. Click on your stack name +3. Go to the **Environment Variables** tab +4. You should see the Vault secrets listed (values are encrypted) + +--- + +## Testing Sync Modes + +Dockhand provides three sync modes for granular control: + +| Button | Mode | Description | +|--------|------|-------------| +| Git (purple) | `git` | Only sync from Git repository | +| Vault (cyan) | `vault` | Only sync secrets from Vault | +| All (green) | `all` | Sync both Git and Vault | + +### Test Vault-Only Sync + +1. Change a secret in Vault: + + **CLI:** + ```bash + vault kv put secret/dockhand-test \ + DATABASE_PASSWORD="new-password-123" \ + api_key="ak_1234567890abcdef" \ + admin_token="admin_xyz_token_123" + ``` + + **curl:** + ```bash + curl --request POST \ + --header "X-Vault-Token: myroot" \ + --header "Content-Type: application/json" \ + --data '{ + "data": { + "DATABASE_PASSWORD": "new-password-123", + "api_key": "ak_1234567890abcdef", + "admin_token": "admin_xyz_token_123" + } + }' \ + http://localhost:8200/v1/secret/data/dockhand-test + ``` + +2. Click the **Vault** sync button (cyan key icon) in Dockhand +3. Verify the new secret is stored + +### Test Automatic Redeploy + +The `ADMIN_TOKEN` secret has `triggerRedeploy: true` in `.secrets.yaml`. + +1. Change ADMIN_TOKEN in Vault: + + **CLI:** + ```bash + vault kv put secret/dockhand-test \ + DATABASE_PASSWORD="new-password-123" \ + api_key="ak_1234567890abcdef" \ + admin_token="changed_admin_token" + ``` + + **curl:** + ```bash + curl --request POST \ + --header "X-Vault-Token: myroot" \ + --header "Content-Type: application/json" \ + --data '{ + "data": { + "DATABASE_PASSWORD": "new-password-123", + "api_key": "ak_1234567890abcdef", + "admin_token": "changed_admin_token" + } + }' \ + http://localhost:8200/v1/secret/data/dockhand-test + ``` + +2. Click **Sync All** (green button) +3. The stack should automatically redeploy because `ADMIN_TOKEN` changed and has `triggerRedeploy: true` +4. Refresh `http://localhost:8080` to see the new value + +--- + +## Troubleshooting + +### Connection Errors + +**Error: "ECONNREFUSED"** +- Check Vault is running: `curl http://localhost:8200/v1/sys/health` +- Verify the URL in Dockhand settings + +**Error: "self signed certificate"** +- Enable "Accept Self-Signed Certificates" in Vault settings + +**Error: "permission denied" (403)** +- Check your token has read permissions +- Verify the policy includes the secret path + +### Secrets Not Appearing + +1. Check Vault connection: Settings > Vault > Test Connection +2. Verify the path in `.secrets.yaml` matches Vault +3. Sync the stack after configuration changes +4. Check the secret exists: `vault kv get secret/dockhand-test` + +### Debug Commands + +```bash +# Check Vault status +vault status + +# List all secrets at path +vault kv list secret/ + +# Read specific secret +vault kv get secret/dockhand-test + +# Check Dockhand logs (if running in Docker) +docker logs dockhand + +# Test Vault connection from Dockhand API +curl --request POST http://localhost:3000/api/vault/test +``` + +### Common Issues + +| Issue | Solution | +|-------|----------| +| Secrets show "NOT SET" | Sync the stack (Vault button) after Vault setup | +| Old values after change | Click redeploy or use `triggerRedeploy: true` | +| 403 from Vault | Check token permissions and policy | +| Container port conflict | Stop other containers on port 8080 | + +--- + +## Quick Reference + +### Vault CLI Commands + +```bash +# Set environment +export VAULT_ADDR='http://localhost:8200' +export VAULT_TOKEN='myroot' + +# Manage secrets +vault kv put secret/path key=value # Create/Update +vault kv get secret/path # Read +vault kv delete secret/path # Delete +vault kv list secret/ # List + +# Manage engines +vault secrets enable -path=name kv-v2 # Enable +vault secrets disable name/ # Disable +vault secrets list # List +``` + +### Dockhand API Endpoints + +```bash +# Vault configuration +GET /api/vault/config # Get config +PUT /api/vault/config # Save config +POST /api/vault/test # Test connection + +# Stack sync +POST /api/git/stacks/{id}/sync?mode=git # Git only +POST /api/git/stacks/{id}/sync?mode=vault # Vault only +POST /api/git/stacks/{id}/sync?mode=all # Both + +# Stack management +GET /api/stacks # List stacks +POST /api/stacks/{name}/deploy # Deploy stack +GET /api/stacks/{name}/env # Get environment variables +``` + +### .secrets.yaml Reference + +```yaml +vault: + path: secret/data/myapp # Default path + triggerRedeploy: false # Global redeploy setting + +secrets: + - SECRET_NAME # Simple: name = key + + - name: ENV_VAR_NAME # Explicit mapping + key: vault_key_name + + - name: OTHER_SECRET # Custom path + redeploy + path: secret/data/other + key: secret_key + triggerRedeploy: true +``` + +--- + +## Next Steps + +- Read the [Vault Integration Architecture](./VAULT_INTEGRATION.md) for deep technical details +- Set up AppRole authentication for production use +- Configure automatic secret rotation with Vault policies +- Explore Vault's dynamic secrets for database credentials diff --git a/docs/examples/vault-test/.secrets.yaml b/docs/examples/vault-test/.secrets.yaml new file mode 100644 index 0000000..71aa9df --- /dev/null +++ b/docs/examples/vault-test/.secrets.yaml @@ -0,0 +1,21 @@ +# Vault Secret Mappings for Dockhand +# This file defines which secrets to fetch from HashiCorp Vault + +vault: + # Default path for all secrets (can be overridden per-secret) + path: secret/data/dockhand-test + # Global setting: trigger redeploy when ANY secret changes (default: false) + triggerRedeploy: false + +secrets: + # Simple mapping: environment variable name = Vault key name + - DATABASE_PASSWORD + + # Explicit mapping with different Vault key + - name: API_KEY + key: api_key + + # Secret that triggers automatic redeploy when changed + - name: ADMIN_TOKEN + key: admin_token + triggerRedeploy: true diff --git a/docs/examples/vault-test/compose.yaml b/docs/examples/vault-test/compose.yaml new file mode 100644 index 0000000..77ad8ba --- /dev/null +++ b/docs/examples/vault-test/compose.yaml @@ -0,0 +1,15 @@ +services: + env-viewer: + image: php:8-apache + ports: + - "8080:80" + volumes: + - ./index.php:/var/www/html/index.php:ro + environment: + # Default values (can be overridden by Vault secrets) + - APP_NAME=${APP_NAME:-Dockhand Vault Test} + - APP_ENV=${APP_ENV:-development} + # These will be injected from Vault + - DATABASE_PASSWORD=${DATABASE_PASSWORD:-not-set} + - API_KEY=${API_KEY:-not-set} + - ADMIN_TOKEN=${ADMIN_TOKEN:-not-set} diff --git a/docs/examples/vault-test/index.php b/docs/examples/vault-test/index.php new file mode 100644 index 0000000..8ca6c79 --- /dev/null +++ b/docs/examples/vault-test/index.php @@ -0,0 +1,144 @@ + + + + + + <?= htmlspecialchars(getenv('APP_NAME') ?: 'Vault Test') ?> + + + +
+

+

Environment Variables Viewer

+ +
+

Vault Secrets from HashiCorp Vault

+
+ "; + echo "$name"; + echo "$displayValue"; + echo "
"; + } + ?> +
+
+ +
+

ENV Standard Environment Variables

+
+ "; + echo "$name"; + echo "" . htmlspecialchars($value) . ""; + echo "
"; + } + ?> +
+ + + + + + diff --git a/drizzle-pg/0006_add_vault_config.sql b/drizzle-pg/0006_add_vault_config.sql new file mode 100644 index 0000000..917987c --- /dev/null +++ b/drizzle-pg/0006_add_vault_config.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "vault_config" ( + "id" serial PRIMARY KEY NOT NULL, + "address" text NOT NULL, + "namespace" text, + "default_path" text, + "auth_method" text NOT NULL, + "token" text, + "role_id" text, + "secret_id" text, + "kube_role" text, + "enabled" boolean DEFAULT true, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); diff --git a/drizzle-pg/0007_add_vault_tls_skip.sql b/drizzle-pg/0007_add_vault_tls_skip.sql new file mode 100644 index 0000000..09d187f --- /dev/null +++ b/drizzle-pg/0007_add_vault_tls_skip.sql @@ -0,0 +1 @@ +ALTER TABLE "vault_config" ADD COLUMN "skip_tls_verify" boolean DEFAULT false; diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index e919fd8..b570dd9 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1775312212996, "tag": "0005_add_api_tokens", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1776000000000, + "tag": "0006_add_vault_config", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1776000000001, + "tag": "0007_add_vault_tls_skip", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/0006_add_vault_config.sql b/drizzle/0006_add_vault_config.sql new file mode 100644 index 0000000..97be060 --- /dev/null +++ b/drizzle/0006_add_vault_config.sql @@ -0,0 +1,14 @@ +CREATE TABLE `vault_config` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `address` text NOT NULL, + `namespace` text, + `default_path` text, + `auth_method` text NOT NULL, + `token` text, + `role_id` text, + `secret_id` text, + `kube_role` text, + `enabled` integer DEFAULT true, + `created_at` text DEFAULT CURRENT_TIMESTAMP, + `updated_at` text DEFAULT CURRENT_TIMESTAMP +); diff --git a/drizzle/0007_add_vault_tls_skip.sql b/drizzle/0007_add_vault_tls_skip.sql new file mode 100644 index 0000000..3327616 --- /dev/null +++ b/drizzle/0007_add_vault_tls_skip.sql @@ -0,0 +1 @@ +ALTER TABLE `vault_config` ADD `skip_tls_verify` integer DEFAULT false; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6e39aa6..b011279 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1775311743346, "tag": "0005_add_api_tokens", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1776000000000, + "tag": "0006_add_vault_config", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1776000000001, + "tag": "0007_add_vault_tls_skip", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/lib/config/grid-columns.ts b/src/lib/config/grid-columns.ts index e3eda7a..f65f898 100644 --- a/src/lib/config/grid-columns.ts +++ b/src/lib/config/grid-columns.ts @@ -68,7 +68,7 @@ export const stackColumns: ColumnConfig[] = [ { id: 'diskIO', label: 'Disk I/O', width: 100, minWidth: 70, align: 'right' }, { id: 'networks', label: 'Networks', width: 80, minWidth: 60 }, { id: 'volumes', label: 'Volumes', width: 80, minWidth: 60 }, - { id: 'actions', label: '', fixed: 'end', width: 180, resizable: false } + { id: 'actions', label: '', fixed: 'end', width: 250, resizable: false } ]; // Volume grid columns diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 89b7088..b64b870 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -47,6 +47,7 @@ import { scheduleExecutions, stackEnvironmentVariables, pendingContainerUpdates, + vaultConfig, // Types type Environment, type Registry, @@ -73,7 +74,8 @@ import { type ContainerEvent, type ScheduleExecution, type StackEnvironmentVariable, - type PendingContainerUpdate + type PendingContainerUpdate, + type VaultConfig } from './db/drizzle.js'; import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; @@ -4779,3 +4781,160 @@ export async function removePendingContainerUpdate(environmentId: number, contai eq(pendingContainerUpdates.containerId, containerId) )); } + +// ============================================================================= +// VAULT CONFIGURATION OPERATIONS +// ============================================================================= + +/** + * Get the Vault configuration. + * Returns null if no configuration exists. + */ +export async function getVaultConfig(): Promise { + const results = await db.select().from(vaultConfig).limit(1); + return results[0] ?? null; +} + +/** + * Save or update the Vault configuration. + * Only one configuration is stored at a time. + */ +export async function saveVaultConfig(config: { + address: string; + namespace?: string | null; + defaultPath?: string | null; + authMethod: string; + token?: string | null; + roleId?: string | null; + secretId?: string | null; + kubeRole?: string | null; + skipTlsVerify?: boolean; + enabled?: boolean; +}): Promise { + const now = new Date().toISOString(); + const existing = await getVaultConfig(); + + if (existing) { + // Update existing config + await db.update(vaultConfig) + .set({ + address: config.address, + namespace: config.namespace, + defaultPath: config.defaultPath, + authMethod: config.authMethod, + token: config.token, + roleId: config.roleId, + secretId: config.secretId, + kubeRole: config.kubeRole, + skipTlsVerify: config.skipTlsVerify ?? false, + enabled: config.enabled ?? true, + updatedAt: now + }) + .where(eq(vaultConfig.id, existing.id)); + + return (await getVaultConfig())!; + } else { + // Insert new config + await db.insert(vaultConfig).values({ + address: config.address, + namespace: config.namespace, + defaultPath: config.defaultPath, + authMethod: config.authMethod, + token: config.token, + roleId: config.roleId, + secretId: config.secretId, + kubeRole: config.kubeRole, + skipTlsVerify: config.skipTlsVerify ?? false, + enabled: config.enabled ?? true, + createdAt: now, + updatedAt: now + }); + + return (await getVaultConfig())!; + } +} + +/** + * Delete the Vault configuration. + */ +export async function deleteVaultConfig(): Promise { + await db.delete(vaultConfig); +} + +/** + * Enable or disable Vault integration. + */ +export async function setVaultEnabled(enabled: boolean): Promise { + const now = new Date().toISOString(); + await db.update(vaultConfig) + .set({ enabled, updatedAt: now }); +} + +// ============================================================================= +// STACK ENVIRONMENT VARIABLES - UPSERT OPERATIONS +// ============================================================================= + +/** + * Upsert stack environment variables (update existing, insert new). + * Unlike setStackEnvVars, this does NOT delete existing variables. + * Only updates/inserts the provided keys. + * + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + * @param variables - Array of {key, value, isSecret} objects + */ +export async function upsertStackEnvVars( + stackName: string, + environmentId: number | null, + variables: Array<{ key: string; value: string; isSecret?: boolean }> +): Promise { + const now = new Date().toISOString(); + + for (const v of variables) { + // Check if variable exists + let existing; + if (environmentId === null) { + existing = await db.select() + .from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + isNull(stackEnvironmentVariables.environmentId), + eq(stackEnvironmentVariables.key, v.key) + )) + .limit(1); + } else { + existing = await db.select() + .from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + eq(stackEnvironmentVariables.environmentId, environmentId), + eq(stackEnvironmentVariables.key, v.key) + )) + .limit(1); + } + + const encryptedValue = v.isSecret ? (encrypt(v.value) ?? '') : v.value; + + if (existing.length > 0) { + // Update existing + await db.update(stackEnvironmentVariables) + .set({ + value: encryptedValue, + isSecret: v.isSecret ?? false, + updatedAt: now + }) + .where(eq(stackEnvironmentVariables.id, existing[0].id)); + } else { + // Insert new + await db.insert(stackEnvironmentVariables).values({ + stackName, + environmentId, + key: v.key, + value: encryptedValue, + isSecret: v.isSecret ?? false, + createdAt: now, + updatedAt: now + }); + } + } +} diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 7e2cbfb..b5ff707 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -336,7 +336,8 @@ const REQUIRED_TABLES = [ 'container_events', 'schedule_executions', 'user_preferences', - 'api_tokens' + 'api_tokens', + 'vault_config' ]; /** @@ -900,6 +901,7 @@ export const scheduleExecutions = schemaProxy.scheduleExecutions; export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables; export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates; export const apiTokens = schemaProxy.apiTokens; +export const vaultConfig = schemaProxy.vaultConfig; // Re-export types from SQLite schema (they're compatible with PostgreSQL) export type { @@ -960,7 +962,9 @@ export type { PendingContainerUpdate, NewPendingContainerUpdate, ApiToken, - NewApiToken + NewApiToken, + VaultConfig, + NewVaultConfig } from './schema/index.js'; export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm'; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 8c73eaa..58f42cd 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -502,6 +502,27 @@ export const userPreferences = sqliteTable('user_preferences', { unique().on(table.userId, table.environmentId, table.key) ]); +// ============================================================================= +// VAULT INTEGRATION TABLE +// ============================================================================= + +export const vaultConfig = sqliteTable('vault_config', { + id: integer('id').primaryKey({ autoIncrement: true }), + address: text('address').notNull(), // https://vault:8200 + namespace: text('namespace'), // Optional: Vault Enterprise Namespace + defaultPath: text('default_path'), // Default path prefix, e.g., "secret/data" + authMethod: text('auth_method').notNull(), // 'token', 'approle', 'kubernetes' + // Auth credentials (encrypted) + token: text('token'), // For token auth (encrypted) + roleId: text('role_id'), // For AppRole auth + secretId: text('secret_id'), // For AppRole auth (encrypted) + kubeRole: text('kube_role'), // For Kubernetes auth + skipTlsVerify: integer('skip_tls_verify', { mode: 'boolean' }).default(false), // Accept self-signed certs + enabled: integer('enabled', { mode: 'boolean' }).default(true), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + // ============================================================================= // TYPE EXPORTS // ============================================================================= @@ -592,3 +613,6 @@ export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferIns export type ApiToken = typeof apiTokens.$inferSelect; export type NewApiToken = typeof apiTokens.$inferInsert; + +export type VaultConfig = typeof vaultConfig.$inferSelect; +export type NewVaultConfig = typeof vaultConfig.$inferInsert; diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 23cc370..da46f15 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -504,3 +504,24 @@ export const userPreferences = pgTable('user_preferences', { }, (table) => [ unique().on(table.userId, table.environmentId, table.key) ]); + +// ============================================================================= +// VAULT INTEGRATION TABLE +// ============================================================================= + +export const vaultConfig = pgTable('vault_config', { + id: serial('id').primaryKey(), + address: text('address').notNull(), // https://vault:8200 + namespace: text('namespace'), // Optional: Vault Enterprise Namespace + defaultPath: text('default_path'), // Default path prefix, e.g., "secret/data" + authMethod: text('auth_method').notNull(), // 'token', 'approle', 'kubernetes' + // Auth credentials (encrypted) + token: text('token'), // For token auth (encrypted) + roleId: text('role_id'), // For AppRole auth + secretId: text('secret_id'), // For AppRole auth (encrypted) + kubeRole: text('kube_role'), // For Kubernetes auth + skipTlsVerify: boolean('skip_tls_verify').default(false), // Accept self-signed certs + enabled: boolean('enabled').default(true), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 142e159..b2d2be6 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -656,7 +656,11 @@ export function deleteRepositoryFiles(repoId: number): void { // === Git Stack Functions === -async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { +/** + * Get the repository path for a git stack + * Exported for use by other modules (e.g., secrets sync) + */ +export async function getGitStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { if (stackName && environmentId) { // Use old path if it already exists (backward compat), otherwise use name-based path const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`); @@ -690,7 +694,8 @@ async function getPreviousCommit(repoPath: string, env: GitEnv): Promise { +export async function syncGitStack(stackId: number, options?: { skipVault?: boolean }): Promise { + const skipVault = options?.skipVault ?? false; const gitStack = await getGitStack(stackId); if (!gitStack) { return { success: false, error: 'Git stack not found' }; @@ -723,7 +728,7 @@ export async function syncGitStack(stackId: number): Promise { console.log(`${logPrefix} Repository branch:`, repo.branch); const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; - const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); + const repoPath = await getGitStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); console.log(`${logPrefix} Local repo path:`, repoPath); @@ -862,6 +867,32 @@ export async function syncGitStack(stackId: number): Promise { console.log(`${logPrefix} No env file path configured`); } + // Sync secrets from Vault if .secrets.yaml exists (unless skipVault is true) + if (skipVault) { + console.log(`${logPrefix} Skipping Vault sync (mode: git-only)`); + } else { + try { + const { syncStackSecrets } = await import('./vault-sync.js'); + const vaultResult = await syncStackSecrets(gitStack.stackName, repoPath, gitStack.environmentId); + if (!vaultResult.skipped) { + if (vaultResult.success) { + console.log(`${logPrefix} Vault secrets synced: ${vaultResult.synced} secret(s)`); + } else { + console.warn(`${logPrefix} Vault sync had errors:`, vaultResult.errors.join(', ')); + } + + // Check if any secrets with triggerRedeploy changed + if (vaultResult.triggerRedeploySecrets.length > 0) { + console.log(`${logPrefix} Vault secrets changed (trigger redeploy): ${vaultResult.triggerRedeploySecrets.join(', ')}`); + updated = true; + } + } + } catch (vaultError) { + // Don't fail the sync if Vault sync fails - just log and continue + console.warn(`${logPrefix} Vault sync error (non-fatal):`, vaultError); + } + } + // Update git stack status await updateGitStack(stackId, { syncStatus: 'synced', @@ -1057,7 +1088,7 @@ export async function testGitStack(stackId: number): Promise { } export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise { - const repoPath = await getStackRepoPath(stackId, stackName, environmentId); + const repoPath = await getGitStackRepoPath(stackId, stackName, environmentId); try { if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); @@ -1100,7 +1131,7 @@ export async function deployGitStackWithProgress( } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; - const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); + const repoPath = await getGitStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); const totalSteps = 5; @@ -1276,7 +1307,7 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st return { files: [], error: 'Git stack not found' }; } - const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); + const repoPath = await getGitStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { files: [], error: 'Repository not synced - deploy the stack first' }; } @@ -1388,7 +1419,7 @@ export async function readGitStackEnvFile( return { vars: {}, error: 'Git stack not found' }; } - const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); + const repoPath = await getGitStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { vars: {}, error: 'Repository not synced - deploy the stack first' }; } diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts index 69107d6..9ee63ec 100644 --- a/src/lib/server/host-path.ts +++ b/src/lib/server/host-path.ts @@ -83,14 +83,22 @@ export async function detectHostDataDir(): Promise { detectionAttempted = true; // Check if user explicitly set HOST_DATA_DIR - if (process.env.HOST_DATA_DIR) { - cachedHostDataDir = process.env.HOST_DATA_DIR; + const explicitHostDataDir = process.env.HOST_DATA_DIR; + if (explicitHostDataDir) { + cachedHostDataDir = explicitHostDataDir; console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`); + // Don't return early - still need to populate cachedMounts for path translation } const containerId = getOwnContainerId(); if (!containerId) { console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars'); + if (explicitHostDataDir) { + const dataDir = resolve(process.env.DATA_DIR || '/app/data'); + cachedMounts = [{ source: explicitHostDataDir, destination: dataDir }]; + console.log(`[HostPath] Created synthetic mount: ${explicitHostDataDir} -> ${dataDir}`); + return cachedHostDataDir; + } return null; } @@ -189,6 +197,9 @@ export async function detectHostDataDir(): Promise { // Explicit override wins for DATA_DIR path, but we still inspect to populate // mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars. if (cachedHostDataDir) { + if (explicitHostDataDir) { + console.log(`[HostPath] Using explicit HOST_DATA_DIR with ${cachedMounts?.length || 0} cached mount(s)`); + } return cachedHostDataDir; } @@ -215,6 +226,13 @@ export async function detectHostDataDir(): Promise { return null; } catch (err) { console.warn(`[HostPath] Failed to query Docker API: ${err}`); + // If HOST_DATA_DIR was explicitly set, still use it with synthetic mount + if (explicitHostDataDir) { + const dataDir = resolve(process.env.DATA_DIR || '/app/data'); + cachedMounts = [{ source: explicitHostDataDir, destination: dataDir }]; + console.log(`[HostPath] Created synthetic mount after API error: ${explicitHostDataDir} -> ${dataDir}`); + return cachedHostDataDir; + } return null; } } diff --git a/src/lib/server/secrets-file.ts b/src/lib/server/secrets-file.ts new file mode 100644 index 0000000..da4f9b4 --- /dev/null +++ b/src/lib/server/secrets-file.ts @@ -0,0 +1,303 @@ +/** + * Secrets File Parser Module + * + * Parses .secrets.yaml files from Git stacks to determine which secrets + * to fetch from HashiCorp Vault. + */ + +import yaml from 'js-yaml'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * Configuration from a .secrets.yaml file + */ +export interface SecretsFileConfig { + vault?: { + address?: string; + namespace?: string; + path?: string; + auth?: { + method?: 'token' | 'approle' | 'kubernetes'; + role_id?: string; + secret_id?: string; + token?: string; + kube_role?: string; + }; + /** + * Global default: trigger redeploy when any secret changes + * Default: false + */ + triggerRedeploy?: boolean; + }; + secrets: SecretDefinition[]; +} + +/** + * A single secret definition + */ +export interface SecretDefinition { + /** + * Environment variable name in the stack + */ + name: string; + + /** + * Key in Vault (relative to vault.path unless path is specified) + */ + key?: string; + + /** + * Full Vault path (overrides vault.path) + */ + path?: string; + + /** + * Trigger stack redeploy when this secret changes + * Overrides vault.triggerRedeploy if set + */ + triggerRedeploy?: boolean; +} + +/** + * Parsed and normalized secret configuration + */ +export interface ParsedSecretsConfig { + /** + * Vault path prefix (e.g., "secret/data/myapp") + */ + vaultPath: string; + + /** + * Optional Vault address override + */ + vaultAddress?: string; + + /** + * Optional Vault namespace override + */ + vaultNamespace?: string; + + /** + * Optional auth method override + */ + authOverride?: { + method: 'token' | 'approle' | 'kubernetes'; + roleId?: string; + secretId?: string; + token?: string; + kubeRole?: string; + }; + + /** + * Secrets to fetch, grouped by Vault path + */ + secretsByPath: Map; + + /** + * Global default for triggerRedeploy (from vault.triggerRedeploy) + */ + triggerRedeployDefault: boolean; +} + +/** + * Mapping from Vault key to environment variable name + */ +export interface SecretMapping { + /** + * Environment variable name + */ + envVar: string; + + /** + * Key name in Vault + */ + vaultKey: string; + + /** + * Whether changes to this secret should trigger a stack redeploy + */ + triggerRedeploy: boolean; +} + +// ============================================================================= +// SUPPORTED FILE NAMES +// ============================================================================= + +const SECRETS_FILE_NAMES = [ + '.secrets.yaml', + '.secrets.yml', + 'secrets.yaml', + 'secrets.yml' +]; + +// ============================================================================= +// PARSER FUNCTIONS +// ============================================================================= + +/** + * Find a secrets file in a stack directory + */ +export function findSecretsFile(stackDir: string): string | null { + for (const fileName of SECRETS_FILE_NAMES) { + const filePath = join(stackDir, fileName); + if (existsSync(filePath)) { + return filePath; + } + } + return null; +} + +/** + * Parse a .secrets.yaml file content + */ +export function parseSecretsFile(content: string): SecretsFileConfig { + const parsed = yaml.load(content) as any; + + if (!parsed || typeof parsed !== 'object') { + throw new Error('Invalid secrets file: expected YAML object'); + } + + // Parse vault section + const vault = parsed.vault ? { + address: parsed.vault.address, + namespace: parsed.vault.namespace, + path: parsed.vault.path, + triggerRedeploy: parsed.vault.triggerRedeploy ?? false, + auth: parsed.vault.auth ? { + method: parsed.vault.auth.method, + role_id: parsed.vault.auth.role_id, + secret_id: parsed.vault.auth.secret_id, + token: parsed.vault.auth.token, + kube_role: parsed.vault.auth.kube_role + } : undefined + } : undefined; + + // Parse secrets section + if (!parsed.secrets || !Array.isArray(parsed.secrets)) { + throw new Error('Invalid secrets file: "secrets" must be an array'); + } + + const secrets: SecretDefinition[] = parsed.secrets.map((item: any) => { + // Simple form: just a string (name = key) + if (typeof item === 'string') { + return { + name: item.toUpperCase(), + key: item.toLowerCase(), + triggerRedeploy: undefined // Will use global default + }; + } + + // Object form with explicit mapping + if (typeof item === 'object' && item.name) { + return { + name: item.name, + key: item.key || item.name.toLowerCase(), + path: item.path, + triggerRedeploy: item.triggerRedeploy // May be undefined + }; + } + + throw new Error(`Invalid secret definition: ${JSON.stringify(item)}`); + }); + + return { vault, secrets }; +} + +/** + * Parse and normalize a secrets file into a ready-to-use configuration + */ +export function parseAndNormalizeSecretsFile( + content: string, + defaultPath: string = 'secret/data' +): ParsedSecretsConfig { + const config = parseSecretsFile(content); + + // Determine base vault path + const basePath = config.vault?.path || defaultPath; + + // Global default for triggerRedeploy (false if not specified) + const triggerRedeployDefault = config.vault?.triggerRedeploy ?? false; + + // Group secrets by their path + const secretsByPath = new Map(); + + for (const secret of config.secrets) { + // Determine the full path for this secret + let path: string; + if (secret.path) { + // Full path specified + path = secret.path; + } else { + // Use base path + path = basePath; + } + + // Ensure path uses KV v2 format + if (!path.includes('/data/')) { + const parts = path.split('/'); + if (parts.length >= 2) { + path = `${parts[0]}/data/${parts.slice(1).join('/')}`; + } + } + + // Add to the path group + if (!secretsByPath.has(path)) { + secretsByPath.set(path, []); + } + + // Resolve triggerRedeploy: secret-level overrides global default + const secretTriggerRedeploy = secret.triggerRedeploy !== undefined + ? secret.triggerRedeploy + : triggerRedeployDefault; + + secretsByPath.get(path)!.push({ + envVar: secret.name, + vaultKey: secret.key || secret.name.toLowerCase(), + triggerRedeploy: secretTriggerRedeploy + }); + } + + // Build auth override if present + let authOverride: ParsedSecretsConfig['authOverride']; + if (config.vault?.auth?.method) { + authOverride = { + method: config.vault.auth.method, + roleId: config.vault.auth.role_id, + secretId: config.vault.auth.secret_id, + token: config.vault.auth.token, + kubeRole: config.vault.auth.kube_role + }; + } + + return { + vaultPath: basePath, + vaultAddress: config.vault?.address, + vaultNamespace: config.vault?.namespace, + authOverride, + secretsByPath, + triggerRedeployDefault + }; +} + +/** + * Read and parse a secrets file from a stack directory + */ +export async function readSecretsFile(stackDir: string): Promise { + const filePath = findSecretsFile(stackDir); + if (!filePath) { + return null; + } + + try { + const content = await Bun.file(filePath).text(); + return parseAndNormalizeSecretsFile(content); + } catch (error) { + console.error(`[Secrets] Failed to parse ${filePath}:`, error); + throw error; + } +} diff --git a/src/lib/server/vault-sync.ts b/src/lib/server/vault-sync.ts new file mode 100644 index 0000000..ed84491 --- /dev/null +++ b/src/lib/server/vault-sync.ts @@ -0,0 +1,299 @@ +/** + * Vault Secrets Sync Module + * + * Synchronizes secrets from HashiCorp Vault to stack environment variables. + */ + +import { readSecretsFile, type ParsedSecretsConfig } from './secrets-file.js'; +import { createVaultClient, type VaultConfig, type VaultSecret } from './vault.js'; +import { getVaultConfig, upsertStackEnvVars, getStackSource, getSecretEnvVarsAsRecord } from './db.js'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface SyncResult { + success: boolean; + synced: number; + errors: string[]; + skipped: boolean; + /** + * Whether any secret values changed during sync + */ + secretsChanged: boolean; + /** + * Names of secrets that changed AND have triggerRedeploy enabled + */ + triggerRedeploySecrets: string[]; +} + +export interface StackSyncResult extends SyncResult { + stackName: string; +} + +// ============================================================================= +// SYNC FUNCTIONS +// ============================================================================= + +/** + * Synchronize secrets from Vault for a single stack + * + * @param stackName - Name of the stack + * @param stackDir - Directory containing the stack files + * @param environmentId - Optional environment ID + * @returns Sync result with details + */ +export async function syncStackSecrets( + stackName: string, + stackDir: string, + environmentId?: number | null +): Promise { + const errors: string[] = []; + + // Resolve environment ID from stack_sources if not provided + // This handles cases where git_stacks.environment_id is NULL but stack_sources has the correct value + let resolvedEnvId = environmentId; + if (resolvedEnvId === null || resolvedEnvId === undefined) { + try { + // Try to find the stack source with any environment ID + const { db, stackSources, eq } = await import('./db/drizzle.js'); + const sources = await db.select().from(stackSources) + .where(eq(stackSources.stackName, stackName)); + + // Use the first source's environment ID if available + if (sources.length > 0 && sources[0].environmentId !== null) { + resolvedEnvId = sources[0].environmentId; + console.log(`[Vault] Resolved environment ID ${resolvedEnvId} for stack "${stackName}" from stack_sources`); + } + } catch (error) { + // Ignore errors - proceed with NULL environment ID + console.warn(`[Vault] Could not resolve environment ID for stack "${stackName}":`, error); + } + } + + // 1. Check for secrets file + let secretsConfig: ParsedSecretsConfig | null; + try { + secretsConfig = await readSecretsFile(stackDir); + } catch (error) { + return { + success: false, + synced: 0, + errors: [`Failed to parse secrets file: ${error instanceof Error ? error.message : String(error)}`], + skipped: false, + secretsChanged: false, + triggerRedeploySecrets: [] + }; + } + + if (!secretsConfig) { + // No secrets file - nothing to sync + return { + success: true, + synced: 0, + errors: [], + skipped: true, + secretsChanged: false, + triggerRedeploySecrets: [] + }; + } + + // 2. Get global Vault config + const globalConfig = await getVaultConfig(); + if (!globalConfig || !globalConfig.enabled) { + return { + success: false, + synced: 0, + errors: ['Vault is not configured or disabled. Configure Vault in Settings first.'], + skipped: false, + secretsChanged: false, + triggerRedeploySecrets: [] + }; + } + + // 3. Build effective Vault config (global + overrides from secrets file) + const effectiveConfig: VaultConfig = { + address: secretsConfig.vaultAddress || globalConfig.address, + namespace: secretsConfig.vaultNamespace || globalConfig.namespace || undefined, + defaultPath: globalConfig.defaultPath || undefined, + authMethod: secretsConfig.authOverride?.method || globalConfig.authMethod as VaultConfig['authMethod'], + token: secretsConfig.authOverride?.token || globalConfig.token || undefined, + roleId: secretsConfig.authOverride?.roleId || globalConfig.roleId || undefined, + secretId: secretsConfig.authOverride?.secretId || globalConfig.secretId || undefined, + kubeRole: secretsConfig.authOverride?.kubeRole || globalConfig.kubeRole || undefined, + skipTlsVerify: globalConfig.skipTlsVerify ?? false, + enabled: true + }; + + // 4. Connect to Vault + let vaultClient; + try { + vaultClient = await createVaultClient(effectiveConfig); + } catch (error) { + return { + success: false, + synced: 0, + errors: [`Failed to connect to Vault: ${error instanceof Error ? error.message : String(error)}`], + skipped: false, + secretsChanged: false, + triggerRedeploySecrets: [] + }; + } + + // 4a. Fetch existing secret values for comparison + let existingSecrets: Record = {}; + try { + existingSecrets = await getSecretEnvVarsAsRecord(stackName, resolvedEnvId); + // #region agent log + fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:146',message:'existingSecrets fetched',data:{stackName,resolvedEnvId,existingSecretsKeys:Object.keys(existingSecrets),count:Object.keys(existingSecrets).length},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'A'})}).catch(()=>{}); + // #endregion + } catch (error) { + console.warn(`[Vault] Could not fetch existing secrets for comparison:`, error); + // Continue - we'll treat all secrets as new/changed + } + + // 5. Fetch secrets from each path + const allSecrets: Array<{ key: string; value: string; isSecret: true }> = []; + // Track which secrets have triggerRedeploy enabled (envVar -> triggerRedeploy) + const triggerRedeployMap = new Map(); + + for (const [path, mappings] of secretsConfig.secretsByPath) { + try { + // Get all keys we need from this path + const keys = mappings.map(m => m.vaultKey); + const secrets = await vaultClient.getSecrets(path, keys); + + // Map Vault keys to environment variable names + const secretsByKey = new Map(secrets.map(s => [s.key, s.value])); + + for (const mapping of mappings) { + const value = secretsByKey.get(mapping.vaultKey); + if (value !== undefined) { + allSecrets.push({ + key: mapping.envVar, + value, + isSecret: true + }); + // Track triggerRedeploy setting for this secret + triggerRedeployMap.set(mapping.envVar, mapping.triggerRedeploy); + } else { + errors.push(`Secret "${mapping.vaultKey}" not found at path "${path}"`); + } + } + } catch (error) { + errors.push(`Failed to read secrets from "${path}": ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 5a. Compare old vs new values to detect changes + const changedSecrets: string[] = []; + const triggerRedeploySecrets: string[] = []; + + // #region agent log + fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:189',message:'triggerRedeployMap',data:{map:Object.fromEntries(triggerRedeployMap)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'C'})}).catch(()=>{}); + // #endregion + + for (const secret of allSecrets) { + const oldValue = existingSecrets[secret.key]; + const isChanged = oldValue === undefined || oldValue !== secret.value; + + // #region agent log + fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:195',message:'secret comparison',data:{key:secret.key,oldValueExists:oldValue!==undefined,newValuePreview:secret.value?.substring(0,10),isChanged,triggerRedeploy:triggerRedeployMap.get(secret.key)},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'B'})}).catch(()=>{}); + // #endregion + + if (isChanged) { + changedSecrets.push(secret.key); + // Check if this secret should trigger redeploy + if (triggerRedeployMap.get(secret.key)) { + triggerRedeploySecrets.push(secret.key); + } + } + } + + const secretsChanged = changedSecrets.length > 0; + // #region agent log + fetch('http://127.0.0.1:7244/ingest/82eed265-24ab-4eea-a445-5a08da005e0c',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'vault-sync.ts:210',message:'sync result',data:{secretsChanged,changedSecrets,triggerRedeploySecrets},timestamp:Date.now(),sessionId:'debug-session',hypothesisId:'D'})}).catch(()=>{}); + // #endregion + if (secretsChanged) { + console.log(`[Vault] Detected ${changedSecrets.length} changed secrets for stack "${stackName}": ${changedSecrets.join(', ')}`); + if (triggerRedeploySecrets.length > 0) { + console.log(`[Vault] Secrets that will trigger redeploy: ${triggerRedeploySecrets.join(', ')}`); + } + } + + // 6. Save secrets to database + if (allSecrets.length > 0) { + try { + await upsertStackEnvVars(stackName, resolvedEnvId ?? null, allSecrets); + console.log(`[Vault] Synced ${allSecrets.length} secrets for stack "${stackName}" (env: ${resolvedEnvId ?? 'none'})`); + } catch (error) { + return { + success: false, + synced: 0, + errors: [`Failed to save secrets to database: ${error instanceof Error ? error.message : String(error)}`], + skipped: false, + secretsChanged, + triggerRedeploySecrets + }; + } + } + + return { + success: errors.length === 0, + synced: allSecrets.length, + errors, + skipped: false, + secretsChanged, + triggerRedeploySecrets + }; +} + +/** + * Synchronize secrets for all Git stacks + * + * @returns Map of stack names to their sync results + */ +export async function syncAllStackSecrets(): Promise> { + const results = new Map(); + + // Import dynamically to avoid circular dependency + const { getGitStacks } = await import('./db.js'); + const { getStackDir } = await import('./stacks.js'); + + const stacks = await getGitStacks(); + + for (const stack of stacks) { + try { + const stackDir = await getStackDir(stack); + if (!stackDir) { + results.set(stack.stackName, { + success: false, + synced: 0, + errors: ['Stack directory not found'], + skipped: false, + secretsChanged: false, + triggerRedeploySecrets: [] + }); + continue; + } + + const result = await syncStackSecrets( + stack.stackName, + stackDir, + stack.environmentId + ); + results.set(stack.stackName, result); + } catch (error) { + results.set(stack.stackName, { + success: false, + synced: 0, + errors: [error instanceof Error ? error.message : String(error)], + skipped: false, + secretsChanged: false, + triggerRedeploySecrets: [] + }); + } + } + + return results; +} diff --git a/src/lib/server/vault.ts b/src/lib/server/vault.ts new file mode 100644 index 0000000..9acb6ae --- /dev/null +++ b/src/lib/server/vault.ts @@ -0,0 +1,344 @@ +/** + * HashiCorp Vault Client Module + * + * Provides secure communication with HashiCorp Vault for secret management. + * Supports multiple authentication methods: Token, AppRole, Kubernetes. + */ + +import { decrypt } from './encryption.js'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export interface VaultConfig { + address: string; + namespace?: string; + defaultPath?: string; + authMethod: 'token' | 'approle' | 'kubernetes'; + token?: string; // Encrypted + roleId?: string; + secretId?: string; // Encrypted + kubeRole?: string; + skipTlsVerify?: boolean; // Accept self-signed certificates + enabled: boolean; +} + +export interface VaultSecret { + key: string; + value: string; +} + +export interface VaultConnectionResult { + success: boolean; + error?: string; + version?: string; +} + +interface VaultResponse { + data?: { + data?: Record; + [key: string]: any; + }; + auth?: { + client_token: string; + accessor: string; + policies: string[]; + lease_duration: number; + renewable: boolean; + }; + errors?: string[]; +} + +// ============================================================================= +// VAULT CLIENT CLASS +// ============================================================================= + +export class VaultClient { + private address: string; + private namespace?: string; + private token?: string; + private skipTlsVerify: boolean; + private initialized = false; + + constructor(address: string, namespace?: string, skipTlsVerify = false) { + // Normalize address (remove trailing slash) + this.address = address.replace(/\/$/, ''); + this.namespace = namespace; + this.skipTlsVerify = skipTlsVerify; + } + + /** + * Make an HTTP request to Vault + */ + private async request( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record + ): Promise { + const url = `${this.address}/v1/${path}`; + const headers: Record = { + 'Content-Type': 'application/json' + }; + + if (this.token) { + headers['X-Vault-Token'] = this.token; + } + + if (this.namespace) { + headers['X-Vault-Namespace'] = this.namespace; + } + + try { + const fetchOptions: RequestInit & { tls?: { rejectUnauthorized: boolean } } = { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }; + + // Allow self-signed certificates if configured + if (this.skipTlsVerify) { + fetchOptions.tls = { rejectUnauthorized: false }; + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const text = await response.text(); + let error: string; + try { + const json = JSON.parse(text); + error = json.errors?.join(', ') || text; + } catch { + error = text; + } + throw new Error(`Vault request failed (${response.status}): ${error}`); + } + + // Some Vault responses are empty (204 No Content) + const text = await response.text(); + if (!text) { + return {}; + } + + return JSON.parse(text); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`Vault request failed: ${String(error)}`); + } + } + + /** + * Authenticate with a Vault token + */ + async authenticateWithToken(token: string): Promise { + this.token = token; + // Verify the token works + await this.request('GET', 'auth/token/lookup-self'); + this.initialized = true; + } + + /** + * Authenticate with AppRole + */ + async authenticateWithAppRole(roleId: string, secretId: string): Promise { + const response = await this.request('POST', 'auth/approle/login', { + role_id: roleId, + secret_id: secretId + }); + + if (!response.auth?.client_token) { + throw new Error('AppRole authentication failed: no token returned'); + } + + this.token = response.auth.client_token; + this.initialized = true; + } + + /** + * Authenticate with Kubernetes + */ + async authenticateWithKubernetes(role: string): Promise { + // Read the service account token from the container + const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token'; + let jwt: string; + + try { + jwt = await Bun.file(tokenPath).text(); + } catch (error) { + throw new Error( + `Failed to read Kubernetes service account token from ${tokenPath}. ` + + 'Make sure Dockhand is running in a Kubernetes pod with a service account.' + ); + } + + const response = await this.request('POST', 'auth/kubernetes/login', { + role, + jwt + }); + + if (!response.auth?.client_token) { + throw new Error('Kubernetes authentication failed: no token returned'); + } + + this.token = response.auth.client_token; + this.initialized = true; + } + + /** + * Check if the client is authenticated + */ + isAuthenticated(): boolean { + return this.initialized && !!this.token; + } + + /** + * Read a secret from Vault (KV v2) + * @param path - Full path to the secret (e.g., "secret/data/myapp") + */ + async readSecret(path: string): Promise> { + if (!this.isAuthenticated()) { + throw new Error('Vault client not authenticated'); + } + + const response = await this.request('GET', path); + + // KV v2 returns data.data, KV v1 returns data directly + const data = response.data?.data ?? response.data ?? {}; + + // Convert all values to strings + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + result[key] = String(value); + } + + return result; + } + + /** + * Get specific secrets by keys from a path + * @param path - Full path to the secret (e.g., "secret/data/myapp") + * @param keys - Array of key names to retrieve + */ + async getSecrets(path: string, keys: string[]): Promise { + const allSecrets = await this.readSecret(path); + const result: VaultSecret[] = []; + + for (const key of keys) { + if (key in allSecrets) { + result.push({ key, value: allSecrets[key] }); + } else { + console.warn(`[Vault] Secret key "${key}" not found at path "${path}"`); + } + } + + return result; + } + + /** + * Test connection to Vault + */ + async testConnection(): Promise { + try { + const response = await this.request('GET', 'sys/health'); + return { + success: true, + version: response.data?.version ?? 'unknown' + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } +} + +// ============================================================================= +// FACTORY FUNCTIONS +// ============================================================================= + +/** + * Create and authenticate a Vault client from config + */ +export async function createVaultClient(config: VaultConfig): Promise { + const client = new VaultClient(config.address, config.namespace, config.skipTlsVerify); + + switch (config.authMethod) { + case 'token': + if (!config.token) { + throw new Error('Vault token auth requires a token'); + } + // Decrypt the token before use + const decryptedToken = decrypt(config.token); + if (!decryptedToken) { + throw new Error('Failed to decrypt Vault token'); + } + await client.authenticateWithToken(decryptedToken); + break; + + case 'approle': + if (!config.roleId || !config.secretId) { + throw new Error('Vault AppRole auth requires roleId and secretId'); + } + // Decrypt the secret ID before use + const decryptedSecretId = decrypt(config.secretId); + if (!decryptedSecretId) { + throw new Error('Failed to decrypt Vault secret ID'); + } + await client.authenticateWithAppRole(config.roleId, decryptedSecretId); + break; + + case 'kubernetes': + if (!config.kubeRole) { + throw new Error('Vault Kubernetes auth requires a role'); + } + await client.authenticateWithKubernetes(config.kubeRole); + break; + + default: + throw new Error(`Unknown Vault auth method: ${config.authMethod}`); + } + + return client; +} + +/** + * Test Vault connection with given config (without full authentication) + */ +export async function testVaultConnection(config: VaultConfig): Promise { + try { + const client = await createVaultClient(config); + return await client.testConnection(); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +/** + * Fetch secrets from Vault for a given path and key list + */ +export async function fetchSecretsFromVault( + config: VaultConfig, + path: string, + keys: string[] +): Promise { + const client = await createVaultClient(config); + + // If path doesn't include 'data/' for KV v2, add it + let fullPath = path; + if (!path.includes('/data/')) { + // Convert "secret/myapp" to "secret/data/myapp" + const parts = path.split('/'); + if (parts.length >= 2) { + fullPath = `${parts[0]}/data/${parts.slice(1).join('/')}`; + } + } + + return client.getSecrets(fullPath, keys); +} diff --git a/src/routes/api/git/stacks/[id]/sync/+server.ts b/src/routes/api/git/stacks/[id]/sync/+server.ts index 59d237c..96c32bf 100644 --- a/src/routes/api/git/stacks/[id]/sync/+server.ts +++ b/src/routes/api/git/stacks/[id]/sync/+server.ts @@ -1,12 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGitStack } from '$lib/server/db'; -import { syncGitStack } from '$lib/server/git'; +import { getGitStack, getStackSource } from '$lib/server/db'; +import { syncGitStack, deployGitStack, getGitStackRepoPath } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +import { syncStackSecrets } from '$lib/server/vault-sync'; -export const POST: RequestHandler = async ({ params, cookies }) => { +export const POST: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); + // mode parameter: 'git' | 'vault' | 'all' (default) + const mode = url.searchParams.get('mode') || 'all'; + if (!['git', 'vault', 'all'].includes(mode)) { + return json({ error: 'Invalid mode. Must be git, vault, or all' }, { status: 400 }); + } + try { const id = parseInt(params.id); const gitStack = await getGitStack(id); @@ -19,8 +26,71 @@ export const POST: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Permission denied' }, { status: 403 }); } - const result = await syncGitStack(id); - return json(result); + const logPrefix = `[Sync:${gitStack.stackName}]`; + + // Mode: vault - Only sync Vault secrets + if (mode === 'vault') { + console.log(`${logPrefix} Mode: vault-only`); + const stackDir = await getGitStackRepoPath(id, gitStack.stackName, gitStack.environmentId); + if (!stackDir) { + return json({ error: 'Stack directory not found. Sync git first.' }, { status: 404 }); + } + + const vaultResult = await syncStackSecrets(gitStack.stackName, stackDir, gitStack.environmentId); + + // Auto-deploy if triggerRedeploy secrets changed + if (vaultResult.success && vaultResult.triggerRedeploySecrets.length > 0) { + console.log(`${logPrefix} Vault secrets changed (trigger redeploy): ${vaultResult.triggerRedeploySecrets.join(', ')}`); + const deployResult = await deployGitStack(id, { force: true }); + return json({ + success: true, + mode: 'vault', + synced: vaultResult.synced, + secretsChanged: vaultResult.secretsChanged, + triggerRedeploySecrets: vaultResult.triggerRedeploySecrets, + deployed: true, + deploySuccess: deployResult.success, + deployOutput: deployResult.output, + deployError: deployResult.error + }); + } + + return json({ + success: vaultResult.success, + mode: 'vault', + synced: vaultResult.synced, + errors: vaultResult.errors, + secretsChanged: vaultResult.secretsChanged, + deployed: false + }); + } + + // Mode: git or all - Sync Git (with or without Vault) + const skipVault = mode === 'git'; + console.log(`${logPrefix} Mode: ${mode}, skipVault: ${skipVault}`); + + const syncResult = await syncGitStack(id, { skipVault }); + + // If sync detected changes that require redeploy (e.g., triggerRedeploy secrets changed), + // automatically deploy the stack + if (syncResult.success && syncResult.updated) { + console.log(`${logPrefix} Changes detected (updated=true), triggering auto-deploy...`); + // Use force: true because syncGitStack already detected changes and saved secrets + // The deploy's internal sync would see no changes since secrets are already updated in DB + const deployResult = await deployGitStack(id, { force: true }); + + // Merge deploy result into sync result + return json({ + ...syncResult, + mode, + deployed: true, + deploySuccess: deployResult.success, + deployOutput: deployResult.output, + deployError: deployResult.error + }); + } + + return json({ ...syncResult, mode, deployed: false }); } catch (error) { console.error('Failed to sync git stack:', error); return json({ error: 'Failed to sync git stack' }, { status: 500 }); diff --git a/src/routes/api/stacks/[name]/secrets/sync/+server.ts b/src/routes/api/stacks/[name]/secrets/sync/+server.ts new file mode 100644 index 0000000..c80a802 --- /dev/null +++ b/src/routes/api/stacks/[name]/secrets/sync/+server.ts @@ -0,0 +1,84 @@ +/** + * Stack Secrets Sync API + * + * POST /api/stacks/[name]/secrets/sync - Sync secrets from Vault for a stack + */ + +import { json, type RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { syncStackSecrets } from '$lib/server/vault-sync'; +import { findStackDir } from '$lib/server/stacks'; +import { getStackSource } from '$lib/server/db'; +import { getGitStackRepoPath } from '$lib/server/git'; + +/** + * POST /api/stacks/[name]/secrets/sync + * Sync secrets from Vault for a specific stack + */ +export const POST: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check + if (auth.authEnabled && !await auth.can('stacks', 'edit', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name!); + + // Check if this is a git stack + const source = await getStackSource(stackName, envIdNum); + let stackDir: string | null = null; + + if (source?.sourceType === 'git' && source.gitStackId) { + // Git stack: use git repo path + stackDir = await getGitStackRepoPath(source.gitStackId, stackName, envIdNum); + } else { + // Regular stack: use stacks directory + stackDir = await findStackDir(stackName, envIdNum); + } + + if (!stackDir) { + return json({ error: 'Stack directory not found' }, { status: 404 }); + } + + // Sync secrets + const result = await syncStackSecrets(stackName, stackDir, envIdNum); + + if (result.skipped) { + return json({ + success: true, + message: 'No .secrets.yaml file found in stack directory', + synced: 0 + }); + } + + if (!result.success) { + return json({ + success: false, + message: 'Failed to sync some secrets', + synced: result.synced, + errors: result.errors + }, { status: result.synced > 0 ? 200 : 500 }); + } + + return json({ + success: true, + message: `Successfully synced ${result.synced} secret(s) from Vault`, + synced: result.synced + }); + } catch (error) { + console.error('Failed to sync stack secrets:', error); + return json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to sync secrets' + }, { status: 500 }); + } +}; diff --git a/src/routes/api/vault/config/+server.ts b/src/routes/api/vault/config/+server.ts new file mode 100644 index 0000000..baab2db --- /dev/null +++ b/src/routes/api/vault/config/+server.ts @@ -0,0 +1,196 @@ +/** + * Vault Configuration API + * + * GET /api/vault/config - Get current Vault configuration + * PUT /api/vault/config - Update Vault configuration + * DELETE /api/vault/config - Remove Vault configuration + */ + +import { json, type RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { getVaultConfig, saveVaultConfig, deleteVaultConfig } from '$lib/server/db'; +import { encrypt } from '$lib/server/encryption'; + +export interface VaultConfigResponse { + configured: boolean; + enabled: boolean; + address?: string; + namespace?: string; + defaultPath?: string; + authMethod?: string; + hasToken?: boolean; + roleId?: string; + hasSecretId?: boolean; + kubeRole?: string; + skipTlsVerify?: boolean; +} + +/** + * GET /api/vault/config + * Get current Vault configuration (without sensitive values) + */ +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('settings', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const config = await getVaultConfig(); + + if (!config) { + return json({ + configured: false, + enabled: false + } as VaultConfigResponse); + } + + // Return config without sensitive values + const response: VaultConfigResponse = { + configured: true, + enabled: config.enabled ?? true, + address: config.address, + namespace: config.namespace ?? undefined, + defaultPath: config.defaultPath ?? undefined, + authMethod: config.authMethod, + hasToken: !!config.token, + roleId: config.roleId ?? undefined, + hasSecretId: !!config.secretId, + kubeRole: config.kubeRole ?? undefined, + skipTlsVerify: config.skipTlsVerify ?? false + }; + + return json(response); + } catch (error) { + console.error('Failed to get Vault config:', error); + return json({ error: 'Failed to get Vault configuration' }, { status: 500 }); + } +}; + +/** + * PUT /api/vault/config + * Update Vault configuration + */ +export const PUT: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + + // Validate required fields + if (!body.address || typeof body.address !== 'string') { + return json({ error: 'Vault address is required' }, { status: 400 }); + } + + if (!body.authMethod || !['token', 'approle', 'kubernetes'].includes(body.authMethod)) { + return json({ error: 'Valid auth method is required (token, approle, kubernetes)' }, { status: 400 }); + } + + // Validate auth-specific fields + if (body.authMethod === 'token' && !body.token && !body.keepExistingToken) { + return json({ error: 'Token is required for token authentication' }, { status: 400 }); + } + + if (body.authMethod === 'approle') { + if (!body.roleId) { + return json({ error: 'Role ID is required for AppRole authentication' }, { status: 400 }); + } + if (!body.secretId && !body.keepExistingSecretId) { + return json({ error: 'Secret ID is required for AppRole authentication' }, { status: 400 }); + } + } + + if (body.authMethod === 'kubernetes' && !body.kubeRole) { + return json({ error: 'Kubernetes role is required for Kubernetes authentication' }, { status: 400 }); + } + + // Get existing config for keeping existing secrets + const existingConfig = await getVaultConfig(); + + // Build config object + const configToSave: Parameters[0] = { + address: body.address.trim(), + namespace: body.namespace?.trim() || null, + defaultPath: body.defaultPath?.trim() || null, + authMethod: body.authMethod, + enabled: body.enabled ?? true, + skipTlsVerify: body.skipTlsVerify ?? false, + token: null, + roleId: null, + secretId: null, + kubeRole: null + }; + + // Handle token + if (body.authMethod === 'token') { + if (body.token && body.token !== '***') { + configToSave.token = encrypt(body.token); + } else if (body.keepExistingToken && existingConfig?.token) { + configToSave.token = existingConfig.token; + } + } + + // Handle AppRole + if (body.authMethod === 'approle') { + configToSave.roleId = body.roleId?.trim() || null; + + if (body.secretId && body.secretId !== '***') { + configToSave.secretId = encrypt(body.secretId); + } else if (body.keepExistingSecretId && existingConfig?.secretId) { + configToSave.secretId = existingConfig.secretId; + } + } + + // Handle Kubernetes + if (body.authMethod === 'kubernetes') { + configToSave.kubeRole = body.kubeRole?.trim() || null; + } + + await saveVaultConfig(configToSave); + + // Return updated config + const updatedConfig = await getVaultConfig(); + + return json({ + configured: true, + enabled: updatedConfig?.enabled ?? true, + address: updatedConfig?.address, + namespace: updatedConfig?.namespace ?? undefined, + defaultPath: updatedConfig?.defaultPath ?? undefined, + authMethod: updatedConfig?.authMethod, + hasToken: !!updatedConfig?.token, + roleId: updatedConfig?.roleId ?? undefined, + hasSecretId: !!updatedConfig?.secretId, + kubeRole: updatedConfig?.kubeRole ?? undefined, + skipTlsVerify: updatedConfig?.skipTlsVerify ?? false + } as VaultConfigResponse); + } catch (error) { + console.error('Failed to save Vault config:', error); + return json({ error: 'Failed to save Vault configuration' }, { status: 500 }); + } +}; + +/** + * DELETE /api/vault/config + * Remove Vault configuration + */ +export const DELETE: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + await deleteVaultConfig(); + return json({ success: true }); + } catch (error) { + console.error('Failed to delete Vault config:', error); + return json({ error: 'Failed to delete Vault configuration' }, { status: 500 }); + } +}; diff --git a/src/routes/api/vault/fetch-test/+server.ts b/src/routes/api/vault/fetch-test/+server.ts new file mode 100644 index 0000000..d9fe7de --- /dev/null +++ b/src/routes/api/vault/fetch-test/+server.ts @@ -0,0 +1,121 @@ +/** + * Vault Secret Fetch Test API + * + * POST /api/vault/fetch-test - Test fetching secrets from Vault + */ + +import { json, type RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { getVaultConfig } from '$lib/server/db'; +import { createVaultClient, type VaultConfig } from '$lib/server/vault'; + +/** + * POST /api/vault/fetch-test + * Test fetching secrets from Vault + * + * Request body: + * - path: string - The Vault path to fetch from (e.g., "secret/data/myapp") + * - keys: string[] - The secret keys to look for + * + * Response: + * - success: boolean + * - found: string[] - Keys that were found + * - missing: string[] - Keys that were not found + * - error?: string - Error message if failed + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + + // Validate input + if (!body.path || typeof body.path !== 'string') { + return json({ error: 'Path is required' }, { status: 400 }); + } + + if (!body.keys || !Array.isArray(body.keys) || body.keys.length === 0) { + return json({ error: 'Keys array is required and must not be empty' }, { status: 400 }); + } + + // Get saved Vault config + const savedConfig = await getVaultConfig(); + if (!savedConfig) { + return json({ error: 'No Vault configuration found. Please configure Vault first.' }, { status: 400 }); + } + + if (!savedConfig.enabled) { + return json({ error: 'Vault integration is disabled' }, { status: 400 }); + } + + // Build config for client + const config: VaultConfig = { + address: savedConfig.address, + namespace: savedConfig.namespace || undefined, + defaultPath: savedConfig.defaultPath || undefined, + authMethod: savedConfig.authMethod as VaultConfig['authMethod'], + skipTlsVerify: savedConfig.skipTlsVerify ?? false, + enabled: savedConfig.enabled ?? true, + token: savedConfig.token || undefined, + roleId: savedConfig.roleId || undefined, + secretId: savedConfig.secretId || undefined, + kubeRole: savedConfig.kubeRole || undefined + }; + + // Create and authenticate client + const client = await createVaultClient(config); + + // Normalize path for KV v2 if needed + let fullPath = body.path; + if (!fullPath.includes('/data/')) { + const parts = fullPath.split('/'); + if (parts.length >= 2) { + fullPath = `${parts[0]}/data/${parts.slice(1).join('/')}`; + } + } + + console.log(`[Vault Fetch Test] Fetching from path: ${fullPath}`); + console.log(`[Vault Fetch Test] Looking for keys: ${body.keys.join(', ')}`); + + // Fetch the secret + const secretData = await client.readSecret(fullPath); + + // Check which keys were found + const found: string[] = []; + const missing: string[] = []; + + for (const key of body.keys) { + if (key in secretData) { + found.push(key); + console.log(`[Vault Fetch Test] Key "${key}" found`); + } else { + missing.push(key); + console.log(`[Vault Fetch Test] Key "${key}" NOT found`); + } + } + + // Log available keys (without values) + const availableKeys = Object.keys(secretData); + console.log(`[Vault Fetch Test] Available keys in secret: ${availableKeys.join(', ')}`); + + return json({ + success: true, + found, + missing, + availableKeys, + path: fullPath + }); + } catch (error) { + console.error('[Vault Fetch Test] Error:', error); + return json({ + success: false, + found: [], + missing: [], + error: error instanceof Error ? error.message : 'Failed to fetch secrets from Vault' + }); + } +}; diff --git a/src/routes/api/vault/test/+server.ts b/src/routes/api/vault/test/+server.ts new file mode 100644 index 0000000..0940c41 --- /dev/null +++ b/src/routes/api/vault/test/+server.ts @@ -0,0 +1,135 @@ +/** + * Vault Connection Test API + * + * POST /api/vault/test - Test Vault connection with provided or saved config + */ + +import { json, type RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { getVaultConfig } from '$lib/server/db'; +import { testVaultConnection, type VaultConfig } from '$lib/server/vault'; +import { encrypt, decrypt } from '$lib/server/encryption'; + +/** + * POST /api/vault/test + * Test Vault connection + * + * Can either: + * - Test with provided config (for testing before saving) + * - Test with saved config (if no config provided) + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + let configToTest: VaultConfig; + + if (body.address) { + // Test with provided config + if (!body.authMethod || !['token', 'approle', 'kubernetes'].includes(body.authMethod)) { + return json({ error: 'Valid auth method is required' }, { status: 400 }); + } + + configToTest = { + address: body.address, + namespace: body.namespace || undefined, + defaultPath: body.defaultPath || undefined, + authMethod: body.authMethod, + skipTlsVerify: body.skipTlsVerify ?? false, + enabled: true, + token: undefined, + roleId: undefined, + secretId: undefined, + kubeRole: undefined + }; + + // Handle auth credentials + if (body.authMethod === 'token') { + if (!body.token) { + // Try to use existing token + const existingConfig = await getVaultConfig(); + if (body.keepExistingToken && existingConfig?.token) { + configToTest.token = existingConfig.token; + } else { + return json({ error: 'Token is required' }, { status: 400 }); + } + } else if (body.token !== '***') { + // Encrypt the provided token for the test + configToTest.token = encrypt(body.token) ?? undefined; + } else { + // Using placeholder, get existing + const existingConfig = await getVaultConfig(); + if (existingConfig?.token) { + configToTest.token = existingConfig.token; + } else { + return json({ error: 'Token is required' }, { status: 400 }); + } + } + } + + if (body.authMethod === 'approle') { + configToTest.roleId = body.roleId; + + if (!body.secretId) { + const existingConfig = await getVaultConfig(); + if (body.keepExistingSecretId && existingConfig?.secretId) { + configToTest.secretId = existingConfig.secretId; + } else { + return json({ error: 'Secret ID is required' }, { status: 400 }); + } + } else if (body.secretId !== '***') { + configToTest.secretId = encrypt(body.secretId) ?? undefined; + } else { + const existingConfig = await getVaultConfig(); + if (existingConfig?.secretId) { + configToTest.secretId = existingConfig.secretId; + } else { + return json({ error: 'Secret ID is required' }, { status: 400 }); + } + } + } + + if (body.authMethod === 'kubernetes') { + configToTest.kubeRole = body.kubeRole; + } + } else { + // Test with saved config + const savedConfig = await getVaultConfig(); + if (!savedConfig) { + return json({ error: 'No Vault configuration found' }, { status: 400 }); + } + + configToTest = { + address: savedConfig.address, + namespace: savedConfig.namespace || undefined, + defaultPath: savedConfig.defaultPath || undefined, + authMethod: savedConfig.authMethod as VaultConfig['authMethod'], + skipTlsVerify: savedConfig.skipTlsVerify ?? false, + enabled: savedConfig.enabled ?? true, + token: savedConfig.token || undefined, + roleId: savedConfig.roleId || undefined, + secretId: savedConfig.secretId || undefined, + kubeRole: savedConfig.kubeRole || undefined + }; + } + + const result = await testVaultConnection(configToTest); + + return json({ + success: result.success, + error: result.error, + version: result.version + }); + } catch (error) { + console.error('Failed to test Vault connection:', error); + return json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to test Vault connection' + }); + } +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 568a651..51dcc4b 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -15,7 +15,8 @@ Crown, Users, Info, - GitBranch + GitBranch, + KeyRound } from 'lucide-svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; @@ -24,6 +25,7 @@ import EnvironmentsTab from './environments/EnvironmentsTab.svelte'; import RegistriesTab from './registries/RegistriesTab.svelte'; import GitTab from './git/GitTab.svelte'; + import VaultTab from './vault/VaultTab.svelte'; import ConfigSetsTab from './config-sets/ConfigSetsTab.svelte'; import NotificationsTab from './notifications/NotificationsTab.svelte'; import AuthTab from './auth/AuthTab.svelte'; @@ -63,6 +65,10 @@ Git + + + Vault + Config sets @@ -101,6 +107,10 @@ {#if activeTab === 'git'}{/if} + + {#if activeTab === 'vault'}{/if} + + {#if activeTab === 'config-sets'}{/if} diff --git a/src/routes/settings/vault/VaultTab.svelte b/src/routes/settings/vault/VaultTab.svelte new file mode 100644 index 0000000..52ce2d5 --- /dev/null +++ b/src/routes/settings/vault/VaultTab.svelte @@ -0,0 +1,514 @@ + + +
+ + + +
+
+ +
+
+ HashiCorp Vault Integration + + Configure Vault to automatically sync secrets for Git stacks + +
+
+
+
+ + {#if loading} +
+ +
+ {:else} + + + +
+ Connection Settings +
+ + (enabled = v)} + disabled={!canEdit} + /> +
+
+
+ + +
+ + +

+ The URL of your HashiCorp Vault server +

+
+ + +
+ + +

+ Vault Enterprise namespace (leave empty for OSS Vault) +

+
+ + +
+ + +

+ Default path prefix for KV v2 secrets engine +

+
+ + +
+
+ +
+ +

+ Disable TLS certificate verification. Only use for development or with trusted self-signed certificates. +

+
+
+ (skipTlsVerify = v)} + disabled={!canEdit} + /> +
+
+
+ + + + + Authentication + + + +
+ + { + if (v) authMethod = v as typeof authMethod; + }} + > + + {#if authMethod === 'token'} + Token + {:else if authMethod === 'approle'} + AppRole + {:else if authMethod === 'kubernetes'} + Kubernetes + {/if} + + + Token + AppRole + Kubernetes + + +
+ + + {#if authMethod === 'token'} +
+ + + {#if hasExistingToken} +

+ Token is configured. Enter a new value to replace it. +

+ {/if} +
+ {/if} + + + {#if authMethod === 'approle'} +
+ + +
+
+ + + {#if hasExistingSecretId} +

+ Secret ID is configured. Enter a new value to replace it. +

+ {/if} +
+ {/if} + + + {#if authMethod === 'kubernetes'} +
+ + +

+ The Vault role configured for Kubernetes auth. Dockhand will use the service account token from the pod. +

+
+ {/if} +
+
+ + + {#if canEdit} +
+ + + + + {#if testResult} +
+ {#if testResult.success} + + + Connected + {#if testResult.version} + (v{testResult.version}) + {/if} + + {:else} + + + Failed + + {/if} +
+ {/if} +
+ {/if} + + + + +
+ + How to Use +
+
+ +

+ Add a .secrets.yaml file to your Git stack repository to define which secrets to fetch from Vault: +

+
{`# .secrets.yaml
+
+secrets:
+  # 1. Simple: Env var name = Vault key name
+  - ADMIN_PASSWORD
+
+  # 2. Mapping: Different Vault key name
+  - name: DATABASE_URL
+    key: db_connection_string
+
+  # 3. Override path + key for specific secret
+  - name: SHARED_API_KEY
+    path: kv/data/shared/api
+    key: api_key
+
+# Optional: Override default path for this stack
+# (uses "Default Secret Path" from settings if omitted)
+# vault:
+#   path: kv/data/myapp`}
+

+ Secrets are automatically synced when Git stacks are deployed. You can also manually sync secrets from the stack's environment variables page. +

+
+
+ {/if} +
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index c3ec61b..a4cdfb1 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -14,7 +14,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import * as Popover from '$lib/components/ui/popover'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; - import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte'; + import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, KeyRound } from 'lucide-svelte'; import { formatPorts } from '$lib/utils/port-format'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; @@ -701,10 +701,11 @@ loading = true; } try { - const [stacksRes, sourcesRes, gitStacksRes] = await Promise.all([ + const [stacksRes, sourcesRes, gitStacksRes, vaultRes] = await Promise.all([ fetch(appendEnvParam('/api/stacks', envId)), fetch(appendEnvParam('/api/stacks/sources', envId)), - fetch(appendEnvParam('/api/git/stacks', envId)) + fetch(appendEnvParam('/api/git/stacks', envId)), + fetch('/api/vault/config') ]); // Handle stale environment ID (e.g., after database reset) @@ -728,6 +729,10 @@ const dockerStacks = await safeJson(stacksRes, []); const sourcesData = await safeJson(sourcesRes, {}); const gitStacksData = await safeJson(gitStacksRes, []); + const vaultData = await safeJson(vaultRes, {}); + + // Update vault status + vaultEnabled = vaultData?.enabled === true && !!vaultData?.address; // Debug logging if (gitStacksData?.error) { @@ -996,6 +1001,50 @@ showEditModal = true; } + // Vault integration status + let vaultEnabled = $state(false); + + // Sync git stack with specified mode (git, vault, all) + let syncingStack = $state(null); + let syncingStackMode = $state<'git' | 'vault' | 'all' | null>(null); + + async function syncGitStackWithMode(stackId: number, stackName: string, mode: 'git' | 'vault' | 'all') { + syncingStack = stackName; + syncingStackMode = mode; + try { + const response = await fetch(`/api/git/stacks/${stackId}/sync?mode=${mode}`, { method: 'POST' }); + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || 'Sync failed'); + } + + // Build success message + let message = `${stackName}: `; + if (mode === 'vault') { + message += `Synced ${data.synced ?? 0} secret(s)`; + } else if (mode === 'git') { + message += data.updated ? 'Repository updated' : 'Already up to date'; + } else { + message += data.updated ? 'Changes synced' : 'Already up to date'; + } + + if (data.deployed) { + message += data.deploySuccess ? ' (auto-deployed)' : ' (deploy failed)'; + } + + toast.success(message); + await fetchStacks(); + } catch (error) { + console.error('Sync failed:', error); + const errorMsg = error instanceof Error ? error.message : 'Sync failed'; + toast.error(`Failed to sync ${stackName}: ${errorMsg}`); + } finally { + syncingStack = null; + syncingStackMode = null; + } + } + function getStatusClasses(status: string): string { const base = 'text-xs px-1.5 py-0.5 rounded-sm font-medium inline-flex items-center gap-1 w-[6rem] justify-center shadow-sm whitespace-nowrap'; switch (status.toLowerCase()) { @@ -1702,7 +1751,7 @@ {displayStatus} - {:else if column.id === 'actions'} + {:else if column.id === 'actions'}
{#if operationError?.id === stack.name && operationError?.message}
@@ -1739,21 +1788,65 @@ {:else} {#if source.sourceType === 'git' && source.gitStack} - - {#snippet children()} - - {/snippet} - + {@const gitStackId = source.gitStack.id} + {@const isSyncing = syncingStack === stack.name} + {#if vaultEnabled} + + + + + {:else} + + + {/if} {/if} {#if $canAccess('stacks', 'edit')} {#if source.sourceType === 'git' && source.gitStack} diff --git a/src/routes/stacks/GitDeployProgressPopover.svelte b/src/routes/stacks/GitDeployProgressPopover.svelte index c4f147c..2971c84 100644 --- a/src/routes/stacks/GitDeployProgressPopover.svelte +++ b/src/routes/stacks/GitDeployProgressPopover.svelte @@ -23,14 +23,17 @@ import { appSettings } from '$lib/stores/settings'; import { watchJob } from '$lib/utils/sse-fetch'; + type SyncMode = 'git' | 'vault' | 'all' | 'deploy'; + interface Props { stackId: number; stackName: string; + mode?: SyncMode; onComplete?: () => void; children: Snippet; } - let { stackId, stackName, onComplete, children }: Props = $props(); + let { stackId, stackName, mode = 'deploy', onComplete, children }: Props = $props(); interface StepProgress { status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'env' | 'secrets' | 'deploying' | 'complete' | 'error'; @@ -71,14 +74,73 @@ return 'text-muted-foreground'; } + function getEndpointUrl(): string { + if (mode === 'deploy') { + return `/api/git/stacks/${stackId}/deploy-stream`; + } + // For sync modes, use the sync endpoint with mode parameter + return `/api/git/stacks/${stackId}/sync?mode=${mode}`; + } + + function getActionLabel(): string { + switch (mode) { + case 'git': return 'Syncing Git'; + case 'vault': return 'Syncing Vault'; + case 'all': return 'Syncing All'; + default: return 'Deploying'; + } + } + async function startDeploy() { steps = []; currentStep = null; overallStatus = 'deploying'; errorMessage = ''; + // For sync modes, use a simple POST request (not streaming) + if (mode !== 'deploy') { + try { + currentStep = { status: 'connecting', message: getActionLabel() + '...' }; + steps = [currentStep]; + + const response = await fetch(getEndpointUrl(), { method: 'POST' }); + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || 'Sync failed'); + } + + // Build success message + let message = 'Sync complete'; + if (mode === 'vault' && data.synced !== undefined) { + message = `Synced ${data.synced} secret(s)`; + } else if (mode === 'git') { + message = data.updated ? 'Repository updated' : 'Already up to date'; + } else if (mode === 'all') { + message = data.updated ? 'Changes detected' : 'Already up to date'; + } + + if (data.deployed) { + message += data.deploySuccess ? ' (auto-deployed)' : ' (deploy failed)'; + } + + overallStatus = 'complete'; + currentStep = { status: 'complete', message }; + steps = [...steps, currentStep]; + onComplete?.(); + } catch (error: any) { + console.error('Sync failed:', error); + overallStatus = 'error'; + errorMessage = error.message || 'Sync failed'; + currentStep = { status: 'error', error: errorMessage }; + steps = [...steps, currentStep]; + } + return; + } + + // Original deploy-stream logic for mode === 'deploy' try { - const response = await fetch(`/api/git/stacks/${stackId}/deploy-stream`, { + const response = await fetch(getEndpointUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' } });