diff --git a/docs/superpowers/plans/2026-07-17-web-console.md b/docs/superpowers/plans/2026-07-17-web-console.md new file mode 100644 index 0000000..4e8a193 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-web-console.md @@ -0,0 +1,1429 @@ +# Vantage Web Console (Guacamole Replacement) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a browser-based SSH/RDP/VNC console to Vantage that connects to managed servers through a `guacd` daemon, with SSH-key selection reusing existing stored keys, plus a Windows agent shipped as an MSI installer. + +**Architecture:** Browser runs `guacamole-common-js` and opens a WebSocket to the Go server. The server (using `github.com/wwt/guac`) brokers a short-lived, HMAC-signed session, then tunnels the Guacamole protocol to a `guacd` container over TCP `:4822`. `guacd` connects directly to the target host's SSH/RDP/VNC port. A reduced-role Windows agent registers and heartbeats but never writes `authorized_keys`. + +**Tech Stack:** Go 1.26 (gin, mongo-driver v2, `github.com/wwt/guac`), Next.js (App Router) + vendored `guacamole-common-js`, `guacamole/guacd` Docker image, WiX v4 MSI, nssm, Gitea Actions. + +## Global Constraints + +- Go version: **1.26** (both `server/go.mod` and `agent/go.mod`). +- Encryption of private material reuses existing AES-256-GCM helpers in `server/internal/services/crypto.go` (`encryptString`/`decryptString`); env var `KEY_ENCRYPTION_KEY` (64-hex). Never add a second crypto path. +- No external CDN in the frontend — `guacamole-common-js` is **vendored** into the repo. +- Decrypted private keys / RDP passwords: never persisted, never logged, never returned to the browser. They go only to `guacd`. +- `guacd` is bound to the internal Docker network only — never published to a host port. +- Existing keys already store `private_key_enc` and agents already upload generated private keys (`UploadGeneratedKey` carries `private_key`). Do **not** re-implement key storage. +- MongoDB collection names are lowercase plural: `servers`, `keys`, `assignments`, `console_sessions`. +- Follow existing service pattern: package-level functions in `server/internal/services/*.go` using `db.Col("")` with a 5s `context.WithTimeout`. +- `os_type` is inferred server-side from `os_info` (which is `" "`) — **no proto change**. + +--- + +## File Structure + +**Server (Go):** +- `server/internal/models/server.go` — add `OSType`, `ConsoleProtocols`, `SSHPort`, `RDPPort`. +- `server/internal/models/console_session.go` — NEW `ConsoleSession` model. +- `server/internal/services/servers.go` — infer `os_type` on register; default console fields. +- `server/internal/services/console.go` — NEW broker: token sign/verify, guacd param build, session lifecycle. +- `server/internal/services/console_test.go` — NEW unit tests. +- `server/internal/api/console.go` — NEW HTTP handlers `POST /api/console/connect`, `GET /api/console/tunnel`. +- `server/internal/api/handlers.go` — register the two new routes; add `/install.ps1` route. +- `server/internal/api/install_windows.go` — NEW `/install.ps1` PowerShell script handler. + +**Agent (Go):** +- `agent/internal/config/config.go` — OS-aware config dir (`C:\ProgramData\vantage` on Windows). +- `agent/internal/sync/sync.go` — guard `authorized_keys` read/write behind `runtime.GOOS == "linux"`. + +**Deploy / CI:** +- `deploy/docker-compose.yml` — add `guacd` service. +- `.gitea/workflows/agent-release.yml` — add windows/amd64 build + WiX MSI + checksums. +- `installer/vantage-agent.wxs` — NEW WiX v4 source. +- `installer/nssm.exe` — bundled nssm payload (downloaded in CI, not committed if large — see Task 12). + +**Frontend (Next.js):** +- `web/lib/guacamole-common.js` — NEW vendored library. +- `web/lib/guacConsole.ts` — NEW thin wrapper: build client, wire WebSocket tunnel. +- `web/app/servers/[id]/console/page.tsx` — NEW console page. +- `web/app/servers/[id]/page.tsx` — add Connect buttons. + +--- + +## Task 1: Extend Server model with console fields + +**Files:** +- Modify: `server/internal/models/server.go` + +**Interfaces:** +- Produces: `Server.OSType string`, `Server.ConsoleProtocols []string`, `Server.SSHPort int`, `Server.RDPPort int`. + +- [ ] **Step 1: Add fields to the Server struct** + +In `server/internal/models/server.go`, add these fields to `type Server struct` (after `OSInfo`): + +```go + OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` // linux | windows + ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` // ssh | rdp | vnc + SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` + RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` +``` + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./...` +Expected: success, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/models/server.go +git commit -m "feat: add console fields to Server model" +``` + +--- + +## Task 2: Infer os_type and default console fields on register + +**Files:** +- Modify: `server/internal/services/servers.go:82` (`RegisterServer`) +- Test: `server/internal/services/servers_console_test.go` (NEW) + +**Interfaces:** +- Consumes: `Server.OSType`, `Server.ConsoleProtocols`, `Server.SSHPort`, `Server.RDPPort` (Task 1). +- Produces: `func OSTypeFromInfo(osInfo string) string` in package `services`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/servers_console_test.go`: + +```go +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) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run TestOSTypeFromInfo` +Expected: FAIL — `undefined: OSTypeFromInfo`. + +- [ ] **Step 3: Implement OSTypeFromInfo and wire into RegisterServer** + +Add to `server/internal/services/servers.go` (top-level func): + +```go +// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the +// agent-reported os_info string, which is formatted " ". +// 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 +} +``` + +Ensure `strings` is imported in `servers.go`. Then, inside `RegisterServer`, in the `$set` map that already includes `"os_info": osInfo` (around servers.go:108), add the derived fields: + +```go + osType := OSTypeFromInfo(osInfo) + protocols, sshPort, rdpPort := defaultConsoleFields(osType) +``` + +and add to the same `$set` bson.M: + +```go + "os_type": osType, + "console_protocols": protocols, + "ssh_port": sshPort, + "rdp_port": rdpPort, +``` + +Note: only set console defaults if not already present to avoid clobbering user edits on re-register. Use `$setOnInsert` for `console_protocols`, `ssh_port`, `rdp_port`; keep `os_type` in `$set` (it can legitimately change). If `RegisterServer` currently uses a plain `UpdateOne`/`$set`, split into `$set` (os_type, os_info, status, last_seen) and `$setOnInsert` (the three console fields). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run TestOSTypeFromInfo` +Expected: PASS. + +- [ ] **Step 5: Build** + +Run: `cd server && go build ./...` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/services/servers.go server/internal/services/servers_console_test.go +git commit -m "feat: infer os_type and default console config on register" +``` + +--- + +## Task 3: ConsoleSession model + +**Files:** +- Create: `server/internal/models/console_session.go` + +**Interfaces:** +- Produces: `models.ConsoleSession` with fields `SessionID, ServerID, Protocol, KeyID, User, StartedAt, EndedAt, ClientIP`. + +- [ ] **Step 1: Create the model** + +Create `server/internal/models/console_session.go`: + +```go +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"` +} +``` + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./...` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/models/console_session.go +git commit -m "feat: add ConsoleSession model" +``` + +--- + +## Task 4: Session token sign/verify + +**Files:** +- Create: `server/internal/services/console.go` +- Test: `server/internal/services/console_test.go` + +**Interfaces:** +- Produces: + - `func SignSessionToken(sessionID string, ttl time.Duration) (string, error)` + - `func VerifySessionToken(token string) (sessionID string, err error)` + - Token format: `base64url(sessionID "." expiryUnix) "." base64url(HMAC-SHA256)`. + - HMAC key derived from `KEY_ENCRYPTION_KEY` (reuse `encryptionKey()` from crypto.go). + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/console_test.go`: + +```go +package services + +import ( + "testing" + "time" +) + +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") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run TestSessionToken` +Expected: FAIL — `undefined: SignSessionToken`. + +- [ ] **Step 3: Implement token functions** + +Create `server/internal/services/console.go`: + +```go +package services + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" +) + +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 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run TestSessionToken` +Expected: PASS (all three). + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/console.go server/internal/services/console_test.go +git commit -m "feat: signed expiring console session tokens" +``` + +--- + +## Task 5: guacd connection parameter builder + +**Files:** +- Modify: `server/internal/services/console.go` +- Test: `server/internal/services/console_test.go` + +**Interfaces:** +- Produces: + - `type GuacParams struct { Protocol string; Params map[string]string }` + - `func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass string) (*GuacParams, error)` + +- [ ] **Step 1: Write the failing test** + +Append to `server/internal/services/console_test.go`: + +```go +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") + } +} + +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") + } +} +``` + +Add `"github.com/mrhid6/vantage/server/internal/models"` to the test imports. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run TestBuildGuacParams` +Expected: FAIL — `undefined: BuildGuacParams`. + +- [ ] **Step 3: Implement BuildGuacParams** + +Append to `server/internal/services/console.go` (add `"strconv"` already imported; add `"github.com/mrhid6/vantage/server/internal/models"` import): + +```go +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 is the decrypted SSH private key (ssh only); rdpUser/rdpPass are +// used for rdp. None of these values are persisted or logged by the caller. +func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass string) (*GuacParams, error) { + host := srv.IPAddress + switch protocol { + case "ssh": + p := map[string]string{ + "hostname": host, + "port": portOr(srv.SSHPort, 22), + } + if privateKey != "" { + p["private-key"] = privateKey + } + 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) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run TestBuildGuacParams` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/console.go server/internal/services/console_test.go +git commit -m "feat: build guacd connection params per protocol" +``` + +--- + +## Task 6: Session lifecycle (create/end) in Mongo + +**Files:** +- Modify: `server/internal/services/console.go` + +**Interfaces:** +- Consumes: `models.ConsoleSession` (Task 3), `db.Col` pattern. +- Produces: + - `func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error)` + - `func EndConsoleSession(sessionID string) error` + - `func GetConsoleSession(sessionID string) (*models.ConsoleSession, error)` + +- [ ] **Step 1: Implement the three functions** + +Append to `server/internal/services/console.go` (add imports `"context"`, `"github.com/google/uuid"`, `"github.com/mrhid6/vantage/server/internal/db"`, `"go.mongodb.org/mongo-driver/v2/bson"`): + +```go +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 +} + +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 +} +``` + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./... && go test ./internal/services/ -run 'TestSessionToken|TestBuildGuacParams'` +Expected: build success, existing tests still PASS. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/services/console.go +git commit -m "feat: console session lifecycle persistence" +``` + +--- + +## Task 7: Add wwt/guac dependency and guacd to docker-compose + +**Files:** +- Modify: `server/go.mod`, `server/go.sum` +- Modify: `deploy/docker-compose.yml` + +**Interfaces:** +- Produces: `github.com/wwt/guac` available to import; `guacd` service reachable at host `guacd:4822` on the compose network. + +- [ ] **Step 1: Add the Go dependency** + +Run: `cd server && go get github.com/wwt/guac@latest` +Expected: `go.mod`/`go.sum` updated. + +- [ ] **Step 2: Add guacd service to docker-compose** + +In `deploy/docker-compose.yml`, add under `services:` (no `ports:` — internal only): + +```yaml + guacd: + image: guacamole/guacd:1.5.5 + restart: unless-stopped +``` + +Then add to the `server` service `environment:` block: + +```yaml + GUACD_ADDR: guacd:4822 +``` + +And add `guacd` to the `server` service `depends_on:` (simple list form is fine): + +```yaml + guacd: + condition: service_started +``` + +- [ ] **Step 3: Validate compose** + +Run: `cd deploy && docker compose config >/dev/null` +Expected: no error (prints nothing). + +- [ ] **Step 4: Commit** + +```bash +git add server/go.mod server/go.sum deploy/docker-compose.yml +git commit -m "feat: add wwt/guac dep and guacd service" +``` + +--- + +## Task 8: Console API — connect + tunnel handlers + +**Files:** +- Create: `server/internal/api/console.go` +- Modify: `server/internal/api/handlers.go` (register routes) + +**Interfaces:** +- Consumes: `services.CreateConsoleSession`, `services.SignSessionToken`, `services.VerifySessionToken`, `services.GetConsoleSession`, `services.EndConsoleSession`, `services.BuildGuacParams`, `services.GetServer`, `services.GetPrivateKey`, `services.OSTypeFromInfo`. +- Produces: routes `POST /api/console/connect`, `GET /api/console/tunnel`. + +- [ ] **Step 1: Implement the connect handler + tunnel handler** + +Create `server/internal/api/console.go`: + +```go +package api + +import ( + "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"` + } + 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 + } + + 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 + } + srv, err := services.GetServer(sess.ServerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + // Decrypt private key in-memory only (ssh). + var privKey string + 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 + } + } + + // RDP creds are single-use, passed via the connect step into the session + // document is avoided; instead they are re-supplied here as query params + // over the already-authenticated WS token. For ssh they are empty. + gp, err := services.BuildGuacParams(srv, sess.Protocol, privKey, c.Query("u"), c.Query("p")) + 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) { + info := guac.NewGuacamoleConfiguration() + info.Protocol = gp.Protocol + for k, v := range gp.Params { + info.Parameters[k] = v + } + // Optional display sizing from client query. + info.OptimalScreenWidth = 1024 + info.OptimalScreenHeight = 768 + info.OptimalResolution = 96 + stream, e := guac.NewInetSocket(guacdAddr) + if e != nil { + return nil, e + } + return guac.NewSimpleTunnel(stream), nil + } + + server := guac.NewWebsocketServer(connect) + server.OnConnect = func(id string, r *http.Request, t guac.Tunnel) {} + server.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { + _ = services.EndConsoleSession(sessionID) + } + server.ServeHTTP(c.Writer, c.Request) +} +``` + +> Note for implementer: `github.com/wwt/guac`'s exact constructor names (`NewGuacamoleConfiguration`, `NewInetSocket`, `NewSimpleTunnel`, `NewWebsocketServer`, `Tunnel`) must be confirmed against the installed version; adjust to match its README if the API differs. The shape (a `connect` callback returning a `Tunnel` that dials guacd, wrapped in a websocket server) is stable across versions. + +- [ ] **Step 2: Register the routes** + +In `server/internal/api/handlers.go`, inside the `apiGroup` block (session-authed), add: + +```go + apiGroup.POST("/console/connect", consoleConnect) + apiGroup.GET("/console/tunnel", consoleTunnel) +``` + +- [ ] **Step 3: Build** + +Run: `cd server && go build ./...` +Expected: success. If the `guac` API names differ, fix per its README until it builds. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/api/console.go server/internal/api/handlers.go +git commit -m "feat: console connect + guacd websocket tunnel endpoints" +``` + +--- + +## Task 9: OS-aware agent config directory + +**Files:** +- Modify: `agent/internal/config/config.go` + +**Interfaces:** +- Produces: `func ConfigDir() string`, `func configPath() string` — Windows returns `C:\ProgramData\vantage`, else `/etc/vantage`. `ConfigPath` const replaced by function usage. + +- [ ] **Step 1: Write the failing test** + +Create `agent/internal/config/config_test.go`: + +```go +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) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd agent && go test ./internal/config/ -run TestConfigDirByOS` +Expected: FAIL — `undefined: ConfigDir`. + +- [ ] **Step 3: Implement OS-aware paths** + +Edit `agent/internal/config/config.go`. Replace the `const ConfigPath = "/etc/vantage/config.yaml"` line and update `Load`/`Save`: + +```go +import ( + "os" + "path/filepath" + "runtime" + "time" + + "gopkg.in/yaml.v3" +) + +// 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") } +``` + +In `Load`, replace `os.ReadFile(ConfigPath)` with `os.ReadFile(configPath())`. +In `Save`, replace `os.MkdirAll("/etc/vantage", 0700)` with `os.MkdirAll(ConfigDir(), 0700)` and `os.WriteFile(ConfigPath, ...)` with `os.WriteFile(configPath(), data, 0600)`. + +Search the rest of the agent for `config.ConfigPath` references and switch them to `config.ConfigDir()`/internal usage: + +Run: `cd agent && grep -rn "ConfigPath" .` — update any hits to use `ConfigDir()` or a new exported helper if needed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd agent && go test ./internal/config/ -run TestConfigDirByOS` +Expected: PASS. + +- [ ] **Step 5: Cross-compile check for Windows** + +Run: `cd agent && GOOS=windows GOARCH=amd64 go build ./...` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add agent/internal/config/config.go agent/internal/config/config_test.go +git commit -m "feat: OS-aware agent config directory" +``` + +--- + +## Task 10: Guard authorized_keys writes to Linux only + +**Files:** +- Modify: `agent/internal/sync/sync.go` + +**Interfaces:** +- Consumes: `runtime.GOOS`. +- Produces: `poll` still calls `SyncKeys` (heartbeat/last_seen) on all OSes, but only reads/writes `authorized_keys` on Linux. + +- [ ] **Step 1: Guard the write path in poll** + +In `agent/internal/sync/sync.go`, edit `poll` so that after `SyncKeys` succeeds, the `authorized_keys` handling is skipped on non-Linux: + +```go +func poll(client *grpcclient.Client, cfg *config.Config, version string) error { + desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version) + if err != nil { + 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) + } + if !keys.StateChanged(current, desired) { + log.Println("authorized_keys unchanged, skipping write") + return nil + } + if err := keys.WriteAuthorizedKeys(desired); err != nil { + return fmt.Errorf("write authorized_keys: %w", err) + } + log.Printf("authorized_keys updated (%d keys)", len(desired)) + return nil +} +``` + +(`runtime` is already imported in sync.go.) + +- [ ] **Step 2: Build both targets** + +Run: +``` +cd agent && go build ./... && GOOS=windows GOARCH=amd64 go build ./... +``` +Expected: both succeed. + +- [ ] **Step 3: Commit** + +```bash +git add agent/internal/sync/sync.go +git commit -m "feat: skip authorized_keys management on non-linux agents" +``` + +--- + +## Task 11: Add Windows build to agent-release workflow + +**Files:** +- Modify: `.gitea/workflows/agent-release.yml` + +**Interfaces:** +- Produces: release asset `vantage-agent-windows-amd64.exe` + its checksum entry. + +- [ ] **Step 1: Add the Windows build to the Build step** + +In `.gitea/workflows/agent-release.yml`, in the `Build` step `run:` block, append: + +```bash + GOOS=windows GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/vantage-agent-windows-amd64.exe ./cmd +``` + +Update the `Checksums` step to include it: + +```bash + sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt +``` + +Add to the release `files:` list: + +```yaml + agent/dist/vantage-agent-windows-amd64.exe +``` + +- [ ] **Step 2: Lint the YAML** + +Run: `cd "$(git rev-parse --show-toplevel)" && python -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/agent-release.yml'))"` +Expected: no error. + +- [ ] **Step 3: Commit** + +```bash +git add .gitea/workflows/agent-release.yml +git commit -m "ci: build windows agent binary in release" +``` + +--- + +## Task 12: WiX MSI source + CI build job + +**Files:** +- Create: `installer/vantage-agent.wxs` +- Modify: `.gitea/workflows/agent-release.yml` + +**Interfaces:** +- Consumes: `agent/dist/vantage-agent-windows-amd64.exe` (Task 11), bundled `nssm.exe`. +- Produces: release asset `vantage-agent.msi`; accepts MSI properties `SERVERID`, `TOKEN`, `SERVERURL`; installs to `C:\Program Files\Vantage\`, writes `C:\ProgramData\vantage\config.yaml`, registers + starts the `VantageAgent` service via nssm. + +- [ ] **Step 1: Create the WiX source** + +Create `installer/vantage-agent.wxs`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Also create `installer/setup.ps1` and add it as a `File`/`Component` (add a third component mirroring the two above, `Source="setup.ps1" Name="setup.ps1"`, and a matching `ComponentRef`): + +```powershell +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 +``` + +- [ ] **Step 2: Add the MSI build job to CI** + +In `.gitea/workflows/agent-release.yml`, add a new job that runs after `build`. It downloads nssm, installs the WiX v4 dotnet tool, and builds the MSI on the Linux runner: + +```yaml + msi: + needs: build + runs-on: ubuntu-docker + container: mcr.microsoft.com/dotnet/sdk:9.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download agent exe artifact + # The build job attached the exe to the release; re-fetch it, or + # pass it between jobs via actions/upload-artifact in build and + # actions/download-artifact here. Use artifacts to avoid release ordering. + uses: actions/download-artifact@v4 + with: + name: agent-windows + path: installer + + - name: Fetch nssm + working-directory: installer + run: | + apt-get update && apt-get install -y unzip curl + curl -fsSL -o nssm.zip https://nssm.cc/release/nssm-2.24.zip + unzip -j nssm.zip 'nssm-2.24/win64/nssm.exe' -d . + + - name: Install WiX + run: dotnet tool install --global wix --version 5.* + + - name: Build MSI + working-directory: installer + run: | + export PATH="$PATH:/root/.dotnet/tools" + cp ../agent/dist/vantage-agent-windows-amd64.exe . 2>/dev/null || true + wix build vantage-agent.wxs -o vantage-agent.msi + sha256sum vantage-agent.msi >> checksums-msi.txt + + - name: Attach MSI to release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + installer/vantage-agent.msi +``` + +In the `build` job, add before its release step so the exe is available to the `msi` job: + +```yaml + - name: Upload agent exe artifact + uses: actions/upload-artifact@v4 + with: + name: agent-windows + path: agent/dist/vantage-agent-windows-amd64.exe +``` + +> Implementer note: WiX v4/v5 CLI is `wix build`. Confirm the installed major version and pin `--version` accordingly; the `.wxs` uses the v4 schema namespace which v5 also accepts. If `download-artifact` cross-job wiring is awkward on this Gitea runner, instead merge the MSI build into the `build` job after the Windows `go build` (simpler, single job) — the `.wxs` and nssm/WiX steps are identical. + +- [ ] **Step 3: Validate WiX source locally (best-effort)** + +Run: `cd "$(git rev-parse --show-toplevel)" && python -c "import xml.dom.minidom,sys; xml.dom.minidom.parse('installer/vantage-agent.wxs')"` +Expected: no error (well-formed XML). + +- [ ] **Step 4: Commit** + +```bash +git add installer/vantage-agent.wxs installer/setup.ps1 .gitea/workflows/agent-release.yml +git commit -m "ci: package windows agent as WiX MSI" +``` + +--- + +## Task 13: Dynamic /install.ps1 handler + +**Files:** +- Create: `server/internal/api/install_windows.go` +- Modify: `server/internal/api/handlers.go` (add route) + +**Interfaces:** +- Consumes: env `GITEA_HOST`, `GRPC_HOST`/`PUBLIC_HOST` (same pattern as `handleInstallScript`). +- Produces: `GET /install.ps1?server_id=&token=` returns a PowerShell script that downloads the MSI, verifies checksum, and runs `msiexec /qn` with injected properties. + +- [ ] **Step 1: Implement the handler** + +Create `server/internal/api/install_windows.go`: + +```go +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 +$ErrorActionPreference = "Stop" + +$ServerId = "%s" +$Token = "%s" +$GiteaHost = "%s" +$ServerUrl = "%s" -replace '^https?://','' + +$rel = Invoke-RestMethod -Uri "https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10" +$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name +if (-not $tag) { throw "Could not determine latest agent version" } +$enc = $tag -replace '/','%%2F' +$base = "https://$GiteaHost/mrhid6/vantage/releases/download/$enc" + +$tmp = Join-Path $env:TEMP "vantage-agent.msi" +Invoke-WebRequest -Uri "$base/vantage-agent.msi" -OutFile $tmp +Invoke-WebRequest -Uri "$base/checksums-msi.txt" -OutFile "$env:TEMP\checksums-msi.txt" + +$expected = (Get-Content "$env:TEMP\checksums-msi.txt" | Select-String 'vantage-agent.msi').ToString().Split()[0] +$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower() +if ($expected -ne $actual) { throw "Checksum mismatch" } + +Start-Process msiexec.exe -Wait -ArgumentList "/i `"$tmp`" /qn SERVERID=$ServerId TOKEN=$Token SERVERURL=$ServerUrl" +Write-Host "Vantage agent installed." +`, serverID, token, giteaHost, grpcHost) + + c.Header("Content-Type", "text/plain; charset=utf-8") + c.String(http.StatusOK, script) +} +``` + +- [ ] **Step 2: Register the route** + +In `server/internal/api/handlers.go`, next to `r.GET("/install", handleInstallScript)`, add: + +```go + r.GET("/install.ps1", handleInstallScriptWindows) +``` + +- [ ] **Step 3: Build** + +Run: `cd server && go build ./...` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/api/install_windows.go server/internal/api/handlers.go +git commit -m "feat: dynamic windows install.ps1 endpoint" +``` + +--- + +## Task 14: Vendor guacamole-common-js + console wrapper + +**Files:** +- Create: `web/lib/guacamole-common.js` (vendored) +- Create: `web/lib/guacConsole.ts` + +**Interfaces:** +- Produces: `export function openConsole(container: HTMLElement, wsUrl: string): { disconnect: () => void }`. + +- [ ] **Step 1: Vendor the library** + +Download `guacamole-common-js` (Apache-2.0) `all.min.js` matching guacd 1.5.x and save it as `web/lib/guacamole-common.js`. Prepend a comment line noting version + license. (No CDN reference — the file is committed.) + +Run to confirm it's non-empty: +`cd web && test -s lib/guacamole-common.js && echo ok` +Expected: `ok`. + +- [ ] **Step 2: Write the wrapper** + +Create `web/lib/guacConsole.ts`: + +```ts +// 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(); + }, + }; +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd web && npx tsc --noEmit` +Expected: no errors from `lib/guacConsole.ts`. + +- [ ] **Step 4: Commit** + +```bash +git add web/lib/guacamole-common.js web/lib/guacConsole.ts +git commit -m "feat: vendor guacamole-common-js and console wrapper" +``` + +--- + +## Task 15: Console page + Connect buttons + +**Files:** +- Create: `web/app/servers/[id]/console/page.tsx` +- Modify: `web/app/servers/[id]/page.tsx` + +**Interfaces:** +- Consumes: `openConsole` (Task 14), `POST /api/console/connect`, keys list (`GET /api/keys`), server detail (`GET /api/servers/:id`). + +- [ ] **Step 1: Build the console page** + +Create `web/app/servers/[id]/console/page.tsx`. Match the existing App-Router + fetch style used elsewhere in `web/app` (check a sibling page such as `web/app/servers/[id]/page.tsx` for the API base + auth pattern, and reuse it). The page must: + +1. Load `Guacamole` by injecting the vendored script once: + ```tsx + useEffect(() => { + const s = document.createElement("script"); + s.src = "/lib/guacamole-common.js"; // served from web/public — see step 2 + s.async = true; + document.body.appendChild(s); + return () => { document.body.removeChild(s); }; + }, []); + ``` +2. Load the server (for `console_protocols`) and keys (`has_private_key === true` only) to populate a protocol ``; for rdp, username/password inputs. +3. On **Connect**, `POST /api/console/connect` with `{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`, receive `{ token, ws_path }`, then build the WS URL: + ```ts + const proto = location.protocol === "https:" ? "wss" : "ws"; + const extra = protocol === "rdp" ? `&u=${encodeURIComponent(rdpUser)}&p=${encodeURIComponent(rdpPass)}` : ""; + const wsUrl = `${proto}://${location.host}${ws_path}?token=${encodeURIComponent(token)}${extra}`; + const conn = openConsole(containerRef.current!, wsUrl); + ``` +4. Render a full-height `
` for the display and a Disconnect button calling `conn.disconnect()`. + +- [ ] **Step 2: Serve the vendored script from public** + +Copy the vendored library into `web/public/lib/` so Next serves it statically: + +Run: `cd web && mkdir -p public/lib && cp lib/guacamole-common.js public/lib/guacamole-common.js` +Expected: file present at `web/public/lib/guacamole-common.js`. + +- [ ] **Step 3: Add Connect buttons to the server detail page** + +In `web/app/servers/[id]/page.tsx`, for each protocol in the server's `console_protocols`, render a link/button to `/servers/${id}/console?protocol=${p}`. Follow the existing button styling in that file. + +- [ ] **Step 4: Build the frontend** + +Run: `cd web && npm run build` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add web/app/servers/[id]/console/page.tsx web/app/servers/[id]/page.tsx web/public/lib/guacamole-common.js +git commit -m "feat: web console page with protocol + key selection" +``` + +--- + +## Task 16: End-to-end verification + +**Files:** none (verification only). + +- [ ] **Step 1: Run all Go tests** + +Run: `cd server && go test ./... && cd ../agent && go test ./...` +Expected: PASS. + +- [ ] **Step 2: Cross-compile the agent for Windows** + +Run: `cd agent && GOOS=windows GOARCH=amd64 go build ./cmd` +Expected: success. + +- [ ] **Step 3: Bring up the stack and drive one SSH session** + +Run: `cd deploy && docker compose up -d --build` +Then, following the app UI: +1. Add a Linux server, install the agent, confirm it registers (status active). +2. Upload or generate an SSH key that has private material; assign it. +3. Open `/servers/[id]/console`, pick SSH + the key, click Connect. +4. Confirm a live terminal renders and accepts input. +5. Confirm a `console_sessions` document exists with `started_at` and, after closing, `ended_at`. + +Expected: interactive SSH session works end-to-end; audit log shows `console.opened`. + +- [ ] **Step 4: Commit any fixes discovered during verification** + +```bash +git add -A +git commit -m "fix: web console e2e adjustments" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** guacd/wwt/guac tunnel (Tasks 7-8), SSH key reuse via existing `GetPrivateKey` (Task 8), RDP creds (Tasks 5,8), direct network path (Task 8 `guacd:4822`), data model (Tasks 1,3), broker + signed token (Tasks 4-6), Windows agent reduced role (Tasks 9-10), MSI (Task 12), install.ps1 (Task 13), CI windows build (Task 11), frontend (Tasks 14-15), session recording explicitly out of scope — no task. All covered. +- **os_type without proto change:** confirmed — `os_info` already `" "`, inferred in Task 2. +- **Library API caveat:** `wwt/guac` and `guacamole-common-js` exact symbol names are flagged for confirmation at implementation time (Tasks 8, 14) — the surrounding shape is fixed.