Compare commits

..
Author SHA1 Message Date
mrhid6 b7561ed4e5 fix: Fixed agent ci install
Server Deploy / deploy (push) Successful in 23s
Agent Release / build (push) Successful in 35s
Agent Release / msi (push) Has been cancelled
2026-07-17 13:07:45 +01:00
mrhid6 15fdf591e0 fix: Fixed agent ci install
Server Deploy / deploy (push) Successful in 25s
Agent Release / build (push) Successful in 31s
Agent Release / msi (push) Failing after 4m15s
2026-07-17 12:40:34 +01:00
mrhid6 e09a61c0af fix: Fixed agent ci install
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 1m28s
Agent Release / msi (push) Failing after 54s
2026-07-17 12:31:13 +01:00
mrhid6 55bae898f3 fix compile error
Server Deploy / deploy (push) Successful in 1m34s
2026-07-17 12:29:04 +01:00
mrhid6 a9d602d021 feat: harden console sessions + complete protocol support
Server Deploy / deploy (push) Failing after 1m9s
Agent Release / build (push) Successful in 1m52s
Agent Release / msi (push) Failing after 52s
- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener)
- wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field)
- passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
2026-07-17 12:19:07 +01:00
mrhid6 2fe08ad7e9 fix: marshal MSI install properties into deferred CustomActionData 2026-07-17 11:54:33 +01:00
mrhid6 1ad5b5d6db fix: send ssh username to guacd (default root) for console SSH 2026-07-17 11:54:33 +01:00
mrhid6 50907448d2 feat: web console page with protocol + key selection
Includes web/lib/api.ts changes (console_protocols field, connectConsole
method) required for the console page to type-check and build; the task
brief's commit file list omitted this file.
2026-07-17 11:45:19 +01:00
mrhid6 0962745bbc feat: web console page with protocol + key selection 2026-07-17 11:45:10 +01:00
mrhid6 38c51e5a3e feat: vendor guacamole-common-js and console wrapper 2026-07-17 11:42:44 +01:00
mrhid6 c26f120e42 feat: dynamic windows install.ps1 endpoint 2026-07-17 11:39:54 +01:00
mrhid6 d206bb0541 ci: package windows agent as WiX MSI 2026-07-17 11:35:09 +01:00
mrhid6 42d1ec99a8 ci: build windows agent binary in release 2026-07-17 11:33:11 +01:00
mrhid6 91d33918bb feat: skip authorized_keys management on non-linux agents 2026-07-17 11:31:58 +01:00
mrhid6 efa5b36389 feat: OS-aware agent config directory 2026-07-17 11:30:40 +01:00
mrhid6 332c7760ca fix: keep RDP creds out of tunnel URL/logs via single-use encrypted stash 2026-07-17 11:28:00 +01:00
mrhid6 138f708a87 feat: console connect + guacd websocket tunnel endpoints 2026-07-17 11:24:24 +01:00
mrhid6 9ec3cbf901 feat: add wwt/guac dep and guacd service 2026-07-17 11:21:58 +01:00
mrhid6 86ce1b3ff7 feat: console session lifecycle persistence 2026-07-17 11:20:41 +01:00
mrhid6 307946d5aa feat: build guacd connection params per protocol 2026-07-17 11:18:59 +01:00
mrhid6 1967e966ce style: gofmt console.go 2026-07-17 11:17:18 +01:00
mrhid6 19b76044ff feat: signed expiring console session tokens 2026-07-17 11:16:11 +01:00
mrhid6 257e4fa89d feat: add ConsoleSession model 2026-07-17 11:14:46 +01:00
mrhid6 f06009b152 fix: populate console defaults on register when unset (setOnInsert never fired) 2026-07-17 11:12:57 +01:00
mrhid6 aeee7aeccf feat: infer os_type and default console config on register 2026-07-17 11:11:29 +01:00
mrhid6 d69ab709b2 feat: add console fields to Server model 2026-07-17 11:09:13 +01:00
domrichardson d7c90dea07 docs: Web console implementation plan 2026-07-17 11:02:21 +01:00
domrichardson 4edb3bf441 docs: Web console (Guacamole replacement) design spec 2026-07-17 10:56:43 +01:00
domrichardson 73a06227ba fix: Fixed endpoint
Server Deploy / deploy (push) Successful in 1m20s
2026-07-03 11:56:49 +01:00
domrichardson 7c30d26878 feat: Updated secret group yaml view
Server Deploy / deploy (push) Successful in 1m38s
2026-07-03 11:33:20 +01:00
domrichardson 19596ff2a3 feat: Secret management
Server Deploy / deploy (push) Successful in 1m33s
2026-07-03 10:37:43 +01:00
domrichardson c3c16083f7 feat: Audit and settings
Server Deploy / deploy (push) Successful in 1m26s
2026-06-25 11:30:26 +01:00
domrichardson e37a09ef0d feat: Servers status icon
Server Deploy / deploy (push) Successful in 2m32s
2026-06-25 10:10:43 +01:00
domrichardson 02e84ed548 feat: Updates button
Server Deploy / deploy (push) Successful in 1m25s
2026-06-25 09:51:45 +01:00
49 changed files with 5042 additions and 83 deletions
+64 -1
View File
@@ -36,10 +36,13 @@ jobs:
GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-arm64 ./cmd
GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-windows-amd64.exe ./cmd
- name: Checksums
working-directory: agent/dist
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 > checksums.txt
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
@@ -48,4 +51,64 @@ jobs:
files: |
agent/dist/vantage-agent-linux-amd64
agent/dist/vantage-agent-linux-arm64
agent/dist/vantage-agent-windows-amd64.exe
agent/dist/checksums.txt
msi:
needs: build
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: agent/go.sum
- name: Build agent exe
working-directory: agent
shell: pwsh
run: |
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -o ../installer/vantage-agent-windows-amd64.exe ./cmd
- name: Fetch nssm
working-directory: installer
shell: pwsh
run: |
Invoke-WebRequest -Uri https://nssm.cc/release/nssm-2.24.zip -OutFile nssm.zip
Expand-Archive -Path nssm.zip -DestinationPath nssm-extract -Force
Copy-Item nssm-extract/nssm-2.24/win64/nssm.exe -Destination nssm.exe
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.*
- name: Build MSI
working-directory: installer
shell: pwsh
run: |
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
wix build vantage-agent.wxs -o vantage-agent.msi
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
- name: Attach MSI to release
working-directory: installer
shell: pwsh
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
$headers = @{ Authorization = "token $env:TOKEN" }
# gitea-release-action can't find a slashed tag, so append via the API directly
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
$name = [uri]::EscapeDataString($f)
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
-ContentType "application/octet-stream" `
-Uri "$api/releases/$($rel.id)/assets?name=$name"
}
+8 -1
View File
@@ -1,4 +1,11 @@
node_modules
dist
build
.env
.env
docs
.superpowers
installer/*.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
+18 -4
View File
@@ -2,12 +2,26 @@ package config
import (
"os"
"path/filepath"
"runtime"
"time"
"gopkg.in/yaml.v3"
)
const ConfigPath = "/etc/vantage/config.yaml"
// ConfigDir returns the platform-specific config directory.
func ConfigDir() string {
if runtime.GOOS == "windows" {
base := os.Getenv("ProgramData")
if base == "" {
base = `C:\ProgramData`
}
return filepath.Join(base, "vantage")
}
return "/etc/vantage"
}
func configPath() string { return filepath.Join(ConfigDir(), "config.yaml") }
type Config struct {
ServerURL string `yaml:"server_url"`
@@ -19,7 +33,7 @@ type Config struct {
}
func Load() (*Config, error) {
data, err := os.ReadFile(ConfigPath)
data, err := os.ReadFile(configPath())
if err != nil {
return nil, err
}
@@ -38,8 +52,8 @@ func Save(cfg *Config) error {
if err != nil {
return err
}
if err := os.MkdirAll("/etc/vantage", 0700); err != nil {
if err := os.MkdirAll(ConfigDir(), 0700); err != nil {
return err
}
return os.WriteFile(ConfigPath, data, 0600)
return os.WriteFile(configPath(), data, 0600)
}
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"runtime"
"strings"
"testing"
)
func TestConfigDirByOS(t *testing.T) {
d := ConfigDir()
if runtime.GOOS == "windows" {
if !strings.Contains(strings.ToLower(d), "programdata") {
t.Fatalf("windows config dir = %q, want ProgramData path", d)
}
} else {
if d != "/etc/vantage" {
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
}
}
}
+5
View File
@@ -91,6 +91,11 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return fmt.Errorf("SyncKeys: %w", err)
}
// Windows agents register and heartbeat only — no authorized_keys management.
if runtime.GOOS != "linux" {
return nil
}
current, err := keys.ReadAuthorizedKeys()
if err != nil {
return fmt.Errorf("read authorized_keys: %w", err)
+21 -33
View File
@@ -1,37 +1,30 @@
services:
mongo:
image: mongo:8
restart: unless-stopped
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
redis:
image: redis:8
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test:
- CMD
- redis-cli
- ping
interval: 10s
timeout: 5s
retries: 5
server:
build:
context: ../server
dockerfile: Dockerfile
guacd:
image: docker.io/guacamole/guacd:1.6.0
restart: unless-stopped
ports:
- "8080:8080"
- "9090:9090"
- 4822:4822
server:
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
restart: unless-stopped
ports:
- 8080:8080
- 9090:9090
environment:
MONGO_URI: mongodb://mongo:27017/vantage
MONGO_URI: ${MONGO_URI:-}
REDIS_ADDR: redis:6379
GITEA_HOST: ${GITEA_HOST}
PUBLIC_HOST: ${PUBLIC_HOST}
@@ -42,24 +35,19 @@ services:
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
GUACD_ADDR: guacd:4822
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
web:
build:
context: ../web
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://server:8080
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
restart: unless-stopped
ports:
- "3000:3000"
- 3000:3000
depends_on:
- server
volumes:
mongo_data:
redis_data:
mongo_data: null
redis_data: null
networks: {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
# Vantage Web Console (Guacamole Replacement) — Design
**Date:** 2026-07-17
**Status:** Approved design, pre-implementation
## Goal
Add a browser-based remote-access console to Vantage — SSH, RDP, and VNC into
managed servers — as a self-hosted Guacamole replacement. Users select an SSH
key to connect over SSH. RDP targets are reachable from a new Windows agent that
registers the host and reports status. Windows agent ships as an MSI installer
produced by CI.
## Non-Goals (YAGNI)
- Session recording / replay (may be added later).
- Native Go RDP implementation (guacd handles protocol translation).
- Per-user Linux/Windows account management from the agent.
- Tunneling console traffic through the agent (direct network path assumed).
---
## Architecture
```
Browser (guacamole-common-js, vendored — no CDN)
│ Guacamole protocol over WebSocket
Go server: /api/console/tunnel (github.com/wwt/guac)
│ Guacamole protocol over TCP :4822
guacd container (Apache Guacamole daemon)
│ SSH :22 / RDP :3389 / VNC :5900 — direct to target IP
Target host (LAN / VPN line-of-sight from server)
```
- **Browser:** loads vendored `guacamole-common-js`, renders RDP/VNC display and
SSH terminal. No external CDN (matches existing infra rules).
- **Go server:** exposes a WebSocket tunnel endpoint using `github.com/wwt/guac`
(Go Guacamole tunnel library). No Java `guacamole-client` required.
- **guacd:** new container in `deploy/docker-compose.yml`, bound to the internal
docker network only, reachable by the server on `:4822`.
- **Network path:** guacd connects **directly** to the target IP. Requires the
central server to have network line-of-sight to hosts (homelab LAN / VPN). The
agent's outbound-only guarantee is unchanged — the console path is
server→target, not agent-mediated.
---
## Data Model Changes
### `keys` — extend to hold private material
```json
{
"key_id": "uuid",
"label": "dom-macbook",
"public_key": "ssh-ed25519 AAAA...",
"private_key_enc": "<AES-256-GCM ciphertext | null>",
"has_private": true,
"passphrase_enc": "<AES-256-GCM ciphertext | null>",
"fingerprint": "SHA256:...",
"source": "uploaded|generated",
"created_at": "ISODate"
}
```
- A key may be created from an uploaded **private+public** pair, upload of a
public key only, or agent generation.
- Agent key generation now also uploads `private_key_enc` (reuses the existing
AES-256 key used for at-rest encryption). Private key no longer stays local
only — it is stored encrypted so the console can reuse it.
- Optional `passphrase_enc` for passphrase-protected private keys.
- Console lists only keys where `has_private = true`.
### `servers` — extend with console metadata
```json
{
"...": "...existing fields...",
"os_type": "linux|windows",
"console_protocols": ["ssh"],
"ssh_port": 22,
"rdp_port": 3389
}
```
- `os_type` set at registration from the agent.
- `console_protocols` lists enabled protocols per server (`ssh`, `rdp`, `vnc`).
- Port fields default to standard ports, overridable in the UI.
### `console_sessions` — new collection (audit)
```json
{
"session_id": "uuid",
"server_id": "uuid",
"protocol": "ssh|rdp|vnc",
"key_id": "uuid | null",
"user": "who opened it",
"started_at": "ISODate",
"ended_at": "ISODate | null",
"client_ip": "string"
}
```
---
## Session Broker + Connection Flow
New service: `server/internal/services/console.go`.
1. Browser `POST /api/console/connect`
`{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`.
2. Broker validates request, loads the server (host IP, port for protocol),
loads the key and **decrypts `private_key_enc` in memory only**.
3. Builds the guacd connection parameter map:
- **SSH:** `hostname`, `port`, `username`, `private-key` (decrypted),
`passphrase` (if any).
- **RDP:** `hostname`, `port`, `username`, `password`, `security=any`,
`ignore-cert=true`.
- **VNC:** `hostname`, `port`, `password`.
4. Creates a `console_sessions` document, returns a short-lived signed session
token.
5. Browser opens WebSocket `/api/console/tunnel?token=…`. The `wwt/guac` handler
validates the token, dials guacd `:4822`, and pipes bytes in both directions.
6. On socket close, the broker sets `ended_at` on the session doc.
### Security
- Decrypted private keys and RDP passwords are **never persisted, never logged,
never sent to the browser** — passed only to guacd.
- Session token: short TTL (~60s to open the WebSocket), single-use,
HMAC-signed, bound to the authenticated user.
- guacd is bound to the internal docker network only; not exposed publicly.
- At-rest encryption (`private_key_enc`, `passphrase_enc`) reuses the existing
AES-256 key already used for agent-generated private keys.
---
## Windows Agent
Same Go codebase as the Linux agent, with a reduced role: **register +
heartbeat + status only**. No `authorized_keys` management (meaningless on
Windows).
- Build target: `GOOS=windows GOARCH=amd64``vantage-agent-windows-amd64.exe`.
- Agent detects OS at registration and sends `os_type=windows`.
- The key-sync loop is disabled on Windows via a runtime OS check (or build tag)
— no `authorized_keys` writes are ever attempted.
- Config file: `C:\ProgramData\vantage\config.yaml`, locked down via ACL to the
equivalent of `0600`.
- Runs as a Windows service via **nssm**.
---
## Windows Installer (MSI)
Agent ships as a WiX v4 MSI produced in CI.
- **WiX v4** chosen because it is a dotnet tool that builds MSIs
**cross-platform** — runs on the Linux Gitea act_runner. (Inno Setup is
Windows-only and does not fit the runner.)
- MSI bundles `vantage-agent.exe`, installs it to `C:\Program Files\Vantage\`,
and registers the nssm service (ships nssm or uses a CustomAction).
- Accepts install parameters as MSI properties for silent/headless install:
```
msiexec /i vantage-agent.msi /qn SERVERID=<id> TOKEN=<token> SERVERURL=vantage..:9090
```
- GUI install (double-click) prompts for server-id / token / server-url via a
dialog.
### Two install paths
1. **Installer direct** — user downloads `vantage-agent.msi`, double-clicks,
fills the dialog. No script required.
2. **PowerShell one-liner** — served dynamically (like the existing bash
`/install`). Script downloads the `.msi`, verifies SHA-256, then runs
`msiexec /qn` with injected `SERVERID` / `TOKEN` / `SERVERURL`. Used by the
copy-paste "Add Server" flow.
The PowerShell script (`/install.ps1`) steps:
1. Detect arch.
2. Download `vantage-agent.msi` from the latest Gitea `agent/v*` release.
3. Verify SHA-256 against `checksums.txt`.
4. Run `msiexec /i vantage-agent.msi /qn SERVERID=.. TOKEN=.. SERVERURL=..`.
---
## Frontend Routes
| Route | Change |
| ------------------------- | ------------------------------------------------------------- |
| `/servers` | Show `os_type` badge, enabled console protocols |
| `/servers/[id]` | Add **Connect** button(s) per enabled protocol |
| `/servers/[id]/console` | New — full-screen console (guacamole-common-js), key picker |
| `/servers/new` | Offer Windows (MSI) vs Linux (bash) install instructions |
Console page: select protocol + SSH key (SSH) or enter RDP credentials, call
`/api/console/connect`, open the tunnel WebSocket, mount the Guacamole client.
---
## CI/CD Changes
### `agent-release.yml`
- Add `windows/amd64` build: `vantage-agent-windows-amd64.exe`.
- Add WiX v4 MSI build job → `vantage-agent.msi`.
- Add both to `checksums.txt` and release assets.
Release assets become:
- `vantage-agent-linux-amd64`
- `vantage-agent-linux-arm64`
- `vantage-agent-windows-amd64.exe`
- `vantage-agent.msi`
- `checksums.txt`
### `server-deploy.yml`
- Add guacd service to `deploy/docker-compose.yml` (deployed alongside server).
---
## New Dependencies
- **Go:** `github.com/wwt/guac` (Guacamole tunnel/WebSocket in Go).
- **Container:** `guacamole/guacd` official image.
- **Frontend:** vendored `guacamole-common-js` (no CDN).
- **CI:** WiX v4 dotnet tool; nssm binary bundled for the MSI.
---
## Open Implementation Notes
- Confirm `wwt/guac` API surface for connection-parameter passing and token auth
binding during implementation.
- nssm packaging inside MSI: bundle the nssm binary as a payload + CustomAction,
or run `sc.exe`-based service install if nssm proves awkward in WiX.
- ACL hardening of `C:\ProgramData\vantage\config.yaml` in the MSI CustomAction.
+25
View File
@@ -0,0 +1,25 @@
param(
[string]$ServerId,
[string]$Token,
[string]$ServerUrl,
[string]$InstallDir
)
$cfgDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
$cfg = @"
server_url: "$ServerUrl"
server_id: "$ServerId"
pre_reg_token: "$Token"
agent_token: ""
poll_interval: 30s
tls: true
"@
Set-Content -Path (Join-Path $cfgDir "config.yaml") -Value $cfg -Encoding utf8
# Lock down ACL: SYSTEM + Administrators only
icacls (Join-Path $cfgDir "config.yaml") /inheritance:r /grant:r "SYSTEM:F" "Administrators:F" | Out-Null
$nssm = Join-Path $InstallDir "nssm.exe"
$exe = Join-Path $InstallDir "vantage-agent.exe"
& $nssm install VantageAgent $exe
& $nssm set VantageAgent Start SERVICE_AUTO_START
& $nssm start VantageAgent
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Vantage Agent" Manufacturer="Vantage"
Version="1.0.0.0" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
<MediaTemplate EmbedCab="yes" />
<!-- Public properties settable via msiexec: SERVERID, TOKEN, SERVERURL -->
<Property Id="SERVERID" Secure="yes" />
<Property Id="TOKEN" Secure="yes" />
<Property Id="SERVERURL" Secure="yes" />
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="Vantage">
<Component Id="AgentExe" Guid="*">
<File Id="AgentExe" Source="vantage-agent-windows-amd64.exe" Name="vantage-agent.exe" KeyPath="yes" />
</Component>
<Component Id="NssmExe" Guid="*">
<File Id="NssmExe" Source="nssm.exe" Name="nssm.exe" KeyPath="yes" />
</Component>
<Component Id="SetupScript" Guid="*">
<File Id="SetupScript" Source="setup.ps1" Name="setup.ps1" KeyPath="yes" />
</Component>
</Directory>
</StandardDirectory>
<Feature Id="Main">
<ComponentRef Id="AgentExe" />
<ComponentRef Id="NssmExe" />
<ComponentRef Id="SetupScript" />
</Feature>
<!-- Write config.yaml, then install + start the service via nssm.
Implemented as sequenced CustomActions running a helper script.
Deferred CustomActions run out-of-process (and with Impersonate="no",
as SYSTEM) with NO access to the installer property table, so
"[SERVERID]"/"[TOKEN]"/"[SERVERURL]"/"[INSTALLDIR]" would resolve to
empty strings if referenced directly on the deferred action. The fix
is the standard CustomActionData marshaling pattern: an immediate
SetProperty (type 51) with the SAME Id as the deferred CustomAction
runs first (while property values are still visible) and resolves
the formatted string; the deferred Directory/ExeCommand CustomAction
that shares that Id then receives the resolved string back as its
CustomActionData, referenced here as "[WriteConfig]". This avoids
pulling in the WixToolset.Util extension (WixQuietExec64) purely to
get CustomActionData plumbing.
NOTE: this only builds/validates the MSI's XML in CI - it has not
been verified with a real install on Windows. Needs a smoke test
(msiexec /i, confirm C:\ProgramData\Vantage\config.yaml or similar
is written with the correct values, and the service starts) on an
actual Windows machine before this is trusted in production. -->
<SetProperty Id="WriteConfig"
Before="WriteConfig" Sequence="execute" Condition="NOT Installed"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -ServerId "[SERVERID]" -Token "[TOKEN]" -ServerUrl "[SERVERURL]" -InstallDir "[INSTALLDIR]"' />
<CustomAction Id="WriteConfig" Directory="INSTALLDIR" ExeCommand="[WriteConfig]"
Execute="deferred" Impersonate="no" Return="check" />
<InstallExecuteSequence>
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed" />
</InstallExecuteSequence>
</Package>
</Wix>
+8 -2
View File
@@ -23,6 +23,10 @@ func main() {
}
log.Println("connected to MongoDB")
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if err := auth.InitRedis(redisAddr); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
@@ -38,7 +42,7 @@ func main() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := services.MarkOfflineServers(5 * time.Minute); err != nil {
if err := services.MarkOfflineServers(); err != nil {
log.Printf("mark offline error: %v", err)
}
}
@@ -52,7 +56,9 @@ func main() {
}()
// Start REST server
r := gin.Default()
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
r.Use(corsMiddleware())
api.RegisterRoutes(r)
+4
View File
@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.20.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.2.2
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
@@ -26,14 +27,17 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+12
View File
@@ -40,8 +40,11 @@ github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
@@ -50,6 +53,8 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -65,10 +70,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -81,6 +90,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@@ -116,6 +127,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"net"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
// POST /api/console/connect
// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? }
// Returns: { session_id, token, ws_path }
func consoleConnect(c *gin.Context) {
var body struct {
ServerID string `json:"server_id" binding:"required"`
Protocol string `json:"protocol" binding:"required"`
KeyID string `json:"key_id"`
RDPUsername string `json:"rdp_username"`
RDPPassword string `json:"rdp_password"`
SSHUsername string `json:"ssh_username"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
srv, err := services.GetServer(body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
token, err := services.SignSessionToken(sess.SessionID, time.Minute)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.Protocol == "ssh" {
if err := services.SetConsoleSSHUser(sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+")")
c.JSON(http.StatusOK, gin.H{
"session_id": sess.SessionID,
"token": token,
"ws_path": "/api/console/tunnel",
})
}
// GET /api/console/tunnel?token=... (WebSocket upgrade)
func consoleTunnel(c *gin.Context) {
token := c.Query("token")
sessionID, err := services.VerifySessionToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
sess, err := services.GetConsoleSession(sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// User-bound: the caller (authenticated via session cookie) must be the same
// user who opened the session. Blocks a leaked token being used by someone else.
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
// Single-use: atomically spend the token so a replay within its TTL is rejected.
if err := services.ConsumeSessionToken(sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Decrypt private key + passphrase in-memory only (ssh).
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
}
passphrase, _ = services.GetPassphrase(sess.KeyID)
}
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
}
}
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
guacdAddr := os.Getenv("GUACD_ADDR")
if guacdAddr == "" {
guacdAddr = "guacd:4822"
}
// Build a guac tunnel config from our params.
connect := func(r *http.Request) (guac.Tunnel, error) {
config := guac.NewGuacamoleConfiguration()
config.Protocol = gp.Protocol
for k, v := range gp.Params {
config.Parameters[k] = v
}
config.OptimalScreenWidth = 1024
config.OptimalScreenHeight = 768
config.OptimalResolution = 96
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
if err != nil {
return nil, err
}
conn, err := net.DialTCP("tcp", nil, addr)
if err != nil {
return nil, err
}
stream := guac.NewStream(conn, guac.SocketTimeout)
if err := stream.Handshake(config); err != nil {
return nil, err
}
return guac.NewSimpleTunnel(stream), nil
}
wsServer := guac.NewWebsocketServer(connect)
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
_ = services.EndConsoleSession(sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
@@ -11,10 +12,25 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
func actorFromCtx(c *gin.Context) string {
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
return sess.Email
}
return "admin"
}
func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/install.ps1", handleInstallScriptWindows)
r.GET("/update", handleUpdateScript)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Lives under /api (so the reverse
// proxy routes it to the backend) but on a distinct subpath to avoid
// colliding with the session-authed GET /api/secrets/:group. Returns a
// group as flat JSON.
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
@@ -37,6 +53,20 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
apiGroup.GET("/secrets", listSecretGroups)
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
@@ -44,6 +74,9 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", consoleConnect)
apiGroup.GET("/console/tunnel", consoleTunnel)
}
}
@@ -75,6 +108,7 @@ func newServer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -120,10 +154,16 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hostname := id
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -160,6 +200,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -181,17 +222,19 @@ func createKey(c *gin.Context) {
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
PrivateKey string `json:"private_key"`
Passphrase string `json:"passphrase"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
@@ -227,10 +270,16 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
label := id
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -249,6 +298,7 @@ func assignKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -260,6 +310,7 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -285,6 +336,7 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
@@ -303,6 +355,7 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
}
@@ -362,6 +415,47 @@ echo "vantage-agent updated to ${VERSION} and restarted."
c.String(http.StatusOK, script)
}
func listAuditEvents(c *gin.Context) {
limit := int64(100)
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(body.Alerts, body.Email); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
func handleInstallScriptWindows(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = os.Getenv("PUBLIC_HOST")
}
if grpcHost == "" {
grpcHost = "vantage.example.com"
}
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
"$ErrorActionPreference = \"Stop\"\n"+
"\n"+
"$ServerId = \"%s\"\n"+
"$Token = \"%s\"\n"+
"$GiteaHost = \"%s\"\n"+
"$ServerUrl = \"%s\" -replace '^https?://',''\n"+
"\n"+
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
"$enc = $tag -replace '/','%%2F'\n"+
"$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+
"\n"+
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
"\n"+
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
"\n"+
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn SERVERID=$ServerId TOKEN=$Token SERVERURL=$ServerUrl\"\n"+
"Write-Host \"Vantage agent installed.\"\n",
serverID, token, giteaHost, grpcHost)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, script)
}
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
)
// groupNamePattern restricts group and key names to characters that are safe
// in URLs and Kubernetes/env contexts.
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
if len(values) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, values)
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
// be created with at least one key/value pair.
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
Values map[string]string `json:"values"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validName(body.Group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
if len(body.Values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "a group must be created with at least one key"})
return
}
for k := range body.Values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(secrets) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
// putSecretGroup upserts one or more keys into an existing (or new) group.
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
var values map[string]string
if err := c.ShouldBindJSON(&values); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
return
}
for k := range values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+2 -1
View File
@@ -57,7 +57,8 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
// Agent-generated keys carry no passphrase over the wire (proto has no field).
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
EventType string `bson:"event_type" json:"event_type"`
Actor string `bson:"actor" json:"actor"`
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+28
View File
@@ -0,0 +1,28 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ConsoleSession struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
User string `bson:"user" json:"user"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
// TokenConsumedAt marks the one-time session token as spent. Set atomically
// when the tunnel opens; a second open with the same token is rejected.
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
RDPUserEnc string `bson:"rdp_user_enc,omitempty" json:"-"`
RDPPassEnc string `bson:"rdp_pass_enc,omitempty" json:"-"`
}
+2
View File
@@ -16,5 +16,7 @@ type Key struct {
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
HasPrivateKey bool `bson:"-" json:"has_private_key"`
PassphraseEncrypted string `bson:"passphrase_enc,omitempty" json:"-"`
HasPassphrase bool `bson:"-" json:"has_passphrase"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Secret is a single key/value pair within a group. The value is stored
// encrypted (AES-256-GCM) and is never serialized to JSON.
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Group string `bson:"group" json:"group"`
Key string `bson:"key" json:"key"`
EncryptedValue string `bson:"encrypted_value" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// GroupSummary describes a group in the list view.
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
UpdatedAt time.Time `json:"updated_at"`
}
+4
View File
@@ -18,6 +18,10 @@ type Server struct {
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
+39
View File
@@ -0,0 +1,39 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
// SecretsSettings holds configuration for the secrets vault / ESO integration.
// The read token is stored as a SHA-256 hash and never returned to clients.
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
}
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"context"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func LogEvent(eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
EventType: eventType,
Actor: actor,
ServerID: serverID,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
}
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
log.Printf("audit log error: %v", err)
}
}
func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var events []models.AuditEvent
if err := cursor.All(ctx, &events); err != nil {
return nil, err
}
return events, nil
}
+250
View File
@@ -0,0 +1,250 @@
package services
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
func sessionHMACKey() ([]byte, error) {
// Reuse the AES key material as the HMAC secret. Distinct domain via prefix.
k, err := encryptionKey()
if err != nil {
return nil, err
}
mac := hmac.New(sha256.New, k)
mac.Write([]byte("vantage-console-session-v1"))
return mac.Sum(nil), nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// SignSessionToken returns a signed, expiring token binding a session id.
func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
key, err := sessionHMACKey()
if err != nil {
return "", err
}
exp := time.Now().Add(ttl).Unix()
payload := fmt.Sprintf("%s.%d", b64([]byte(sessionID)), exp)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return payload + "." + b64(mac.Sum(nil)), nil
}
// VerifySessionToken checks signature + expiry and returns the session id.
func VerifySessionToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("malformed token")
}
payload := parts[0] + "." + parts[1]
key, err := sessionHMACKey()
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return "", fmt.Errorf("invalid signature")
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", fmt.Errorf("invalid expiry")
}
if time.Now().Unix() > exp {
return "", fmt.Errorf("token expired")
}
sid, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("invalid session id")
}
return string(sid), nil
}
type GuacParams struct {
Protocol string
Params map[string]string
}
func portOr(v, def int) string {
if v == 0 {
v = def
}
return strconv.Itoa(v)
}
// BuildGuacParams assembles the guacd connection parameter map for a protocol.
// privateKey/passphrase are the decrypted SSH private key and its optional
// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries
// the password for vnc. None of these values are persisted or logged by the caller.
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
host := srv.IPAddress
switch protocol {
case "ssh":
p := map[string]string{
"hostname": host,
"port": portOr(srv.SSHPort, 22),
}
if sshUser == "" {
sshUser = "root"
}
p["username"] = sshUser
if privateKey != "" {
p["private-key"] = privateKey
}
if passphrase != "" {
p["passphrase"] = passphrase
}
return &GuacParams{Protocol: "ssh", Params: p}, nil
case "rdp":
return &GuacParams{Protocol: "rdp", Params: map[string]string{
"hostname": host,
"port": portOr(srv.RDPPort, 3389),
"username": rdpUser,
"password": rdpPass,
"security": "any",
"ignore-cert": "true",
}}, nil
case "vnc":
return &GuacParams{Protocol: "vnc", Params: map[string]string{
"hostname": host,
"port": "5900",
"password": rdpPass,
}}, nil
default:
return nil, fmt.Errorf("unsupported protocol %q", protocol)
}
}
func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
KeyID: keyID,
User: user,
ClientIP: clientIP,
StartedAt: time.Now(),
}
if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil {
return nil, err
}
return s, nil
}
func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.ConsoleSession
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
}
// StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the
// session document. They are consumed (and cleared) when the tunnel opens.
func StashConsoleRDPCreds(sessionID, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
u, err := encryptString(username)
if err != nil {
return err
}
p, err := encryptString(password)
if err != nil {
return err
}
_, err = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
}
// ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then
// clears them from the session document (single-use). Returns empty strings if
// none were stored.
func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(sessionID)
if err != nil {
return "", "", err
}
if s.RDPUserEnc == "" && s.RDPPassEnc == "" {
return "", "", nil
}
if s.RDPUserEnc != "" {
if username, err = decryptString(s.RDPUserEnc); err != nil {
return "", "", err
}
}
if s.RDPPassEnc != "" {
if password, err = decryptString(s.RDPPassEnc); err != nil {
return "", "", err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
// SetConsoleSSHUser persists the SSH username to use on the session doc.
func SetConsoleSSHUser(sessionID, username string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
// ConsumeSessionToken atomically marks a session's one-time token as spent.
// It returns an error if the token was already consumed (replay) or the session
// does not exist, so the tunnel can be opened at most once per issued token.
func ConsumeSessionToken(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return fmt.Errorf("session token already used")
}
return nil
}
func EndConsoleSession(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"testing"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestSessionTokenRoundTrip(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", time.Minute)
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := VerifySessionToken(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got != "sess-123" {
t.Fatalf("got %q want sess-123", got)
}
}
func TestSessionTokenExpired(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", -time.Second)
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := VerifySessionToken(tok); err == nil {
t.Fatalf("expected expiry error, got nil")
}
}
func TestSessionTokenTampered(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, _ := SignSessionToken("sess-123", time.Minute)
if _, err := VerifySessionToken(tok + "x"); err == nil {
t.Fatalf("expected signature error, got nil")
}
}
func TestBuildGuacParamsSSH(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "ssh" {
t.Fatalf("protocol %q", p.Protocol)
}
if p.Params["hostname"] != "10.0.0.5" || p.Params["port"] != "22" {
t.Fatalf("bad host/port: %+v", p.Params)
}
if p.Params["private-key"] != "PRIVATE-KEY-DATA" {
t.Fatalf("missing private-key")
}
if p.Params["username"] != "root" {
t.Fatalf("expected default username root, got %q", p.Params["username"])
}
}
func TestBuildGuacParamsRDP(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389}
p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["port"] != "3389" || p.Params["username"] != "administrator" || p.Params["password"] != "s3cret" {
t.Fatalf("bad rdp params: %+v", p.Params)
}
if p.Params["ignore-cert"] != "true" {
t.Fatalf("expected ignore-cert=true")
}
}
func TestBuildGuacParamsUnknownProtocol(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9"}
if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil {
t.Fatalf("expected error for unknown protocol")
}
}
func TestBuildGuacParamsSSHPassphrase(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["username"] != "deploy" {
t.Fatalf("username %q", p.Params["username"])
}
if p.Params["passphrase"] != "s3cret-phrase" {
t.Fatalf("missing passphrase: %+v", p.Params)
}
}
func TestBuildGuacParamsVNC(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.7"}
p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" {
t.Fatalf("bad vnc params: %+v", p.Params)
}
}
+9 -2
View File
@@ -22,7 +22,9 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
// encryptString encrypts a plaintext value with AES-256-GCM using the
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -43,7 +45,8 @@ func encryptPrivateKey(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
// decryptString reverses encryptString.
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -70,3 +73,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
}
return string(plaintext), nil
}
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
+25 -1
View File
@@ -33,9 +33,10 @@ func computeFingerprint(pubKey string) string {
func setKeyMeta(k *models.Key) {
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
KeyID: uuid.NewString(),
Label: label,
@@ -52,6 +53,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
}
key.PrivateKeyEncrypted = enc
}
if passphrase != "" {
enc, err := encryptString(passphrase)
if err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
key.PassphraseEncrypted = enc
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -90,6 +98,22 @@ func GetPrivateKey(keyID string) (string, error) {
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
// if the key has none stored.
func GetPassphrase(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return "", err
}
if key.PassphraseEncrypted == "" {
return "", nil
}
return decryptString(key.PassphraseEncrypted)
}
type KeyWithCount struct {
models.Key `bson:",inline"`
AssignedCount int `bson:"-" json:"assigned_count"`
+174
View File
@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"sort"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
{Key: "updated_at", Value: bson.D{{Key: "$max", Value: "$updated_at"}}},
}}},
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := db.Col("secrets").Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var rows []struct {
Group string `bson:"_id"`
KeyCount int `bson:"key_count"`
UpdatedAt time.Time `bson:"updated_at"`
}
if err := cursor.All(ctx, &rows); err != nil {
return nil, err
}
groups := make([]models.GroupSummary, 0, len(rows))
for _, r := range rows {
groups = append(groups, models.GroupSummary{
Group: r.Group,
KeyCount: r.KeyCount,
UpdatedAt: r.UpdatedAt,
})
}
return groups, nil
}
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []models.Secret
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
return docs, nil
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
if err != nil {
return nil, err
}
result := make(map[string]string, len(docs))
for _, doc := range docs {
val, err := decryptString(doc.EncryptedValue)
if err != nil {
return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err)
}
result[doc.Key] = val
}
return result, nil
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
if err != nil {
return "", err
}
return decryptString(doc.EncryptedValue)
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for key, val := range values {
encrypted, err := encryptString(val)
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"$set": bson.M{
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}
}
return nil
}
// SortedKeys returns the map keys sorted — handy for stable audit messages.
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
return err
}
+82 -13
View File
@@ -79,6 +79,25 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
return &s, nil
}
// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the
// agent-reported os_info string, which is formatted "<GOOS> <GOARCH>".
// Anything that is not explicitly windows defaults to linux.
func OSTypeFromInfo(osInfo string) string {
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
return "windows"
}
return "linux"
}
// defaultConsoleFields returns the initial console configuration for a newly
// registered server based on its os_type.
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
if osType == "windows" {
return []string{"rdp"}, 22, 3389
}
return []string{"ssh"}, 22, 3389
}
func RegisterServer(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -100,18 +119,31 @@ func RegisterServer(serverID, preRegToken, hostname, ipAddress, osInfo string) (
tokenHash := HashToken(agentToken)
now := time.Now()
osType := OSTypeFromInfo(osInfo)
protocols, sshPort, rdpPort := defaultConsoleFields(osType)
setFields := bson.M{
"hostname": hostname,
"ip_address": ipAddress,
"os_info": osInfo,
"os_type": osType,
"agent_token_hash": tokenHash,
"status": "active",
"last_seen": now,
"pre_reg_token": "",
"pre_reg_expires": nil,
}
if len(s.ConsoleProtocols) == 0 {
setFields["console_protocols"] = protocols
setFields["ssh_port"] = sshPort
setFields["rdp_port"] = rdpPort
}
_, err = db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID},
bson.M{"$set": bson.M{
"hostname": hostname,
"ip_address": ipAddress,
"os_info": osInfo,
"agent_token_hash": tokenHash,
"status": "active",
"last_seen": now,
"pre_reg_token": "",
"pre_reg_expires": nil,
}},
bson.M{
"$set": setFields,
},
)
if err != nil {
return "", err
@@ -197,12 +229,49 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
return err
}
func MarkOfflineServers(threshold time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
func MarkOfflineServers() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
_, err := db.Col("servers").UpdateMany(ctx,
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
@@ -0,0 +1,18 @@
package services
import "testing"
func TestOSTypeFromInfo(t *testing.T) {
cases := map[string]string{
"windows amd64": "windows",
"linux amd64": "linux",
"linux arm64": "linux",
"": "linux",
"darwin arm64": "linux",
}
for in, want := range cases {
if got := OSTypeFromInfo(in); got != want {
t.Errorf("OSTypeFromInfo(%q) = %q, want %q", in, got, want)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func GetSettings() (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
return &cp, nil
}
if err != nil {
return nil, err
}
s.Secrets.ReadTokenSet = s.Secrets.ReadTokenHash != ""
return &s, nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return "", err
}
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
if token == "" {
return false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{"alerts": alerts, "email": email}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS.
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load audit log.</div>
) : events && events.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</Card>
</div>
);
}
+15 -1
View File
@@ -12,9 +12,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
@@ -70,6 +71,19 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase{" "}
<span className="text-text-tertiary font-normal">(optional for an encrypted private key)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
+311
View File
@@ -0,0 +1,311 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-text-secondary">{label}</span>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs leading-relaxed text-text-primary">{yaml}</pre>
</div>
);
}
function YamlModal({ group, onClose }: { group: string; onClose: () => void }) {
const [namespace, setNamespace] = useState(group);
const readUrl = typeof window !== "undefined" ? window.location.origin : "https://vantage.example.com";
const ns = namespace.trim() || group;
const externalSecret = `apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ${group}
namespace: ${ns}
spec:
refreshInterval: 15m
secretStoreRef:
name: ${STORE_NAME}
kind: ClusterSecretStore
target:
name: ${group}
creationPolicy: Owner
dataFrom:
- extract:
key: ${group}`;
const clusterStore = `apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: ${STORE_NAME}
spec:
provider:
webhook:
url: "${readUrl}/api/secrets/{{ .remoteRef.key }}/values"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: vantage-eso-token
namespace: external-secrets`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">
Kubernetes manifests for <span className="font-mono">{group}</span>
</h2>
<p className="mb-5 text-sm text-text-secondary">Apply the ExternalSecret in your app&apos;s namespace to sync this group into a Kubernetes Secret via ESO.</p>
<div className="mb-5">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Namespace</label>
<input type="text" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={group} className={`${inputClass} font-mono`} />
</div>
<div className="space-y-5">
<CopyBlock label="ExternalSecret (apply per namespace)" yaml={externalSecret} />
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary">One-time cluster setup: ClusterSecretStore</summary>
<p className="mb-3 mt-2 text-xs text-text-tertiary">
Apply this once per cluster. It requires a Secret named <span className="font-mono">vantage-eso-token</span> in the <span className="font-mono">external-secrets</span>{" "}
namespace holding the read token from Settings, labelled <span className="font-mono">external-secrets.io/type=webhook</span>.
</p>
<CopyBlock label="ClusterSecretStore" yaml={clusterStore} />
</details>
</div>
<div className="mt-6 flex justify-end">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const {
mutate: add,
isPending,
error,
} = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="API_KEY" className={`${inputClass} font-mono`} />
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="myapikey456" className={`${inputClass} font-mono`} />
</div>
<Button variant="primary" loading={isPending} disabled={!key.trim() || !value} onClick={() => add()}>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const [showYaml, setShowYaml] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => setShowYaml(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</Card>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="supersecret123"
className={`${inputClass} font-mono`}
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Group
</Button>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { openConsole } from "@/lib/guacConsole";
export default function ServerConsolePage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const serverId = params.id as string;
const containerRef = useRef<HTMLDivElement>(null);
const connectionRef = useRef<{ disconnect: () => void } | null>(null);
const [protocol, setProtocol] = useState<string>(searchParams.get("protocol") || "");
const [keyId, setKeyId] = useState<string>("");
const [sshUsername, setSshUsername] = useState<string>("root");
const [rdpUsername, setRdpUsername] = useState("");
const [rdpPassword, setRdpPassword] = useState("");
const [vncPassword, setVncPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// Inject the vendored Guacamole client script once.
useEffect(() => {
const s = document.createElement("script");
s.src = "/lib/guacamole-common.js";
s.async = true;
document.body.appendChild(s);
return () => {
document.body.removeChild(s);
};
}, []);
// Disconnect on unmount.
useEffect(() => {
return () => {
connectionRef.current?.disconnect();
};
}, []);
const { data: server, isLoading: serverLoading } = useQuery({
queryKey: ["servers", serverId],
queryFn: () => api.getServer(serverId),
});
const { data: keys, isLoading: keysLoading } = useQuery({
queryKey: ["keys"],
queryFn: () => api.listKeys(),
});
const usableKeys = useMemo(() => (keys ?? []).filter((k) => k.has_private_key === true), [keys]);
const protocols = server?.console_protocols ?? [];
useEffect(() => {
if (!protocol && protocols.length > 0) {
setProtocol(protocols[0]);
}
}, [protocols, protocol]);
async function handleConnect() {
setError(null);
setConnecting(true);
try {
const body: Parameters<typeof api.connectConsole>[0] = {
server_id: serverId,
protocol,
};
if (protocol === "ssh") {
body.key_id = keyId || undefined;
body.ssh_username = sshUsername || undefined;
} else if (protocol === "rdp") {
body.rdp_username = rdpUsername || undefined;
body.rdp_password = rdpPassword || undefined;
} else if (protocol === "vnc") {
body.rdp_password = vncPassword || undefined;
}
const { token, ws_path } = await api.connectConsole(body);
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}${ws_path}?token=${encodeURIComponent(token)}`;
if (containerRef.current) {
const conn = openConsole(containerRef.current, wsUrl);
connectionRef.current = conn;
setConnected(true);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect");
} finally {
setConnecting(false);
}
}
function handleDisconnect() {
connectionRef.current?.disconnect();
connectionRef.current = null;
setConnected(false);
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
}
if (serverLoading || keysLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!server) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-8">
<div className="mb-4 flex items-center gap-3">
<Link href={`/servers/${serverId}`} className="text-text-secondary hover:text-text-primary text-sm">
{server.hostname}
</Link>
</div>
<h1 className="mb-4 text-2xl font-bold text-text-primary">Console</h1>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">{error}</div>}
{!connected ? (
<Card className="mb-4 max-w-xl">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Protocol</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{protocols.length === 0 && <option value="">No protocols available</option>}
{protocols.map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
</div>
{protocol === "ssh" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Username</label>
<input
type="text"
value={sshUsername}
onChange={(e) => setSshUsername(e.target.value)}
placeholder="root"
className="mb-3 w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Key</label>
<select
value={keyId}
onChange={(e) => setKeyId(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value="">Select a key</option>
{usableKeys.map((k) => (
<option key={k.key_id} value={k.key_id}>
{k.label}
</option>
))}
</select>
{usableKeys.length === 0 && (
<p className="mt-1.5 text-xs text-text-tertiary">No keys with stored private material are available.</p>
)}
</div>
)}
{protocol === "rdp" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Username</label>
<input
type="text"
value={rdpUsername}
onChange={(e) => setRdpUsername(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={rdpPassword}
onChange={(e) => setRdpPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
</>
)}
{protocol === "vnc" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={vncPassword}
onChange={(e) => setVncPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
)}
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
Connect
</Button>
</div>
</Card>
) : (
<div className="mb-4 flex items-center gap-3">
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
</div>
)}
<div
ref={containerRef}
className="min-h-[500px] flex-1 rounded-lg border border-border bg-black"
/>
</div>
);
}
+32 -1
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -314,6 +314,15 @@ export default function ServerDetailPage() {
return (
<div className="p-8">
{showGenerateModal && <GenerateKeyModal onClose={() => setShowGenerateModal(false)} onSubmit={(opts) => generateKey(opts)} isPending={isGenerating} />}
{showUpdatesModal && server.available_updates && (
<UpdatesModal
updates={server.available_updates}
onClose={() => setShowUpdatesModal(false)}
onApply={() => applyUpdates()}
isApplying={isApplying}
applySuccess={applySuccess}
/>
)}
<div className="mb-6 flex items-start justify-between">
<div>
@@ -329,6 +338,28 @@ export default function ServerDetailPage() {
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
</div>
<div className="flex gap-2">
{server.console_protocols?.map((p) => (
<Link key={p} href={`/servers/${serverId}/console?protocol=${p}`}>
<Button variant="secondary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
Connect {p.toUpperCase()}
</Button>
</Link>
))}
{server.available_updates && server.available_updates.length > 0 && (
<Button
variant="secondary"
onClick={() => setShowUpdatesModal(true)}
className="border-warning/50 text-warning hover:border-warning hover:bg-warning/10"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{server.available_updates.length} OS Update{server.available_updates.length !== 1 ? "s" : ""}
</Button>
)}
<Button variant="secondary" onClick={() => setShowGenerateModal(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
+40 -14
View File
@@ -2,19 +2,40 @@
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Server, ServerStatus } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { api, Server } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function statusVariant(status: ServerStatus) {
switch (status) {
case "active":
return "success";
case "pending":
return "warning";
case "offline":
return "danger";
}
type DotStatus = "offline" | "needs-update" | "has-package-updates" | "ok";
function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus {
if (server.status === "offline" || server.status === "pending") return "offline";
if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update";
if (server.available_updates && server.available_updates.length > 0) return "has-package-updates";
return "ok";
}
const DOT_CLASSES: Record<DotStatus, string> = {
offline: "bg-danger",
"needs-update": "bg-orange-500",
"has-package-updates": "bg-yellow-400",
ok: "bg-success",
};
const DOT_LABELS: Record<DotStatus, string> = {
offline: "Offline",
"needs-update": "Agent needs updating",
"has-package-updates": "Package updates available",
ok: "OK",
};
function StatusDot({ status }: { status: DotStatus }) {
return (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
</span>
);
}
function formatLastSeen(dateStr: string): string {
@@ -39,6 +60,13 @@ export default function ServersPage() {
refetchInterval: 30_000,
});
const { data: latestVersionData } = useQuery({
queryKey: ["agent-latest-version"],
queryFn: api.getLatestAgentVersion,
staleTime: 5 * 60_000,
});
const latestVersion = latestVersionData?.version;
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
@@ -96,9 +124,7 @@ export default function ServersPage() {
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td>
<Badge variant={statusVariant(server.status)}>
{server.status}
</Badge>
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td>
<span className="text-text-secondary">
+393
View File
@@ -0,0 +1,393 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, AlertSettings, EmailSettings } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none ${
enabled ? "bg-accent" : "bg-surface-2 border border-border"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
}
function ToggleRow({
label,
description,
enabled,
onChange,
}: {
label: string;
description: string;
enabled: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-secondary">{description}</p>
</div>
<Toggle enabled={enabled} onChange={onChange} />
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
function SecretsTokenCard({
tokenSet,
rotatedAt,
}: {
tokenSet: boolean;
rotatedAt?: string;
}) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined"
? `${window.location.origin}/api/secrets/<group>/values`
: "/api/secrets/<group>/values";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
async function copy() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
/>
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">
Copy this token now it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && (
<p className="mt-2 text-xs text-text-tertiary">
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</Card>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: api.getSettings,
});
// Webhook / offline alerting state
const [alertsEnabled, setAlertsEnabled] = useState(false);
const [webhookURL, setWebhookURL] = useState("");
const [thresholdMinutes, setThresholdMinutes] = useState(5);
// Email state
const [emailEnabled, setEmailEnabled] = useState(false);
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
const [fromAddr, setFromAddr] = useState("");
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
const [useTLS, setUseTLS] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!settings) return;
setAlertsEnabled(settings.alerts.enabled);
setWebhookURL(settings.alerts.webhook_url ?? "");
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setEmailEnabled(settings.email?.enabled ?? false);
setSmtpHost(settings.email?.smtp_host ?? "");
setSmtpPort(settings.email?.smtp_port || 587);
setSmtpUser(settings.email?.username ?? "");
setSmtpPass(settings.email?.password ?? "");
setFromAddr(settings.email?.from_addr ?? "");
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
setUseTLS(settings.email?.use_tls ?? false);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: { alerts: AlertSettings; email: EmailSettings }) =>
api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const toList = toAddrs
.split(",")
.map((s) => s.trim())
.filter(Boolean);
save({
alerts: {
enabled: alertsEnabled,
webhook_url: webhookURL,
offline_threshold_minutes: thresholdMinutes,
},
email: {
enabled: emailEnabled,
smtp_host: smtpHost,
smtp_port: smtpPort,
username: smtpUser,
password: smtpPass,
from_addr: fromAddr,
to_addrs: toList,
use_tls: useTLS,
},
});
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure alerting and monitoring behaviour</p>
</div>
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
{/* Webhook alerting */}
<Card>
<CardHeader>
<CardTitle>Webhook Alerting</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
POST a JSON payload to a URL when a server goes offline. Compatible with Slack,
Discord, n8n, and any service that accepts JSON.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable webhook alerts"
description="Webhook fires only when this is on"
enabled={alertsEnabled}
onChange={setAlertsEnabled}
/>
<Field
label="Webhook URL"
hint={`POST body: { event, hostname, server_id, ip_address, timestamp, message }`}
>
<input
type="url"
value={webhookURL}
onChange={(e) => setWebhookURL(e.target.value)}
placeholder="https://hooks.slack.com/... or https://discord.com/api/webhooks/..."
className={inputClass}
/>
</Field>
<Field
label="Offline threshold (minutes)"
hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum."
>
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</Field>
</div>
</Card>
{/* Email alerting */}
<Card>
<CardHeader>
<CardTitle>Email Notifications</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Send an email when a server goes offline. Uses the same offline threshold as the
webhook setting above.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable email alerts"
description="Emails are only sent when this is on"
enabled={emailEnabled}
onChange={setEmailEnabled}
/>
<div className="grid grid-cols-3 gap-3">
<Field label="SMTP Host" hint="">
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.gmail.com"
className={inputClass}
/>
</Field>
<Field label="Port" hint="">
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
placeholder="587"
className={inputClass}
/>
</Field>
<div className="flex flex-col justify-center pt-5">
<ToggleRow
label="TLS (port 465)"
description="Use implicit TLS instead of STARTTLS"
enabled={useTLS}
onChange={(v) => {
setUseTLS(v);
setSmtpPort(v ? 465 : 587);
}}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Username">
<input
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
className={inputClass}
autoComplete="username"
/>
</Field>
<Field label="Password">
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="App password or SMTP password"
className={inputClass}
autoComplete="new-password"
/>
</Field>
</div>
<Field label="From address">
<input
type="email"
value={fromAddr}
onChange={(e) => setFromAddr(e.target.value)}
placeholder="vantage@example.com"
className={inputClass}
/>
</Field>
<Field
label="To addresses"
hint="Separate multiple addresses with commas"
>
<input
type="text"
value={toAddrs}
onChange={(e) => setToAddrs(e.target.value)}
placeholder="admin@example.com, ops@example.com"
className={inputClass}
/>
</Field>
</div>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<div className="mt-6 max-w-xl">
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}
/>
</div>
</div>
);
}
+28
View File
@@ -27,9 +27,37 @@ function KeyIcon() {
);
}
function SecretIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
}
function AuditIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
}
function SettingsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
export function Sidebar() {
+144 -2
View File
@@ -19,6 +19,22 @@ export interface Server {
created_at: string;
available_updates?: PackageUpdate[];
updates_checked_at?: string;
console_protocols?: string[];
}
export interface ConsoleConnectRequest {
server_id: string;
protocol: string;
key_id?: string;
rdp_username?: string;
rdp_password?: string;
ssh_username?: string;
}
export interface ConsoleConnectResponse {
session_id: string;
token: string;
ws_path: string;
}
export interface Key {
@@ -30,6 +46,7 @@ export interface Key {
source: KeySource;
generated_by_server_id?: string;
has_private_key: boolean;
has_passphrase?: boolean;
created_at: string;
assigned_count?: number;
}
@@ -42,6 +59,56 @@ export interface Assignment {
revoked_at: string | null;
}
export interface AuditEvent {
id: string;
event_type: string;
actor: string;
server_id?: string;
key_id?: string;
details: string;
created_at: string;
}
export interface AlertSettings {
enabled: boolean;
webhook_url: string;
offline_threshold_minutes: number;
}
export interface EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
username: string;
password: string;
from_addr: string;
to_addrs: string[];
use_tls: boolean;
}
export interface SecretsSettings {
read_token_set: boolean;
rotated_at?: string;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
}
export interface SecretGroupSummary {
group: string;
key_count: number;
updated_at: string;
}
export interface Secret {
group: string;
key: string;
updated_at: string;
}
export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
@@ -141,6 +208,68 @@ export const api = {
});
},
// Audit
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
},
// Settings
getSettings(): Promise<Settings> {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings }): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
},
rotateSecretsToken(): Promise<{ token: string }> {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
// Secrets
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
return request<{ group: string }>("/secrets", {
method: "POST",
body: JSON.stringify({ group, values }),
});
},
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
},
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
revealSecret(group: string, key: string): Promise<{ value: string }> {
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
method: "POST",
body: JSON.stringify({ key }),
});
},
deleteSecret(group: string, key: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
method: "DELETE",
});
},
deleteSecretGroup(group: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
@@ -150,10 +279,15 @@ export const api = {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
body: JSON.stringify({
label,
public_key,
private_key: private_key || undefined,
passphrase: passphrase || undefined,
}),
});
},
@@ -178,4 +312,12 @@ export const api = {
method: "DELETE",
});
},
// Console
connectConsole(body: ConsoleConnectRequest): Promise<ConsoleConnectResponse> {
return request<ConsoleConnectResponse>("/console/connect", {
method: "POST",
body: JSON.stringify(body),
});
},
};
+27
View File
@@ -0,0 +1,27 @@
// Thin wrapper over the vendored guacamole-common-js client.
// The library attaches a global `Guacamole` object when loaded.
declare const Guacamole: any;
export function openConsole(container: HTMLElement, wsUrl: string): { disconnect: () => void } {
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
container.innerHTML = "";
container.appendChild(client.getDisplay().getElement());
client.connect("");
// Wire keyboard + mouse.
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) =>
client.sendMouseState(state);
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k);
keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k);
return {
disconnect() {
client.disconnect();
},
};
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
File diff suppressed because one or more lines are too long
+21 -6
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -18,9 +22,20 @@
}
],
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long